174 lines
8.7 KiB
JavaScript
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 & tijden</h2>
|
|
<button type="button" class="modal-close-x" data-cs="cancel">×</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…</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…</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 };
|
|
})();
|