All
Suave
Intenso
Product Lotes (WWDDYYPCP · optional PLU)
Container/Bandeja Lotes
Plastic Cover Lotes (family-specific; optional PLU)
Generated ISO (preview)
—
Sequence policy applies when confirmed.
Upload Ticket Photo
Parsed (OCR)
—
Machine Reports
📦 Family Boxes (count once)
0
🎯 Total Pieces
0
⚖️ Total Weight (kg)
0.000
🗑️ Total Waste (kg)
0.000
✅ Yield %
100.0%
🧪 Machines Pass Rate
0/0
Platforms → Orders → Families → Sections (scroll-friendly)
—
Select Platforms & Orders
Suave
Intenso
Delivery date is required to enable Apply; ISO previews will recalc if you change the date later.
Brand
ISO
Platforms
Weights & PPB
OCR & Tickets
Waste
Data
Entries
${p.number}
${p.name}
Boxes
kg/pc
PPB
pcs: 0 · weight: 0.000 kg
${section.name}
${slicesHTML || '
No slices yet.
'}${family}
No sections (add one).
'}
Order
No slices yet.
';
}
renderKPI();
saveAll({silent:true});
}
}
});
// bind existing slice rows
section.slices.forEach(sl=>{
const slEl = U.$(`[data-slice-id="${sl.id}"]`, secEl);
bindSliceRowEvents(plat, order, family, section, sl, slEl);
});
}
function bindSliceRowEvents(plat, order, family, section, sl, slEl){
if (!slEl) return;
// selects (lotes)
const selPlote = U.$('select[data-fld="plote"]', slEl);
const selClote = U.$('select[data-fld="clote"]', slEl);
const selPlst = U.$('select[data-fld="plst"]', slEl);
if (selPlote){ selPlote.value = sl.productLoteId || ''; selPlote.addEventListener('change', e=>{ sl.productLoteId = e.target.value || null; saveAll({silent:true}); }); }
if (selClote){ selClote.value = sl.containerLoteId || ''; selClote.addEventListener('change', e=>{ sl.containerLoteId = e.target.value || null; saveAll({silent:true}); }); }
if (selPlst ){ selPlst .value = sl.plasticLoteId || ''; selPlst .addEventListener('change', e=>{ sl.plasticLoteId = e.target.value || null; saveAll({silent:true}); }); }
// inputs
const fldBoxes = U.$('input[data-fld="boxes"]', slEl);
const fldKgpc = U.$('input[data-fld="kgpc"]', slEl);
const fldPPB = U.$('input[data-fld="ppb"]', slEl);
const fldNotes = U.$('input[data-fld="notes"]', slEl);
if (fldBoxes){ fldBoxes.addEventListener('input', ()=>{ sl.boxes = U.safeNum(fldBoxes.value,0); recalcSliceDisplay(slEl, family, section, sl); renderKPI(); saveAll({silent:true}); }); }
if (fldKgpc ){ fldKgpc .addEventListener('input', ()=>{ sl.kgpc = U.safeNum(fldKgpc.value,''); recalcSliceDisplay(slEl, family, section, sl); renderKPI(); saveAll({silent:true}); }); }
if (fldPPB ){ fldPPB .addEventListener('input', ()=>{ sl.ppb = U.safeNum(fldPPB.value,PPB_DEFAULT); recalcSliceDisplay(slEl, family, section, sl); renderKPI(); saveAll({silent:true}); }); }
if (fldNotes){ fldNotes.addEventListener('input', ()=>{ sl.notes = fldNotes.value; saveAll({silent:true}); }); }
}
function recalcSliceDisplay(slEl, family, section, sl){
if (!slEl) return;
const ppb = Number.isFinite(sl.ppb) ? sl.ppb : PPB_DEFAULT;
const kgpc = (sl.kgpc !== '' && Number.isFinite(sl.kgpc)) ? sl.kgpc : (weightsCfg()?.[family]?.[section.name] ?? 0);
const pcs = U.safeNum(sl.boxes,0) * ppb;
const w = (kgpc ? pcs * kgpc : 0);
const out = U.$('[data-fld="calc"]', slEl);
if (out) out.textContent = `pcs: ${pcs} · weight: ${U.fmt3(w)} kg`;
}
// ---------- order-level actions (duplicate / delete) ----------
root.addEventListener('click', (e)=>{
const btn = e.target.closest('.order button[data-act]');
if (!btn) return;
const act = btn.dataset.act;
const orderEl = btn.closest('.order');
const platformEl = btn.closest('[data-platform-id]');
if (!orderEl || !platformEl) return;
const orderId = orderEl.dataset.orderId;
const pid = platformEl.dataset.platformId;
const plat = state.platforms.find(p=>p.id===pid);
if (!plat) return;
const idx = plat.orders.findIndex(o=>o.id===orderId);
if (idx<0) return;
if (act === 'del-order'){
plat.orders.splice(idx,1);
// if no orders, auto add one (keep UX simple)
if (!plat.orders.length) plat.orders.push(M.newOrder());
renderOrdersForPlatform(pid);
renderKPI();
saveAll({silent:true});
}
if (act === 'dup-order'){
const copy = JSON.parse(JSON.stringify(plat.orders[idx]));
copy.id = U.uid('ord');
// new IDs for sections & slices to avoid collisions
['Suave','Intenso'].forEach(f=>{
copy.families[f].sections.forEach(s=>{
const oldSid = s.id;
s.id = U.uid('sec');
s.slices.forEach(sl=> sl.id = U.uid('sl'));
});
});
plat.orders.splice(idx+1, 0, copy);
renderOrdersForPlatform(pid);
renderKPI();
saveAll({silent:true});
}
});
// ---------- add-section (per family) ----------
root.addEventListener('click', (e)=>{
const btn = e.target.closest('.family button[data-act="add-section"]');
if (!btn) return;
const family = btn.dataset.family;
const orderEl = btn.closest('.order');
const platformEl = btn.closest('[data-platform-id]');
if (!family || !orderEl || !platformEl) return;
const pid = platformEl.dataset.platformId;
const orderId = orderEl.dataset.orderId;
const plat = state.platforms.find(p=>p.id===pid);
const ord = plat?.orders.find(o=>o.id===orderId);
if (!ord) return;
// choose next available section name from catalog
const catalog = U.SECTIONS[family] || [];
const used = new Set(ord.families[family].sections.map(s=>s.name));
const next = catalog.find(n => !used.has(n)) || catalog[0] || 'Section';
ord.families[family].sections.push(M.newSection(next));
renderOrdersForPlatform(pid);
renderKPI();
saveAll({silent:true});
});
// ---------- utilities to refresh lotes selects after Lote Manager changes ----------
bus.on(EVT.LOTES_CHANGED, ()=>{
// re-seed every select that depends on lotes
U.$all('select[data-fld="plote"]', root).forEach(sel=>{
const fam = sel.closest('.family')?.dataset.family || 'Suave';
const val = sel.value;
seedProductLoteSelect(sel, fam);
if (val) sel.value = val; // try to keep if still exists
});
U.$all('select[data-fld="clote"]', root).forEach(sel=>{
const fam = sel.closest('.family')?.dataset.family || 'Suave';
const val = sel.value;
seedContainerLoteSelect(sel, fam);
if (val) sel.value = val;
});
U.$all('select[data-fld="plst"]', root).forEach(sel=>{
const fam = sel.closest('.family')?.dataset.family || 'Suave';
const val = sel.value;
seedPlasticLoteSelect(sel, fam);
if (val) sel.value = val;
});
});
// ---------- after this section loads, re-render platforms to attach order UIs ----------
renderPlatforms();
/* ============================================================================
JS SECTION 4/? — Machines · Tickets & OCR · ISO preview · Reports
(continues; do NOT close the script tag)
============================================================================ */
// ---------------- ISO helpers (local, safe) ----------------
const Iso = (()=>{
function _getDeliveryDate(){
const v = state.deliveryDate || U.$('#olmo-delivery-date', root)?.value || '';
if (!v) return null;
const dt = new Date(`${v}T00:00:00`);
return Number.isFinite(dt.getTime()) ? dt : null;
}
function weeksSinceJan1(dt){
const start = new Date(dt.getFullYear(),0,1);
const dayOfYear = Math.floor((dt - start)/86400000) + 1;
return Math.floor((dayOfYear - 1)/7) + 1;
}
// DD rule: mercadona-dow (Mon=01 … Sun=07). Alternative rules can be added.
function dayCode(dt, rule = 'mercadona-dow'){
let d = dt.getDay(); // 0..6
if (rule === 'mercadona-dow'){
d = (d === 0) ? 7 : d; // Sun→7
return String(d).padStart(2,'0');
}
// fallback: calendar-dom (01..31)
return String(dt.getDate()).padStart(2,'0');
}
function last2Year(dt){ return String(dt.getFullYear()%100).padStart(2,'0'); }
function getPlatformRecord(pn){
const rec = state.platformMap?.[pn];
if (rec) return rec;
// soft fallback if user added platforms but not saved to map:
const p = state.platforms.find(x=>String(x.number)===String(pn));
if (!p) return null;
return {
name: p.name || `PN ${pn}`,
rddx: p.rddx || 3,
plu: { Suave: p.pluSuave || '2284', Intenso: p.pluIntenso || '2283' }
};
}
function core(pn, family){
const dt = _getDeliveryDate();
if (!dt) return null;
const WW = String(weeksSinceJan1(dt)).padStart(2,'0');
const DD = dayCode(dt, state.settings?.ocr?.ddRule || 'mercadona-dow');
const YY = last2Year(dt);
const PN = String(pn).padStart(2,'0');
const prec = getPlatformRecord(PN) || { rddx: 3, plu:{Suave:'2284', Intenso:'2283'} };
const RDN = `R${String(prec.rddx).padStart(2,'0')}`;
const PLU = (family==='Suave') ? (prec.plu?.Suave || '2284') : (prec.plu?.Intenso || '2283');
return { WW, DD, YY, PN, RDN, PLU };
}
function preview(pn, family){
const c = core(pn, family);
if (!c) return '— (set Delivery Date)';
const seqOn = !!state.settings?.iso?.appendSeq;
const digits = Number(state.settings?.iso?.seqDigits || 4);
const seq = seqOn ? ' ' + String((state._isoSeq?.[`${pn}|${family}`]||0)+1).padStart(digits,'0') : '';
return `${c.WW}${c.DD}${c.YY}${c.PN} ${c.RDN} ${c.PLU}${seq}`;
}
function confirmAndBump(pn, family){
if (!state._isoSeq) state._isoSeq = {};
const key = `${pn}|${family}`;
const digits = Number(state.settings?.iso?.seqDigits || 4);
const c = core(pn, family);
if (!c) return null;
const seqOn = !!state.settings?.iso?.appendSeq;
if (seqOn){
const cur = Number(state._isoSeq[key]||0) + 1;
state._isoSeq[key] = cur;
return `${c.WW}${c.DD}${c.YY}${c.PN} ${c.RDN} ${c.PLU} ${String(cur).padStart(digits,'0')}`;
}
return `${c.WW}${c.DD}${c.YY}${c.PN} ${c.RDN} ${c.PLU}`;
}
return { preview, confirmAndBump, core };
})();
// ---------------- Machines UI: selectors & previews ----------------
const mach = {
selPlatform: U.$('#olmo-mach-platform', root),
selOrder: U.$('#olmo-mach-order', root),
selFamily: U.$('#olmo-mach-family', root),
selSection: U.$('#olmo-mach-section', root),
selProfile: U.$('#olmo-ocr-profile', root),
fileInput: U.$('#olmo-mach-photo', root),
previews: U.$('#olmo-mach-photo-previews', root),
ocrParsed: U.$('#olmo-mach-ocr-parsed', root),
ocrText: U.$('#olmo-mach-ocr-text', root),
isoPreview: U.$('#olmo-mach-iso-preview', root),
btnAdd: U.$('button[data-action="machines-add-report"]', root),
listHost: U.$('#olmo-machine-reports', root),
};
function seedMachPlatform(){
if (!mach.selPlatform) return;
mach.selPlatform.innerHTML = '';
const base = document.createElement('option');
base.value = ''; base.textContent = '(choose)';
mach.selPlatform.appendChild(base);
state.platforms.forEach(p=>{
const o = document.createElement('option');
o.value = p.id;
o.textContent = `${String(p.number).padStart(2,'0')} — ${p.name}`;
mach.selPlatform.appendChild(o);
});
seedMachOrder(); // will clear
}
function seedMachOrder(){
if (!mach.selOrder) return;
mach.selOrder.innerHTML = '';
const pid = mach.selPlatform?.value || '';
const plat = state.platforms.find(p=>p.id===pid);
const base = document.createElement('option');
base.value=''; base.textContent='(choose)';
mach.selOrder.appendChild(base);
(plat?.orders || []).forEach(o=>{
const kpi = countOrderSlices(o);
const op = document.createElement('option');
op.value = o.id;
op.textContent = `Order ${o.id.slice(-4)} · ${kpi.boxes} boxes`;
mach.selOrder.appendChild(op);
});
seedMachSections();
refreshIsoPreview();
refreshMachAddEnabled();
}
function seedMachSections(){
if (!mach.selSection) return;
const pid = mach.selPlatform?.value || '';
const oid = mach.selOrder?.value || '';
const fam = mach.selFamily?.value || 'Suave';
mach.selSection.innerHTML = '';
const base = document.createElement('option');
base.value=''; base.textContent='(any)';
mach.selSection.appendChild(base);
const ord = state.platforms.find(p=>p.id===pid)?.orders.find(o=>o.id===oid);
if (!ord) return;
(ord.families?.[fam]?.sections || []).forEach(s=>{
const o = document.createElement('option');
o.value = s.id; o.textContent = s.name;
mach.selSection.appendChild(o);
});
}
function refreshIsoPreview(){
const pid = mach.selPlatform?.value || '';
const fam = mach.selFamily?.value || 'Suave';
if (!pid){
mach.isoPreview.textContent = '—';
return;
}
const pn = state.platforms.find(p=>p.id===pid)?.number || '';
mach.isoPreview.textContent = Iso.preview(pn, fam);
}
function refreshMachAddEnabled(){
const ok = !!(mach.selPlatform?.value && mach.selOrder?.value && state.deliveryDate);
mach.btnAdd.disabled = !ok;
}
// small KPI helper for a single order
function countOrderSlices(o){
let boxes=0;
['Suave','Intenso'].forEach(f=>{
o.families[f].sections.forEach(s=>{
s.slices.forEach(sl=> boxes += Number(sl.boxes||0));
});
});
return { boxes };
}
// ---------------- Ticket photos preview ----------------
function clearPreviews(){ mach.previews.innerHTML=''; }
function addPreview(file){
const url = URL.createObjectURL(file);
const img = document.createElement('img');
img.src = url;
img.alt = file.name;
img.style.maxHeight = '80px';
img.style.border = '1px solid var(--line)';
img.style.borderRadius = '6px';
img.style.padding = '2px';
img.title = file.name;
mach.previews.appendChild(img);
}
// ---------------- OCR minimal parser ----------------
const OCR = (()=>{
function parse(text){
// very permissive; later controlled by settings (stg-ocr-*)
const t = (text||'').replace(/\s+/g,' ').trim();
// WW DD YY PN RDD PLU (loose capture)
const ww = (t.match(/\b([0-5]\d)(?=[^\d]|$)/) || [,''])[1]; // 00..59 weak
const dd = (t.match(/\b(0[1-7]|[1-7])\b/) || [,''])[1]; // 1..7 (mercadona-dow)
const yy = (t.match(/\b([0-9]{2})\b/) || [,''])[1]; // any 2 digits
const pn = (t.match(/\b([0-9]{2})\b/) || [,''])[1]; // any 2 digits (will validate with platform)
const r = (t.match(/\bR\W*([0-9]{2})\b/i) || [,''])[1];
const plu = (t.match(/\b([0-9]{3,6})\b/) || [,''])[1];
return {
raw: text,
ww, dd, yy, pn, rdn: r ? `R${r}` : '',
plu,
ok: !!(ww && dd && yy)
};
}
function format(p){
if (!p || !p.ok) return '—';
return `WW:${p.ww} DD:${p.dd} YY:${p.yy} PN:${p.pn||'??'} ${p.rdn||''} PLU:${p.plu||'—'}`;
}
return { parse, format };
})();
// ---------------- Machine Reports list ----------------
function renderMachineReports(){
const host = mach.listHost;
if (!host) return;
const list = state.machineReports || [];
if (!list.length){
host.innerHTML = 'No machine reports yet.
';
return;
}
host.innerHTML = '';
list.forEach(r=>{
const okBadge = r.ok ? '✅' : '⚠️';
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `
${okBadge} PN ${String(r.pn).padStart(2,'0')} · Order ${r.orderId.slice(-4)} · ${r.family}${r.sectionName? ' · '+r.sectionName:''}
ISO: ${r.iso}
Parsed: ${OCR.format(r.parsed)}
Issuance: ${r.issuanceDate || '—'}
Expiry: ${r.expiryDate || '—'}
${r.photos?.length ? `${r.photos.map(u=>`
`).join('')}
`:''}
${r.notes ? `${U.escapeHTML(r.notes)}
`:''}
`;
host.appendChild(card);
});
// bind delete
host.addEventListener('click', (e)=>{
const btn = e.target.closest('button[data-act="del-mach"]');
if (!btn) return;
const id = btn.dataset.id;
const idx = (state.machineReports||[]).findIndex(x=>x.id===id);
if (idx>=0){
state.machineReports.splice(idx,1);
renderMachineReports();
renderKPI();
bus.emit(EVT.MACHINES_CHANGED);
saveAll({silent:true});
}
}, { once:true });
}
// ---------------- Add report ----------------
function addMachineReport(){
const pid = mach.selPlatform?.value || '';
const oid = mach.selOrder?.value || '';
const fam = mach.selFamily?.value || 'Suave';
const secId = mach.selSection?.value || '';
const plat = state.platforms.find(p=>p.id===pid);
const order = plat?.orders.find(o=>o.id===oid);
if (!plat || !order) return;
// compute ISO and bump seq if enabled
const iso = Iso.confirmAndBump(plat.number, fam);
// photos: keep blobs in memory as object URLs (export to PDF will re-draw from canvas later)
const files = Array.from(mach.fileInput?.files || []);
const photos = files.map(f => URL.createObjectURL(f));
// OCR parsing (text area wins if filled, else blank)
const parsed = OCR.parse(mach.ocrText?.value || '');
// issuance/expiry policy
const shelfDays = Number(state.settings?.ocr?.shelfLifeDays || 7);
const del = state.deliveryDate ? new Date(`${state.deliveryDate}T00:00:00`) : new Date();
const issuance = (state.settings?.ocr?.issuance || 'delivery') === 'scan' ? new Date() : del;
const expiry = new Date(issuance.getTime() + shelfDays*86400000);
const secName = (['Suave','Intenso'].includes(fam) && secId)
? (order.families[fam].sections.find(s=>s.id===secId)?.name || '')
: '';
// pass rule: if parsed has ww/dd/yy and (pn/r/plu if required) AND matches ISO core
const c = Iso.core(plat.number, fam);
const needPN = !!state.settings?.ocr?.requirePN;
const needRDD = !!state.settings?.ocr?.requireRDN;
const needPLU = !!state.settings?.ocr?.requirePLU;
let ok = !!parsed.ok;
if (needPN) ok = ok && (!!parsed.pn);
if (needRDD) ok = ok && (!!parsed.rdn);
if (needPLU) ok = ok && (!!parsed.plu);
// soft match on core tokens if present
if (ok && parsed.pn) ok = ok && (String(parsed.pn).padStart(2,'0') === String(c.PN));
if (ok && parsed.rdn) ok = ok && (parsed.rdn.toUpperCase() === c.RDN);
if (ok && parsed.plu) ok = ok && (parsed.plu === c.PLU);
const rec = {
id: U.uid('mach'),
platformId: plat.id,
orderId: order.id,
pn: plat.number,
family: fam,
sectionId: secId || null,
sectionName: secName,
iso,
parsed,
issuanceDate: issuance.toISOString().slice(0,10),
expiryDate: expiry.toISOString().slice(0,10),
photos,
notes: '',
ok
};
if (!state.machineReports) state.machineReports = [];
state.machineReports.push(rec);
renderMachineReports();
renderKPI();
bus.emit(EVT.MACHINES_CHANGED);
saveAll({silent:false, toast:'Machine report added.'});
}
// ---------------- Bind Machines UI events ----------------
if (mach.selPlatform){
mach.selPlatform.addEventListener('change', ()=>{
seedMachOrder();
});
}
if (mach.selOrder){
mach.selOrder.addEventListener('change', ()=>{
seedMachSections();
refreshIsoPreview();
refreshMachAddEnabled();
});
}
if (mach.selFamily){
mach.selFamily.addEventListener('change', ()=>{
seedMachSections();
refreshIsoPreview();
});
}
if (mach.selSection){
mach.selSection.addEventListener('change', ()=>{ /* no-op; included for completeness */ });
}
if (mach.ocrText){
mach.ocrText.addEventListener('input', ()=>{
const p = OCR.parse(mach.ocrText.value);
mach.ocrParsed.textContent = OCR.format(p);
});
}
if (mach.fileInput){
mach.fileInput.addEventListener('change', ()=>{
clearPreviews();
Array.from(mach.fileInput.files||[]).forEach(addPreview);
});
}
if (mach.btnAdd){
mach.btnAdd.addEventListener('click', addMachineReport);
}
// refresh when date changes (ISO preview depends on it)
bus.on(EVT.DELIVERY_DATE_CHANGED, ()=>{
refreshIsoPreview();
});
// initial seed
seedMachPlatform();
refreshIsoPreview();
renderMachineReports();
refreshMachAddEnabled();
/* ============================================================================
JS SECTION 5/? — Dashboard · KPIs · Tables (input/machines/final)
(continues; do NOT close the script tag)
============================================================================ */
// ---------- math helpers ----------
const Mathx = {
add(a=0,b=0){ return Number(a||0)+Number(b||0); },
fixed3(n){ return (Math.round(Number(n||0)*1000)/1000).toFixed(3); },
pct(n,d){ if(!d) return '—'; return `${(100*Number(n||0)/Number(d||1)).toFixed(1)}%`; }
};
// ---------- core aggregations ----------
function computeTotals(){
// walk platforms->orders->families->sections->slices
let boxes=0, pieces=0, weight=0;
(state.platforms||[]).forEach(p=>{
(p.orders||[]).forEach(o=>{
['Suave','Intenso'].forEach(fam=>{
const famNode = o.families?.[fam];
if (!famNode) return;
famNode.sections.forEach(sec=>{
sec.slices.forEach(sl=>{
const b = Number(sl.boxes||0);
const ppb = Number(sl.ppb || state.defaults.ppb);
const kgpc = Number(sl.kgpc || state.defaults.kgByFamily?.[fam]?.[sec.name] || 0);
boxes += b;
pieces += b*ppb;
weight += b*ppb*kgpc;
});
});
});
});
});
// waste: flat list
const wasteKg = (state.wasteEntries||[]).reduce((sum,w)=>sum + Number(w.netKg||0), 0);
const yieldPct = weight>0 ? (100*(weight - wasteKg)/weight) : 100;
return {
boxes, pieces,
weightKg: Number(weight||0),
wasteKg: Number(wasteKg||0),
yieldPct
};
}
function computeByPlatform(){
const rows = [];
(state.platforms||[]).forEach(p=>{
let boxes=0,pieces=0,weight=0;
(p.orders||[]).forEach(o=>{
['Suave','Intenso'].forEach(f=>{
o.families?.[f]?.sections?.forEach(sec=>{
sec.slices.forEach(sl=>{
const b=Number(sl.boxes||0);
const ppb = Number(sl.ppb || state.defaults.ppb);
const kgpc = Number(sl.kgpc || state.defaults.kgByFamily?.[f]?.[sec.name] || 0);
boxes += b;
pieces += b*ppb;
weight += b*ppb*kgpc;
});
});
});
});
// attach machine pass/total
const mAll = (state.machineReports||[]).filter(r=>r.platformId===p.id);
const mOk = mAll.filter(r=>r.ok).length;
// naive waste apportion: proportional by weight share
const tot = computeTotals();
const wShare = (tot.weightKg>0) ? (weight/tot.weightKg) : 0;
const waste = (state.wasteEntries||[]).reduce((s,w)=>s + Number(w.netKg||0),0) * wShare;
rows.push({
pn: p.number,
name: p.name,
boxes, pieces,
weightKg: weight,
wasteKg: waste,
yieldPct: (weight>0 ? (100*(weight-waste)/weight) : 100),
mPass: mOk, mAll: mAll.length
});
});
return rows;
}
function computeByFamily(){
const out = { Suave:{boxes:0,pieces:0,weight:0}, Intenso:{boxes:0,pieces:0,weight:0} };
(state.platforms||[]).forEach(p=>{
(p.orders||[]).forEach(o=>{
['Suave','Intenso'].forEach(f=>{
const famNode = o.families?.[f]; if(!famNode) return;
famNode.sections.forEach(sec=>{
sec.slices.forEach(sl=>{
const b = Number(sl.boxes||0);
const ppb = Number(sl.ppb || state.defaults.ppb);
const kgpc = Number(sl.kgpc || state.defaults.kgByFamily?.[f]?.[sec.name] || 0);
out[f].boxes += b;
out[f].pieces += b*ppb;
out[f].weight += b*ppb*kgpc;
});
});
});
});
});
// proportional waste split
const totW = (state.wasteEntries||[]).reduce((s,w)=>s+Number(w.netKg||0),0);
const sumWeight = out.Suave.weight + out.Intenso.weight || 0.00001;
return [
{
family:'Suave',
boxes: out.Suave.boxes,
pieces: out.Suave.pieces,
weightKg: out.Suave.weight,
wasteKg: totW * (out.Suave.weight/sumWeight),
yieldPct: out.Suave.weight>0 ? 100*(out.Suave.weight - totW*(out.Suave.weight/sumWeight))/out.Suave.weight : 100
},
{
family:'Intenso',
boxes: out.Intenso.boxes,
pieces: out.Intenso.pieces,
weightKg: out.Intenso.weight,
wasteKg: totW * (out.Intenso.weight/sumWeight),
yieldPct: out.Intenso.weight>0 ? 100*(out.Intenso.weight - totW*(out.Intenso.weight/sumWeight))/out.Intenso.weight : 100
}
];
}
function computeBySection(){
const map = new Map(); // key = section name
(state.platforms||[]).forEach(p=>{
(p.orders||[]).forEach(o=>{
['Suave','Intenso'].forEach(f=>{
o.families?.[f]?.sections?.forEach(sec=>{
sec.slices.forEach(sl=>{
const nm = sec.name;
const b=Number(sl.boxes||0);
const ppb = Number(sl.ppb || state.defaults.ppb);
const kgpc = Number(sl.kgpc || state.defaults.kgByFamily?.[f]?.[sec.name] || 0);
const row = map.get(nm) || { section:nm, boxes:0,pieces:0,weight:0 };
row.boxes += b;
row.pieces += b*ppb;
row.weight += b*ppb*kgpc;
map.set(nm,row);
});
});
});
});
});
// proportional waste by weight share
const totW = (state.wasteEntries||[]).reduce((s,w)=>s+Number(w.netKg||0),0);
const sumWeight = Array.from(map.values()).reduce((s,r)=>s+r.weight,0) || 0.00001;
return Array.from(map.values()).map(r=>{
const wk = totW * (r.weight/sumWeight);
return {
section: r.section,
boxes: r.boxes,
pieces: r.pieces,
weightKg: r.weight,
wasteKg: wk,
wastePct: (r.weight>0) ? (100*wk/r.weight) : 0
};
});
}
// ---------- KPI strip ----------
function renderKPI(){
const k = computeTotals();
U.text('#olmo-kpi-boxes', root, String(k.boxes));
U.text('#olmo-kpi-pieces', root, String(k.pieces));
U.text('#olmo-kpi-weight', root, Mathx.fixed3(k.weightKg));
U.text('#olmo-kpi-waste', root, Mathx.fixed3(k.wasteKg));
U.text('#olmo-kpi-yield', root, `${k.yieldPct.toFixed(1)}%`);
const mp = (state.machineReports||[]);
const ok = mp.filter(r=>r.ok).length;
U.text('#olmo-kpi-machines', root, `${ok}/${mp.length}`);
}
// ---------- Final Summary tables ----------
function fillTable(tbodyEl, rows, columns){
tbodyEl.innerHTML = '';
rows.forEach(r=>{
const tr = document.createElement('tr');
columns.forEach(c=>{
const td = document.createElement('td');
const v = (typeof c === 'function') ? c(r) : r[c];
td.textContent = (v!=null && v!==undefined) ? (''+v) : '';
tr.appendChild(td);
});
tbodyEl.appendChild(tr);
});
}
function renderFinalSummary(){
// By Platform
const byP = computeByPlatform();
const tp = root.querySelector('#olmo-sum-by-platform tbody');
fillTable(tp, byP, [
r=>String(r.pn).padStart(2,'0'),
'name',
r=>String(r.boxes),
r=>String(r.pieces),
r=>Mathx.fixed3(r.weightKg),
r=>Mathx.fixed3(r.wasteKg),
r=>`${r.yieldPct.toFixed(1)}%`,
r=>`${r.mPass}/${r.mAll}`
]);
// By Family
const byF = computeByFamily();
const tf = root.querySelector('#olmo-sum-by-family tbody');
fillTable(tf, byF, [
'family',
r=>String(r.boxes),
r=>String(r.pieces),
r=>Mathx.fixed3(r.weightKg),
r=>Mathx.fixed3(r.wasteKg),
r=>Mathx.pct(r.weightKg - r.wasteKg, r.weightKg)
]);
// By Section
const byS = computeBySection();
const ts = root.querySelector('#olmo-sum-by-section tbody');
fillTable(ts, byS, [
'section',
r=>String(r.boxes),
r=>String(r.pieces),
r=>Mathx.fixed3(r.weightKg),
r=>Mathx.fixed3(r.wasteKg),
r=>`${r.wastePct.toFixed(1)}%`
]);
// By Lote (flatten from state.lotesLinks if present)
const tl = root.querySelector('#olmo-sum-by-lote tbody');
const lotRows = (state.productLotes||[]).map(L=>({
lote:L.code, family:L.family, section:L.section,
supplier:L.supplier||'', company:L.companyName||'',
pcp:L.providerCode||'', registry:L.registry||'',
boxes:L.boxes||0, pieces:L.pieces||0,
producedKg:L.producedKg||0, wasteKg:L.wasteKg||0,
wastePct: L.producedKg>0 ? (100*(L.wasteKg||0)/(L.producedKg||1)) : 0
}));
fillTable(tl, lotRows, [
'lote','family','section','supplier','company','pcp','registry',
r=>String(r.boxes), r=>String(r.pieces),
r=>Mathx.fixed3(r.producedKg), r=>Mathx.fixed3(r.wasteKg),
r=>`${r.wastePct.toFixed(1)}%`
]);
// Plastic table
const tpv = root.querySelector('#olmo-sum-plastic tbody');
const plastRows = (state.plasticLotes||[]).map(p=>({
family:p.family, code:p.code, optPLU:p.optPLU||'',
boxesLinked: p.boxesLinked||0, notes:p.notes||''
}));
fillTable(tpv, plastRows, ['family','code','optPLU','boxesLinked','notes']);
// Waste raw
const twr = root.querySelector('#olmo-sum-waste-raw tbody');
fillTable(twr, (state.wasteEntries||[]).map(w=>({
when:(w.createdAtISO||'').slice(0,16).replace('T',' '),
lote:w.loteCode||'',
family:w.family||'',
base:w.baseType||'',
rollers:w.rollerQty||0,
boxes:w.boxes||0,
grossKg:w.grossKg||0,
without:w.withoutBaseKg||0,
netKg:w.netKg||0,
notes:w.notes||''
})), [
'when','lote','family','base','rollers','boxes',
r=>Mathx.fixed3(r.grossKg),
r=>Mathx.fixed3(r.without),
r=>Mathx.fixed3(r.netKg),
'notes'
]);
// Machines flat
const tmf = root.querySelector('#olmo-sum-machines-flat tbody');
fillTable(tmf, (state.machineReports||[]).map(m=>{
const core = Iso.core(m.pn, m.family) || {};
return {
pn: String(m.pn).padStart(2,'0'),
order: m.orderId.slice(-4),
family: m.family,
section: m.sectionName||'',
lote: m.productLote||'',
iso: m.iso,
WW: core.WW||'',
DD: core.DD||'',
YY: core.YY||'',
PNN: core.PN||'',
R: core.RDN||'',
PLU: core.PLU||'',
OK: m.ok ? 'YES' : 'NO',
issuance: m.issuanceDate||'',
expiry: m.expiryDate||'',
notes: m.notes||''
};
}), ['pn','order','family','section','lote','iso','WW','DD','YY','PNN','R','PLU','OK','issuance','expiry','notes']);
}
// ---------- Dashboard tabs ----------
root.addEventListener('click', (e)=>{
const t = e.target.closest('[data-action="dash-tab"]');
if(!t) return;
root.querySelectorAll('[data-action="dash-tab"]').forEach(x=>x.classList.remove('on'));
t.classList.add('on');
const tab = t.dataset.tab;
U.show('#olmo-tab-input', root, tab==='input');
U.show('#olmo-tab-machines', root, tab==='machines');
U.show('#olmo-tab-final', root, tab==='final');
if (tab==='final') renderFinalSummary();
});
// re-render KPI & final summary when data sources change
bus.on(EVT.PLATFORMS_CHANGED, ()=>{ renderKPI(); });
bus.on(EVT.WASTE_CHANGED, ()=>{ renderKPI(); });
bus.on(EVT.MACHINES_CHANGED, ()=>{ renderKPI(); });
// initial KPI
renderKPI();
/* ============================================================================
JS SECTION 6/? — PDF Export (rich, multi-page with tables & charts)
(continues; do NOT close the script tag)
============================================================================ */
// NOTE: uses html2canvas + jsPDF if present in the page.
// If not available, falls back to a simplified printable window.
async function exportPDF(){
const title = (U.$('#olmo-pdf-title', root)?.value || 'LUX — Aggregate Report').trim();
const logoFile = U.$('#olmo-logo-file', root)?.files?.[0] || null;
const shift = U.$('#olmo-shift', root)?.value || 'G1';
const del = state.deliveryDate || '—';
const worker = U.$('#olmo-worker-fullname', root)?.value || '—';
// Build a hidden print container
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:-9999px;top:-9999px;width:1000px;background:#fff;color:#111;padding:24px;font-family:Inter,system-ui,Arial';
document.body.appendChild(wrap);
const addH = (tag, txt, css='')=>{
const el = document.createElement(tag);
el.textContent = txt; el.style.cssText = css; wrap.appendChild(el); return el;
};
const addHTML = (html)=>{
const holder = document.createElement('div'); holder.innerHTML = html; wrap.appendChild(holder); return holder;
};
// Header
addH('h1', title, 'margin:0 0 4px 0');
addH('div', `Shift: ${shift} · Delivery: ${del} · Worker: ${worker}`, 'opacity:.8;margin-bottom:12px');
if (logoFile){
const img = document.createElement('img');
img.style.maxHeight = '60px';
img.src = URL.createObjectURL(logoFile);
wrap.appendChild(img);
}
// KPI snapshot
const k = computeTotals();
addHTML(`
| Family Boxes | ${k.boxes} | Pieces | ${k.pieces} | Weight(kg) | ${Mathx.fixed3(k.weightKg)} | Waste(kg) | ${Mathx.fixed3(k.wasteKg)} | Yield% | ${k.yieldPct.toFixed(1)}% |
| PN | Name | Boxes | Pieces | Weight(kg) | Waste(kg) | Yield% | Machines |
|---|---|---|---|---|---|---|---|
| ${String(r.pn).padStart(2,'0')} | ${U.escapeHTML(r.name)} | ${r.boxes} | ${r.pieces} | ${Mathx.fixed3(r.weightKg)} | ${Mathx.fixed3(r.wasteKg)} | ${r.yieldPct.toFixed(1)}% | ${r.mPass}/${r.mAll} |
| Family | Boxes | Pieces | Weight(kg) | Waste(kg) | Yield% |
|---|---|---|---|---|---|
| ${r.family} | ${r.boxes} | ${r.pieces} | ${Mathx.fixed3(r.weightKg)} | ${Mathx.fixed3(r.wasteKg)} | ${Mathx.pct(r.weightKg - r.wasteKg, r.weightKg)} |
| PN | Order | Family | Section | ISO | OK | Issuance | Expiry |
|---|---|---|---|---|---|---|---|
| ${String(m.pn).padStart(2,'0')} | ${m.orderId.slice(-4)} | ${m.family} | ${U.escapeHTML(m.sectionName||'')} | ${U.escapeHTML(m.iso||'')} | ${m.ok?'YES':'NO'} | ${m.issuanceDate||''} | ${m.expiryDate||''} |
