1 Commits
5 changed files with 210 additions and 11 deletions
+16
View File
@@ -46,6 +46,7 @@ js/unit/ UnitModel: 1 vloer + 4 wanden, naden, wandstukken
js/house/ HouseModel (woning) + woning-PDF + CAD-export (house-dxf.js) + BIM-export (house-ifc.js) + woning-editor js/house/ HouseModel (woning) + woning-PDF + CAD-export (house-dxf.js) + BIM-export (house-ifc.js) + woning-editor
js/cost/ CostModel (kostenberekening), posten + noodwaarden, CostCsv (CSV's), venster Kosten & tijden js/cost/ CostModel (kostenberekening), posten + noodwaarden, CostCsv (CSV's), venster Kosten & tijden
js/app.js menubalk, versienummer, actieve editor js/app.js menubalk, versienummer, actieve editor
js/crm.js koppeling met de Van Emous CRM (alleen in de Docker-stack, zie "CRM")
light.html light-versie (modelpagina's), los te openen: light.html?model=amstel light.html light-versie (modelpagina's), los te openen: light.html?model=amstel
js/light/ LightModel (keuzes op een model toepassen, indelingscontrole, kerncijfers), LightPdf (klant-PDF) + light-UI js/light/ LightModel (keuzes op een model toepassen, indelingscontrole, kerncijfers), LightPdf (klant-PDF) + light-UI
css/light.css stijl light-versie, alles onder .tectum-light css/light.css stijl light-versie, alles onder .tectum-light
@@ -410,6 +411,21 @@ texturen, kleurstalen en `kosten/*.csv` laden via `tectumUrl()` (`js/core/geomet
Per ontwerp wordt de GLB één keer gemaakt; `el.tectumLight.glb()` geeft `{ blob, url, stats }` (op Per ontwerp wordt de GLB één keer gemaakt; `el.tectumLight.glb()` geeft `{ blob, url, stats }` (op
`light.html` de testknop GLB). `light.html` de testknop GLB).
## CRM (woningen van leads en klanten)
Alleen in de Docker-stack (`../docker`), waar Tectum op `tectum.<domein>` achter het wachtwoord staat. Caddy zet
`api/crm/*` daar door naar de CRM (`/api/tectum/*`) met de sleutel `TECTUM_CRM_KEY`; de browser ziet die nooit.
- **Nieuw** toont onder de standaardmodellen de woningen uit de CRM (zoeken op woning, naam, relatie of plaats);
kiezen opent de nieuwste versie in een eigen tab (wolkje in de tab, naam "woning · vN").
- **`?woning=<id>`** opent die woning direct (de knop "Bewerken in Tectum" bij een woning in de CRM).
- **Opslaan in CRM** (bovenbalk, Bestand, Ctrl+S in een CRM-tab) vraagt wat er veranderd is en zet het ontwerp als
nieuwe versie bij de woning in de CRM. Staat er intussen een nieuwere versie (iemand anders), dan vraagt Tectum
eerst of het toch moet. **Downloaden (JSON + PDF)** doet in een CRM-tab wat Opslaan anders doet.
- Sluiten van een CRM-tab met wijzigingen die nog niet in de CRM staan vraagt om bevestiging.
Zonder stack (`start.sh`) bestaat `api/crm/` niet en is van de CRM niets te zien.
## Kosten en prijzen (CSV) ## Kosten en prijzen (CSV)
Alle waarden van het kostenmodel staan in twee CSV-bestanden in `kosten/`, die Alle waarden van het kostenmodel staan in twee CSV-bestanden in `kosten/`, die
+17
View File
@@ -345,6 +345,23 @@ html, body { height: 100%; }
.new-option span { font-size: 13px; } .new-option span { font-size: 13px; }
.new-option:hover:not(.disabled) { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); } .new-option:hover:not(.disabled) { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); }
.new-option.disabled { opacity: .4; cursor: not-allowed; } .new-option.disabled { opacity: .4; cursor: not-allowed; }
.new-crm { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border-soft); max-width: 640px; }
.new-crm h3 { margin: 0 0 10px; font-size: 13px; color: var(--primary); }
.new-crm-q { width: 100%; box-sizing: border-box; font: inherit; font-size: 13px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; }
.new-crm-list { list-style: none; margin: 8px 0 0; padding: 0; max-height: 260px; overflow-y: auto; }
.new-crm-item { display: flex; gap: 10px; align-items: center; width: 100%; text-align: left; background: none; border: 0; border-radius: 6px; padding: 6px 8px; cursor: pointer; color: var(--text); font: inherit; }
.new-crm-item:hover, .new-crm-item:focus-visible { background: var(--primary-soft); color: var(--primary); }
.new-crm-item .bi { font-size: 18px; }
.new-crm-item strong { display: block; font-size: 13px; }
.new-crm-item small { display: block; font-size: 12px; color: var(--text-muted); }
.new-crm-empty { font-size: 12px; color: var(--text-muted); padding: 6px 8px; }
.doc-tab .bi-cloud { margin-right: 4px; color: var(--primary); }
#crm-toast { position: fixed; left: 50%; bottom: 24px; transform: translate(-50%, 20px); opacity: 0; pointer-events: none; z-index: 900;
background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 10px 16px; font-size: 13px;
box-shadow: 0 12px 30px rgba(33, 33, 33, .2); transition: opacity .2s, transform .2s; max-width: 90vw; }
#crm-toast.open { opacity: 1; transform: translate(-50%, 0); }
#crm-toast.ok { border-color: var(--ok); background: var(--ok-soft); }
#crm-toast.err { border-color: var(--danger); background: var(--danger-soft); }
.hn-title { font-size: 12px; font-weight: bold; color: var(--primary); text-transform: uppercase; letter-spacing: .8px; padding: 2px 6px 6px; } .hn-title { font-size: 12px; font-weight: bold; color: var(--primary); text-transform: uppercase; letter-spacing: .8px; padding: 2px 6px 6px; }
#house-nav-tree { display: flex; flex-direction: column; gap: 2px; } #house-nav-tree { display: flex; flex-direction: column; gap: 2px; }
+2
View File
@@ -76,6 +76,7 @@
<span>Los: vloer</span> <span>Los: vloer</span>
</button> </button>
</div> </div>
<div class="new-crm" hidden></div>
</div> </div>
</div> </div>
@@ -124,6 +125,7 @@
<script src="js/house/house-dxf.js"></script> <script src="js/house/house-dxf.js"></script>
<script src="js/house/house-ifc.js"></script> <script src="js/house/house-ifc.js"></script>
<script src="js/house/house.js"></script> <script src="js/house/house.js"></script>
<script src="js/crm.js"></script>
<script src="js/app.js"></script> <script src="js/app.js"></script>
</body> </body>
</html> </html>
+73 -11
View File
@@ -4,7 +4,7 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
const APP_VERSION = '0.7.8'; const APP_VERSION = '0.7.9';
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang. // 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
let tabs = []; let tabs = [];
@@ -41,7 +41,9 @@ const MENUS = [
{ label: 'Bestand', items: [ { label: 'Bestand', items: [
{ label: 'Nieuw…', icon: 'file-earmark-plus', keys: 'Ctrl+N', run: openNewModal }, { label: 'Nieuw…', icon: 'file-earmark-plus', keys: 'Ctrl+N', run: openNewModal },
{ label: 'Openen…', icon: 'folder2-open', keys: 'Ctrl+O', run: () => editorCall('triggerImport'), enabled: () => editorHas('triggerImport') }, { label: 'Openen…', icon: 'folder2-open', keys: 'Ctrl+O', run: () => editorCall('triggerImport'), enabled: () => editorHas('triggerImport') },
{ label: 'Opslaan (JSON + PDF)', icon: 'save', keys: 'Ctrl+S', run: () => editorCall('exportDesign'), enabled: () => editorHas('exportDesign') }, { label: 'Opslaan (JSON + PDF)', icon: 'save', keys: 'Ctrl+S', run: () => editorCall('exportDesign'), enabled: () => editorHas('exportDesign') && !activeCrm() },
{ label: 'Opslaan in CRM (nieuwe versie)', icon: 'cloud-upload', keys: 'Ctrl+S', run: saveToCrm, enabled: () => !!activeCrm() },
{ label: 'Downloaden (JSON + PDF)', icon: 'download', run: () => editorCall('exportDesign'), enabled: () => editorHas('exportDesign') && !!activeCrm() },
{ sep: true }, { head: 'Exporteren' }, { sep: true }, { head: 'Exporteren' },
{ label: 'CAD (DXF)', icon: 'vector-pen', run: () => editorCall('exportDxf'), enabled: () => editorHas('exportDxf') }, { label: 'CAD (DXF)', icon: 'vector-pen', run: () => editorCall('exportDxf'), enabled: () => editorHas('exportDxf') },
{ label: 'BIM (IFC)', icon: 'boxes', run: () => editorCall('exportIfc'), enabled: () => editorHas('exportIfc') }, { label: 'BIM (IFC)', icon: 'boxes', run: () => editorCall('exportIfc'), enabled: () => editorHas('exportIfc') },
@@ -71,7 +73,7 @@ const MENUS = [
]; ];
const KEY_HELP = [ const KEY_HELP = [
['Bestand', [['Ctrl+N', 'Nieuw ontwerp'], ['Ctrl+O', 'Openen'], ['Ctrl+S', 'Opslaan (JSON + PDF)']]], ['Bestand', [['Ctrl+N', 'Nieuw ontwerp (ook uit de CRM)'], ['Ctrl+O', 'Openen'], ['Ctrl+S', 'Opslaan (JSON + PDF; woning uit de CRM: nieuwe versie in de CRM)']]],
['Gereedschap (woning)', [['V', 'Selecteren'], ['W', 'Binnenwand tekenen'], ['R', 'Ruimte vullen'], ['I', 'Inrichting plaatsen'], ['Gereedschap (woning)', [['V', 'Selecteren'], ['W', 'Binnenwand tekenen'], ['R', 'Ruimte vullen'], ['I', 'Inrichting plaatsen'],
['Esc', 'Gereedschap stoppen, daarna selectie opheffen'], ['Del', 'Selectie verwijderen'], ['D / Shift+D', 'Inrichting draaien'], ['Dubbelklik', 'Onderdeel bewerken (wand, vloer)']]], ['Esc', 'Gereedschap stoppen, daarna selectie opheffen'], ['Del', 'Selectie verwijderen'], ['D / Shift+D', 'Inrichting draaien'], ['Dubbelklik', 'Onderdeel bewerken (wand, vloer)']]],
['Beeld', [['1 / 2 / 3', '2D + 3D, alleen 2D, alleen 3D'], ['4', 'Rapport aan/uit'], ['X', 'Doorsnede aan/uit'], ['F', 'Plattegrond passend'], ['Beeld', [['1 / 2 / 3', '2D + 3D, alleen 2D, alleen 3D'], ['4', 'Rapport aan/uit'], ['X', 'Doorsnede aan/uit'], ['F', 'Plattegrond passend'],
@@ -101,11 +103,57 @@ function renderTabBar() {
const bar = document.getElementById('doc-tabs'); const bar = document.getElementById('doc-tabs');
bar.innerHTML = tabs.map(tab => ` bar.innerHTML = tabs.map(tab => `
<div class="doc-tab ${tab.id === activeTabId ? 'active' : ''}" data-tab-id="${tab.id}" role="tab" aria-selected="${tab.id === activeTabId}" title="${UI.esc(tab.name)}"> <div class="doc-tab ${tab.id === activeTabId ? 'active' : ''}" data-tab-id="${tab.id}" role="tab" aria-selected="${tab.id === activeTabId}" title="${UI.esc(tab.name)}">
<span class="doc-name">${UI.esc(tab.name)}</span> ${tab.crm ? '<i class="bi bi-cloud" title="Woning uit de CRM"></i>' : ''}<span class="doc-name">${UI.esc(tab.name)}</span>
<button type="button" class="doc-close" data-close-id="${tab.id}" title="Sluiten" aria-label="Sluiten">&times;</button> <button type="button" class="doc-close" data-close-id="${tab.id}" title="Sluiten" aria-label="Sluiten">&times;</button>
</div> </div>
`).join(''); `).join('');
document.getElementById('top-save').disabled = !editorHas('exportDesign'); const save = document.getElementById('top-save');
save.disabled = !editorHas('exportDesign');
save.querySelector('span').textContent = activeCrm() ? 'Opslaan in CRM' : 'Opslaan';
save.title = activeCrm() ? 'Opslaan als nieuwe versie in de CRM (Ctrl+S)' : 'Opslaan als JSON + PDF (Ctrl+S)';
}
// ---- CRM (js/crm.js): woning uit de CRM in een tab (tab.crm = { id, versie, titel }), terug als nieuwe versie
const activeCrm = () => { const t = tabs.find(x => x.id === activeTabId); return t && t.crm ? t : null; };
const crmName = (crm) => `${crm.titel} · v${crm.versie}`;
function saveActive() {
if (activeCrm()) saveToCrm(); else editorCall('exportDesign');
}
async function openCrmWoning(id) {
try {
const { woning, state } = await TectumCrm.load(id);
const open = tabs.find(t => t.crm && t.crm.id === woning.id);
if (open && !window.confirm(`${woning.titel} is al geopend. Nog een keer openen (versie ${woning.versie} uit de CRM)?`)) {
switchToTab(open.id);
return;
}
const crm = { id: woning.id, versie: woning.versie, titel: woning.titel };
openNew('woning', null, { stateJson: state, name: crmName(crm), crm });
TectumCrm.toast(`${woning.titel} geladen uit de CRM (versie ${woning.versie}${woning.contact ? ', ' + woning.contact.naam : ''}).`, 'ok');
} catch (err) {
TectumCrm.toast('Laden uit de CRM mislukt: ' + err.message, 'err');
}
}
async function saveToCrm() {
const tab = activeCrm();
if (!tab || !currentEditor) return;
const note = window.prompt(`Opslaan als nieuwe versie van ${tab.crm.titel} in de CRM.\nWat is er veranderd?`, '');
if (note === null) return;
const state = currentEditor.getStateJson();
try {
const versie = await TectumCrm.saveVersion(tab.crm.id, state, tab.crm.versie, note.trim());
if (versie === null) return;
tab.crm.versie = versie;
tab.baseJson = JSON.stringify(state);
tab.name = crmName(tab.crm);
renderTabBar();
TectumCrm.toast(`Opgeslagen in de CRM als versie ${versie}.`, 'ok');
} catch (err) {
TectumCrm.toast('Opslaan in de CRM mislukt: ' + err.message, 'err');
}
} }
function onTabSaved(tab, filenameWithExt) { function onTabSaved(tab, filenameWithExt) {
@@ -138,7 +186,7 @@ function switchToTab(tabId) {
renderTabBar(); renderTabBar();
} }
function openNew(kind, template) { function openNew(kind, template, opts) {
if (!editorModuleFor(kind)) return; if (!editorModuleFor(kind)) return;
const oldTab = tabs.find(t => t.id === activeTabId); const oldTab = tabs.find(t => t.id === activeTabId);
@@ -148,10 +196,13 @@ function openNew(kind, template) {
if (currentEditor && typeof currentEditor.unmount === 'function') currentEditor.unmount(); if (currentEditor && typeof currentEditor.unmount === 'function') currentEditor.unmount();
currentEditor = null; currentEditor = null;
const tab = { id: tabIdCounter++, kind, template: template || null, name: defaultNameFor(kind), savedName: null, stateJson: null }; const tab = { id: tabIdCounter++, kind, template: template || null, name: (opts && opts.name) || defaultNameFor(kind), savedName: null,
stateJson: (opts && opts.stateJson) || null, crm: (opts && opts.crm) || null };
tabs.push(tab); tabs.push(tab);
activeTabId = tab.id; activeTabId = tab.id;
mountEditorForTab(tab); mountEditorForTab(tab);
// geladen ontwerp (CRM): wijzigingen tellen vanaf hier, niet vanaf het standaardmodel
if (tab.stateJson && currentEditor && typeof currentEditor.getStateJson === 'function') tab.baseJson = JSON.stringify(currentEditor.getStateJson());
renderTabBar(); renderTabBar();
} }
@@ -162,6 +213,7 @@ function isTabDirty(tab) {
? currentEditor.getStateJson() ? currentEditor.getStateJson()
: tab.stateJson; : tab.stateJson;
if (!state) return false; if (!state) return false;
if (tab.baseJson) return JSON.stringify(state) !== tab.baseJson;
return JSON.stringify(state) !== JSON.stringify(module.getDefaultState(tab.template)); return JSON.stringify(state) !== JSON.stringify(module.getDefaultState(tab.template));
} }
@@ -178,7 +230,9 @@ function closeTab(tabId) {
switchToTab(tabId); switchToTab(tabId);
} }
if (isTabDirty(tab) && currentEditor && typeof currentEditor.exportDesign === 'function') { if (tab.crm && isTabDirty(tab)) {
if (!window.confirm(`De wijzigingen aan ${tab.crm.titel} staan nog niet in de CRM (Opslaan in CRM). Toch sluiten?`)) return;
} else if (isTabDirty(tab) && currentEditor && typeof currentEditor.exportDesign === 'function') {
const saved = currentEditor.exportDesign(); const saved = currentEditor.exportDesign();
if (!saved) return; if (!saved) return;
} }
@@ -233,10 +287,16 @@ function wireNewModal() {
overlay.classList.remove('open'); overlay.classList.remove('open');
}); });
}); });
newCrmPicker = TectumCrm.mountPicker(overlay.querySelector('.new-crm'), (id) => {
overlay.classList.remove('open');
openCrmWoning(id);
});
} }
let newCrmPicker = null;
function openNewModal() { function openNewModal() {
document.getElementById('new-overlay').classList.add('open'); document.getElementById('new-overlay').classList.add('open');
if (newCrmPicker) newCrmPicker.refresh();
} }
function toggleBrowserFullscreen() { function toggleBrowserFullscreen() {
@@ -267,7 +327,7 @@ function wireHotkeys() {
return; return;
} }
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
const act = { s: () => editorCall('exportDesign'), n: openNewModal, o: () => editorCall('triggerImport'), '\\': togglePanels }[k]; const act = { s: saveActive, n: openNewModal, o: () => editorCall('triggerImport'), '\\': togglePanels }[k];
if (act && !e.altKey && !e.shiftKey) { e.preventDefault(); act(); } if (act && !e.altKey && !e.shiftKey) { e.preventDefault(); act(); }
return; return;
} }
@@ -288,7 +348,7 @@ document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.app-version-slot').forEach(el => { el.textContent = 'v' + APP_VERSION; }); document.querySelectorAll('.app-version-slot').forEach(el => { el.textContent = 'v' + APP_VERSION; });
document.querySelectorAll('.app-version-number').forEach(el => { el.textContent = APP_VERSION; }); document.querySelectorAll('.app-version-number').forEach(el => { el.textContent = APP_VERSION; });
Menubar.init(document.getElementById('menubar'), MENUS); Menubar.init(document.getElementById('menubar'), MENUS);
document.getElementById('top-save').addEventListener('click', () => editorCall('exportDesign')); document.getElementById('top-save').addEventListener('click', saveActive);
document.getElementById('keys-overlay').addEventListener('click', (e) => { document.getElementById('keys-overlay').addEventListener('click', (e) => {
if (e.target.id === 'keys-overlay' || e.target.closest('[data-close]')) document.getElementById('keys-overlay').classList.remove('open'); if (e.target.id === 'keys-overlay' || e.target.closest('[data-close]')) document.getElementById('keys-overlay').classList.remove('open');
}); });
@@ -302,7 +362,9 @@ document.addEventListener('DOMContentLoaded', () => {
note.innerHTML = '<i class="bi bi-exclamation-triangle"></i><span>Tectum is geopend als bestand. Start het via een webserver: <code>./start.sh</code> of <code>python3 -m http.server</code>, en open <code>http://localhost:8000</code>. De omgeving (Dag/Nacht) werkt anders niet.</span>'; note.innerHTML = '<i class="bi bi-exclamation-triangle"></i><span>Tectum is geopend als bestand. Start het via een webserver: <code>./start.sh</code> of <code>python3 -m http.server</code>, en open <code>http://localhost:8000</code>. De omgeving (Dag/Nacht) werkt anders niet.</span>';
document.body.appendChild(note); document.body.appendChild(note);
} }
openNew('woning', 'amstel'); // ?woning=<id>: die woning uit de CRM openen (knop "Bewerken in Tectum" in de CRM), anders het standaardmodel
const crmId = Number(new URLSearchParams(location.search).get('woning'));
if (crmId) openCrmWoning(crmId); else openNew('woning', 'amstel');
setTimeout(() => { setTimeout(() => {
const splash = document.getElementById('splash-screen'); const splash = document.getElementById('splash-screen');
+102
View File
@@ -0,0 +1,102 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// Koppeling met de Van Emous CRM (alleen in de Docker-stack): woningen van leads en klanten laden (Nieuw › Uit de
// CRM, of ?woning=<id> in de URL) en een gewijzigde woning als nieuwe versie terugzetten (Opslaan in CRM).
// De API staat op api/crm/ naast deze pagina: Caddy stuurt dat na het Tectum-wachtwoord door naar de CRM, met de
// sleutel erbij. Zonder stack (start.sh) geeft api/crm/ een 404 en blijft alles van de CRM verborgen.
window.TectumCrm = (function () {
const API = 'api/crm/woningen';
let available = null;
async function call(url, opts) {
const res = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json', ...(opts && opts.body ? { 'Content-Type': 'application/json' } : {}) }, ...opts });
let data = null;
try { data = await res.json(); } catch (e) { /* geen JSON: geen CRM */ }
if (!data) throw new Error(`CRM niet bereikbaar (HTTP ${res.status})`);
return { status: res.status, data };
}
// Eén keer kijken of de CRM bereikbaar is (de lijst ophalen); onthoudt de uitkomst
function isAvailable() {
if (!available) available = list('').then(() => true, () => false);
return available;
}
async function list(q) {
const { data } = await call(`${API}?q=${encodeURIComponent(q || '')}`);
if (!data.ok) throw new Error(data.error || 'CRM-fout');
return data.woningen;
}
// Ontwerp van een woning (nieuwste versie, of `versie`), klaar voor de editor: gecontroleerd en zonder de
// bestandsvelden (type, version, exportedAt), zoals bij Openen.
async function load(id, versie) {
const { data } = await call(`${API}/${encodeURIComponent(id)}${versie ? `?versie=${versie}` : ''}`);
if (!data.ok) throw new Error(data.error || 'Woning niet gevonden');
HouseModel.validateState(data.ontwerp);
const { type, version, exportedAt, ...state } = data.ontwerp;
return { woning: data.woning, state };
}
// Nieuwe versie bewaren. basis = de versie die geladen was; is er intussen een nieuwere, dan vraagt dit eerst.
async function saveVersion(id, state, basis, opmerking) {
const ontwerp = { type: 'house-design', version: 1, exportedAt: new Date().toISOString(), configurator: APP_VERSION, ...state };
const send = (forceer) => call(`${API}/${encodeURIComponent(id)}/versies`, { method: 'POST', body: JSON.stringify({ ontwerp, opmerking, basis, forceer }) });
let { status, data } = await send(false);
if (status === 409) {
if (!window.confirm(`In de CRM staat intussen versie ${data.versie} van deze woning (u bewerkt versie ${basis}). Toch opslaan als nieuwe versie?`)) return null;
({ data } = await send(true));
}
if (!data.ok) throw new Error(data.error || 'Opslaan mislukt');
return data.versie;
}
function toast(msg, cls) {
let el = document.getElementById('crm-toast');
if (!el) {
el = document.createElement('div');
el.id = 'crm-toast';
el.setAttribute('role', 'status');
document.body.appendChild(el);
}
el.textContent = msg;
el.className = 'open ' + (cls || '');
clearTimeout(toast._t);
toast._t = setTimeout(() => { el.className = ''; }, 5000);
}
const date = (s) => (s || '').slice(0, 10).split('-').reverse().join('-');
// Sectie "Woningen uit de CRM" in het venster Nieuw: zoeken en kiezen; verborgen zolang de CRM er niet is
function mountPicker(host, onPick) {
host.innerHTML = `<h3>Woningen van leads en klanten (CRM)</h3>
<input type="search" class="new-crm-q" placeholder="Zoek op woning, naam of plaats" aria-label="Zoek in de CRM">
<ul class="new-crm-list" role="list"></ul>`;
const q = host.querySelector('.new-crm-q'), ul = host.querySelector('.new-crm-list');
let req = 0, timer = null;
const render = (rows) => {
ul.innerHTML = rows.length ? rows.map(w => `<li><button type="button" class="new-crm-item" data-id="${w.id}">
<i class="bi bi-house"></i><span><strong>${UI.esc(w.titel)}</strong>
<small>${UI.esc([w.contact && w.contact.naam, w.relatie, w.contact && w.contact.plaats].filter(Boolean).join(' · '))}
· versie ${w.versie} · ${date(w.datum)}</small></span></button></li>`).join('')
: '<li class="new-crm-empty">Geen woningen gevonden.</li>';
};
const search = () => {
const id = ++req;
list(q.value.trim()).then(rows => { if (id === req) render(rows); }, err => { if (id === req) ul.innerHTML = `<li class="new-crm-empty">${UI.esc(err.message)}</li>`; });
};
q.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(search, 250); });
ul.addEventListener('click', (e) => {
const b = e.target.closest('.new-crm-item');
if (b) onPick(Number(b.dataset.id));
});
isAvailable().then(ok => { host.hidden = !ok; if (ok) search(); });
return { refresh: () => { if (!host.hidden) search(); } };
}
return { isAvailable, list, load, saveVersion, mountPicker, toast };
})();