Files
Tectum/js/cost/cost-settings.js
T

174 lines
8.7 KiB
JavaScript

/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
window.CostSettings = (() => {
let overlay = null;
const esc = UI.esc;
const SJABLOON_BALKEN = [[38, 235], [70, 220]];
function build() {
overlay = document.createElement('div');
overlay.id = 'cost-settings-overlay';
overlay.innerHTML = `
<div id="cost-settings-modal">
<div class="modal-header">
<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. 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="ui-btn" data-cs="reset">Standaard</button>
<button type="button" class="ui-btn" data-cs="export">intern.csv downloaden</button>
<button type="button" class="ui-btn" data-cs="export-changes" title="Alleen de velden die afwijken van intern.csv">Wijzigingen downloaden</button>
<button type="button" class="ui-btn" data-cs="import">Importeren&hellip;</button>
<span style="flex:1"></span>
<button type="button" class="ui-btn" data-cs="cancel" data-esc>Annuleren</button>
<button type="button" class="ui-btn ui-btn-primary" data-cs="save">Toepassen</button>
</div>
<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) => {
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') {
CostModel.saveSettings(readForm());
close();
}
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.querySelector('[data-file=import]').addEventListener('change', (e) => {
const file = e.target.files[0];
e.target.value = '';
if (!file) return;
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]) => {
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}"${price ? ' disabled' : ''}>`}
<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(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;
}
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', !el.disabled && typeof d === 'number' ? parseFloat(el.value) !== d : el.value !== d);
});
}
function saveFile(text, name) {
const a = document.createElement('a');
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();
fill(CostModel.getSettings());
overlay.classList.add('open');
}
function close() { if (overlay) overlay.classList.remove('open'); }
return { open, close };
})();