/*! * Tectum * Copyright © 2026 Machiel Heinen. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ window.CostModel = (() => { const listeners = []; let session = {}; try { localStorage.removeItem('tectum.costs'); localStorage.removeItem('tectum.leveranciers'); } catch (e) { /* oude browseropslag */ } 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 CostCsv.overlay('intern', v); } function mergeValues(base, over) { const v = JSON.parse(JSON.stringify(base)); Object.entries(over || {}).forEach(([g, items]) => { const grp = window.COST_DEFAULTS[g]; if (!grp || !items || typeof items !== 'object') return; Object.entries(items).forEach(([k, val]) => { const it = grp.items[k]; if (!it) return; if (it.options ? Object.prototype.hasOwnProperty.call(it.options, val) : typeof val === 'number' && isFinite(val)) v[g][k] = val; }); }); return v; } 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 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] && !listed(g, k)) (diff[g] = diff[g] || {})[k] = val; })); session = diff; listeners.forEach(fn => fn()); } 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())); 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); } 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']; function compute(parts, totals, settings) { 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; 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 (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 }); }; const Z = S.zagen, V = S.verbinden, P = S.plaat; const sawFactor = Z.methode === 'hand' ? Z.handFactor : 1; 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; const robot = Z.robotStuk + ca.length * Z.robotSnede + angled * Z.robotSchuin; saw.pieces += count; saw.cuts += count * ca.length; saw.angled += count * angled; saw.robotSec += count * robot; return count * robot * sawFactor; }; const sawLabel = Z.methode === 'hand' ? 'Zagen (handmatig)' : 'Zagen (robot)'; const jointSec = V.eersteSchroef + Math.max(0, V.schroevenPerVerbinding - 1) * V.volgendeSchroef; 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`, n, 'st.', n * V.prijsPer1000 / 1000, joints * jointSec * f, V.personeel); }; 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); }; 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 }); 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; 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'); 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++) { const a = studs[i - 1], b = studs[i]; if (b.x - a.x > (a.width + b.width) / 2 + 1) continue; const ov = Math.min(a.y + a.length / 2, b.y + b.length / 2) - Math.max(a.y - a.length / 2, b.y - b.length / 2); if (ov <= 0) continue; dblPairs++; dblScrews += Math.max(2, Math.floor(ov / V.dubbelAfstand)); } 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', 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); addSheet(el, 'Plaatwerk', 'Cementplaat', (st.cementplaatAreaM2 || 0) + (st.trasraamAreaM2 || 0), P.cementplaatM2, P.cementplaatMontage); const I = S.isolatie; const wool = (p.specs && p.specs.voidVolumeM3) || 0; add(el, 'Isolatie', 'Isoleren', 'Glaswol', wool, 'm³', wool * I.glaswolM3, wool * I.glaswolMontage); const stoneIso = (st.stoneIsolatieM3 || 0) + (st.trasraamIsolatieM3 || 0); add(el, 'Isolatie', 'Gevel', 'Isolatieblok steen', stoneIso, 'm³', stoneIso * I.steenIsolatieM3, 0); const G = S.gevel; const doek = st.doekAreaM2 || 0; 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); 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); const K = S.kozijnen; const opKey = p.seam ? `iw${p.id}` : null; if (!opKey || !seenOpenings.has(opKey)) { if (opKey) seenOpenings.add(opKey); (p.config.openings || []).forEach(op => { 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 { add(el, 'Kozijnen', 'Kozijnen', `${op.type === 'voordeur' ? 'Voordeur' : 'Deur'} ${op.w}×${op.h}`, 1, 'st.', op.type === 'voordeur' ? K.voordeur : K.deur, K.montage); } }); } } 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)); function joistLayer(el, cd, angleDeg, what, roof) { const rims = ['front', 'back'].filter(k => cd.rims && cd.rims[k]).length; const n = cd.joists.length; const ang = angleDeg || 0; add(el, 'Hout', 'Zagen', `${sawLabel}: ${what}balken ${n} + kopbalken ${rims}`, 2 * (n + rims), 'sneden', 0, sawSec([ang, ang], n) + sawSec([0, 0], rims)); addJoints(el, n * V.verbindingenPerVloerbalk, `${what}balken`, roof); } parts.units.forEach(u => { const fl = newEl(u.floorElement, `Unit ${u.id} vloer`, u.id); const fs = u.floorSpecs; 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); 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); 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³', 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)); const cap = u.roof.unitRoof && u.roof.unitRoof.cap; if (cap) { const n = cap.beams.length; add(rf, 'Hout', 'Zagen', `${sawLabel}: wolfseinden ${n} balken`, 2 * n, 'sneden', 0, cap.beams.reduce((t, bm) => t + sawSec([bm.angle, bm.angle], 1), 0)); addJoints(rf, n * V.verbindingenPerVloerbalk, 'wolfseindbalken', true); } addSheet(rf, 'Plaatwerk', 'OSB plafond', rs.osbAreaM2, P.osbM2, P.osbMontage); addSheet(rf, 'Plaatwerk', `Dakbeschot OSB ${RoofModel.BESCHOT_MM} mm`, rs.beschotM2 || 0, D.beschotM2, D.beschotMontage); add(rf, 'Isolatie', 'Isoleren', 'Glaswol dak', rs.voidM3, 'm³', rs.voidM3 * S.isolatie.glaswolM3, rs.voidM3 * S.isolatie.glaswolMontage); 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); } }); const wo = newEl('WON', 'Woning (dakrand, fundering, verlichting)', null); const D = S.dak; if (totals.roofEdge) { const e = totals.roofEdge; add(wo, 'Dak', 'Dak', `Boeideel ${e.fasciaHeight} mm`, e.fasciaLengthM, 'm', e.fasciaLengthM * D.boeideelM, e.fasciaLengthM * D.boeideelMontage); add(wo, 'Dak', 'Dak', 'Onderplaat boeideel', e.soffitM2, 'm²', e.soffitM2 * D.onderplaatM2, e.soffitM2 * D.onderplaatMontage); add(wo, 'Dak', 'Dak', `Daktrim ${e.trimHeight} mm`, e.trimLengthM, 'm', e.trimLengthM * D.daktrimM, e.trimLengthM * D.daktrimMontage); } if (parts.sandwich) { const sw = parts.sandwich; add(wo, 'Vloer', 'Vloer', `Sandwichpanelen (${sw.panelCount} platen)`, sw.areaM2, 'm²', sw.areaM2 * S.vloer.sandwichM2, sw.panelCount * S.vloer.sandwichMontage); } const L = S.verlichting, lm = totals.lamps; add(wo, 'Verlichting', 'Verlichting', 'Gevellampen', lm.total, 'st.', lm.total * L.gevellamp, lm.total * L.montage); const spots = totals.roofEdge ? totals.roofEdge.spots : 0; 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); const elk = parts.elektra, E = S.elektra; if (elk && elk.enabled) { const unitIds = new Set(parts.units.map(u => u.id)), elOf = {}; const elFor = (uid) => { const id = unitIds.has(uid) ? uid : null, k = id || '-'; return elOf[k] || (elOf[k] = newEl(id ? `${id}-EL` : 'EL', id ? `Unit ${id} elektra` : 'Elektra', id)); }; const ea = (uid, label, qty, unit, mat, sec) => add(elFor(uid), 'Elektra', 'Elektra', label, qty, unit, mat, sec, E.personeel); const mkUnit = elk.meterkast ? elk.meterkast.unitId : null; const groupUnit = (nr) => (elk.groups.find(g => g.nr === nr) || {}).unitId || mkUnit; const buisPrijs = (d) => d >= 25 ? E.buis25M : d >= 19 ? E.buis19M : E.buis16M; const agg = {}; elk.runs.forEach(r => { const uid = groupUnit(r.group), a = agg[uid] = agg[uid] || { types: {}, holes: 0, cross: {} }; a.types[r.type] = (a.types[r.type] || 0) + r.lengthMM / 1000; a.holes += r.holes || 0; if (r.crossings) { const n = ElektraModel.cableInfo(r.type).cores.length; a.cross[n] = (a.cross[n] || 0) + r.crossings; } }); Object.keys(agg).forEach(uid => { const a = agg[uid]; Object.keys(a.types).sort().forEach(type => { 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()}`, 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', 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); }); }); const count = (list, uid) => list.filter(x => x.unitId === uid).length; parts.units.forEach(u => { const uid = u.id; const nSw = count(elk.switches, uid), combos = elk.combos.filter(c => c.unitId === uid); const raam1 = combos.filter(c => c.switches.length === 1).length, raam2 = combos.length - raam1; ea(uid, `Schakelaars (${raam1}× 1-voudig, ${raam2}× 2-voudig raam)`, nSw, 'st.', nSw * (E.schakelaar + E.inbouwdoos) + raam1 * E.afdekraam1 + raam2 * E.afdekraam2, nSw * (E.doosPlaatsen + E.afmonteren)); const nW = count(elk.wcds, uid); ea(uid, 'Wandcontactdozen dubbel (2 inbouwdozen)', nW, 'st.', nW * (E.wcdDubbel + 2 * E.inbouwdoos), nW * (2 * E.doosPlaatsen + E.afmonteren)); elk.apps.filter(a => a.unitId === uid).forEach(a => { const price = a.app === 'koken' ? E.perilex : a.app === 'warmtepomp' ? E.werkschakelaar : E.wcdEnkel; ea(uid, `Aansluitpunt ${a.label.toLowerCase()}`, 1, 'st.', price + E.inbouwdoos, E.doosPlaatsen + E.afmonteren); }); const nV = count(elk.floorWcds, uid); ea(uid, 'Vloerdoos met dubbele wcd', nV, 'st.', nV * E.vloerdoosSt, nV * (E.vloerdoos + E.afmonteren)); const nB = count(elk.boxes, uid), nKl = nB * 3 + nSw + nW; ea(uid, `Verdeeldozen plafond (+ ${nKl} lasklemmen)`, nB, 'st.', nB * E.verdeeldoosSt + nKl * E.lasklem, nB * E.verdeeldoos); const nSpot = elk.switches.filter(sw => sw.unitId === uid).reduce((t, sw) => t + sw.lamps.filter(l => l.kind === 'binnenspot').length, 0); ea(uid, 'Spotdozen plafond (luchtdicht)', nSpot, 'st.', nSpot * E.spotdoosSt, nSpot * E.spotdoos); const nDv = elk.runs.filter(r => r.type === 'buiten' && r.from.startsWith('VD') && groupUnit(r.group) === uid).length; ea(uid, 'Kabeldoorvoer naar buiten', nDv, 'st.', nDv * E.doorvoer, 0); }); if (elk.meterkast) { const g1 = elk.groups.filter(g => (g.poles || 1) === 1).length, g2 = elk.groups.length - g1; ea(mkUnit, `Groepenkast 3×25 A: kast, hoofdschakelaar, ${elk.rcdCount} aardlek`, 1, 'st.', E.kast + E.hoofdschakelaar + elk.rcdCount * E.aardlek, E.kastMontage); ea(mkUnit, `Groepen: ${g1}× automaat 1P+N${g2 ? `, ${g2}× 2-fase` : ''}`, elk.groups.length, 'groep', g1 * E.automaat + g2 * E.automaat2f, elk.groups.length * E.groepMontage); ea(mkUnit, 'Meten, testen en opleveren', 1, 'woning', 0, E.keuring); } } 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); 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); if ((f.cost === 'keuken' || f.cost === 'kookeiland') && f.opts && f.opts.kookplaat) add(el, 'Inrichting', 'Inrichting', 'Inductiekookplaat', 1, 'st.', IN.kookplaat || 0, 0); }); 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) => { const m = new Map(); elements.forEach(el => el.lines.forEach(l => { const r = m.get(l[key]) || { name: l[key], mat: 0, sec: 0 }; r.mat += l.mat; r.sec += l.sec; m.set(l[key], r); })); return Array.from(m.values()).sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name)); }; // 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; return { direct, indirect, qa, total: direct + indirect + qa }; }; const prodHours = (clock) => clock / 3600 * (1 + A.toeslagUren / 100); const costOf = (r) => mat0(r.mat) + split(r.sec).total * A.uurloon + prodHours(r.clock) * perHour; const mat0 = (mat) => mat * (1 + (O.verlies + O.klein) / 100); elements.forEach(el => { el.kostprijs = costOf(el); }); const units = parts.units.map(u => { const r = Object.assign({ id: u.id }, sumLines(elements.filter(el => el.unit === u.id).flatMap(el => el.lines))); r.transport = B.transportUnit; r.kostprijs = costOf(r) + r.transport; return r; }); const rest = sumLines(elements.filter(el => el.unit === null).flatMap(el => el.lines)); units.push(Object.assign({ id: null, transport: 0 }, rest, { kostprijs: costOf(rest) + B.kraan })); const all = sumLines(elements.flatMap(el => el.lines)); const h = split(all.sec); h.prod = prodHours(all.clock); const n = parts.units.length; const pct = (v) => String(v).replace('.', ',') + '%'; const eur0 = (v) => eur(v); const posts = [ ['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)}`, 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] ]; const kostprijs = posts.reduce((a, p) => a + p[1], 0); const algemeen = kostprijs * O.algemeen / 100; const marge = (kostprijs + algemeen) * O.marge / 100; const exBtw = kostprijs + algemeen + marge; const btw = exBtw * O.btw / 100; const rows = posts.filter(p => p[1] > 0).map(([label, amount]) => ({ label, amount })) .concat([ { label: 'Kostprijs', amount: kostprijs, sub: true }, { label: `Algemene kosten ${pct(O.algemeen)}`, amount: algemeen }, { label: `Marge ${pct(O.marge)}`, amount: marge }, { label: 'Totaal excl. btw', amount: exBtw, sub: true }, { label: `Btw ${pct(O.btw)}`, amount: btw } ]); return { settings: S, elements, units, saw: Object.assign(saw, { handSec: saw.robotSec * Z.handFactor, method: Z.methode }), categories: byKey('cat', CAT_ORDER), activities: byKey('act', ACT_ORDER), summary: { mat: all.mat, hoursDirect: h.direct, hoursIndirect: h.indirect, hoursQa: h.qa, hours: h.total, kostprijs, exBtw, btw, inclBtw: exBtw + btw, rows } }; } function eur(n, decimals = 0) { return '€ ' + n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); } function uur(hours) { return hours.toLocaleString('nl-NL', { minimumFractionDigits: 1, maximumFractionDigits: 1 }); } return { defaultValues, getSettings, saveSettings, onChange, importFile, compute, eur, uur }; })();