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:
Machiel
2026-10-11 00:34:27 +02:00
co-authored by Claude Opus 5.5
parent b5510d56c7
commit ceda0b554d
45 changed files with 4117 additions and 3700 deletions
+199
View File
@@ -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();
+49 -27
View File
@@ -4,30 +4,13 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// cost/cost-defaults.js — STANDAARD kosten en tijden (datafile).
//
// Dit zijn de fabrieksinstellingen. Aanpassen kan op twee manieren:
// 1. in de app: menu Instellingen > Kosten & tijden (opgeslagen in deze
// browser, exporteren/importeren als JSON);
// 2. hier in dit bestand (geldt dan voor iedereen als nieuwe standaard).
// Tip: exporteer in de app en neem de waarden hier over.
//
// Een JS-bestand i.p.v. JSON omdat de app ook via file:// draait (fetch van
// lokale bestanden werkt daar niet).
//
// Opbouw: groep -> items. Elk item: { label, unit, value } of met
// options: { sleutel: 'tekst' } voor een keuze. Nieuwe items verschijnen
// vanzelf in het instellingenvenster; de berekening (CostModel) moet ze
// natuurlijk wel gebruiken. Bedragen excl. btw. Tijden in seconden tenzij
// anders vermeld. LET OP: de getallen zijn voorlopige schattingen.
// ============================================================
// Alleen noodwaarden: de echte waarden staan in kosten/intern.csv en kosten/leveranciers.csv.
window.COST_DEFAULTS = {
arbeid: {
label: 'Arbeid',
items: {
uurloon: { label: 'Uurloon productie (all-in)', unit: '€/u', value: 45 },
uurloon: { label: 'Uurloon productie (all-in)', unit: '€/u', value: 55 },
bezetting: { label: 'Bezetting per productiestap (heftruck, vegen, aangeven): gemiddeld personeel', unit: 'man', value: 2.2 },
toeslagUren: { label: 'Toeslag indirecte uren (klaarleggen, opruimen, intern transport)', unit: '%', value: 10 },
qa: { label: 'QA (laatste aanpassingen, klantcontact) over de totale werktijd', unit: '%', value: 4 }
@@ -38,7 +21,6 @@ window.COST_DEFAULTS = {
label: 'Zagen',
items: {
methode: { label: 'Zaagmethode', options: { robot: 'Zaagrobot', hand: 'Handmatig' }, value: 'robot' },
// Machinetijd testontwerp (409 stuks, 818 sneden): ~2 u 55 min.
robotSnede: { label: 'Robot: tijd per haakse snede', unit: 's', value: 5 },
robotSchuin: { label: 'Robot: extra tijd per schuine snede', unit: 's', value: 2.5 },
robotStuk: { label: 'Robot: handling per stuk (invoer, uitvoer, etiket)', unit: 's', value: 15 },
@@ -111,7 +93,9 @@ window.COST_DEFAULTS = {
rabatM2: { label: 'Rabat (gevelbekleding)', unit: '€/m²', value: 35 },
rabatMontage: { label: 'Montage rabat', unit: 's/m²', value: 600 },
steenM2: { label: 'Steenstrips (gevel + trasraam)', unit: '€/m²', value: 60 },
steenMontage: { label: 'Montage steenstrips', unit: 's/m²', value: 1800 }
steenMontage: { label: 'Montage steenstrips', unit: 's/m²', value: 1800 },
stucM2: { label: 'Stucwerk gevel (wapening + sierpleister op cementgebonden plaat)', unit: '€/m²', value: 45 },
stucMontage: { label: 'Stucwerk aanbrengen (wapenen, pleisteren)', unit: 's/m²', value: 1500 }
}
},
@@ -131,7 +115,12 @@ window.COST_DEFAULTS = {
onderplaatM2: { label: 'Onderplaat boeideel', unit: '€/m²', value: 30 },
onderplaatMontage: { label: 'Montage onderplaat', unit: 's/m²', value: 900 },
daktrimM: { label: 'Daktrim', unit: '€/m', value: 12 },
daktrimMontage: { label: 'Montage daktrim', unit: 's/m', value: 300 }
daktrimMontage: { label: 'Montage daktrim', unit: 's/m', value: 300 },
staalKg: { label: 'Stalen nokbalk IPE S235 (op maat gezaagd, geboord, gegrond)', unit: '€/kg', value: 2.9 },
nokOplegging: { label: 'Oplegging nokbalk (stalen schoen + bouten), 2 per balk', unit: '€/st.', value: 45 },
nokPlaatsen: { label: 'Stalen nokbalk plaatsen, stellen en vastzetten', unit: 's', value: 2700 },
nokVulhoutMontage: { label: 'Vulhout in de holling van de nokbalk aanbrengen (beide zijden)', unit: 's/m', value: 240 },
nokPersoneel: { label: 'Personeel bij plaatsen nokbalk (i.p.v. de bezetting)', unit: 'man', value: 3 }
}
},
@@ -145,16 +134,51 @@ window.COST_DEFAULTS = {
}
},
afwerking: {
label: 'Afwerking ruimtes',
items: {
valsPlafondM2: { label: 'Vals plafond (regelwerk + gipsplaat, afgewerkt)', unit: '€/m²', value: 38 },
valsPlafondMontage: { label: 'Montage vals plafond', unit: 's/m²', value: 1800 },
fiboM2: { label: 'FIBO-wandpaneel (incl. profielen en kit)', unit: '€/m²', value: 70 },
fiboMontage: { label: 'Montage FIBO-wandpaneel', unit: 's/m²', value: 1500 }
}
},
kozijnen: {
label: 'Kozijnen',
items: {
raamM2: { label: 'Raamkozijn incl. glas', unit: '€/m²', value: 450 },
deur: { label: 'Binnen-/buitendeur incl. kozijn', unit: '€/st.', value: 900 },
voordeur: { label: 'Voordeur incl. kozijn', unit: '€/st.', value: 1800 },
schuifdeurBlad: { label: 'Schuifdeur per blad (blad, rail of pocket, beslag, aftimmering)', unit: '€/st.', value: 650 },
montage: { label: 'Montage per kozijn', unit: 's', value: 5400 }
}
},
inrichting: {
label: 'Inrichting',
items: {
personeel: { label: 'Personeel bij inrichting (installateur/monteur, i.p.v. de bezetting)', unit: 'man', value: 1 },
toilet: { label: 'Wandcloset met inbouwreservoir, voorzetwand en bedieningsplaat', unit: '€/st.', value: 650 },
toiletMontage: { label: 'Montage wandcloset (reservoir, voorzetwand, afmonteren)', unit: 's', value: 10800 },
fontein: { label: 'Fontein met kraan en sifon', unit: '€/st.', value: 220 },
fonteinMontage: { label: 'Montage fontein', unit: 's', value: 3600 },
wastafel: { label: 'Badmeubel met wastafel, kraan en spiegel', unit: '€/st.', value: 850 },
wastafelMontage: { label: 'Montage badmeubel', unit: 's', value: 5400 },
douche: { label: 'Inloopdouche (douchegoot, glaswand, regendouche-set)', unit: '€/st.', value: 1450 },
doucheMontage: { label: 'Montage inloopdouche (goot, afschot, glaswand, kraan)', unit: 's', value: 14400 },
keukenM: { label: 'Keukenblok (kasten, werkblad, spoelbak, apparatuur) per strekkende meter', unit: '€/m', value: 1500 },
keukenMontage: { label: 'Montage keukenblok', unit: 's/m', value: 3600 },
kookeilandM: { label: 'Kookeiland (kasten, werkblad) per strekkende meter', unit: '€/m', value: 1300 },
kookeilandMontage: { label: 'Montage kookeiland', unit: 's/m', value: 3600 },
kookplaat: { label: 'Inductiekookplaat 4 zones', unit: '€/st.', value: 650 },
kastM: { label: 'Kledingkast / inbouwkast per strekkende meter', unit: '€/m', value: 550 },
kastMontage: { label: 'Montage kast', unit: 's/m', value: 1800 },
boiler: { label: 'Elektrische boiler 80 l incl. inlaatcombinatie', unit: '€/st.', value: 600 },
boilerMontage: { label: 'Montage boiler', unit: 's', value: 7200 }
}
},
verlichting: {
label: 'Verlichting',
items: {
@@ -165,8 +189,6 @@ window.COST_DEFAULTS = {
}
},
// Indicatieve groothandelsprijzen (excl. btw) en tijden; zie ElektraModel
// voor de hoeveelheden (strekkende meters per leidingtype, boorgaten, dozen).
elektra: {
label: 'Elektra',
items: {
@@ -216,9 +238,9 @@ window.COST_DEFAULTS = {
bedrijf: {
label: 'Afschrijving & bedrijfskosten',
items: {
machines: { label: 'Afschrijving machines (zaagrobot, gereedschap, inrichting) per productie-uur', unit: '€/u', value: 8 },
slijtage: { label: 'Slijtage en verbruik gereedschap (zaagbladen, bits, accu’s) per productie-uur', unit: '€/u', value: 2 },
huisvesting: { label: 'Huisvesting productiehal (huur, energie) per productie-uur', unit: '€/u', value: 6 },
machines: { label: 'Afschrijving machines (zaagrobot, gereedschap, inrichting) per uur', unit: '€/u', value: 8 },
slijtage: { label: 'Slijtage en verbruik gereedschap (zaagbladen, bits, accu’s) per uur', unit: '€/u', value: 2 },
huisvesting: { label: 'Huisvesting productiehal (huur, energie) per uur', unit: '€/u', value: 6 },
transportUnit: { label: 'Transport per unit naar de bouwplaats', unit: '€/unit', value: 750 },
kraan: { label: 'Kraan voor plaatsen woning', unit: '€', value: 1200 }
}
+86 -88
View File
@@ -4,38 +4,20 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// cost/cost-model.js — CostModel: ENIGE bron van de kostenberekening
// (materiaal + arbeid). Geen DOM. Rekent op de doorgerekende woning
// (HouseModel.computeParts + computeTotals) met de kosten/tijden uit
// COST_DEFAULTS (cost-defaults.js) plus de eigen instellingen van de
// gebruiker (localStorage, instellingenvenster cost-settings.js).
//
// Opbouw: per element (wandstuk, vloer, dak, woning-breed) regels
// { cat, act, label, qty, unit, mat (€), sec (s arbeid) }
// cat = materiaalgroep, act = werkzaamheid. Opgeteld per element, per unit
// en voor de woning; daarna opslagen (indirecte uren, materiaal, marge, btw).
//
// Plain script, geladen na house-model.js en cost-defaults.js.
// ============================================================
window.CostModel = (() => {
const STORAGE_KEY = 'tectum.costs';
const listeners = [];
let session = {};
try { localStorage.removeItem('tectum.costs'); localStorage.removeItem('tectum.leveranciers'); } catch (e) { /* oude browseropslag */ }
// ------------------------------------------------------------
// Instellingen: standaard (COST_DEFAULTS) + eigen waarden (localStorage).
// values = { groep: { sleutel: waarde } }.
// ------------------------------------------------------------
function defaultValues() {
const v = {};
Object.entries(window.COST_DEFAULTS).forEach(([g, grp]) => {
v[g] = {};
Object.entries(grp.items).forEach(([k, it]) => { v[g][k] = it.value; });
});
return v;
return CostCsv.overlay('intern', v);
}
// Alleen bekende sleutels met een geldig type overnemen.
function mergeValues(base, over) {
const v = JSON.parse(JSON.stringify(base));
Object.entries(over || {}).forEach(([g, items]) => {
@@ -49,36 +31,40 @@ window.CostModel = (() => {
});
return v;
}
function readOverrides() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') || {}; } catch (e) { return {}; }
const listed = (g, k) => !!CostCsv.entry('leveranciers', `${g}.${k}`);
function getSettings(opts) {
const own = mergeValues(defaultValues(), session);
const v = opts && opts.leveranciers === false ? own : CostCsv.overlay('leveranciers', own);
v.balken = CostCsv.balken('leveranciers');
return v;
}
function getSettings() { return mergeValues(defaultValues(), readOverrides()); }
// Slaat alleen de afwijkingen van de standaard op.
function saveSettings(values) {
const def = defaultValues(), diff = {};
Object.entries(mergeValues(def, values)).forEach(([g, items]) => Object.entries(items).forEach(([k, val]) => {
if (val !== def[g][k]) (diff[g] = diff[g] || {})[k] = val;
if (val !== def[g][k] && !listed(g, k)) (diff[g] = diff[g] || {})[k] = val;
}));
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(diff)); } catch (e) { /* geen opslag: alleen deze sessie */ }
session = diff;
listeners.forEach(fn => fn());
}
function resetSettings() { saveSettings(defaultValues()); }
function onChange(fn) { listeners.push(fn); return () => { const i = listeners.indexOf(fn); if (i >= 0) listeners.splice(i, 1); }; }
CostCsv.onChange(() => listeners.forEach(fn => fn()));
// Export/import (JSON-bestand): altijd de volledige set waarden.
function exportJson(values) {
return { type: 'tectum-costs', version: 1, exportedAt: new Date().toISOString(), values: mergeValues(defaultValues(), values) };
}
function importJson(obj) {
if (!obj || obj.type !== 'tectum-costs' || typeof obj.values !== 'object') throw new Error('geen Tectum-kostenbestand');
return mergeValues(defaultValues(), obj.values);
function importFile(text) {
if (/^\s*\{/.test(text)) {
const obj = JSON.parse(text);
if (!obj || obj.type !== 'tectum-costs' || typeof obj.values !== 'object') throw new Error('geen Tectum-kostenbestand');
return mergeValues(defaultValues(), obj.values);
}
const r = CostCsv.parse(text, 'intern'), over = {};
Object.entries(r.values).forEach(([code, e]) => {
const [g, k] = code.split('.');
if (window.COST_DEFAULTS[g]) (over[g] = over[g] || {})[k] = e.value;
});
return mergeValues(defaultValues(), over);
}
// ------------------------------------------------------------
// Berekening
// ------------------------------------------------------------
const CAT_ORDER = ['Hout', 'Schroeven', 'Plaatwerk', 'Isolatie', 'Gevel', 'Dak', 'Vloer', 'Kozijnen', 'Verlichting', 'Elektra'];
const ACT_ORDER = ['Zagen', 'Regelwerk monteren', 'Wanden plaatsen', 'Plaatwerk', 'Isoleren', 'Gevel', 'Latwerk', 'Dak', 'Vloer', 'Kozijnen', 'Verlichting', 'Elektra'];
const CAT_ORDER = ['Hout', 'Staal', 'Schroeven', 'Plaatwerk', 'Isolatie', 'Gevel', 'Dak', 'Vloer', 'Kozijnen', 'Afwerking', 'Inrichting', 'Verlichting', 'Elektra'];
const ACT_ORDER = ['Zagen', 'Regelwerk monteren', 'Wanden plaatsen', 'Plaatwerk', 'Isoleren', 'Gevel', 'Latwerk', 'Dak', 'Vloer', 'Kozijnen', 'Afwerking', 'Inrichting', 'Verlichting', 'Elektra'];
const VERTICAL_KINDS = ['verticale balk', 'kreupele stijl'];
const DORPEL_KINDS = ['bovendorpel', 'onderdorpel'];
@@ -86,20 +72,16 @@ window.CostModel = (() => {
const S = settings || getSettings();
const elements = [];
const newEl = (element, label, unit) => { const el = { element, label, unit, lines: [] }; elements.push(el); return el; };
// sec = tijd voor één persoon; crew = personeel bij die stap (standaard
// de gemiddelde bezetting per productiestap). Opgeslagen sec = manuren.
// sec = tijd voor één persoon; opgeslagen sec = manuren (× crew).
const add = (el, cat, act, label, qty, unit, mat, sec, crew) => {
if (!(qty > 0)) return;
const c = sec ? (crew !== undefined ? crew : S.arbeid.bezetting) : 0;
// clock = doorlooptijd van de stap (s), voor afschrijving/huisvesting.
// clock = doorlooptijd van de stap (klokuren), voor afschrijving/huisvesting.
el.lines.push({ cat, act, label, qty, unit, mat: mat || 0, sec: (sec || 0) * c, clock: c ? sec : 0, crew: c });
};
// --- Gedeelde rekenregels ---
const Z = S.zagen, V = S.verbinden, P = S.plaat;
const sawFactor = Z.methode === 'hand' ? Z.handFactor : 1;
// Zaagtijd van één stuk (s): handling + sneden (+ extra per schuine snede).
// saw: totalen voor het zaagoverzicht (robot- en handtijd, los van de keuze).
const saw = { pieces: 0, cuts: 0, angled: 0, robotSec: 0 };
const sawSec = (cutAngles, count = 1) => {
const ca = cutAngles || [0, 0], angled = ca.filter(a => a !== 0).length;
@@ -109,39 +91,40 @@ window.CostModel = (() => {
};
const sawLabel = Z.methode === 'hand' ? 'Zagen (handmatig)' : 'Zagen (robot)';
const jointSec = V.eersteSchroef + Math.max(0, V.schroevenPerVerbinding - 1) * V.volgendeSchroef;
// Constructie monteren (lijmen + schroeven): schroeftijd x V.monteerFactor,
// met V.personeel man (manuren), op het dak factor V.dakFactor (roof = true).
const addJoints = (el, joints, label, roof) => {
const n = joints * V.schroevenPerVerbinding;
const f = V.monteerFactor * (roof ? V.dakFactor : 1);
add(el, 'Schroeven', 'Regelwerk monteren', `${label} monteren, lijmen + schroeven (${joints} verb. × ${V.schroevenPerVerbinding}, ×${V.monteerFactor}${roof ? `, dak ×${V.dakFactor}` : ''})`, n, 'st.', n * V.prijsPer1000 / 1000, joints * jointSec * f, V.personeel);
add(el, 'Schroeven', 'Regelwerk monteren', `${label} monteren, lijmen + schroeven`, n, 'st.', n * V.prijsPer1000 / 1000, joints * jointSec * f, V.personeel);
};
// Plaat: materiaal + montage + plaatschroeven.
const addSheet = (el, cat, label, m2, priceM2, montageSecM2) => {
if (!(m2 > 0)) return;
add(el, cat, 'Plaatwerk', label, m2, 'm²', m2 * priceM2, m2 * montageSecM2);
const n = Math.ceil(m2 * P.schroevenPerM2);
add(el, 'Schroeven', 'Plaatwerk', `Plaatschroeven ${label.toLowerCase()}`, n, 'st.', n * P.plaatschroefPer1000 / 1000, n * P.tijdPerPlaatschroef);
};
// Snijverlies wandbalken (inkoop in handelslengtes, hele woning).
const waste = totals.purchaseStockMM > 0 ? totals.purchaseWasteMM / Math.max(1, totals.purchaseStockMM - totals.purchaseWasteMM) : 0;
const balken = S.balken || {};
const woodCost = (w, h, lengthM) => {
const perM = balken[CostCsv.balkKey(w, h)];
return perM !== undefined ? lengthM * perM : w * h / 1e6 * lengthM * S.hout.constructieM3;
};
const joistWoodCost = (m3, sec) => sec && sec.joistW && sec.joistH ? woodCost(sec.joistW, sec.joistH, m3 / (sec.joistW * sec.joistH / 1e6)) : m3 * S.hout.constructieM3;
const fmtM = (v) => v.toLocaleString('nl-NL', { maximumFractionDigits: 1 });
// --- Wandstukken (buiten + binnen/naad) ---
const seenOpenings = new Set(); // naadwand: openingen één keer (beide delen hebben dezelfde deur)
function wallElement(p, unitId) {
const el = newEl(p.element, p.label, unitId);
const bom = WallModel.computeBOM(p.config, p.frame);
const woodM3 = bom.reduce((a, b) => a + b.width * b.depth * b.length, 0) / 1e9;
add(el, 'Hout', 'Zagen', `Regelwerk ${bom.length} st. (incl. ${Math.round(waste * 100)}% snijverlies)`, woodM3 * (1 + waste), 'm³', woodM3 * (1 + waste) * S.hout.constructieM3, 0);
const woodMat = bom.reduce((a, b) => a + woodCost(b.width, b.depth, b.length / 1000), 0);
add(el, 'Hout', 'Zagen', `Regelwerk ${bom.length} st.`, woodM3 * (1 + waste), 'm³', woodMat * (1 + waste), 0);
const cuts = bom.reduce((a, b) => a + (b.cutAngles || [0, 0]).length, 0);
add(el, 'Hout', 'Zagen', `${sawLabel}: ${bom.length} stuks`, cuts, 'sneden', 0, bom.reduce((a, b) => a + sawSec(b.cutAngles), 0));
const verticals = bom.filter(b => VERTICAL_KINDS.includes(b.kind)).length;
const dorpels = bom.filter(b => DORPEL_KINDS.includes(b.kind)).length;
const topPlates = bom.filter(b => b.kind === 'bovenregel').length;
addJoints(el, 2 * verticals + 2 * dorpels + Math.max(0, topPlates - 1), 'Stijlen/dorpels');
// Dubbele stijlen (deur, hoek: twee stijlen tegen elkaar): verticaal
// elke V.dubbelAfstand mm een schroef over de gezamenlijke lengte.
const studs = bom.filter(b => VERTICAL_KINDS.includes(b.kind)).sort((a, b) => a.x - b.x);
let dblScrews = 0, dblPairs = 0;
for (let i = 1; i < studs.length; i++) {
@@ -152,10 +135,10 @@ window.CostModel = (() => {
dblPairs++;
dblScrews += Math.max(2, Math.floor(ov / V.dubbelAfstand));
}
add(el, 'Schroeven', 'Regelwerk monteren', `Dubbele stijlen ${dblPairs}× (schroef elke ${V.dubbelAfstand} mm, ×${V.monteerFactor})`, dblScrews, 'st.',
add(el, 'Schroeven', 'Regelwerk monteren', `Dubbele stijlen ${dblPairs}×`, dblScrews, 'st.',
dblScrews * V.prijsPer1000 / 1000, dblScrews * V.volgendeSchroef * V.monteerFactor, V.personeel);
const M = S.montage;
add(el, 'Hout', 'Wanden plaatsen', `Plaatsen en vastzetten (${M.muurVastzetten} min)`, 1, 'sectie', 0, M.muurVastzetten * 60, M.muurPersoneel);
add(el, 'Hout', 'Wanden plaatsen', 'Plaatsen en vastzetten', 1, 'sectie', 0, M.muurVastzetten * 60, M.muurPersoneel);
const st = p.stats || {};
addSheet(el, 'Plaatwerk', 'Spaanplaat', (st.spaanplaatAreaM2 || 0) + (st.spaanplaatBuitenAreaM2 || 0), P.spaanplaatM2, P.spaanplaatMontage);
@@ -168,14 +151,14 @@ window.CostModel = (() => {
const G = S.gevel;
const doek = st.doekAreaM2 || 0;
// Montage per strekkende meter wand (breedte van het stuk).
const doekM = doek > 0 ? p.place.width / 1000 : 0;
add(el, 'Gevel', 'Gevel', `Vochtdoek (${fmtM(doekM)} m¹ wand)`, doek, 'm²', doek * G.vochtdoekM2, doekM * G.vochtdoekMontageM);
// Rabat over het doek, boven het trasraam (gevelvlak uit WallModel).
const rabat = p.buitenMateriaal === 'vochtdoek' ? (st.rabatAreaM2 || 0) : 0;
add(el, 'Gevel', 'Gevel', 'Rabat', rabat, 'm²', rabat * G.rabatM2, rabat * G.rabatMontage);
const steen = (st.stoneAreaM2 || 0) + (st.trasraamStoneAreaM2 || 0);
add(el, 'Gevel', 'Gevel', 'Steenstrips', steen, 'm²', steen * G.steenM2, steen * G.steenMontage);
const stuc = st.stucAreaM2 || 0;
add(el, 'Gevel', 'Gevel', 'Stucwerk', stuc, 'm²', stuc * (G.stucM2 || 0), stuc * (G.stucMontage || 0));
const latM = st.battenLengthM || 0;
add(el, 'Hout', 'Latwerk', `Latwerk (${st.battenCount || 0} latten inkoop, ${st.battenPieces || 0} stukken)`, latM, 'm', latM * S.hout.latwerkM,
(st.battenPieces || 0) * S.hout.latStukMontage + latM * S.hout.latMeterMontage);
@@ -185,7 +168,11 @@ window.CostModel = (() => {
if (!opKey || !seenOpenings.has(opKey)) {
if (opKey) seenOpenings.add(opKey);
(p.config.openings || []).forEach(op => {
if (op.type === 'raam') {
const fill = KozijnModel.normalize(op.fill);
if (fill && fill.type === 'schuifdeur') {
add(el, 'Kozijnen', 'Kozijnen', `Schuifdeur ${op.w}×${op.h} (${fill.leaves} blad${fill.leaves > 1 ? 'en' : ''}, ${fill.mount === 'opbouw' ? 'op de wand' : 'in de wand'})`,
fill.leaves, 'blad', fill.leaves * (K.schuifdeurBlad || K.deur), K.montage);
} else if (op.type === 'raam') {
const m2 = WallModel.openingArea(op) / 1e6;
add(el, 'Kozijnen', 'Kozijnen', `Raam ${op.w}×${op.h}`, m2, 'm²', m2 * K.raamM2, K.montage);
} else {
@@ -197,7 +184,6 @@ window.CostModel = (() => {
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => wallElement(p, u.id))));
parts.interior.forEach(p => wallElement(p, p.seamUnit || null));
// --- Vloer- en dakbalken (balklaag: zagen + verbindingen) ---
function joistLayer(el, cd, angleDeg, what, roof) {
const rims = ['front', 'back'].filter(k => cd.rims && cd.rims[k]).length;
const n = cd.joists.length;
@@ -207,25 +193,21 @@ window.CostModel = (() => {
}
parts.units.forEach(u => {
// Vloer
const fl = newEl(u.floorElement, `Unit ${u.id} vloer`, u.id);
const fs = u.floorSpecs;
add(fl, 'Hout', 'Zagen', 'Vloerbalken', fs.joistWoodM3, 'm³', fs.joistWoodM3 * S.hout.constructieM3, 0);
add(fl, 'Hout', 'Zagen', 'Vloerbalken', fs.joistWoodM3, 'm³', joistWoodCost(fs.joistWoodM3, u.floorData.calcData), 0);
joistLayer(fl, u.floorData.calcData, 0, 'Vloer');
addSheet(fl, 'Plaatwerk', 'OSB vloer', fs.osbAreaM2, P.osbM2, P.osbMontage);
add(fl, 'Isolatie', 'Isoleren', 'Glaswol vloer', fs.voidM3, 'm³', fs.voidM3 * S.isolatie.glaswolM3, fs.voidM3 * S.isolatie.glaswolMontage);
// Afwerking: vloer min de voetafdruk van de wanden op deze unit.
const foot = u.walls.reduce((a, w) => a + w.pieces.filter(p => !p.place.baseY).reduce((b, p) => b + p.place.width * p.place.thickness, 0), 0)
+ parts.interior.filter(p => p.seamUnit === u.id).reduce((a, p) => a + p.place.width * p.place.thickness, 0);
const finM2 = Math.max(0, u.rect.W * u.rect.D - foot) / 1e6;
add(fl, 'Vloer', 'Vloer', 'Vloerafwerking', finM2, 'm²', finM2 * S.vloer.afwerkingM2, finM2 * S.vloer.afwerkingMontage);
// Dak
const rf = newEl(`${u.id}-D`, `Unit ${u.id} dak`, u.id);
const rs = u.roof.specs, D = S.dak;
add(rf, 'Hout', 'Zagen', 'Dakbalken', rs.joistWoodM3, 'm³', rs.joistWoodM3 * S.hout.constructieM3, 0);
add(rf, 'Hout', 'Zagen', 'Dakbalken', rs.joistWoodM3, 'm³', joistWoodCost(rs.joistWoodM3, u.roof.planes[0] && u.roof.planes[0].data.calcData), 0);
u.roof.planes.forEach((pl, i) => joistLayer(rf, pl.data.calcData, Math.round(Math.abs(pl.theta || 0) * 180 / Math.PI * 10) / 10, u.roof.planes.length > 1 ? `Dak ${i + 1} ` : 'Dak', true));
// Wolfsdak: bovenste deel (sporen, hoekkepers, nokbalk, kopbalken).
const cap = u.roof.unitRoof && u.roof.unitRoof.cap;
if (cap) {
const n = cap.beams.length;
@@ -238,9 +220,16 @@ window.CostModel = (() => {
add(rf, 'Dak', 'Dak', 'Dakleer (EPDM)', rs.dakleerM2 || 0, 'm²', (rs.dakleerM2 || 0) * D.dakleerM2, (rs.dakleerM2 || 0) * D.dakleerMontage);
add(rf, 'Dak', 'Dak', 'Riet', rs.rietM2 || 0, 'm²', (rs.rietM2 || 0) * D.rietM2, (rs.rietM2 || 0) * D.rietMontage);
add(rf, 'Dak', 'Dak', 'Felsplaten (staande naad)', rs.felsM2 || 0, 'm²', (rs.felsM2 || 0) * D.felsM2, (rs.felsM2 || 0) * D.felsMontage);
const rb = u.roof.unitRoof && u.roof.unitRoof.ridgeBeam;
if (rb) {
const lm = rb.length / 1000;
add(rf, 'Staal', 'Dak', `Stalen nokbalk ${rb.profile.name}, ${fmtM(lm)} m (+ 2 opleggingen)`, rb.steelKg, 'kg',
rb.steelKg * D.staalKg + 2 * D.nokOplegging, D.nokPlaatsen, D.nokPersoneel);
add(rf, 'Hout', 'Dak', `Vulhout holling nokbalk 2× ${Math.round(rb.fillW)}×${Math.round(rb.fillH)}`, rb.fillM3, 'm³',
woodCost(Math.round(rb.fillW), Math.round(rb.fillH), 2 * lm), lm * D.nokVulhoutMontage);
}
});
// --- Woning-breed: dakrand, sandwichpanelen, verlichting ---
const wo = newEl('WON', 'Woning (dakrand, fundering, verlichting)', null);
const D = S.dak;
if (totals.roofEdge) {
@@ -259,10 +248,6 @@ window.CostModel = (() => {
add(wo, 'Verlichting', 'Verlichting', 'Boeideel-spots', spots, 'st.', spots * L.boeideelSpot, spots * L.montage);
add(wo, 'Verlichting', 'Verlichting', 'Binnenspots', lm.indoor, 'st.', lm.indoor * L.binnenspot, lm.indoor * L.montage);
// --- Elektra (ElektraModel): per unit de leidingen (strekkende meters
// per type: buis + draad per ader, of XMvK), boorgaten, steekverbinders,
// dozen en aansluitingen; meterkast, groepen en keuring bij de unit waar
// de meterkast hangt. Arbeid met E.personeel man.
const elk = parts.elektra, E = S.elektra;
if (elk && elk.enabled) {
const unitIds = new Set(parts.units.map(u => u.id)), elOf = {};
@@ -287,10 +272,10 @@ window.CostModel = (() => {
const c = ElektraModel.cableInfo(type), m = a.types[type];
if (c.conduit) {
const draad = c.mm2 >= 2.5 ? E.draad25M : E.draad15M;
ea(uid, `${c.label}: ${c.use.toLowerCase()} (buis + ${c.cores.length} aders)`, m, 'm', m * (buisPrijs(c.conduit) + c.cores.length * draad), m * (E.buisLeggen + E.draadTrekken));
ea(uid, `${c.label}: ${c.use.toLowerCase()}`, m, 'm', m * (buisPrijs(c.conduit) + c.cores.length * draad), m * (E.buisLeggen + E.draadTrekken));
} else ea(uid, `${c.label}: ${c.use.toLowerCase()}`, m, 'm', m * E.xmvkM, m * E.kabelLeggen);
});
ea(uid, `Boorgaten stijlen/regels/balken (${E.boorgat} s per gat)`, a.holes, 'st.', 0, a.holes * E.boorgat);
ea(uid, 'Boorgaten stijlen/regels/balken', a.holes, 'st.', 0, a.holes * E.boorgat);
Object.keys(a.cross).forEach(n => {
const k = a.cross[n];
ea(uid, `Steekverbinder module-naad ${n}-polig`, k, 'set', k * (Number(n) >= 5 ? E.steekverbinder5 : E.steekverbinder3), k * E.steekverbinderMontage);
@@ -326,7 +311,26 @@ window.CostModel = (() => {
}
}
// --- Optellen ---
const AF = S.afwerking, rmOf = {};
if (AF && parts.rooms) parts.rooms.rooms.filter(r => r.ceilM2 > 0 || r.fiboM2 > 0).forEach(r => {
const el = rmOf[r.unitId] || (rmOf[r.unitId] = newEl(`${r.unitId}-RU`, `Unit ${r.unitId} ruimtes`, r.unitId));
add(el, 'Afwerking', 'Afwerking', `${r.name}: vals plafond ${r.ceiling} mm`, r.ceilM2, 'm²', r.ceilM2 * AF.valsPlafondM2, r.ceilM2 * AF.valsPlafondMontage);
add(el, 'Afwerking', 'Afwerking', `${r.name}: FIBO-wandpanelen`, r.fiboM2, 'm²', r.fiboM2 * AF.fiboM2, r.fiboM2 * AF.fiboMontage);
});
const IN = S.inrichting, fxOf = {};
if (IN && parts.fixtures) parts.fixtures.filter(f => f.cost).forEach(f => {
const el = fxOf[f.unitId] || (fxOf[f.unitId] = newEl(`${f.unitId}-IN`, `Unit ${f.unitId} inrichting`, f.unitId));
const m = f.w / 1000, crew = IN.personeel;
const perM = (what, price, sec) => add(el, 'Inrichting', 'Inrichting', `${what} ${f.w} mm`, m, 'm', m * (price || 0), m * (sec || 0), crew);
if (f.cost === 'keuken') perM('Keukenblok', IN.keukenM, IN.keukenMontage);
else if (f.cost === 'kookeiland') {
perM('Kookeiland', IN.kookeilandM, IN.kookeilandMontage);
if (f.opts && f.opts.kookplaat) add(el, 'Inrichting', 'Inrichting', 'Inductiekookplaat', 1, 'st.', IN.kookplaat || 0, 0);
} else if (f.cost === 'kast') perM(f.label, IN.kastM, IN.kastMontage);
else add(el, 'Inrichting', 'Inrichting', f.label, 1, 'st.', IN[f.cost] || 0, IN[f.cost + 'Montage'] || 0, crew);
});
const sumLines = (lines) => lines.reduce((a, l) => ({ mat: a.mat + l.mat, sec: a.sec + l.sec, clock: a.clock + l.clock }), { mat: 0, sec: 0, clock: 0 });
elements.forEach(el => Object.assign(el, sumLines(el.lines)));
const byKey = (key, order) => {
@@ -338,15 +342,12 @@ window.CostModel = (() => {
}));
return Array.from(m.values()).sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name));
};
// Kostprijs van materiaal + directe arbeidstijd (s), overal dezelfde
// regels: materiaal + verlies + klein materiaal; uren + indirect + QA
// tegen het uurloon (manuren); afschrijving/slijtage/huisvesting per
// productie-uur = doorlooptijd (klokuren, niet x personeel) + indirect, zonder QA.
// Afschrijving/slijtage/huisvesting per productie-uur: klokuren (niet × personeel) + indirect, zonder QA.
const O = S.opslag, B = S.bedrijf, A = S.arbeid;
const perHour = B.machines + B.slijtage + B.huisvesting;
const split = (sec) => {
const direct = sec / 3600, indirect = direct * A.toeslagUren / 100;
const qa = (direct + indirect) * A.qa / 100; // QA: % van de totale werktijd
const qa = (direct + indirect) * A.qa / 100;
return { direct, indirect, qa, total: direct + indirect + qa };
};
const prodHours = (clock) => clock / 3600 * (1 + A.toeslagUren / 100);
@@ -373,10 +374,10 @@ window.CostModel = (() => {
['Materiaal', all.mat],
[`Materiaalverlies ${pct(O.verlies)}`, all.mat * O.verlies / 100],
[`Klein materiaal ${pct(O.klein)}`, all.mat * O.klein / 100],
[`Arbeid ${uur(h.total)} manuur × ${eur0(A.uurloon)} (incl. bezetting, indirect en QA)`, h.total * A.uurloon],
[`Afschrijving machines ${uur(h.prod)} productie-uur × ${eur(B.machines, 2)}`, h.prod * B.machines],
[`Slijtage gereedschap ${uur(h.prod)} productie-uur × ${eur(B.slijtage, 2)}`, h.prod * B.slijtage],
[`Huisvesting productiehal ${uur(h.prod)} productie-uur × ${eur(B.huisvesting, 2)}`, h.prod * B.huisvesting],
[`Arbeid ${uur(h.total)} manuur × ${eur0(A.uurloon)}`, h.total * A.uurloon],
[`Afschrijving machines ${uur(h.prod)} uur × ${eur(B.machines, 2)}`, h.prod * B.machines],
[`Slijtage gereedschap ${uur(h.prod)} uur × ${eur(B.slijtage, 2)}`, h.prod * B.slijtage],
[`Huisvesting productiehal ${uur(h.prod)} uur × ${eur(B.huisvesting, 2)}`, h.prod * B.huisvesting],
[`Transport ${n} unit${n === 1 ? '' : 's'} × ${eur0(B.transportUnit)}`, n * B.transportUnit],
['Kraan plaatsen', B.kraan]
];
@@ -385,7 +386,6 @@ window.CostModel = (() => {
const marge = (kostprijs + algemeen) * O.marge / 100;
const exBtw = kostprijs + algemeen + marge;
const btw = exBtw * O.btw / 100;
// Overzicht als rijen (kaart en PDF): sub = subtotaal.
const rows = posts.filter(p => p[1] > 0).map(([label, amount]) => ({ label, amount }))
.concat([
{ label: 'Kostprijs', amount: kostprijs, sub: true },
@@ -402,14 +402,12 @@ window.CostModel = (() => {
};
}
// Bedrag als "€ 1.234" (of met decimalen).
function eur(n, decimals = 0) {
return '€ ' + n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
}
// Uren als decimaal getal, bijv. "12,5".
function uur(hours) {
return hours.toLocaleString('nl-NL', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
}
return { STORAGE_KEY, defaultValues, getSettings, saveSettings, resetSettings, onChange, exportJson, importJson, compute, eur, uur };
return { defaultValues, getSettings, saveSettings, onChange, importFile, compute, eur, uur };
})();
+85 -36
View File
@@ -4,18 +4,12 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// cost/cost-settings.js — CostSettings: venster "Kosten & tijden". Bouwt
// het formulier automatisch uit COST_DEFAULTS (elk item een invoerveld),
// opslaan via CostModel.saveSettings (localStorage). Exporteren/importeren
// als JSON-bestand; "Standaard" zet alle velden terug op cost-defaults.js.
// Alleen UI; de rekenregels staan in CostModel.
// ============================================================
window.CostSettings = (() => {
let overlay = null;
const esc = UI.esc;
const SJABLOON_BALKEN = [[38, 235], [70, 220]];
function build() {
overlay = document.createElement('div');
@@ -26,17 +20,20 @@ window.CostSettings = (() => {
<h2>Kosten &amp; tijden</h2>
<button type="button" class="modal-close-x" data-cs="cancel">&times;</button>
</div>
<div class="cs-pricelist"></div>
<div class="cs-body"></div>
<div class="material-info">Bedragen excl. btw. Opslaan bewaart in deze browser én downloadt een JSON-bestand; een gewijzigd veld is gemarkeerd. Standaardwaarden staan in js/cost/cost-defaults.js.</div>
<div class="material-info">Bedragen excl. btw. De CSV's in kosten/ zijn leidend: Toepassen geldt tot herladen; om een wijziging te houden "intern.csv downloaden" en het bestand in kosten/ vervangen, of met "Wijzigingen downloaden" alleen de aangepaste velden (met de standaardwaarde ernaast) opslaan. Prijzen pas je aan in leveranciers.csv (CSV = daaruit, anders noodwaarde). Een veld dat afwijkt van intern.csv is gemarkeerd.</div>
<div class="modal-footer">
<button type="button" class="io-btn" data-cs="reset">Standaard</button>
<button type="button" class="io-btn" data-cs="export">Exporteren&hellip;</button>
<button type="button" class="io-btn" data-cs="export">intern.csv downloaden</button>
<button type="button" class="io-btn" data-cs="export-changes" title="Alleen de velden die afwijken van intern.csv">Wijzigingen downloaden</button>
<button type="button" class="io-btn" data-cs="import">Importeren&hellip;</button>
<span style="flex:1"></span>
<button type="button" class="io-btn" data-cs="cancel">Annuleren</button>
<button type="button" class="material-toggle-btn active" data-cs="save" style="flex:none; padding:6px 18px;">Opslaan</button>
<button type="button" class="material-toggle-btn active" data-cs="save" style="flex:none; padding:6px 18px;">Toepassen</button>
</div>
<input type="file" accept=".json,application/json" style="display:none">
<input type="file" data-file="import" accept=".csv,.json,text/csv,application/json,text/plain" style="display:none">
<input type="file" data-file="leveranciers" accept=".csv,text/csv,text/plain" style="display:none">
</div>`;
document.body.appendChild(overlay);
overlay.addEventListener('click', (e) => {
@@ -45,39 +42,93 @@ window.CostSettings = (() => {
if (!btn) return;
const a = btn.dataset.cs;
if (a === 'cancel') close();
else if (a === 'save') { // ook als JSON-bestand (om te delen/bewaren)
const v = readForm();
CostModel.saveSettings(v);
download(CostModel.exportJson(v));
else if (a === 'save') {
CostModel.saveSettings(readForm());
close();
}
else if (a === 'reset') fill(CostModel.defaultValues());
else if (a === 'export') download(CostModel.exportJson(readForm()));
else if (a === 'import') overlay.querySelector('input[type=file]').click();
else if (a === 'reset') fill(withLeveranciers(CostModel.defaultValues()));
else if (a === 'export') exportIntern();
else if (a === 'export-changes') exportChanges();
else if (a === 'import') overlay.querySelector('[data-file=import]').click();
else if (a === 'lev-load') overlay.querySelector('[data-file=leveranciers]').click();
else if (a === 'lev-clear') CostCsv.reload('leveranciers');
else if (a === 'lev-template') saveFile(CostCsv.template('leveranciers', readForm(), SJABLOON_BALKEN), 'leveranciers.csv');
});
overlay.addEventListener('input', markChanged);
overlay.querySelector('input[type=file]').addEventListener('change', (e) => {
overlay.querySelector('[data-file=import]').addEventListener('change', (e) => {
const file = e.target.files[0];
e.target.value = '';
if (!file) return;
file.text().then(txt => fill(CostModel.importJson(JSON.parse(txt))))
file.text().then(txt => fill(withLeveranciers(CostModel.importFile(txt))))
.catch(err => alert('Importeren mislukt: ' + err.message));
});
overlay.querySelector('[data-file=leveranciers]').addEventListener('change', (e) => {
const file = e.target.files[0];
e.target.value = '';
if (!file) return;
file.text().then(txt => CostCsv.loadText('leveranciers', txt, file.name))
.catch(err => alert('Leverancierslijst laden mislukt: ' + err.message));
});
CostCsv.onChange(() => {
if (!overlay.classList.contains('open')) return;
const v = readForm();
renderForm();
fill(withLeveranciers(v));
});
overlay.addEventListener('input', markChanged);
}
const levEntry = (g, k) => CostCsv.entry('leveranciers', `${g}.${k}`);
const withLeveranciers = (values) => CostCsv.overlay('leveranciers', values);
function exportIntern() {
saveFile(CostCsv.template('intern', readForm(CostModel.getSettings({ leveranciers: false }))), 'intern.csv');
}
function exportChanges() {
const text = CostCsv.changes(readForm(CostModel.getSettings({ leveranciers: false })), CostModel.defaultValues());
if (!text) return alert('Geen wijzigingen ten opzichte van intern.csv.');
const d = new Date(), p = (n) => String(n).padStart(2, '0');
saveFile(text, `wijzigingen-intern-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}.csv`);
}
function renderStatus() {
const when = (st) => st.loadedAt ? new Date(st.loadedAt).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }) : '';
const block = (list, extra) => {
const st = CostCsv.status(list);
const head = !st.source
? `<b>${esc(st.label)}:</b> geen ${esc(st.url)} gevonden`
: `<b>${esc(st.label)}:</b> ${esc(st.name || '')}${st.source === 'tijdelijk' ? ' (tijdelijk ingeladen, tot herladen)' : ''} · ${st.count} waarden · ${esc(when(st))}`;
const issues = st.errors.map(e => `<li class="err">${esc(e)}</li>`)
.concat(st.unknown.map(u => `<li>regel ${u.line}: code ${esc(u.code)}${u.label ? ` (${esc(u.label)})` : ''} wordt (nog) niet gebruikt</li>`));
return `<div class="cs-list"><div>${head}${extra ? extra(st) : ''}</div>`
+ (issues.length ? `<details><summary>${st.errors.length} fout(en), ${st.unknown.length} onbekende code(s)</summary><ul>${issues.join('')}</ul></details>` : '') + '</div>';
};
overlay.querySelector('.cs-pricelist').innerHTML = block('intern')
+ block('leveranciers', (st) => ` <button type="button" class="io-btn" data-cs="lev-load">Tijdelijk laden&hellip;</button>`
+ `<button type="button" class="io-btn" data-cs="lev-template">Sjabloon</button>`
+ (st.source === 'tijdelijk' ? `<button type="button" class="io-btn" data-cs="lev-clear">Terug naar server</button>` : ''));
}
function renderForm() {
renderStatus();
const body = overlay.querySelector('.cs-body');
body.innerHTML = Object.entries(window.COST_DEFAULTS).map(([g, grp]) => `
<section class="cs-group">
<h3>${esc(grp.label)}</h3>
${Object.entries(grp.items).map(([k, it]) => `
<label class="cs-row">
<span class="cs-label">${esc(it.label)}</span>
${Object.entries(grp.items).map(([k, it]) => {
const lev = levEntry(g, k), int = CostCsv.entry('intern', `${g}.${k}`), price = CostCsv.isPrice(it);
const tip = lev ? `Uit leveranciers.csv${lev.supplier ? ': ' + lev.supplier : ''}${lev.article ? ', art. ' + lev.article : ''}${lev.updated ? ', ' + lev.updated : ''} (regel ${lev.line})`
: price ? `${g}.${k}: niet in leveranciers.csv, noodwaarde uit cost-defaults.js`
: `code ${g}.${k}${int ? ` (intern.csv regel ${int.line})` : ': niet in intern.csv, noodwaarde uit cost-defaults.js'}`;
return `
<label class="cs-row${lev ? ' from-list' : ''}" title="${esc(tip)}">
<span class="cs-label">${esc(CostCsv.labelOf(`${g}.${k}`, it.label))}</span>
${it.options
? `<select data-g="${g}" data-k="${k}">${Object.entries(it.options).map(([v, t]) => `<option value="${esc(v)}">${esc(t)}</option>`).join('')}</select>`
: `<input type="number" step="any" data-g="${g}" data-k="${k}">`}
: `<input type="number" step="any" data-g="${g}" data-k="${k}"${price ? ' disabled' : ''}>`}
<span class="cs-unit">${esc(it.unit || '')}</span>
</label>`).join('')}
</label>`;
}).join('')}
</section>`).join('');
}
@@ -85,36 +136,34 @@ window.CostSettings = (() => {
overlay.querySelectorAll('[data-g]').forEach(el => { el.value = values[el.dataset.g][el.dataset.k]; });
markChanged();
}
function readForm() {
const v = CostModel.getSettings();
overlay.querySelectorAll('[data-g]').forEach(el => {
function readForm(base) {
const v = base || CostModel.getSettings();
overlay.querySelectorAll('[data-g]:not(:disabled)').forEach(el => {
const it = window.COST_DEFAULTS[el.dataset.g].items[el.dataset.k];
const val = it.options ? el.value : parseFloat(el.value);
if (it.options || isFinite(val)) v[el.dataset.g][el.dataset.k] = val;
});
return v;
}
// Afwijkend van de standaard: gemarkeerd.
function markChanged() {
const def = CostModel.defaultValues();
overlay.querySelectorAll('[data-g]').forEach(el => {
const d = def[el.dataset.g][el.dataset.k];
el.closest('.cs-row').classList.toggle('changed', typeof d === 'number' ? parseFloat(el.value) !== d : el.value !== d);
el.closest('.cs-row').classList.toggle('changed', !el.disabled && typeof d === 'number' ? parseFloat(el.value) !== d : el.value !== d);
});
}
function download(obj) {
const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' });
function saveFile(text, name) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `tectum-kosten-${new Date().toISOString().slice(0, 10)}.json`;
a.href = URL.createObjectURL(new Blob([text], { type: 'text/csv' }));
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
function open() {
if (!overlay) build();
renderForm(); // opnieuw: nieuwe items in cost-defaults.js verschijnen vanzelf
renderForm();
fill(CostModel.getSettings());
overlay.classList.add('open');
}