v0.5.2: elektra (meterkast 3×25 A, schakelaars met letters op hun lampen, wcd, apparaatgroepen incl. perilex, groepenverklaring met fasen/aardlek, leidingen naar de meterkast met strekkende meters per type, PDF-blad + plattegrond, verplaatsbaar door slepen, bouwfase Elektra met installatiebuizen in 3D); ribbon, bouwfasen, kozijnen, kostenmodel en eerdere ongecommitte wijzigingen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8cdfa21f46
commit
e1a688728c
@@ -0,0 +1,185 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* 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.
|
||||
// ============================================================
|
||||
|
||||
window.COST_DEFAULTS = {
|
||||
arbeid: {
|
||||
label: 'Arbeid',
|
||||
items: {
|
||||
uurloon: { label: 'Uurloon productie (all-in)', unit: '€/u', value: 45 },
|
||||
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 }
|
||||
}
|
||||
},
|
||||
|
||||
zagen: {
|
||||
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 },
|
||||
handFactor: { label: 'Handmatig: factor op de robottijd', unit: '×', value: 7 }
|
||||
}
|
||||
},
|
||||
|
||||
verbinden: {
|
||||
label: 'Verbindingen (regelwerk)',
|
||||
items: {
|
||||
schroevenPerVerbinding: { label: 'Schroeven per verbinding (stijl/dorpel op regel)', unit: 'st.', value: 3 },
|
||||
eersteSchroef: { label: 'Tijd eerste schroef (gat + schroef + uitlijnen)', unit: 's', value: 10 },
|
||||
volgendeSchroef: { label: 'Tijd volgende schroef (gat + schroef)', unit: 's', value: 5 },
|
||||
prijsPer1000: { label: 'Inkoop constructieschroeven (6×120)', unit: '€/1000', value: 45 },
|
||||
verbindingenPerVloerbalk: { label: 'Verbindingen per vloer-/dakbalk', unit: 'st.', value: 2 },
|
||||
dubbelAfstand: { label: 'Dubbele stijl (deur, hoek): schroef verticaal elke', unit: 'mm', value: 100 },
|
||||
monteerFactor: { label: 'Monteren: factor op de schroeftijd (incl. lijmen, positioneren)', unit: '×', value: 6 },
|
||||
personeel: { label: 'Personeel bij monteren constructie (vloer, wand, dak; i.p.v. de bezetting)', unit: 'man', value: 2 },
|
||||
dakFactor: { label: 'Dak: factor op de tijd per schroef (moeilijker werken)', unit: '×', value: 4 }
|
||||
}
|
||||
},
|
||||
|
||||
montage: {
|
||||
label: 'Montage',
|
||||
items: {
|
||||
muurVastzetten: { label: 'Muursectie plaatsen en vastzetten', unit: 'min', value: 10 },
|
||||
muurPersoneel: { label: 'Personeel bij plaatsen muursectie (i.p.v. de bezetting)', unit: 'man', value: 3 }
|
||||
}
|
||||
},
|
||||
|
||||
hout: {
|
||||
label: 'Hout',
|
||||
items: {
|
||||
constructieM3: { label: 'Constructiehout vuren C24 (regelwerk, balken)', unit: '€/m³', value: 550 },
|
||||
latwerkM: { label: 'Latwerk (kruislat)', unit: '€/m', value: 0.9 },
|
||||
latStukMontage: { label: 'Latwerk: per gezaagd stuk (meten, zagen op locatie, passen)', unit: 's', value: 240 },
|
||||
latMeterMontage: { label: 'Latwerk: bevestigen per strekkende meter', unit: 's/m', value: 30 }
|
||||
}
|
||||
},
|
||||
|
||||
plaat: {
|
||||
label: 'Plaatwerk',
|
||||
items: {
|
||||
spaanplaatM2: { label: 'Spaanplaat', unit: '€/m²', value: 9 },
|
||||
spaanplaatMontage: { label: 'Montage spaanplaat (passen, zagen, plaatsen)', unit: 's/m²', value: 120 },
|
||||
osbM2: { label: 'OSB (vloeren, plafonds dak)', unit: '€/m²', value: 12 },
|
||||
osbMontage: { label: 'Montage OSB', unit: 's/m²', value: 90 },
|
||||
cementplaatM2: { label: 'Cementgebonden plaat (gevel, trasraam)', unit: '€/m²', value: 28 },
|
||||
cementplaatMontage: { label: 'Montage cementplaat', unit: 's/m²', value: 240 },
|
||||
schroevenPerM2: { label: 'Plaatschroeven per m²', unit: 'st./m²', value: 15 },
|
||||
tijdPerPlaatschroef: { label: 'Tijd per plaatschroef', unit: 's', value: 4 },
|
||||
plaatschroefPer1000: { label: 'Inkoop plaatschroeven (4×40)', unit: '€/1000', value: 15 }
|
||||
}
|
||||
},
|
||||
|
||||
isolatie: {
|
||||
label: 'Isolatie',
|
||||
items: {
|
||||
glaswolM3: { label: 'Glaswol', unit: '€/m³', value: 40 },
|
||||
glaswolMontage: { label: 'Montage glaswol', unit: 's/m³', value: 900 },
|
||||
steenIsolatieM3: { label: 'Isolatieblok steengevel', unit: '€/m³', value: 120 }
|
||||
}
|
||||
},
|
||||
|
||||
gevel: {
|
||||
label: 'Gevel',
|
||||
items: {
|
||||
vochtdoekM2: { label: 'Vochtdoek', unit: '€/m²', value: 1.5 },
|
||||
vochtdoekMontageM: { label: 'Montage vochtdoek per strekkende meter wand', unit: 's/m', value: 600 },
|
||||
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 }
|
||||
}
|
||||
},
|
||||
|
||||
dak: {
|
||||
label: 'Dak & dakrand',
|
||||
items: {
|
||||
dakleerM2: { label: 'Dakleer (EPDM)', unit: '€/m²', value: 18 },
|
||||
dakleerMontage: { label: 'Montage dakleer', unit: 's/m²', value: 600 },
|
||||
rietM2: { label: 'Riet', unit: '€/m²', value: 90 },
|
||||
rietMontage: { label: 'Montage riet', unit: 's/m²', value: 3600 },
|
||||
boeideelM: { label: 'Boeideel', unit: '€/m', value: 25 },
|
||||
boeideelMontage: { label: 'Montage boeideel', unit: 's/m', value: 600 },
|
||||
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 }
|
||||
}
|
||||
},
|
||||
|
||||
vloer: {
|
||||
label: 'Vloer & fundering',
|
||||
items: {
|
||||
afwerkingM2: { label: 'Vloerafwerking', unit: '€/m²', value: 35 },
|
||||
afwerkingMontage: { label: 'Montage vloerafwerking', unit: 's/m²', value: 1200 },
|
||||
sandwichM2: { label: 'Sandwichpaneel', unit: '€/m²', value: 35 },
|
||||
sandwichMontage: { label: 'Montage per sandwichpaneel', unit: 's', value: 600 }
|
||||
}
|
||||
},
|
||||
|
||||
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 },
|
||||
montage: { label: 'Montage per kozijn', unit: 's', value: 5400 }
|
||||
}
|
||||
},
|
||||
|
||||
verlichting: {
|
||||
label: 'Verlichting',
|
||||
items: {
|
||||
gevellamp: { label: 'Gevellamp', unit: '€/st.', value: 45 },
|
||||
boeideelSpot: { label: 'Boeideel-spot', unit: '€/st.', value: 25 },
|
||||
binnenspot: { label: 'Binnenspot', unit: '€/st.', value: 20 },
|
||||
montage: { label: 'Montage per lamp/spot (incl. bekabeling)', unit: 's', value: 900 }
|
||||
}
|
||||
},
|
||||
|
||||
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 },
|
||||
transportUnit: { label: 'Transport per unit naar de bouwplaats', unit: '€/unit', value: 750 },
|
||||
kraan: { label: 'Kraan voor plaatsen woning', unit: '€', value: 1200 }
|
||||
}
|
||||
},
|
||||
|
||||
opslag: {
|
||||
label: 'Opslagen',
|
||||
items: {
|
||||
verlies: { label: 'Materiaalverlies (beschadiging, misgezaagd, restmateriaal; naast het berekende snijverlies balken)', unit: '%', value: 5 },
|
||||
klein: { label: 'Klein materiaal (lijm, kit, tape, nieten)', unit: '%', value: 3 },
|
||||
algemeen: { label: 'Algemene kosten (kantoor, verzekering, garantie) over de kostprijs', unit: '%', value: 5 },
|
||||
marge: { label: 'Winstmarge', unit: '%', value: 15 },
|
||||
btw: { label: 'Btw', unit: '%', value: 21 }
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,346 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* 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 = [];
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
// 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]) => {
|
||||
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;
|
||||
}
|
||||
function readOverrides() {
|
||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') || {}; } catch (e) { return {}; }
|
||||
}
|
||||
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;
|
||||
}));
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(diff)); } catch (e) { /* geen opslag: alleen deze sessie */ }
|
||||
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); }; }
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Berekening
|
||||
// ------------------------------------------------------------
|
||||
const CAT_ORDER = ['Hout', 'Schroeven', 'Plaatwerk', 'Isolatie', 'Gevel', 'Dak', 'Vloer', 'Kozijnen', 'Verlichting'];
|
||||
const ACT_ORDER = ['Zagen', 'Regelwerk monteren', 'Wanden plaatsen', 'Plaatwerk', 'Isoleren', 'Gevel', 'Latwerk', 'Dak', 'Vloer', 'Kozijnen', 'Verlichting'];
|
||||
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; crew = personeel bij die stap (standaard
|
||||
// de gemiddelde bezetting per productiestap). Opgeslagen sec = manuren.
|
||||
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.
|
||||
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;
|
||||
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;
|
||||
// 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);
|
||||
};
|
||||
// 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 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 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++) {
|
||||
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}× (schroef elke ${V.dubbelAfstand} mm, ×${V.monteerFactor})`, 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);
|
||||
|
||||
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;
|
||||
// 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 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 => {
|
||||
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));
|
||||
|
||||
// --- 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;
|
||||
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 => {
|
||||
// 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);
|
||||
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);
|
||||
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;
|
||||
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);
|
||||
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);
|
||||
});
|
||||
|
||||
// --- Woning-breed: dakrand, sandwichpanelen, verlichting ---
|
||||
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);
|
||||
|
||||
// --- Optellen ---
|
||||
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));
|
||||
};
|
||||
// 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.
|
||||
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
|
||||
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)} (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],
|
||||
[`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;
|
||||
// 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 },
|
||||
{ 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 }
|
||||
};
|
||||
}
|
||||
|
||||
// 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 };
|
||||
})();
|
||||
@@ -0,0 +1,124 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* 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;
|
||||
|
||||
function build() {
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'cost-settings-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div id="cost-settings-modal">
|
||||
<div class="modal-header">
|
||||
<h2>Kosten & tijden</h2>
|
||||
<button type="button" class="modal-close-x" data-cs="cancel">×</button>
|
||||
</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="modal-footer">
|
||||
<button type="button" class="io-btn" data-cs="reset">Standaard</button>
|
||||
<button type="button" class="io-btn" data-cs="export">Exporteren…</button>
|
||||
<button type="button" class="io-btn" data-cs="import">Importeren…</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>
|
||||
</div>
|
||||
<input type="file" accept=".json,application/json" style="display:none">
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) return close();
|
||||
const btn = e.target.closest('[data-cs]');
|
||||
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));
|
||||
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();
|
||||
});
|
||||
overlay.addEventListener('input', markChanged);
|
||||
overlay.querySelector('input[type=file]').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))))
|
||||
.catch(err => alert('Importeren mislukt: ' + err.message));
|
||||
});
|
||||
}
|
||||
|
||||
function renderForm() {
|
||||
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>
|
||||
${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}">`}
|
||||
<span class="cs-unit">${esc(it.unit || '')}</span>
|
||||
</label>`).join('')}
|
||||
</section>`).join('');
|
||||
}
|
||||
|
||||
function fill(values) {
|
||||
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 => {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
function download(obj) {
|
||||
const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `tectum-kosten-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!overlay) build();
|
||||
renderForm(); // opnieuw: nieuwe items in cost-defaults.js verschijnen vanzelf
|
||||
fill(CostModel.getSettings());
|
||||
overlay.classList.add('open');
|
||||
}
|
||||
function close() { if (overlay) overlay.classList.remove('open'); }
|
||||
|
||||
return { open, close };
|
||||
})();
|
||||
Reference in New Issue
Block a user