200 lines
9.2 KiB
JavaScript
200 lines
9.2 KiB
JavaScript
/*!
|
||
* 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();
|