v0.6.0: ruimtes (verfemmer: functie, netto m², vloerafwerking, FIBO-wandpanelen, eigen verfkleur, vals plafond, ruimtestaat op de PDF), inrichting (sanitair, keuken, kookeiland, kasten, bed, zithoek, boiler; slepen met snappen op het wandvlak, draaien, kosten), kozijnen met deurrichting, glasdeur en schuifdeuren (inbouw/opbouw, deuren open in de loopmodus) plus dagkanten, gevel stucwerk en wandstukken splitsen met eigen gevel, dak over de hele woning met overkapping, geschilderd plafond, model Amstel als nieuw ontwerp, kosten en prijzen uit kosten/intern.csv en kosten/leveranciers.csv (CostCsv); easter egg verwijderd; overbodige comments opgeschoond (alleen copyright, uitzonderingen en valkuilen blijven)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b5510d56c7
commit
ceda0b554d
@@ -0,0 +1,199 @@
|
||||
/*!
|
||||
* 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(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();
|
||||
Reference in New Issue
Block a user