v0.0.7: documenten met versies bij leads/klanten en relaties (tabel documents, versies in files; oude bestanden worden bij het starten versie 1 van een eigen document); woning = document met versies, de viewer toont de nieuwste (oudere via keuzelijst), nieuwe versie uploaden met opmerking, PDF's per woningversie; kaart (kaart-app, /kaart/) onder de woning en bij een klantrelatie zodra het adres bekend is, met de kavel en getekende woningen uit een kaveltoets-lead; blokken zonder gegevens verborgen; knop Bewerken in Tectum (TECTUM_URL) en API /api/tectum/woningen (lijst, ontwerp, nieuwe versie met conflictcontrole) met sleutel TECTUM_CRM_KEY
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
db3e26d258
commit
c69c3773dc
@@ -19,19 +19,34 @@ Eenvoudige CRM in Python (Flask) met SQLite, in de Van Emous-huisstijl. Versie 0
|
||||
familie of bedrijf, met meerdere contactpersonen en hun rol) en **partners** met een type (leverancier,
|
||||
aannemer, installateur, ...; de typen pas je aan bij Instellingen). Een lead omzetten naar klant zet de
|
||||
persoon in een nieuwe of bestaande klantrelatie. Leveranciers van producten worden automatisch partners.
|
||||
- **Gekozen model** per lead/contact: een woningontwerp uit de configurator (`house-design`-JSON, via het
|
||||
formulier, de API of geüpload bij Bestanden) wordt het gekozen model; het nieuwste telt, een ander ontwerp
|
||||
kies je op de contactpagina. Daar staat het in een **3D-viewer** op de engine van de configurator
|
||||
(`/files/<id>/viewer`): 3D, doorsnede en plattegrond, met maten, m², inhoud, gewicht en een indicatieve
|
||||
prijs (uit de kosten-CSV's van de configurator). In de leadlijst staat het model als kolom.
|
||||
- **Documenten met versies** bij leads/klanten en bij relaties: elk document heeft een of meer versies (nieuwe
|
||||
versie uploaden met een opmerking, oude versies blijven te openen en te verwijderen). Soorten: bestand, woning
|
||||
en de tekeningen (werktekening, klant-PDF) van een woning. Bestanden van vóór v0.0.7 worden bij het starten
|
||||
vanzelf versie 1 van een eigen document.
|
||||
- **Woning** per lead/klant: een woningontwerp uit Tectum (`house-design`-JSON, via het formulier, de API, geüpload
|
||||
bij Documenten of bewaard vanuit Tectum) is een woning met versies. Een nieuwe woning wordt de gekozen woning
|
||||
(de nieuwste telt), een andere kies je op de contactpagina. Daar staat de nieuwste versie in een **3D-viewer** op
|
||||
de engine van de configurator (`/files/<id>/viewer`, een oudere versie via de keuzelijst): 3D, doorsnede en
|
||||
plattegrond, met maten, m², inhoud, gewicht en een indicatieve prijs (uit de kosten-CSV's van de configurator).
|
||||
Zonder woning is het blok er niet. In de leadlijst staat de woning als kolom (Model).
|
||||
- **Tectum** (in de stack): **Bewerken in Tectum** bij de woning opent hem op `tectum.<domein>/?woning=<id>`; daar
|
||||
bewaart **Opslaan in CRM** een nieuwe versie. Tectum toont de woningen van leads en klanten ook bij Nieuw. De API
|
||||
daarvoor is `/api/tectum/woningen` (lijst, `?q=`), `/api/tectum/woningen/<id>` (ontwerp, `?versie=`) en `POST
|
||||
/api/tectum/woningen/<id>/versies` (`{ontwerp, opmerking, basis}`; 409 als er intussen een nieuwere versie is),
|
||||
alleen met de sleutel `TECTUM_CRM_KEY` (Caddy stuurt die namens Tectum mee; leeg = uit). `TECTUM_URL` = adres
|
||||
van Tectum voor de knop.
|
||||
- **Kaart** bij een lead/klant (onder de woning) en een klantrelatie zodra het adres bekend is (straat met
|
||||
huisnummer en postcode of plaats, of anders het adres uit het woningontwerp): de kaveltoets van de kaart-app
|
||||
(`KAART_URL`, standaard `/kaart/`, in de stack op hetzelfde domein). Kwam de lead uit de kaveltoets, dan opent de
|
||||
kaart die kavel met de getekende woningen. Zonder adres is het blok er niet.
|
||||
Met **Lagen** in de viewer kies je een bouwfase (t/m regelwerk, elektra, beplating, …) of losse lagen
|
||||
(regelwerk, elektra, kozijnen, dak, …), zoals Lagen in de configurator, zodat de ploeg de opbouw kan bekijken.
|
||||
- **PDF's per ontwerp** (`HousePdf` van de configurator, gemaakt in de viewer en bewaard bij het contact): een
|
||||
**werktekening** (alle tekenbladen, elektra, zaaglijst en kosten; intern, niet in het klantportaal) en een
|
||||
**klant-PDF** zoals de light-configurator die maakt (`LightPdf`: voorblad met beeld, kerncijfers, keuzes, indicatieve
|
||||
prijs en QR-code, dan aanzichten en plattegrond en de werkwijze; wel in het portaal). Ontbreken ze, dan maakt de viewer ze vanzelf bij
|
||||
het eerste openen; opnieuw maken via PDF in de weergave. Op de contactpagina bij het gekozen model en op de
|
||||
relatiepagina (Ontwerpen en tekeningen, voor alle contactpersonen).
|
||||
prijs en QR-code, dan aanzichten en plattegrond en de werkwijze; wel in het portaal). Per woningversie: ontbreken
|
||||
ze voor de nieuwste versie, dan maakt de viewer ze vanzelf bij het openen; opnieuw maken via PDF in de weergave.
|
||||
Op de contactpagina bij de woning en op de relatiepagina (Woningen en tekeningen, voor alle contactpersonen).
|
||||
- Op mobiel staan bij contact en relatie de gegevens bovenaan, de 3D-weergave verderop.
|
||||
- **Taken/opvolging** per contact met datum en eigenaar; "Mijn taken" op het dashboard.
|
||||
- **API voor formulieren** (`POST /api/leads`): JSON, gewone formulieren en multipart met bestanden.
|
||||
|
||||
@@ -4,7 +4,7 @@ Starten: python app.py
|
||||
E-mail ophalen: python app.py sync (bijv. elke 10 minuten via cron)
|
||||
Demoleads: python app.py demo (standaardmodellen uit demo/modellen als gekozen model)
|
||||
"""
|
||||
__version__ = '0.0.6'
|
||||
__version__ = '0.0.7'
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
@@ -20,7 +20,7 @@ import sys
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta
|
||||
from email.utils import getaddresses
|
||||
from urllib.parse import quote as url_quote, unquote, urlparse
|
||||
from urllib.parse import quote as url_quote, unquote, urlencode, urlparse
|
||||
from xml.etree import ElementTree as ET
|
||||
from xml.sax.saxutils import escape as xml_escape
|
||||
|
||||
@@ -74,6 +74,13 @@ VIEWER_SCRIPTS = ['js/vendor/three.min.js', 'js/vendor/OrbitControls.js', 'js/co
|
||||
'js/cost/cost-defaults.js', 'js/cost/cost-csv.js', 'js/cost/cost-model.js']
|
||||
DEMO_MODELS = os.path.join(BASE, 'demo', 'modellen')
|
||||
|
||||
# Andere apps in de stack. Tectum: knop "Bewerken in Tectum" bij een woning (TECTUM_URL) en de API /api/tectum/
|
||||
# (woningen laden en als nieuwe versie bewaren; sleutel TECTUM_CRM_KEY, die Caddy namens Tectum meestuurt).
|
||||
# Kaart: de kaveltoets-kaart bij een adres (KAART_URL; in de stack /kaart/ op hetzelfde domein).
|
||||
TECTUM_URL = (os.environ.get('TECTUM_URL') or '').rstrip('/')
|
||||
TECTUM_KEY = os.environ.get('TECTUM_CRM_KEY') or ''
|
||||
KAART_URL = os.environ.get('KAART_URL') or '/kaart/'
|
||||
|
||||
SETTING_KEYS = ['company_name', 'company_address', 'company_postcode_city', 'company_phone',
|
||||
'company_email', 'company_website', 'company_kvk', 'company_btw', 'company_iban',
|
||||
'quote_prefix', 'quote_valid_days', 'quote_intro', 'quote_footer', 'allowed_origins', 'api_assign_user',
|
||||
@@ -90,7 +97,8 @@ MAILBOX_PURPOSES = [('inbox', 'Inbox'), ('facturen', 'Facturen'), ('nieuwsbrief'
|
||||
FIELD_LOOKUP = importer.lookup(importer.FIELD_ALIASES)
|
||||
API_SKIP = {'api_key', 'redirect', '_gotcha', 'website_hp', 'g-recaptcha-response'}
|
||||
|
||||
PUBLIC = {'static', 'login', 'setup', 'api_leads', 'api_products', 'dav', 'dav_wellknown', 'unsubscribe'}
|
||||
PUBLIC = {'static', 'login', 'setup', 'api_leads', 'api_products', 'dav', 'dav_wellknown', 'unsubscribe',
|
||||
'api_tectum_woningen', 'api_tectum_woning', 'api_tectum_versie'}
|
||||
PORTAL_PUBLIC = {'portal_login', 'portal_invite'}
|
||||
ADMIN_ONLY = {'settings', 'users', 'user_edit', 'mailbox_edit', 'routines', 'routine_edit', 'routine_run',
|
||||
'routine_delete'}
|
||||
@@ -182,9 +190,14 @@ MIGRATIONS = {
|
||||
'contacts': {'relation_id': 'INTEGER REFERENCES relations(id) ON DELETE SET NULL',
|
||||
'relation_role': "TEXT DEFAULT ''",
|
||||
'design_file_id': 'INTEGER REFERENCES files(id) ON DELETE SET NULL',
|
||||
# gekozen woning (document); design_file_id is van vóór de documenten
|
||||
'design_id': 'INTEGER REFERENCES documents(id) ON DELETE SET NULL',
|
||||
# nieuwsbrieven: afgemeld (afmeldlink) of onbestelbaar (bounce); beide vallen buiten elk segment
|
||||
'unsubscribed_at': 'TEXT', 'bounced_at': 'TEXT'},
|
||||
'files': {'title': "TEXT DEFAULT ''", 'kind': "TEXT DEFAULT ''", 'design_id': 'INTEGER'},
|
||||
'files': {'title': "TEXT DEFAULT ''", 'kind': "TEXT DEFAULT ''", 'design_id': 'INTEGER',
|
||||
'document_id': 'INTEGER REFERENCES documents(id) ON DELETE CASCADE', 'version': 'INTEGER NOT NULL DEFAULT 1',
|
||||
'note': "TEXT DEFAULT ''", 'user_id': 'INTEGER REFERENCES users(id) ON DELETE SET NULL',
|
||||
'source_file_id': 'INTEGER'},
|
||||
'quotes': {'bestek': "TEXT DEFAULT ''", 'design_file_id': 'INTEGER REFERENCES files(id) ON DELETE SET NULL'},
|
||||
'users': {'dav_password_hash': 'TEXT', 'cal_sync': 'INTEGER NOT NULL DEFAULT 0'},
|
||||
'emails': {'archived': 'INTEGER NOT NULL DEFAULT 0', 'cc': "TEXT DEFAULT ''", 'attachments': "TEXT DEFAULT ''",
|
||||
@@ -192,6 +205,36 @@ MIGRATIONS = {
|
||||
}
|
||||
|
||||
|
||||
def migrate_documents(con):
|
||||
"""Bestanden van vóór de documenten (v0.0.7): elk bestand wordt versie 1 van een eigen document; een woningontwerp
|
||||
een woning, de PDF's (werk/klant) tekeningen van die woning, en het gekozen model de gekozen woning."""
|
||||
rows = con.execute('SELECT id, contact_id, original, title, kind, design_id, created_at FROM files '
|
||||
'WHERE document_id IS NULL AND contact_id IS NOT NULL ORDER BY id').fetchall()
|
||||
if not rows:
|
||||
return
|
||||
woning = {}
|
||||
add = lambda kind, title, cid, ts, design=None: con.execute(
|
||||
'INSERT INTO documents (contact_id, kind, title, design_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
(cid, kind, title, design, ts, ts)).lastrowid
|
||||
for fid, cid, original, title, kind, _, ts in rows:
|
||||
if title and not kind and original.lower().endswith('.json'):
|
||||
woning[fid] = add('woning', title, cid, ts)
|
||||
con.execute('UPDATE files SET document_id=?, version=1 WHERE id=?', (woning[fid], fid))
|
||||
for fid, cid, original, title, kind, design, ts in rows:
|
||||
if fid in woning:
|
||||
continue
|
||||
if kind in ('werk', 'klant') and design in woning:
|
||||
name = con.execute('SELECT title FROM documents WHERE id=?', (woning[design],)).fetchone()[0]
|
||||
did = add(kind, f"{name} {'werktekening' if kind == 'werk' else 'klant-PDF'}", cid, ts, woning[design])
|
||||
con.execute('UPDATE files SET document_id=?, version=1, source_file_id=? WHERE id=?', (did, design, fid))
|
||||
else:
|
||||
did = add('bestand', original, cid, ts)
|
||||
con.execute('UPDATE files SET document_id=?, version=1 WHERE id=?', (did, fid))
|
||||
for fid, did in woning.items():
|
||||
con.execute('UPDATE contacts SET design_id=? WHERE design_file_id=? AND design_id IS NULL', (did, fid))
|
||||
print(f'{len(rows)} bestand(en) omgezet naar documenten met versies')
|
||||
|
||||
|
||||
def init_db():
|
||||
con = sqlite3.connect(DB_PATH)
|
||||
with open(os.path.join(BASE, 'schema.sql')) as f:
|
||||
@@ -212,9 +255,11 @@ def init_db():
|
||||
"INSERT INTO relations (kind, type, name, created_at, updated_at) VALUES ('partner', 'Leverancier', ?, ?, ?)",
|
||||
(name, now(), now())).lastrowid
|
||||
con.execute('UPDATE products SET supplier_id=? WHERE supplier=? AND supplier_id IS NULL', (rid, name))
|
||||
con.execute('CREATE INDEX IF NOT EXISTS idx_files_document ON files(document_id, version)')
|
||||
# woningontwerpen van vóór het gekozen model: titel uit de JSON, het nieuwste wordt het gekozen model
|
||||
for fid, cid, stored in con.execute("SELECT id, contact_id, stored FROM files WHERE original LIKE '%.json' "
|
||||
"AND (title IS NULL OR title='') ORDER BY created_at DESC, id DESC").fetchall():
|
||||
"AND (title IS NULL OR title='') AND document_id IS NULL "
|
||||
"ORDER BY created_at DESC, id DESC").fetchall():
|
||||
try:
|
||||
with open(os.path.join(UPLOADS, os.path.basename(stored)), encoding='utf-8-sig') as f:
|
||||
design = json.load(f)
|
||||
@@ -224,6 +269,7 @@ def init_db():
|
||||
title = str((design.get('project') or {}).get('naam') or '').strip() or 'Woningontwerp'
|
||||
con.execute('UPDATE files SET title=? WHERE id=?', (title, fid))
|
||||
con.execute('UPDATE contacts SET design_file_id=? WHERE id=? AND design_file_id IS NULL', (fid, cid))
|
||||
migrate_documents(con)
|
||||
# vóór de mailboxen stond één mailbox in de instellingen: die wordt de eerste (gedeelde) mailbox, met de
|
||||
# bestaande e-mail erin (uid krijgt het mailbox-voorvoegsel, zoals do_sync het nu maakt)
|
||||
if not con.execute('SELECT COUNT(*) FROM mailboxes').fetchone()[0]:
|
||||
@@ -434,19 +480,84 @@ def convert_contact(contact_id, relation_id=None, new_relation=''):
|
||||
link_relation(contact_id, rid)
|
||||
|
||||
|
||||
def save_upload(fs, contact_id=None):
|
||||
def store_upload(fs):
|
||||
"""Sla een geüpload bestand op in de uploadmap; geeft de opgeslagen naam (of None zonder bestand)."""
|
||||
if not fs or not fs.filename:
|
||||
return None
|
||||
ext = os.path.splitext(secure_filename(fs.filename))[1].lower()
|
||||
stored = uuid.uuid4().hex + ext
|
||||
path = os.path.join(UPLOADS, stored)
|
||||
fs.save(path)
|
||||
if contact_id:
|
||||
cur = db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) '
|
||||
'VALUES (?, ?, ?, ?, ?, ?)',
|
||||
(contact_id, stored, fs.filename[:200], fs.mimetype or '', os.path.getsize(path), now()))
|
||||
if ext == '.json':
|
||||
note_design(contact_id, path, cur.lastrowid)
|
||||
stored = uuid.uuid4().hex + os.path.splitext(secure_filename(fs.filename))[1].lower()
|
||||
fs.save(os.path.join(UPLOADS, stored))
|
||||
return stored
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- documenten en versies
|
||||
# Een document hoort bij een contact of een relatie en heeft een of meer versies (rijen in files, versie 1, 2, …;
|
||||
# de hoogste is de huidige). Soorten: zie DOC_KINDS. Een woning is een house-design-JSON uit Tectum; werk/klant zijn
|
||||
# de PDF's van een woning (design_id), elk gemaakt van een bepaalde woningversie (files.source_file_id).
|
||||
|
||||
DOC_KINDS = {'bestand': 'Bestand', 'woning': 'Woning', 'werk': 'Werktekening', 'klant': 'Klant-PDF'}
|
||||
PDF_KINDS = {'werk': 'werktekening', 'klant': 'klant-PDF'}
|
||||
FILE_COLS = ('f.id, f.document_id, f.version, f.stored, f.original, f.mime, f.size, f.note, f.created_at, '
|
||||
'f.source_file_id, f.user_id')
|
||||
|
||||
|
||||
def create_document(kind, title, contact_id=None, relation_id=None, design_id=None):
|
||||
ts = now()
|
||||
return db().execute('INSERT INTO documents (contact_id, relation_id, kind, title, design_id, created_at, updated_at) '
|
||||
'VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
(contact_id, relation_id, kind, str(title or '')[:200], design_id, ts, ts)).lastrowid
|
||||
|
||||
|
||||
def add_version(doc_id, stored, original, mime='', note='', source_file_id=None):
|
||||
"""Nieuwe versie van een document (het bestand staat al in de uploadmap); geeft (file_id, versienummer)."""
|
||||
doc = q1('SELECT contact_id FROM documents WHERE id=?', doc_id)
|
||||
version = scalar('SELECT COALESCE(MAX(version), 0) + 1 FROM files WHERE document_id=?', doc_id)
|
||||
ts = now()
|
||||
cur = db().execute(
|
||||
'INSERT INTO files (contact_id, stored, original, mime, size, created_at, document_id, version, note, user_id, '
|
||||
'source_file_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
(doc['contact_id'], stored, str(original or stored)[:200], mime or mimetypes.guess_type(stored)[0] or '',
|
||||
os.path.getsize(os.path.join(UPLOADS, stored)), ts, doc_id, version, str(note or '')[:500],
|
||||
current_user_id(), source_file_id))
|
||||
db().execute('UPDATE documents SET updated_at=? WHERE id=?', (ts, doc_id))
|
||||
return cur.lastrowid, version
|
||||
|
||||
|
||||
def latest_file(doc_id):
|
||||
return q1(f'SELECT {FILE_COLS} FROM files f WHERE f.document_id=? ORDER BY f.version DESC LIMIT 1', doc_id)
|
||||
|
||||
|
||||
def doc_log(doc, text):
|
||||
"""Regel in de tijdlijn van het contact van een document (bij een relatie: geen tijdlijn)."""
|
||||
if doc['contact_id']:
|
||||
log(doc['contact_id'], 'file', text)
|
||||
|
||||
|
||||
def save_upload(fs, contact_id=None, relation_id=None, document_id=None, note=''):
|
||||
"""Upload opslaan. Zonder contact/relatie/document alleen het bestand (productafbeelding). Met een document een
|
||||
nieuwe versie daarvan; anders een nieuw document (een woningontwerp uit Tectum wordt een woning).
|
||||
Geeft de opgeslagen naam, of None (geen bestand, of geen woningontwerp als nieuwe versie van een woning)."""
|
||||
stored = store_upload(fs)
|
||||
if not stored or not (contact_id or relation_id or document_id):
|
||||
return stored
|
||||
design = read_design(os.path.join(UPLOADS, stored)) if stored.endswith('.json') else None
|
||||
if document_id:
|
||||
doc = q1('SELECT * FROM documents WHERE id=?', document_id)
|
||||
if not doc or (doc['kind'] == 'woning' and not design):
|
||||
remove_upload(stored)
|
||||
return None
|
||||
fid, version = add_version(doc['id'], stored, fs.filename, fs.mimetype, note)
|
||||
if doc['kind'] == 'woning':
|
||||
note_design(doc, design, version, note)
|
||||
else:
|
||||
doc_log(doc, f"{doc['title']}: versie {version}" + (f' ({note})' if note else ''))
|
||||
return stored
|
||||
if design:
|
||||
did = create_document('woning', design_title(design), contact_id, relation_id)
|
||||
add_version(did, stored, fs.filename, 'application/json', note)
|
||||
note_design(q1('SELECT * FROM documents WHERE id=?', did), design, 1, note)
|
||||
else:
|
||||
did = create_document('bestand', fs.filename, contact_id, relation_id)
|
||||
add_version(did, stored, fs.filename, fs.mimetype, note)
|
||||
return stored
|
||||
|
||||
|
||||
@@ -464,51 +575,105 @@ def design_title(design):
|
||||
return str((design.get('project') or {}).get('naam') or '').strip() or 'Woningontwerp'
|
||||
|
||||
|
||||
def note_design(contact_id, path, file_id):
|
||||
"""Een woningontwerp uit de configurator wordt het gekozen model van het contact (het nieuwste telt),
|
||||
met een regel in de tijdlijn."""
|
||||
design = read_design(path)
|
||||
if not design:
|
||||
def note_design(doc, design, version, note=''):
|
||||
"""Tijdlijn bij een woning(versie). Een nieuwe woning wordt de gekozen woning van het contact (de nieuwste telt);
|
||||
een nieuwe versie verandert de keuze niet."""
|
||||
if not doc['contact_id']:
|
||||
return
|
||||
project = design.get('project') or {}
|
||||
title = design_title(design)
|
||||
db().execute('UPDATE files SET title=? WHERE id=?', (title, file_id))
|
||||
db().execute('UPDATE contacts SET design_file_id=?, updated_at=? WHERE id=?', (file_id, now(), contact_id))
|
||||
parts = [f'Woningontwerp: {title} (gekozen model)',
|
||||
if version == 1:
|
||||
db().execute('UPDATE contacts SET design_id=?, updated_at=? WHERE id=?', (doc['id'], now(), doc['contact_id']))
|
||||
head = f"Woning: {doc['title']} (gekozen woning)"
|
||||
else:
|
||||
head = f"Woning {doc['title']}: versie {version}"
|
||||
parts = [head + (f' — {note}' if note else ''),
|
||||
' '.join(filter(None, [project.get('straat'), project.get('nummer'), project.get('plaats')])),
|
||||
project.get('opmerking') or '']
|
||||
log(contact_id, 'file', '\n'.join(p for p in parts if p))
|
||||
project.get('opmerking') or '' if version == 1 else '']
|
||||
log(doc['contact_id'], 'file', '\n'.join(p for p in parts if p))
|
||||
|
||||
|
||||
PDF_KINDS = {'werk': 'werktekening', 'klant': 'klant-PDF'}
|
||||
def save_design_json(design, contact_id=None, document_id=None, note='', relation_id=None, original=None):
|
||||
"""Woningontwerp (dict) bewaren: nieuwe woning, of een nieuwe versie van `document_id`. Geeft (doc_id, file_id, versie)."""
|
||||
stored = uuid.uuid4().hex + '.json'
|
||||
with open(os.path.join(UPLOADS, stored), 'w', encoding='utf-8') as f:
|
||||
json.dump(design, f, ensure_ascii=False)
|
||||
name = original or (secure_filename(design_title(design)) or 'ontwerp') + '.json'
|
||||
doc_id = document_id or create_document('woning', design_title(design), contact_id, relation_id)
|
||||
fid, version = add_version(doc_id, stored, name, 'application/json', note)
|
||||
note_design(q1('SELECT * FROM documents WHERE id=?', doc_id), design, version, note)
|
||||
return doc_id, fid, version
|
||||
|
||||
|
||||
def design_pdfs(design_ids):
|
||||
"""PDF's (werktekening, klant) per woningontwerp: {design_id: {kind: {url, date, id}}}."""
|
||||
def documents_of(contact_id=None, relation_id=None, contact_ids=None, kinds=None):
|
||||
"""Documenten met hun versies (nieuwste eerst): [{doc, latest, versions, …}], recentst gewijzigd eerst."""
|
||||
where, args = [], []
|
||||
if contact_id:
|
||||
where.append('d.contact_id=?'); args.append(contact_id)
|
||||
if relation_id:
|
||||
where.append('d.relation_id=?'); args.append(relation_id)
|
||||
if contact_ids is not None:
|
||||
where.append(f'd.contact_id IN {id_list(contact_ids or [0])}'); args += contact_ids or [0]
|
||||
sql = (f"SELECT d.*, c.name AS contact_name FROM documents d LEFT JOIN contacts c ON c.id=d.contact_id "
|
||||
f"WHERE ({' OR '.join(where) or '1=0'})")
|
||||
if kinds:
|
||||
sql += f' AND d.kind IN {id_list(kinds)}'
|
||||
args += list(kinds)
|
||||
docs = q(sql + ' ORDER BY d.updated_at DESC, d.id DESC', *args)
|
||||
files = {}
|
||||
if docs:
|
||||
for f in q(f'SELECT {FILE_COLS}, u.name AS user_name FROM files f LEFT JOIN users u ON u.id=f.user_id '
|
||||
f'WHERE f.document_id IN {id_list([d["id"] for d in docs])} ORDER BY f.version DESC',
|
||||
*[d['id'] for d in docs]):
|
||||
files.setdefault(f['document_id'], []).append(f)
|
||||
out = []
|
||||
for d in docs:
|
||||
versions = files.get(d['id'], [])
|
||||
if versions:
|
||||
out.append({**dict(d), 'latest': versions[0], 'versions': versions})
|
||||
return out
|
||||
|
||||
|
||||
def design_pdfs(design_ids, current_only=False):
|
||||
"""PDF's (werktekening, klant) per woning: {woning_id: {kind: {url, date, id, version, current}}}. current = gemaakt
|
||||
van de nieuwste woningversie; met current_only alleen die (de viewer maakt de rest dan opnieuw)."""
|
||||
out = {}
|
||||
if design_ids:
|
||||
for f in q(f"SELECT * FROM files WHERE kind IN ('werk', 'klant') AND design_id IN ({', '.join('?' * len(design_ids))})",
|
||||
if not design_ids:
|
||||
return out
|
||||
latest = {d: (latest_file(d) or {'id': None})['id'] for d in design_ids}
|
||||
for d in q(f"SELECT * FROM documents WHERE kind IN ('werk', 'klant') AND design_id IN {id_list(design_ids)}",
|
||||
*design_ids):
|
||||
out.setdefault(f['design_id'], {})[f['kind']] = {
|
||||
'url': url_for('upload_file', name=f['stored']), 'date': fmt_dt(f['created_at']), 'id': f['id']}
|
||||
f = latest_file(d['id'])
|
||||
if not f:
|
||||
continue
|
||||
current = f['source_file_id'] == latest[d['design_id']]
|
||||
if current or not current_only:
|
||||
out.setdefault(d['design_id'], {})[d['kind']] = {
|
||||
'url': url_for('upload_file', name=f['stored']), 'date': fmt_dt(f['created_at']), 'id': f['id'],
|
||||
'version': f['version'], 'current': current}
|
||||
return out
|
||||
|
||||
|
||||
def contact_designs(contact_id):
|
||||
"""Alle woningontwerpen bij een contact, nieuwste eerst."""
|
||||
return [f for f in q('SELECT * FROM files WHERE contact_id=? AND original LIKE ? ORDER BY created_at DESC, id DESC',
|
||||
contact_id, '%.json') if f['title']]
|
||||
"""Alle woningversies bij een contact, nieuwste eerst (voor het bestek: id = de versie, title met versienummer)."""
|
||||
return q("SELECT f.id, f.stored, f.original, f.created_at, f.version, f.document_id, "
|
||||
"d.title || ' · versie ' || f.version AS title, d.title AS design_title FROM files f "
|
||||
"JOIN documents d ON d.id=f.document_id WHERE d.contact_id=? AND d.kind='woning' "
|
||||
"ORDER BY d.updated_at DESC, f.version DESC", contact_id)
|
||||
|
||||
|
||||
def save_design_json(design, contact_id):
|
||||
stored = uuid.uuid4().hex + '.json'
|
||||
path = os.path.join(UPLOADS, stored)
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(design, f, ensure_ascii=False)
|
||||
name = secure_filename(str((design.get('project') or {}).get('naam') or 'ontwerp')) or 'ontwerp'
|
||||
cur = db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
(contact_id, stored, f'{name}.json', 'application/json', os.path.getsize(path), now()))
|
||||
note_design(contact_id, path, cur.lastrowid)
|
||||
def chosen_design_file(contact):
|
||||
"""Nieuwste versie van de gekozen woning van een contact, of None."""
|
||||
return latest_file(contact['design_id']) if contact and contact['design_id'] else None
|
||||
|
||||
|
||||
def remove_document(doc_id):
|
||||
"""Document met alle versies en (bij een woning) zijn tekeningen weg, ook van schijf."""
|
||||
for f in q('SELECT f.stored FROM files f JOIN documents d ON d.id=f.document_id WHERE d.id=? OR d.design_id=?',
|
||||
doc_id, doc_id):
|
||||
remove_upload(f['stored'])
|
||||
db().execute('DELETE FROM files WHERE document_id IN (SELECT id FROM documents WHERE id=? OR design_id=?)',
|
||||
(doc_id, doc_id))
|
||||
db().execute('DELETE FROM documents WHERE id=? OR design_id=?', (doc_id, doc_id))
|
||||
|
||||
|
||||
def remove_upload(stored):
|
||||
@@ -795,7 +960,7 @@ def dashboard():
|
||||
# ---------------------------------------------------------------- contacten
|
||||
|
||||
CONTACT_FROM = ('FROM contacts c LEFT JOIN users u ON u.id=c.assigned_user_id '
|
||||
'LEFT JOIN relations r ON r.id=c.relation_id LEFT JOIN files f ON f.id=c.design_file_id')
|
||||
'LEFT JOIN relations r ON r.id=c.relation_id LEFT JOIN documents f ON f.id=c.design_id')
|
||||
CONTACT_HIDDEN = ('portal_token', 'portal_password_hash')
|
||||
|
||||
|
||||
@@ -864,6 +1029,8 @@ def contacts_export():
|
||||
|
||||
|
||||
def delete_contact(cid):
|
||||
for d in q('SELECT id FROM documents WHERE contact_id=?', cid):
|
||||
remove_document(d['id'])
|
||||
for f in q('SELECT stored FROM files WHERE contact_id=?', cid):
|
||||
remove_upload(f['stored'])
|
||||
db().execute('DELETE FROM contacts WHERE id=?', (cid,))
|
||||
@@ -947,33 +1114,73 @@ def all_relations():
|
||||
return q('SELECT id, kind, type, name FROM relations ORDER BY kind, name')
|
||||
|
||||
|
||||
KAVEL_LINK = re.compile(r'https?://\S+?/(?:kavel|kaart)/\?(\S*\bid=[^\s]+)')
|
||||
|
||||
|
||||
def kaart_src(address='', link_query=''):
|
||||
"""Adres van de kaart (kaart-app) voor een kavellink uit een lead, of anders de eerste treffer voor het adres."""
|
||||
if link_query:
|
||||
return f'{KAART_URL}?{link_query}'
|
||||
return f'{KAART_URL}?{urlencode({"adres": address})}' if address else ''
|
||||
|
||||
|
||||
def full_address(row, design=None):
|
||||
"""Volledig adres (straat + nummer, postcode/plaats) van een contact of relatie; anders dat uit het
|
||||
woningontwerp. Leeg als het niet bekend is (dan geen kaart)."""
|
||||
street, place = (row['address'] or '').strip(), ' '.join(filter(None, [(row['postcode'] or '').strip(),
|
||||
(row['city'] or '').strip()]))
|
||||
if street and place and re.search(r'\d', street):
|
||||
return f'{street}, {place}'
|
||||
project = (design or {}).get('project') or {}
|
||||
street = ' '.join(filter(None, [str(project.get('straat') or '').strip(), str(project.get('nummer') or '').strip()]))
|
||||
place = ' '.join(filter(None, [str(project.get('postcode') or '').strip(), str(project.get('plaats') or '').strip()]))
|
||||
return f'{street}, {place}' if street and place and re.search(r'\d', street) else ''
|
||||
|
||||
|
||||
def contact_kavel_link(cid):
|
||||
"""Querystring van de nieuwste kavellink in de tijdlijn (lead uit de kaveltoets), met perceel en woningen."""
|
||||
for a in q("SELECT body FROM activities WHERE contact_id=? AND (body LIKE '%/kavel/?%' OR body LIKE '%/kaart/?%') "
|
||||
"ORDER BY created_at DESC, id DESC", cid):
|
||||
m = KAVEL_LINK.search(a['body'])
|
||||
if m:
|
||||
return m.group(1)
|
||||
return ''
|
||||
|
||||
|
||||
def design_meta(design_file):
|
||||
d = read_design(os.path.join(UPLOADS, os.path.basename(design_file['stored']))) or {}
|
||||
project = d.get('project') or {}
|
||||
return d, {'units': len(d.get('units') or []), 'exported': (d.get('exportedAt') or '')[:19],
|
||||
'address': ' '.join(filter(None, [project.get('straat'), project.get('nummer'), project.get('plaats')])),
|
||||
'note': project.get('opmerking') or '', 'version': d.get('configurator') or ''}
|
||||
|
||||
|
||||
@app.route('/contacts/<int:cid>')
|
||||
def contact_view(cid):
|
||||
c = q1('SELECT c.*, u.name AS user_name, r.name AS relation_name, r.kind AS relation_kind, r.type AS relation_type '
|
||||
'FROM contacts c LEFT JOIN users u ON u.id=c.assigned_user_id LEFT JOIN relations r ON r.id=c.relation_id '
|
||||
'WHERE c.id=?', cid) or abort(404)
|
||||
designs = contact_designs(cid)
|
||||
design = next((f for f in designs if f['id'] == c['design_file_id']), None)
|
||||
design_meta = {}
|
||||
if design:
|
||||
d = read_design(os.path.join(UPLOADS, design['stored'])) or {}
|
||||
project = d.get('project') or {}
|
||||
design_meta = {'units': len(d.get('units') or []), 'exported': (d.get('exportedAt') or '')[:19],
|
||||
'address': ' '.join(filter(None, [project.get('straat'), project.get('nummer'),
|
||||
project.get('plaats')])),
|
||||
'note': project.get('opmerking') or '', 'version': d.get('configurator') or ''}
|
||||
docs = documents_of(contact_id=cid)
|
||||
woningen = [d for d in docs if d['kind'] == 'woning']
|
||||
woning = next((d for d in woningen if d['id'] == c['design_id']), None)
|
||||
# welke versie in de viewer: ?versie=<file-id> van deze woning, anders de nieuwste
|
||||
shown = woning and next((v for v in woning['versions'] if v['id'] == request.args.get('versie', type=int)),
|
||||
woning['latest'])
|
||||
design, meta = design_meta(shown) if shown else ({}, {})
|
||||
return render_template(
|
||||
'contact.html', c=c, extra=json.loads(c['extra'] or '{}'),
|
||||
mail_templates=all_mail_templates(), mail_vars=mail_vars(c),
|
||||
activities=q('SELECT a.*, u.name AS user_name FROM activities a LEFT JOIN users u ON u.id=a.user_id '
|
||||
'WHERE a.contact_id=? ORDER BY a.created_at DESC, a.id DESC', cid),
|
||||
files=q('SELECT * FROM files WHERE contact_id=? ORDER BY created_at DESC', cid),
|
||||
documents=docs, DOC_KINDS=DOC_KINDS,
|
||||
emails=q(f'SELECT * FROM emails e WHERE e.contact_id=? AND {visible_mail()} ORDER BY e.date DESC LIMIT 50', cid),
|
||||
mailboxes=user_mailboxes(sending=True),
|
||||
tasks=q('SELECT t.*, u.name AS user_name FROM tasks t LEFT JOIN users u ON u.id=t.user_id '
|
||||
'WHERE t.contact_id=? ORDER BY t.done, t.due_date IS NULL, t.due_date', cid),
|
||||
designs=designs, design=design, design_meta=design_meta,
|
||||
pdfs=design_pdfs([design['id']]).get(design['id'], {}) if design else {}, PDF_KINDS=PDF_KINDS,
|
||||
woningen=woningen, woning=woning, shown=shown, design_meta=meta,
|
||||
pdfs=design_pdfs([woning['id']]).get(woning['id'], {}) if woning else {}, PDF_KINDS=PDF_KINDS,
|
||||
tectum_url=f"{TECTUM_URL}/?woning={woning['id']}" if woning and TECTUM_URL else '',
|
||||
kaart=kaart_src(full_address(c, design), contact_kavel_link(cid)),
|
||||
quotes=quotes_with_totals('WHERE qu.contact_id=?', cid),
|
||||
appointments=calendar_items(datetime.combine(date.today(), datetime.min.time()),
|
||||
datetime.combine(date.today() + timedelta(days=366), datetime.min.time()),
|
||||
@@ -1042,9 +1249,10 @@ def contact_note(cid):
|
||||
@app.post('/contacts/<int:cid>/upload')
|
||||
def contact_upload(cid):
|
||||
get_or_404('contacts', cid)
|
||||
count = sum(1 for fs in request.files.getlist('files') if save_upload(fs, cid))
|
||||
note = request.form.get('note', '').strip()
|
||||
count = sum(1 for fs in request.files.getlist('files') if save_upload(fs, cid, note=note))
|
||||
if count:
|
||||
log(cid, 'file', f'{count} bestand(en) toegevoegd')
|
||||
log(cid, 'file', f'{count} document(en) toegevoegd' + (f' ({note})' if note else ''))
|
||||
db().commit()
|
||||
return redirect(url_for('contact_view', cid=cid))
|
||||
|
||||
@@ -1118,45 +1326,100 @@ def upload_file(name):
|
||||
return serve_upload(name, f['original'] if f else None)
|
||||
|
||||
|
||||
def doc_back(doc):
|
||||
if doc['contact_id']:
|
||||
return redirect(url_for('contact_view', cid=doc['contact_id']))
|
||||
if doc['relation_id']:
|
||||
return redirect(url_for('relation_view', rid=doc['relation_id']))
|
||||
return redirect(back(url_for('dashboard')))
|
||||
|
||||
|
||||
def doc_or_404(did):
|
||||
return get_or_404('documents', did)
|
||||
|
||||
|
||||
@app.post('/documents/<int:did>/version')
|
||||
def document_version(did):
|
||||
"""Nieuwe versie van een document uploaden (bij een woning: een woningontwerp uit Tectum)."""
|
||||
doc = doc_or_404(did)
|
||||
if not save_upload(request.files.get('file'), document_id=did, note=request.form.get('note', '').strip()):
|
||||
flash('Kies een bestand' + (' met een woningontwerp uit Tectum (JSON).' if doc['kind'] == 'woning' else '.'),
|
||||
'error')
|
||||
else:
|
||||
flash('Nieuwe versie opgeslagen.')
|
||||
db().commit()
|
||||
return doc_back(doc)
|
||||
|
||||
|
||||
@app.post('/documents/<int:did>/delete')
|
||||
def document_delete(did):
|
||||
"""Document met alle versies verwijderen (bij een woning ook de tekeningen)."""
|
||||
doc = doc_or_404(did)
|
||||
remove_document(did)
|
||||
if doc['kind'] == 'woning' and doc['contact_id']:
|
||||
c = q1('SELECT design_id FROM contacts WHERE id=?', doc['contact_id'])
|
||||
if c and c['design_id'] is None:
|
||||
rest = q("SELECT id FROM documents WHERE contact_id=? AND kind='woning' ORDER BY updated_at DESC LIMIT 1",
|
||||
doc['contact_id'])
|
||||
db().execute('UPDATE contacts SET design_id=? WHERE id=?', (rest[0]['id'] if rest else None, doc['contact_id']))
|
||||
doc_log(doc, f"{DOC_KINDS.get(doc['kind'], 'Document')} verwijderd: {doc['title']}")
|
||||
db().commit()
|
||||
return doc_back(doc)
|
||||
|
||||
|
||||
@app.post('/files/<int:fid>/delete')
|
||||
def file_delete(fid):
|
||||
"""Eén versie verwijderen; de laatste versie weg = het hele document weg."""
|
||||
f = get_or_404('files', fid)
|
||||
chosen = f['contact_id'] and scalar('SELECT design_file_id IS ? FROM contacts WHERE id=?', fid, f['contact_id'])
|
||||
for pdf in q('SELECT stored FROM files WHERE design_id=?', fid):
|
||||
remove_upload(pdf['stored'])
|
||||
db().execute('DELETE FROM files WHERE design_id=?', (fid,))
|
||||
doc = q1('SELECT * FROM documents WHERE id=?', f['document_id']) if f['document_id'] else None
|
||||
if not doc:
|
||||
remove_upload(f['stored'])
|
||||
db().execute('DELETE FROM files WHERE id=?', (fid,))
|
||||
if chosen:
|
||||
rest = contact_designs(f['contact_id'])
|
||||
db().execute('UPDATE contacts SET design_file_id=? WHERE id=?', (rest[0]['id'] if rest else None, f['contact_id']))
|
||||
db().commit()
|
||||
return redirect(url_for('contact_view', cid=f['contact_id']))
|
||||
return redirect(back(url_for('dashboard')))
|
||||
if scalar('SELECT COUNT(*) FROM files WHERE document_id=?', doc['id']) <= 1:
|
||||
return document_delete(doc['id'])
|
||||
for pdf in q('SELECT stored, id FROM files WHERE source_file_id=?', fid):
|
||||
remove_upload(pdf['stored'])
|
||||
db().execute('DELETE FROM files WHERE id=?', (pdf['id'],))
|
||||
remove_upload(f['stored'])
|
||||
db().execute('DELETE FROM files WHERE id=?', (fid,))
|
||||
db().execute("DELETE FROM documents WHERE kind IN ('werk', 'klant') AND design_id=? "
|
||||
"AND NOT EXISTS (SELECT 1 FROM files WHERE document_id=documents.id)", (doc['id'],))
|
||||
doc_log(doc, f"{doc['title']}: versie {f['version']} verwijderd")
|
||||
db().commit()
|
||||
return doc_back(doc)
|
||||
|
||||
|
||||
@app.post('/contacts/<int:cid>/design')
|
||||
def contact_design(cid):
|
||||
"""Gekozen model: een van de woningontwerpen bij het contact (of geen)."""
|
||||
"""Gekozen woning: een van de woningen bij het contact (of geen)."""
|
||||
c = get_or_404('contacts', cid)
|
||||
fid = request.form.get('file_id', type=int)
|
||||
f = next((d for d in contact_designs(cid) if d['id'] == fid), None)
|
||||
if (f['id'] if f else None) != c['design_file_id']:
|
||||
db().execute('UPDATE contacts SET design_file_id=?, updated_at=? WHERE id=?', (f['id'] if f else None, now(), cid))
|
||||
log(cid, 'status', f"Gekozen model: {f['title']}" if f else 'Geen gekozen model meer')
|
||||
did = request.form.get('document_id', type=int)
|
||||
d = q1("SELECT * FROM documents WHERE id=? AND contact_id=? AND kind='woning'", did, cid) if did else None
|
||||
if (d['id'] if d else None) != c['design_id']:
|
||||
db().execute('UPDATE contacts SET design_id=?, updated_at=? WHERE id=?', (d['id'] if d else None, now(), cid))
|
||||
log(cid, 'status', f"Gekozen woning: {d['title']}" if d else 'Geen gekozen woning meer')
|
||||
db().commit()
|
||||
return redirect(url_for('contact_view', cid=cid))
|
||||
|
||||
|
||||
@app.route('/files/<int:fid>/viewer')
|
||||
def design_viewer(fid):
|
||||
"""3D-viewer van een woningontwerp (in een iframe op de contactpagina, of los)."""
|
||||
"""3D-viewer van een woningversie (in een iframe op de contactpagina, of los). PDF's maken (en bij het openen
|
||||
vanzelf aanvullen) alleen bij de nieuwste versie."""
|
||||
f = get_or_404('files', fid)
|
||||
design = read_design(os.path.join(UPLOADS, os.path.basename(f['stored']))) or abort(404)
|
||||
pdfs = design_pdfs([fid]).get(fid, {})
|
||||
cfg = {'pdfUrl': url_for('design_pdf', fid=fid) if f['contact_id'] else '', 'pdfs': pdfs,
|
||||
'auto': len(pdfs) < len(PDF_KINDS)}
|
||||
doc = q1('SELECT * FROM documents WHERE id=?', f['document_id']) if f['document_id'] else None
|
||||
latest = doc and doc['kind'] == 'woning' and latest_file(doc['id'])['id'] == fid
|
||||
pdfs = design_pdfs([doc['id']], current_only=True).get(doc['id'], {}) if latest else {}
|
||||
cfg = {'pdfUrl': url_for('design_pdf', fid=fid) if latest else '', 'pdfs': pdfs,
|
||||
'auto': bool(latest) and len(pdfs) < len(PDF_KINDS)}
|
||||
name = (doc['title'] if doc else '') or design_title(design)
|
||||
if doc and doc['kind'] == 'woning':
|
||||
name += f" · versie {f['version']}"
|
||||
resp = make_response(render_template(
|
||||
'viewer.html', design=design, name=f['title'] or design_title(design), scripts=VIEWER_SCRIPTS, cfg=cfg,
|
||||
'viewer.html', design=design, name=name, scripts=VIEWER_SCRIPTS, cfg=cfg,
|
||||
engine=os.path.exists(os.path.join(ENGINE, 'js', 'house', 'house-model.js'))))
|
||||
resp.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
||||
return resp
|
||||
@@ -1164,29 +1427,32 @@ def design_viewer(fid):
|
||||
|
||||
@app.post('/files/<int:fid>/pdf')
|
||||
def design_pdf(fid):
|
||||
"""PDF van een woningontwerp, gemaakt door de viewer (HousePdf van de configurator). Per ontwerp één werktekening
|
||||
(intern) en één klant-PDF (ook in het portaal); een nieuwe vervangt de vorige."""
|
||||
"""PDF van een woningversie, gemaakt door de viewer (HousePdf van de configurator). Per woning een document
|
||||
werktekening (intern) en klant-PDF (ook in het portaal), met een versie per woningversie; opnieuw maken van
|
||||
dezelfde woningversie vervangt die versie."""
|
||||
f = get_or_404('files', fid)
|
||||
doc = q1("SELECT * FROM documents WHERE id=? AND kind='woning'", f['document_id']) if f['document_id'] else None
|
||||
kind, fs = request.form.get('kind'), request.files.get('pdf')
|
||||
if kind not in PDF_KINDS or not fs or not f['contact_id'] or not f['title']:
|
||||
if kind not in PDF_KINDS or not fs or not doc:
|
||||
return jsonify(ok=False, error='Ongeldige PDF'), 400
|
||||
head = fs.stream.read(5)
|
||||
fs.stream.seek(0)
|
||||
if head != b'%PDF-':
|
||||
return jsonify(ok=False, error='Geen PDF'), 400
|
||||
for old in q('SELECT * FROM files WHERE design_id=? AND kind=?', fid, kind):
|
||||
pdf_doc = q1('SELECT * FROM documents WHERE design_id=? AND kind=?', doc['id'], kind)
|
||||
pdf_id = pdf_doc['id'] if pdf_doc else create_document(kind, f"{doc['title']} {PDF_KINDS[kind]}", doc['contact_id'],
|
||||
doc['relation_id'], doc['id'])
|
||||
for old in q('SELECT * FROM files WHERE document_id=? AND source_file_id=?', pdf_id, fid):
|
||||
remove_upload(old['stored'])
|
||||
db().execute('DELETE FROM files WHERE id=?', (old['id'],))
|
||||
stored = uuid.uuid4().hex + '.pdf'
|
||||
fs.save(os.path.join(UPLOADS, stored))
|
||||
name = secure_filename(f"{f['title']} {PDF_KINDS[kind]}") or 'ontwerp'
|
||||
cur = db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at, title, kind, design_id) '
|
||||
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
(f['contact_id'], stored, f'{name}.pdf', 'application/pdf',
|
||||
os.path.getsize(os.path.join(UPLOADS, stored)), now(), '', kind, fid))
|
||||
log(f['contact_id'], 'file', f"{PDF_KINDS[kind][:1].upper() + PDF_KINDS[kind][1:]} gemaakt van ontwerp {f['title']}")
|
||||
name = secure_filename(f"{doc['title']} v{f['version']} {PDF_KINDS[kind]}") or 'ontwerp'
|
||||
new_id, _ = add_version(pdf_id, stored, f'{name}.pdf', 'application/pdf', f"van versie {f['version']}", fid)
|
||||
doc_log(doc, f"{PDF_KINDS[kind][:1].upper() + PDF_KINDS[kind][1:]} gemaakt van woning {doc['title']} "
|
||||
f"(versie {f['version']})")
|
||||
db().commit()
|
||||
row = q1('SELECT * FROM files WHERE id=?', cur.lastrowid)
|
||||
row = q1('SELECT * FROM files WHERE id=?', new_id)
|
||||
return jsonify(ok=True, url=url_for('upload_file', name=stored), date=fmt_dt(row['created_at']), id=row['id'])
|
||||
|
||||
|
||||
@@ -1294,9 +1560,14 @@ def relation_view(rid):
|
||||
'WHERE c.relation_id=? ORDER BY c.name', rid)
|
||||
ids = [m['id'] for m in members] or [0]
|
||||
marks = ', '.join('?' * len(ids))
|
||||
designs = q(f'SELECT f.*, c.name AS contact_name, c.design_file_id = f.id AS chosen FROM files f '
|
||||
f"JOIN contacts c ON c.id=f.contact_id WHERE f.contact_id IN ({marks}) AND f.title<>'' "
|
||||
f"AND f.original LIKE '%.json' ORDER BY chosen DESC, f.created_at DESC", *ids)
|
||||
chosen = {m['design_id'] for m in members if m['design_id']}
|
||||
# woningen van de contactpersonen (gekozen eerst) en documenten van de relatie zelf
|
||||
designs = sorted(documents_of(contact_ids=ids, relation_id=rid, kinds=('woning',)), key=lambda d: d['id'] not in chosen)
|
||||
for d in designs:
|
||||
d['chosen'] = d['id'] in chosen
|
||||
docs = documents_of(relation_id=rid)
|
||||
design = next((d for d in designs if d['chosen']), designs[0] if designs else None)
|
||||
design_data = read_design(os.path.join(UPLOADS, design['latest']['stored'])) if design else None
|
||||
return render_template(
|
||||
'relation.html', r=r, members=members,
|
||||
candidates=q('SELECT id, name, email FROM contacts WHERE relation_id IS NULL ORDER BY name LIMIT 500'),
|
||||
@@ -1307,7 +1578,20 @@ def relation_view(rid):
|
||||
tasks=q(f'SELECT t.*, c.name AS contact_name FROM tasks t JOIN contacts c ON c.id=t.contact_id '
|
||||
f'WHERE t.done=0 AND t.contact_id IN ({marks}) ORDER BY t.due_date IS NULL, t.due_date', *ids),
|
||||
products=q('SELECT * FROM products WHERE supplier_id=? ORDER BY active DESC, name', rid),
|
||||
designs=designs, pdfs=design_pdfs([d['id'] for d in designs]), PDF_KINDS=PDF_KINDS)
|
||||
designs=designs, pdfs=design_pdfs([d['id'] for d in designs]), PDF_KINDS=PDF_KINDS,
|
||||
documents=docs, DOC_KINDS=DOC_KINDS,
|
||||
kaart=kaart_src(full_address(r, design_data)) if r['kind'] == 'klant' else '')
|
||||
|
||||
|
||||
@app.post('/relations/<int:rid>/upload')
|
||||
def relation_upload(rid):
|
||||
get_or_404('relations', rid)
|
||||
note = request.form.get('note', '').strip()
|
||||
count = sum(1 for fs in request.files.getlist('files') if save_upload(fs, relation_id=rid, note=note))
|
||||
if count:
|
||||
db().commit()
|
||||
flash(f'{count} document(en) toegevoegd.')
|
||||
return redirect(url_for('relation_view', rid=rid))
|
||||
|
||||
|
||||
@app.post('/relations/<int:rid>/member')
|
||||
@@ -1329,6 +1613,8 @@ def relation_member_remove(rid, cid):
|
||||
|
||||
|
||||
def delete_relation(rid):
|
||||
for d in q('SELECT id FROM documents WHERE relation_id=?', rid):
|
||||
remove_document(d['id'])
|
||||
for c in q('SELECT id FROM contacts WHERE relation_id=?', rid):
|
||||
link_relation(c['id'], None, '')
|
||||
db().execute("UPDATE products SET supplier_id=NULL WHERE supplier_id=?", (rid,))
|
||||
@@ -1808,7 +2094,7 @@ def mail_vars(contact=None, user=None):
|
||||
'bedrijfstelefoon': get_setting('company_phone'), 'website': get_setting('company_website')}
|
||||
if contact:
|
||||
name = (contact['name'] or '').strip()
|
||||
design = q1('SELECT title FROM files WHERE id=?', contact['design_file_id']) if contact['design_file_id'] else None
|
||||
design = q1('SELECT title FROM documents WHERE id=?', contact['design_id']) if contact['design_id'] else None
|
||||
values.update(voornaam=name.split()[0] if name else '', naam=name, email=contact['email'] or '',
|
||||
telefoon=contact['phone'] or '', bedrijf=contact['company'] or '', adres=contact['address'] or '',
|
||||
postcode=contact['postcode'] or '', plaats=contact['city'] or '',
|
||||
@@ -2134,7 +2420,7 @@ def segment_where(f):
|
||||
where.append('c.created_at<?')
|
||||
args.append((date.fromisoformat(f['created_to']) + timedelta(days=1)).isoformat())
|
||||
if f.get('has_design'):
|
||||
where.append('c.design_file_id IS NOT NULL')
|
||||
where.append('c.design_id IS NOT NULL')
|
||||
return ' AND '.join(where), args
|
||||
|
||||
|
||||
@@ -2381,7 +2667,7 @@ def campaign_test(c):
|
||||
ids = segment_ids(json.loads(seg['filters'] or '{}')) if seg else []
|
||||
contact = q1('SELECT * FROM contacts WHERE id=?', ids[0]) if ids else None
|
||||
contact = contact or {'id': 0, 'name': g.user['name'], 'email': g.user['email'], 'phone': '', 'company': '',
|
||||
'address': '', 'postcode': '', 'city': '', 'design_file_id': None}
|
||||
'address': '', 'postcode': '', 'city': '', 'design_id': None}
|
||||
cfg = mailbox_cfg(q1('SELECT * FROM mailboxes WHERE id=?', c['mailbox_id']))
|
||||
msg = campaign_message(c, cfg, contact, g.user['email'], g.user, request.url_root)
|
||||
msg.replace_header('Subject', f"[Test] {msg['Subject']}")
|
||||
@@ -2926,7 +3212,8 @@ def quote_bestek(qid):
|
||||
qt = get_or_404('quotes', qid)
|
||||
contact = q1('SELECT * FROM contacts WHERE id=?', qt['contact_id']) if qt['contact_id'] else None
|
||||
designs = contact_designs(contact['id']) if contact else []
|
||||
want = request.args.get('file', type=int) or qt['design_file_id'] or (contact['design_file_id'] if contact else None)
|
||||
chosen = chosen_design_file(contact)
|
||||
want = request.args.get('file', type=int) or qt['design_file_id'] or (chosen['id'] if chosen else None)
|
||||
f = next((d for d in designs if d['id'] == want), designs[0] if designs else None)
|
||||
if not f:
|
||||
flash('Er is geen woningontwerp bij het contact van deze offerte. Kies eerst een klant met een gekozen model.', 'error')
|
||||
@@ -3059,7 +3346,7 @@ def portal_home():
|
||||
return render_template(
|
||||
'portal.html', c=g.portal,
|
||||
quotes=quotes_with_totals("WHERE qu.contact_id=? AND qu.status<>'concept'", cid),
|
||||
files=q("SELECT * FROM files WHERE contact_id=? AND kind<>'werk' ORDER BY created_at DESC", cid),
|
||||
documents=[d for d in documents_of(contact_id=cid) if d['kind'] != 'werk'],
|
||||
messages=q("SELECT * FROM activities WHERE contact_id=? AND type='portal' AND body LIKE 'Bericht:%' "
|
||||
"ORDER BY created_at DESC LIMIT 20", cid),
|
||||
s=settings_dict())
|
||||
@@ -3112,7 +3399,8 @@ def portal_message():
|
||||
|
||||
@app.route('/portal/files/<name>')
|
||||
def portal_file(name):
|
||||
f = q1("SELECT * FROM files WHERE stored=? AND contact_id=? AND kind<>'werk'", name, g.portal['id'])
|
||||
f = q1("SELECT f.* FROM files f JOIN documents d ON d.id=f.document_id WHERE f.stored=? AND d.contact_id=? "
|
||||
"AND d.kind<>'werk'", name, g.portal['id'])
|
||||
if f is None:
|
||||
abort(404)
|
||||
return serve_upload(name, f['original'])
|
||||
@@ -3297,6 +3585,88 @@ def search_groups(term, limit):
|
||||
return groups
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- API voor Tectum
|
||||
# Tectum (de configurator) laadt woningen van leads en klanten en bewaart wijzigingen als nieuwe versie. Tectum staat
|
||||
# achter het wachtwoord van Caddy; Caddy stuurt /api/crm/* van Tectum hierheen met X-API-Key = TECTUM_CRM_KEY.
|
||||
# Zonder sleutel (leeg) staat de API uit.
|
||||
|
||||
def tectum_auth():
|
||||
key = request.headers.get('X-API-Key') or ''
|
||||
if not TECTUM_KEY or not secrets.compare_digest(key, TECTUM_KEY):
|
||||
abort(make_response(jsonify(ok=False, error='Ongeldige sleutel'), 401))
|
||||
|
||||
|
||||
def tectum_woning(d, latest):
|
||||
return {'id': d['id'], 'titel': d['title'], 'versie': latest['version'], 'datum': latest['created_at'],
|
||||
'opmerking': latest['note'] or '',
|
||||
'contact': {'id': d['contact_id'], 'naam': d['contact_name'] or '', 'soort': d['contact_type'] or '',
|
||||
'plaats': d['contact_city'] or ''} if d['contact_id'] else None,
|
||||
'relatie': d['relation_name'] or ''}
|
||||
|
||||
|
||||
TECTUM_FROM = ("FROM documents d LEFT JOIN contacts c ON c.id=d.contact_id "
|
||||
"LEFT JOIN relations r ON r.id=COALESCE(d.relation_id, c.relation_id) ")
|
||||
TECTUM_COLS = ("SELECT d.*, c.name AS contact_name, c.type AS contact_type, c.city AS contact_city, "
|
||||
"r.name AS relation_name ")
|
||||
|
||||
|
||||
@app.get('/api/tectum/woningen')
|
||||
def api_tectum_woningen():
|
||||
"""Woningen van leads en klanten, recentst gewijzigd eerst; ?q= zoekt op woning, contact, relatie en plaats."""
|
||||
tectum_auth()
|
||||
term = request.args.get('q', '').strip()
|
||||
sql, args = TECTUM_COLS + TECTUM_FROM + "WHERE d.kind='woning'", []
|
||||
if term:
|
||||
sql += ' AND (d.title LIKE ? OR c.name LIKE ? OR r.name LIKE ? OR c.city LIKE ?)'
|
||||
args += [f'%{term}%'] * 4
|
||||
out = []
|
||||
for d in q(sql + ' ORDER BY d.updated_at DESC LIMIT 100', *args):
|
||||
latest = latest_file(d['id'])
|
||||
if latest:
|
||||
out.append(tectum_woning(d, latest))
|
||||
return jsonify(ok=True, woningen=out)
|
||||
|
||||
|
||||
@app.get('/api/tectum/woningen/<int:did>')
|
||||
def api_tectum_woning(did):
|
||||
"""Eén woning met het ontwerp (house-design) van de nieuwste versie, of ?versie=<n>, en de lijst van versies."""
|
||||
tectum_auth()
|
||||
d = q1(TECTUM_COLS + TECTUM_FROM + "WHERE d.id=? AND d.kind='woning'", did)
|
||||
if not d:
|
||||
return jsonify(ok=False, error='Woning niet gevonden'), 404
|
||||
versions = q(f'SELECT {FILE_COLS} FROM files f WHERE f.document_id=? ORDER BY f.version DESC', did)
|
||||
want = request.args.get('versie', type=int)
|
||||
f = next((v for v in versions if v['version'] == want), versions[0] if versions else None)
|
||||
design = read_design(os.path.join(UPLOADS, f['stored'])) if f else None
|
||||
if not design:
|
||||
return jsonify(ok=False, error='Ontwerp niet leesbaar'), 404
|
||||
return jsonify(ok=True, woning={**tectum_woning(d, versions[0]), 'geladen': f['version']}, ontwerp=design,
|
||||
versies=[{'versie': v['version'], 'datum': v['created_at'], 'opmerking': v['note'] or ''}
|
||||
for v in versions])
|
||||
|
||||
|
||||
@app.post('/api/tectum/woningen/<int:did>/versies')
|
||||
def api_tectum_versie(did):
|
||||
"""Nieuwe versie van een woning vanuit Tectum. Body: {ontwerp, opmerking, basis}; basis = de versie die Tectum
|
||||
geladen had. Is er intussen een nieuwere versie, dan 409 (tenzij forceer: true)."""
|
||||
tectum_auth()
|
||||
data = request.get_json(silent=True) or {}
|
||||
design = data.get('ontwerp')
|
||||
if not isinstance(design, dict) or design.get('type') != 'house-design':
|
||||
return jsonify(ok=False, error='Geen woningontwerp (house-design)'), 400
|
||||
d = q1("SELECT * FROM documents WHERE id=? AND kind='woning'", did)
|
||||
if not d:
|
||||
return jsonify(ok=False, error='Woning niet gevonden'), 404
|
||||
current = latest_file(did)['version']
|
||||
if not data.get('forceer') and isinstance(data.get('basis'), int) and data['basis'] < current:
|
||||
return jsonify(ok=False, error=f'Er is intussen een nieuwere versie ({current})', versie=current), 409
|
||||
note = str(data.get('opmerking') or '').strip()[:500] or 'vanuit Tectum'
|
||||
_, _, version = save_design_json(design, document_id=did, note=note,
|
||||
original=f"{secure_filename(d['title']) or 'woning'}-v{current + 1}.json")
|
||||
db().commit()
|
||||
return jsonify(ok=True, versie=version)
|
||||
|
||||
|
||||
@app.route('/search')
|
||||
def search():
|
||||
term = request.args.get('q', '').strip()[:100]
|
||||
|
||||
+17
@@ -68,6 +68,23 @@ CREATE TABLE IF NOT EXISTS activities (
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Documenten bij een contact (lead/klant) of relatie, elk met een of meer versies (tabel files). Soort: bestand,
|
||||
-- woning (house-design-JSON uit Tectum; de nieuwste versie telt), werk/klant (PDF's van een woning: design_id).
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE,
|
||||
relation_id INTEGER REFERENCES relations(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL DEFAULT 'bestand',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
design_id INTEGER REFERENCES documents(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_contact ON documents(contact_id, kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_relation ON documents(relation_id, kind);
|
||||
|
||||
-- Bestanden: elke rij is één versie van een document (document_id, version). source_file_id bij een PDF = de
|
||||
-- woningversie waarvan hij gemaakt is. title/kind/design_id zijn van vóór de documenten (alleen nog voor de migratie).
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -248,6 +248,25 @@ dl.props dd { margin: 0; overflow-wrap: anywhere; white-space: pre-line; }
|
||||
.file .muted { padding: 0 0.5rem 0.35rem; font-size: 0.8rem; }
|
||||
.viewer-frame { aspect-ratio: 16 / 10; min-height: 320px; border: 1px solid var(--line); border-radius: var(--radius-md); overflow: hidden; background: var(--blue); }
|
||||
.viewer-frame iframe { display: block; width: 100%; height: 100%; border: 0; }
|
||||
.kaart-frame { height: 640px; max-height: 80vh; 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; }
|
||||
/* documenten met versies (contact, relatie, portaal) */
|
||||
.docs { margin-bottom: 1rem; }
|
||||
.doc { border-top: 1px solid var(--line); }
|
||||
.doc:first-child { border-top: 0; }
|
||||
.doc > summary { list-style: none; display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0; font: inherit; }
|
||||
.doc > summary::-webkit-details-marker { display: none; }
|
||||
.doc[open] > summary { margin-bottom: 0.25rem; }
|
||||
.doc .doc-icon { flex: 0 0 40px; height: 40px; border-radius: 10px; background: var(--blue); display: flex; align-items: center; justify-content: center; overflow: hidden; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; color: var(--ink); }
|
||||
.doc .doc-icon img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.doc .doc-main { min-width: 0; flex: 1; }
|
||||
.doc .doc-title { display: block; color: var(--ink); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.doc .doc-main .muted { display: block; font-size: 0.8rem; }
|
||||
.doc .versions { list-style: none; padding: 0 0 0 52px; margin: 0 0 0.5rem; font-size: 0.85rem; }
|
||||
.doc .versions li { display: flex; flex-wrap: wrap; gap: 0 0.5rem; align-items: baseline; padding: 0.2rem 0; }
|
||||
.doc .versions form { margin-left: auto; }
|
||||
.doc .doc-forms { padding: 0 0 0.75rem 52px; }
|
||||
.doc .doc-forms .actions { margin-top: 0.5rem; }
|
||||
details summary { cursor: pointer; font-family: var(--font-heading); font-size: 1.3rem; font-weight: 500; }
|
||||
details[open] summary { margin-bottom: 1rem; }
|
||||
.mailbody { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 15px; line-height: 1.7; }
|
||||
|
||||
@@ -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 %}
|
||||
@@ -67,7 +67,7 @@
|
||||
{% block scripts %}
|
||||
{% if engine %}
|
||||
<script type="application/json" id="design">{{ design|tojson }}</script>
|
||||
<script type="application/json" id="bestek-meta">{{ {'design': {'file_id': design_file.id, 'title': design_file.title}, 'configurator': design.get('configurator') or ''}|tojson }}</script>
|
||||
<script type="application/json" id="bestek-meta">{{ {'design': {'file_id': design_file.id, 'title': design_file.design_title or design_file.title}, 'configurator': design.get('configurator') or ''}|tojson }}</script>
|
||||
{% for src in scripts %}<script src="{{ src }}"></script>
|
||||
{% endfor %}<script src="{{ url_for('static', filename='bestek.js') }}"></script>
|
||||
{% endif %}
|
||||
|
||||
+47
-47
@@ -1,5 +1,6 @@
|
||||
{% extends 'base.html' %}
|
||||
{% from '_compose.html' import compose %}
|
||||
{% from '_documents.html' import documents_list with context %}
|
||||
{% block title %}{{ c.name }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="head">
|
||||
@@ -28,15 +29,24 @@
|
||||
|
||||
<div class="row gx-4 stack">
|
||||
<div class="col-lg-7 col-xl-8">
|
||||
{% if woningen %}
|
||||
<div class="card"><div class="card-body">
|
||||
<div class="card-top">
|
||||
<h2>Gekozen model</h2>
|
||||
{% if design %}
|
||||
<h2>Woning</h2>
|
||||
<div class="actions">
|
||||
{% if designs|length > 1 %}
|
||||
{% if woningen|length > 1 or not woning %}
|
||||
<form method="post" action="{{ url_for('contact_design', cid=c.id) }}" class="inline">
|
||||
<select class="form-select form-select-sm w-auto" name="file_id" onchange="this.form.submit()" aria-label="Ander ontwerp kiezen">
|
||||
{% for d in designs %}<option value="{{ d.id }}" {{ 'selected' if d.id == design.id }}>{{ d.title }} · {{ d.created_at|dt('%d-%m-%Y') }}</option>{% endfor %}
|
||||
<select class="form-select form-select-sm w-auto" name="document_id" onchange="this.form.submit()" aria-label="Andere woning kiezen">
|
||||
{% if not woning %}<option value="">Kies een woning…</option>{% endif %}
|
||||
{% for d in woningen %}<option value="{{ d.id }}" {{ 'selected' if woning and d.id == woning.id }}>{{ d.title }} · {{ d.updated_at|dt('%d-%m-%Y') }}</option>{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if woning %}
|
||||
{% if woning.versions|length > 1 %}
|
||||
<form method="get" action="{{ url_for('contact_view', cid=c.id) }}" class="inline">
|
||||
<select class="form-select form-select-sm w-auto" name="versie" onchange="this.form.submit()" aria-label="Versie">
|
||||
{% for v in woning.versions %}<option value="{{ v.id }}" {{ 'selected' if v.id == shown.id }}>Versie {{ v.version }}{{ ' (nieuwste)' if loop.first }} · {{ v.created_at|dt('%d-%m-%Y') }}</option>{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -44,19 +54,22 @@
|
||||
<input type="hidden" name="contact_id" value="{{ c.id }}"><input type="hidden" name="bestek" value="1">
|
||||
<button class="btn btn-primary btn-sm">Offerte met bestek</button>
|
||||
</form>
|
||||
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('design_viewer', fid=design.id) }}" target="_blank">Openen</a>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('upload_file', name=design.stored) }}" download="{{ design.original }}">JSON</a>
|
||||
</div>
|
||||
{% 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>
|
||||
{% if design %}
|
||||
<div class="viewer-frame"><iframe src="{{ url_for('design_viewer', fid=design.id) }}" title="3D-weergave {{ design.title }}" loading="lazy" allowfullscreen></iframe></div>
|
||||
<p class="muted" style="margin:.75rem 0 0"><strong style="color:var(--ink)">{{ design.title }}</strong>
|
||||
</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 %}
|
||||
· ontvangen {{ design.created_at|dt }}{% if design_meta.version %} · configurator {{ design_meta.version }}{% endif %}</p>
|
||||
· {{ shown.created_at|dt }}{% if design_meta.version %} · configurator {{ design_meta.version }}{% endif %}</p>
|
||||
{% if design_meta.note %}<p style="white-space:pre-line;margin:.5rem 0 0">{{ design_meta.note }}</p>{% endif %}
|
||||
<p class="pdf-links" style="margin:.5rem 0 0">Tekeningen:
|
||||
{% for k, label in PDF_KINDS.items() %}{% set p = pdfs.get(k) %}
|
||||
{% if shown.id == woning.latest.id %}
|
||||
<p class="pdf-links" style="margin:.5rem 0 0">Tekeningen van deze versie:
|
||||
{% for k, label in PDF_KINDS.items() %}{% set p = pdfs.get(k) if pdfs.get(k) and pdfs.get(k).current else None %}
|
||||
<a data-pdf="{{ k }}" href="{{ p.url if p else '#' }}" target="_blank" {{ 'hidden' if not p }}>{{ (label[:1]|upper) ~ label[1:] }}</a><span class="muted" data-pdf-wait="{{ k }}" {{ 'hidden' if p }}>{{ label }} wordt gemaakt…</span>{% if k == 'werk' %} <span class="muted">(intern)</span> ·{% endif %}
|
||||
{% endfor %}
|
||||
<span class="muted">· opnieuw maken via PDF in de weergave</span></p>
|
||||
@@ -68,18 +81,28 @@
|
||||
if (w) w.hidden = true;
|
||||
});
|
||||
</script>
|
||||
{% elif designs %}
|
||||
<p class="muted">Er is geen model gekozen.</p>
|
||||
<form method="post" action="{{ url_for('contact_design', cid=c.id) }}" class="d-flex flex-wrap gap-2">
|
||||
<select class="form-select w-auto" name="file_id" aria-label="Ontwerp">
|
||||
{% for d in designs %}<option value="{{ d.id }}">{{ d.title }} · {{ d.created_at|dt('%d-%m-%Y') }}</option>{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn-outline-primary">Kiezen</button>
|
||||
{% endif %}
|
||||
<details style="margin-top:1rem">
|
||||
<summary style="font-size:1rem">Nieuwe versie uploaden</summary>
|
||||
<form method="post" action="{{ url_for('document_version', did=woning.id) }}" enctype="multipart/form-data" class="row g-2">
|
||||
<div class="col-md-6"><input class="form-control" type="file" name="file" accept=".json,application/json" required aria-label="Woningontwerp (JSON)"></div>
|
||||
<div class="col-md"><input class="form-control" name="note" placeholder="Wat is er veranderd?" aria-label="Opmerking"></div>
|
||||
<div class="col-md-auto"><button class="btn btn-outline-primary">Opslaan als versie {{ woning.latest.version + 1 }}</button></div>
|
||||
</form>
|
||||
</details>
|
||||
{% else %}
|
||||
<p class="empty">Nog geen model. Een ontwerp uit de configurator (Bestand › Opslaan, JSON) komt hier vanzelf als je het bij Bestanden uploadt of als het met het formulier meekomt.</p>
|
||||
<p class="muted" style="margin:0">Er is geen woning gekozen.</p>
|
||||
{% endif %}
|
||||
</div></div>
|
||||
{% endif %}
|
||||
|
||||
{% if kaart %}
|
||||
<div class="card"><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">Openen</a></div></div>
|
||||
<div class="kaart-frame"><iframe src="{{ kaart }}" title="Kaart van de kavel" loading="lazy"></iframe></div>
|
||||
</div></div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card o-2"><div class="card-body">
|
||||
<h2>Taken</h2>
|
||||
@@ -196,31 +219,8 @@
|
||||
</div></div>
|
||||
|
||||
<div class="card"><div class="card-body">
|
||||
<h2>Bestanden</h2>
|
||||
{% if files %}
|
||||
<div class="files">
|
||||
{% for f in files %}
|
||||
<div class="file">
|
||||
<a href="{{ url_for('design_viewer', fid=f.id) if f.title else url_for('upload_file', name=f.stored) }}" target="_blank" class="thumb">
|
||||
{% if f.mime.startswith('image/') %}<img src="{{ url_for('upload_file', name=f.stored) }}" alt="{{ f.original }}" loading="lazy">
|
||||
{% elif f.title %}3D{% else %}{{ f.original.rsplit('.', 1)[-1][:4] if '.' in f.original else 'bestand' }}{% endif %}
|
||||
</a>
|
||||
<div class="name" title="{{ f.original }}">{{ f.title or f.original }}</div>
|
||||
{% if f.kind == 'werk' %}<div class="muted">Intern, niet in portaal</div>{% endif %}
|
||||
{% if f.title %}{% if c.design_file_id == f.id %}<div class="muted">Gekozen model</div>{% else %}
|
||||
<form method="post" action="{{ url_for('contact_design', cid=c.id) }}"><input type="hidden" name="file_id" value="{{ f.id }}"><button class="linkbtn">Kies als model</button></form>
|
||||
{% endif %}{% endif %}
|
||||
<form method="post" action="{{ url_for('file_delete', fid=f.id) }}" onsubmit="return confirm('Bestand verwijderen?')">
|
||||
<button class="linkbtn">Verwijderen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('contact_upload', cid=c.id) }}" enctype="multipart/form-data">
|
||||
<input class="form-control" type="file" name="files" multiple required aria-label="Bestanden kiezen">
|
||||
<div class="actions"><button class="btn btn-outline-primary btn-sm">Uploaden</button></div>
|
||||
</form>
|
||||
<h2>Documenten</h2>
|
||||
{{ documents_list(documents, url_for('contact_upload', cid=c.id), c.design_id or -1, url_for('contact_design', cid=c.id)) }}
|
||||
</div></div>
|
||||
|
||||
<div class="card"><div class="card-body">
|
||||
|
||||
@@ -37,15 +37,16 @@
|
||||
<div class="col-md-6">
|
||||
<div class="card"><div class="card-body">
|
||||
<h2>Bestanden</h2>
|
||||
{% if files %}
|
||||
{% if documents %}
|
||||
<div class="files">
|
||||
{% for f in files %}
|
||||
{% for d in documents %}{% set f = d.latest %}
|
||||
<div class="file">
|
||||
<a href="{{ url_for('portal_file', name=f.stored) }}" target="_blank" class="thumb">
|
||||
{% if f.mime.startswith('image/') %}<img src="{{ url_for('portal_file', name=f.stored) }}" alt="{{ f.original }}" loading="lazy">
|
||||
{% else %}{{ f.original.rsplit('.', 1)[-1][:4] if '.' in f.original else 'bestand' }}{% endif %}
|
||||
</a>
|
||||
<div class="name" title="{{ f.original }}">{{ f.original }}</div>
|
||||
<div class="name" title="{{ d.title }}">{{ d.title }}</div>
|
||||
{% if d.versions|length > 1 %}<div class="muted">versie {{ f.version }}</div>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
+22
-7
@@ -1,4 +1,5 @@
|
||||
{% extends 'base.html' %}
|
||||
{% from '_documents.html' import documents_list with context %}
|
||||
{% block title %}{{ r.name }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="head">
|
||||
@@ -58,17 +59,18 @@
|
||||
|
||||
{% if designs %}
|
||||
<div class="card"><div class="card-body">
|
||||
<h2>Ontwerpen en tekeningen</h2>
|
||||
<h2>Woningen en tekeningen</h2>
|
||||
<div class="table-responsive"><table class="table table-stack">
|
||||
<thead><tr><th>Ontwerp</th><th>Contact</th><th>Tekeningen</th><th>Ontvangen</th></tr></thead>
|
||||
<thead><tr><th>Woning</th><th>Contact</th><th>Versie</th><th>Tekeningen</th><th>Gewijzigd</th></tr></thead>
|
||||
<tbody>
|
||||
{% for d in designs %}{% set p = pdfs.get(d.id, {}) %}
|
||||
<tr>
|
||||
<td class="stack-title"><a href="{{ url_for('design_viewer', fid=d.id) }}" target="_blank">{{ d.title }}</a>{% if d.chosen %} <span class="chip">Gekozen model</span>{% endif %}</td>
|
||||
<td data-label="Contact"><a href="{{ url_for('contact_view', cid=d.contact_id) }}">{{ d.contact_name }}</a></td>
|
||||
<td data-label="Tekeningen"><span>{% for k, label in PDF_KINDS.items() %}{% if p.get(k) %}<a href="{{ p[k].url }}" target="_blank">{{ (label[:1]|upper) ~ label[1:] }}</a>{% if not loop.last %} · {% endif %}{% endif %}{% endfor %}
|
||||
{% if not p %}<span class="muted">Wordt gemaakt bij openen van het contact</span>{% endif %}</span></td>
|
||||
<td data-label="Ontvangen" class="muted">{{ d.created_at|dt('%d-%m-%Y') }}</td>
|
||||
<td class="stack-title"><a href="{{ url_for('design_viewer', fid=d.latest.id) }}" target="_blank">{{ d.title }}</a>{% if d.chosen %} <span class="chip">Gekozen woning</span>{% endif %}</td>
|
||||
<td data-label="Contact">{% if d.contact_id %}<a href="{{ url_for('contact_view', cid=d.contact_id) }}">{{ d.contact_name }}</a>{% else %}<span class="muted">relatie</span>{% endif %}</td>
|
||||
<td data-label="Versie">{{ d.latest.version }}</td>
|
||||
<td data-label="Tekeningen"><span>{% for k, label in PDF_KINDS.items() %}{% if p.get(k) %}<a href="{{ p[k].url }}" target="_blank">{{ (label[:1]|upper) ~ label[1:] }}</a>{% if not p[k].current %} <span class="muted">(v{{ p[k].version }}, ouder)</span>{% endif %}{% if not loop.last %} · {% endif %}{% endif %}{% endfor %}
|
||||
{% if not p %}<span class="muted">Wordt gemaakt bij openen van de woning</span>{% endif %}</span></td>
|
||||
<td data-label="Gewijzigd" class="muted">{{ d.latest.created_at|dt('%d-%m-%Y') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -76,6 +78,14 @@
|
||||
</div></div>
|
||||
{% endif %}
|
||||
|
||||
{% if kaart %}
|
||||
<div class="card"><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">Openen</a></div></div>
|
||||
<div class="kaart-frame"><iframe src="{{ kaart }}" title="Kaart van de kavel" loading="lazy"></iframe></div>
|
||||
</div></div>
|
||||
{% endif %}
|
||||
|
||||
{% if r.kind == 'partner' %}
|
||||
<div class="card"><div class="card-body">
|
||||
<h2>Producten van deze leverancier</h2>
|
||||
@@ -127,6 +137,11 @@
|
||||
{% if r.notes %}<h3>Notities</h3><p style="white-space:pre-line;margin:0">{{ r.notes }}</p>{% endif %}
|
||||
</div></div>
|
||||
|
||||
<div class="card"><div class="card-body">
|
||||
<h2>Documenten van de relatie</h2>
|
||||
{{ documents_list(documents, url_for('relation_upload', rid=r.id)) }}
|
||||
</div></div>
|
||||
|
||||
{% if tasks %}
|
||||
<div class="card"><div class="card-body">
|
||||
<h2>Open taken</h2>
|
||||
|
||||
Reference in New Issue
Block a user