/*! * Tectum * Copyright © 2026 Machiel Heinen. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ window.CostCsv = (() => { const BALK_RE = /^hout\.balk\.(\d+)x(\d+)$/; const LISTS = { intern: { url: 'kosten/intern.csv', label: 'Intern', pricesOnly: false }, leveranciers: { url: 'kosten/leveranciers.csv', label: 'Leveranciers', pricesOnly: true } }; const listeners = []; const empty = () => ({ source: null, name: null, loadedAt: null, values: {}, unknown: [], errors: [] }); const state = { intern: empty(), leveranciers: empty() }; function splitLine(line, sep) { const out = []; let cur = '', q = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (q) { if (ch === '"' && line[i + 1] === '"') { cur += '"'; i++; } else if (ch === '"') q = false; else cur += ch; } else if (ch === '"') q = true; else if (ch === sep) { out.push(cur.trim()); cur = ''; } else cur += ch; } out.push(cur.trim()); return out; } function detectSep(header) { const n = (c) => header.split(c).length; return [';', '\t', ','].reduce((best, c) => n(c) > n(best) ? c : best, ';'); } function parseNumber(s) { let t = String(s || '').replace(/[^\d,.\-]/g, ''); if (!t) return NaN; const c = t.lastIndexOf(','), d = t.lastIndexOf('.'); if (c >= 0 && d >= 0) t = c > d ? t.replace(/\./g, '').replace(',', '.') : t.replace(/,/g, ''); else if (c >= 0) t = t.replace(',', '.'); return parseFloat(t); } const normUnit = (u) => String(u || '').toLowerCase().replace(/\s+/g, '').replace(/^eur(o)?/, '€').replace(/\.$/, '').replace('m2', 'm²').replace('m3', 'm³'); const COLS = { code: ['code', 'sleutel', 'id'], value: ['waarde', 'prijs', 'value', 'price'], unit: ['eenheid', 'unit'], label: ['omschrijving', 'naam', 'description'], supplier: ['leverancier', 'supplier'], article: ['artikelnummer', 'artikel', 'sku'], updated: ['bijgewerkt', 'datum', 'date', 'updated'] }; const isPrice = (it) => !it.options && /^€/.test(it.unit || '') && it.unit !== '€/u'; function itemFor(code) { if (BALK_RE.test(code)) return { unit: '€/m', price: true }; const [g, k] = code.split('.'); const it = window.COST_DEFAULTS[g] && window.COST_DEFAULTS[g].items[k]; return it ? Object.assign({ price: isPrice(it) }, it) : null; } function optionKey(it, s) { const t = String(s || '').trim().toLowerCase(); return Object.keys(it.options).find(k => k.toLowerCase() === t || String(it.options[k]).toLowerCase() === t); } function parse(text, list) { const pricesOnly = LISTS[list].pricesOnly; const lines = String(text).replace(/^/, '').split(/\r?\n/); const hi = lines.findIndex(l => l.trim() && !l.trim().startsWith('#')); if (hi < 0) return { values: {}, unknown: [], errors: [] }; const sep = detectSep(lines[hi]); const head = splitLine(lines[hi], sep).map(h => h.toLowerCase()); const col = {}; Object.entries(COLS).forEach(([k, names]) => { col[k] = head.findIndex(h => names.includes(h)); }); if (col.code < 0 || col.value < 0) throw new Error('kolommen "code" en "waarde" (of "prijs") ontbreken'); const values = {}, unknown = [], errors = []; for (let i = hi + 1; i < lines.length; i++) { if (!lines[i].trim() || lines[i].trim().startsWith('#')) continue; const f = splitLine(lines[i], sep), get = (k) => col[k] >= 0 ? (f[col[k]] || '') : ''; const code = get('code'), raw = get('value'), line = i + 1; if (!code) continue; const it = itemFor(code); if (!it) { unknown.push({ code, line, label: get('label') }); continue; } if (it.price !== pricesOnly) { errors.push(`regel ${line}: ${code} hoort in ${it.price ? 'leveranciers.csv' : 'intern.csv'}`); continue; } let value; if (it.options) { value = optionKey(it, raw); if (value === undefined) { errors.push(`regel ${line}: ${code}: "${raw}" is geen keuze (${Object.keys(it.options).join(', ')})`); continue; } } else { value = parseNumber(raw); if (!(isFinite(value) && value >= 0)) { errors.push(`regel ${line}: ${code}: geen geldig getal "${raw}"`); continue; } if (get('unit') && normUnit(get('unit')) !== normUnit(it.unit)) { errors.push(`regel ${line}: ${code}: eenheid "${get('unit')}", verwacht ${it.unit}`); continue; } } const prev = values[code]; if (prev && (!pricesOnly || prev.value <= value)) { if (!pricesOnly) errors.push(`regel ${line}: ${code} staat ook op regel ${prev.line}; die telt`); continue; } values[code] = { value, unit: it.unit || '', label: get('label'), supplier: get('supplier'), article: get('article'), updated: get('updated'), line }; } return { values, unknown, errors }; } const notify = () => listeners.forEach(fn => fn()); function apply(list, text, source, name, loadedAt) { try { state[list] = Object.assign({ source, name, loadedAt }, parse(text, list)); } catch (e) { state[list] = Object.assign(empty(), { source, name, loadedAt, errors: [e.message] }); } } function loadList(list) { const url = LISTS[list].url; return fetch(tectumUrl(url), { cache: 'no-store' }) .then(r => r.ok ? r.text().then(t => apply(list, t, 'server', url, r.headers.get('last-modified') || new Date().toISOString())) : null) .catch(() => null); } function load() { return Promise.all(Object.keys(LISTS).map(loadList)).then(notify); } function loadText(list, text, name) { parse(text, list); apply(list, text, 'tijdelijk', name, new Date().toISOString()); notify(); } function reload(list) { state[list] = empty(); return loadList(list).then(notify); } const entry = (list, code) => state[list].values[code] || null; const labelOf = (code, fallback) => (entry('intern', code) || entry('leveranciers', code) || {}).label || fallback; const balkKey = (w, h) => `${Math.round(Math.min(w, h))}x${Math.round(Math.max(w, h))}`; function overlay(list, values) { Object.entries(state[list].values).forEach(([code, e]) => { const [g, k] = code.split('.'); if (values[g] && k in values[g]) values[g][k] = e.value; }); return values; } function balken(list) { const out = {}; Object.entries(state[list].values).forEach(([code, e]) => { const m = code.match(BALK_RE); if (m) out[balkKey(+m[1], +m[2])] = e.value; }); return out; } function status(list) { const s = state[list]; return { url: LISTS[list].url, label: LISTS[list].label, source: s.source, name: s.name, loadedAt: s.loadedAt, count: Object.keys(s.values).length, unknown: s.unknown.slice(), errors: s.errors.slice() }; } function onChange(fn) { listeners.push(fn); return () => { const i = listeners.indexOf(fn); if (i >= 0) listeners.splice(i, 1); }; } function template(list, values, balkMaten) { const pricesOnly = LISTS[list].pricesOnly; const num = (v) => typeof v === 'number' ? String(Math.round(v * 10000) / 10000).replace('.', ',') : v; const rows = [pricesOnly ? ['code', 'omschrijving', 'eenheid', 'prijs', 'leverancier', 'artikelnummer', 'bijgewerkt'] : ['code', 'omschrijving', 'eenheid', 'waarde']]; const row = (code, label, unit, v) => { const e = entry(list, code) || {}; rows.push(pricesOnly ? [code, label, unit, num(v), e.supplier || '', e.article || '', e.updated || ''] : [code, label, unit, num(v)]); }; Object.entries(window.COST_DEFAULTS).forEach(([g, grp]) => Object.entries(grp.items).forEach(([k, it]) => { if (pricesOnly === isPrice(it)) row(`${g}.${k}`, labelOf(`${g}.${k}`, it.label), it.unit || '', values[g][k]); })); if (!pricesOnly) return finish(rows); const have = balken(list), maten = (balkMaten || []).concat(Object.keys(have).map(k => k.split('x').map(Number))); const seen = new Set(); maten.forEach(([w, h]) => { const key = balkKey(w, h); if (seen.has(key)) return; seen.add(key); const perM = have[key] !== undefined ? have[key] : w * h / 1e6 * values.hout.constructieM3; row(`hout.balk.${key}`, labelOf(`hout.balk.${key}`, `Constructiehout vuren C24 ${key.replace('x', '×')}`), '€/m', perM); }); return finish(rows); } function changes(values, base) { const num = (v) => typeof v === 'number' ? String(Math.round(v * 10000) / 10000).replace('.', ',') : v; const rows = [['code', 'omschrijving', 'eenheid', 'waarde', 'standaard']]; Object.entries(window.COST_DEFAULTS).forEach(([g, grp]) => Object.entries(grp.items).forEach(([k, it]) => { if (isPrice(it) || values[g][k] === base[g][k]) return; rows.push([`${g}.${k}`, labelOf(`${g}.${k}`, it.label), it.unit || '', num(values[g][k]), num(base[g][k])]); })); return rows.length > 1 ? finish(rows) : null; } // BOM: Excel/LibreOffice herkennen dan UTF-8 (€, ², ×). function finish(rows) { const q = (s) => /[;"\n]/.test(s) ? `"${String(s).replace(/"/g, '""')}"` : s; return '\uFEFF' + rows.map(r => r.map(q).join(';')).join('\n') + '\n'; } return { LISTS, isPrice, load, loadText, reload, entry, labelOf, overlay, balken, balkKey, status, onChange, template, changes, parse }; })(); CostCsv.load();