/* Fluxul de cântărire a unui produs finit din 1..N componente. * * State machine: * fără pachet -> alegi produsul finit și începi * pas curent -> cântărești componenta (cântar sau manual), Enter acceptă * ultimul pas -> serverul finalizează pachetul și întoarce eticheta * finalizat -> eticheta se tipărește; Enter începe următorul pachet */ (function () { 'use strict'; var cfg = window.CANTARIRE || {}; var $ = function (id) { return document.getElementById(id); }; // Polling: rapid cat timp se cantareste, rar cand pagina doar asteapta. var POLL_ACTIVE_MS = Number(cfg.pollMs) || 700; var POLL_IDLE_MS = 2500; var pollTimer = null; var el = { flow: $('flow-body'), title: $('flow-title'), progress: $('flow-progress'), message: $('flow-message'), weightValue: $('weight-value'), weightUnit: $('weight-unit'), stability: $('stability'), age: $('age'), raw: $('raw'), scaleDot: $('scale-dot'), scaleState: $('scale-state'), scaleProfile: $('scale-profile'), scaleError: $('scale-error'), profileSelect: $('profile-select'), toast: $('toast'), frame: $('print-frame') }; var state = cfg.initial || null; var recipes = cfg.recipes || []; var activeProfile = cfg.activeProfile || ''; var scaleReady = Boolean(cfg.scaleReady); var manualMode = !scaleReady; var busy = false; var polling = false; var reading = null; var forceArmedAt = 0; var lastLabel = null; var lastRecipeId = null; var lastLot = ''; var lastOperator = cfg.defaultOperator || ''; var toastTimer = null; // -- utilitare ----------------------------------------------------------- function esc(value) { return String(value === null || value === undefined ? '' : value) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"'); } function fmt(value) { var number = Number(value); if (!isFinite(number)) return '--'; var text = number.toFixed(3); return text.replace(/\.?0+$/, '') || '0'; } function message(text, kind) { if (!el.message) return; el.message.textContent = text || ''; el.message.className = 'message ' + (kind || ''); } function toast(text, kind) { if (!el.toast) return; el.toast.textContent = text; el.toast.className = 'toast show ' + (kind || ''); clearTimeout(toastTimer); toastTimer = setTimeout(function () { el.toast.className = 'toast ' + (kind || ''); }, 3200); } function setProgress(text) { if (el.progress) el.progress.textContent = text || ''; } function getJSON(url) { return fetch(url, { headers: { Accept: 'application/json' }, cache: 'no-store' }) .then(function (response) { return response.json(); }); } function postJSON(url, body) { return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body || {}) }).then(function (response) { return response.json().then(function (data) { return { status: response.status, data: data }; }); }); } function setBusy(value) { busy = value; document.querySelectorAll('#flow-body button, #flow-body input, #flow-body select') .forEach(function (node) { node.disabled = value; }); } // -- stare --------------------------------------------------------------- function adopt(data) { if (!data) return; if (data.state === null) { state = null; } else if (data.package) { state = { package: data.package, recipe: data.recipe, items: data.items || [], step: data.step, planned_total: data.planned_total, total_weight: data.total_weight, complete: Boolean(data.complete), open: Boolean(data.open) }; if (data.recipe) lastRecipeId = data.recipe.id; if (data.package) { lastLot = data.package.lot || lastLot; lastOperator = data.package.operator || lastOperator; } } render(); } async function refresh() { try { var data = await getJSON('/api/package'); adopt(data); } catch (error) { message('Nu pot citi starea pachetului: ' + error, 'error'); } } // -- randare ------------------------------------------------------------- function render() { if (!el.flow) return; if (!state) { renderStart(); } else if (state.complete) { renderDone(); } else { renderStep(); } } function recipeOptions(selected) { return recipes.map(function (recipe) { var suffix = ' · ' + recipe.components.length + ' comp.'; return ''; }).join(''); } function breakdown(recipe) { return (recipe.components || []).map(function (part) { return '
  • ' + (part.target_weight ? fmt(part.target_weight) + ' ' + esc(recipe.unit) : '—') + ' ' + esc(part.name) + '
  • '; }).join(''); } function renderStart() { if (el.title) el.title.textContent = 'Pachet nou'; setProgress(''); if (!recipes.length) { el.flow.innerHTML = '

    Nu există produse finite active. ' + 'Creează unul.

    '; return; } var selected = lastRecipeId || (recipes[0] && recipes[0].id) || null; var recipe = recipes.filter(function (r) { return r.id === selected; })[0] || recipes[0]; el.flow.innerHTML = '
    ' + ' ' + ' ' + '
    ' + '' + '
    ' + '
    ' + '
    ' + '
    ' + '
    ' + '
    ' + '
    ' + ' ' + '
    ' + '

    Enter pornește pachetul. Apoi cântărești fiecare componentă în ordinea de mai sus.

    '; var select = $('start-recipe'); if (select) { select.addEventListener('change', function () { var chosen = recipes.filter(function (r) { return String(r.id) === select.value; })[0]; if (chosen) { lastRecipeId = chosen.id; var plan = $('start-plan'); if (plan) plan.innerHTML = breakdown(chosen); } }); } var button = $('btn-start'); if (button) button.addEventListener('click', startPackage); } function renderStep() { var step = state.step; if (!step) { refresh(); return; } if (el.title) el.title.textContent = state.recipe ? state.recipe.name : 'Cântărire'; setProgress('Pas ' + step.position + ' din ' + step.total); var accepted = (state.items || []).map(function (item) { return '
  • ' + fmt(item.weight) + ' ' + esc(item.unit) + ' ' + esc(item.component_name) + '
  • '; }).join(''); el.flow.innerHTML = '
    ' + '
    Pune pe cântar
    ' + '
    ' + esc(step.component_name) + '
    ' + (step.target_weight ? '
    Țintă: ' + fmt(step.target_weight) + ' ' + esc(step.unit) + '
    ' : '') + '
    ' + (manualMode ? '
    ' + '' + '
    ' : '
    ') + '
    ' + ' ' + (accepted ? ' ' : '') + ' ' + '
    ' + (accepted ? '

    Acceptate

    ' : ''); if (manualMode) { var input = $('manual-weight'); if (input) { input.focus(); input.select(); } } var accept = $('btn-accept'); if (accept) accept.addEventListener('click', acceptStep); var undo = $('btn-undo'); if (undo) undo.addEventListener('click', undoStep); var voidButton = $('btn-void'); if (voidButton) voidButton.addEventListener('click', voidPackage); var toggle = $('btn-manual-toggle'); if (toggle) { toggle.addEventListener('click', function () { manualMode = true; renderStep(); }); } } function renderDone() { var pkg = state.package || {}; if (el.title) el.title.textContent = pkg.recipe_name || 'Pachet finalizat'; setProgress('Finalizat'); var accepted = (state.items || []).map(function (item) { return '
  • ' + fmt(item.weight) + ' ' + esc(item.unit) + ' ' + esc(item.component_name) + '
  • '; }).join(''); el.flow.innerHTML = '
    ' + '
    ✓
    ' + '
    ' + fmt(pkg.total_weight) + '' + ' ' + esc(pkg.unit) + '
    ' + '
    Pachet #' + pkg.id + (pkg.lot ? ' · Lot ' + esc(pkg.lot) : '') + '
    ' + '
    ' + '' + '
    ' + ' ' + (lastLabel ? ' ' : '') + ' Vezi eticheta' + '
    '; var next = $('btn-next'); if (next) next.addEventListener('click', startPackage); var reprint = $('btn-reprint'); if (reprint) { reprint.addEventListener('click', function () { if (lastLabel) printLabel(lastLabel); }); } if (next) next.focus(); } // -- acțiuni ------------------------------------------------------------- async function startPackage() { if (busy) return; var select = $('start-recipe'); var recipeId = select ? Number(select.value) : lastRecipeId; if (!recipeId) { toast('Alege produsul finit.', 'error'); return; } setBusy(true); message('Se pregătește pachetul…', 'info'); try { var result = await postJSON('/api/packages', { recipe_id: recipeId, operator: ($('start-operator') ? $('start-operator').value.trim() : '') || lastOperator, lot: ($('start-lot') ? $('start-lot').value.trim() : '') || lastLot, // Serverul alege sablonul: produsul finit -> setarea implicita -> primul sablon. template_id: null }); if (!result.data.ok) { message(result.data.error || 'Pachetul nu a putut fi început.', 'error'); toast(result.data.error || 'Eroare.', 'error'); return; } lastLabel = null; adopt(result.data); message('Pachet început. Cântărește prima componentă.', 'info'); } catch (error) { message('Eroare de rețea: ' + error, 'error'); } finally { setBusy(false); render(); } } function manualWeight() { var input = $('manual-weight'); if (!input) return null; var raw = String(input.value || '').trim().replace(',', '.'); return raw === '' ? null : raw; } async function acceptStep() { if (busy || !state || !state.package) return; var force = forceArmedAt && Date.now() - forceArmedAt < 5000; var body = {}; if (force) body.force = true; if (manualMode) { var value = manualWeight(); if (value === null) { toast('Introdu greutatea sau conectează cântarul.', 'error'); return; } body.weight = value; } setBusy(true); message('Se înregistrează…', 'info'); try { var result = await postJSON('/api/packages/' + state.package.id + '/items', body); var data = result.data; if (!result.status || result.status >= 400 || !data.ok) { if (data.reading) applyReading(data.reading); var unstable = /stabil/i.test(data.error || ''); if (unstable) { forceArmedAt = Date.now(); message((data.error || 'Greutate instabilă.') + ' Apasă din nou pe Acceptă pentru a folosi valoarea curentă.', 'error'); } else { message(data.error || 'Cântărirea nu a putut fi salvată.', 'error'); toast(data.error || 'Eroare la salvare.', 'error'); } return; } forceArmedAt = 0; adopt(data); if (data.label) { lastLabel = data.label; if (cfg.autoPrint !== false) { await printLabel(data.label); message('Etichetă tipărită. Apasă Enter pentru următorul pachet.', 'success'); } else { message('Pachet finalizat. Apasă „Tipărește eticheta” sau Enter pentru următorul pachet.', 'success'); } updateStats(); } else { message('Acceptat: ' + fmt(state.total_weight) + ' ' + (state.package.unit || ''), 'success'); } } catch (error) { message('Eroare de rețea la salvare.', 'error'); console.error(error); } finally { setBusy(false); render(); } } async function undoStep() { if (busy || !state || !state.package) return; setBusy(true); try { var result = await postJSON('/api/packages/' + state.package.id + '/undo', {}); if (result.data.ok) { adopt(result.data); message('Ultima cântărire a fost anulată.', 'info'); } else { message(result.data.error || 'Nu se poate anula.', 'error'); } } finally { setBusy(false); render(); } } async function voidPackage() { if (busy || !state || !state.package) return; if (!confirm('Renunți la pachetul început? Cântăririle lui nu vor apărea în istoric.')) return; setBusy(true); try { await postJSON('/api/packages/' + state.package.id + '/void', {}); state = null; lastLabel = null; message('Pachet anulat.', 'info'); } finally { setBusy(false); render(); } } // -- tipărire ------------------------------------------------------------ function acknowledge(payload) { if (!payload || !payload.ack_url) return; fetch(payload.ack_url, { method: 'POST', headers: { Accept: 'application/json' } }) .catch(function () { /* confirmarea este auxiliară */ }); } function printLabel(payload) { if (!payload) return Promise.resolve(); if (window.CantarirePrint && typeof window.CantarirePrint.print === 'function') { return Promise.resolve(window.CantarirePrint.print(payload)).then(function (result) { if (result && result.method === 'qz') acknowledge(payload); return result; }).catch(function (error) { console.error('Tipărirea a eșuat:', error); toast('Imprimanta nu a răspuns.', 'error'); }); } if (el.frame) { el.frame.src = payload.print_url + '?auto=1'; return Promise.resolve(); } window.open(payload.print_url, '_blank', 'noopener'); return Promise.resolve(); } function updateStats() { var node = $('stat-today-count'); if (!node) return; var value = Number(node.textContent) || 0; node.textContent = String(value + 1); } // -- citirea cântarului -------------------------------------------------- function applyReading(data) { reading = data; if (!data) return; var ok = Boolean(data.ok); var stale = Boolean(data.stale); var stable = Boolean(data.stable); var hasValue = data.value !== null && data.value !== undefined; if (el.weightValue) { el.weightValue.textContent = hasValue ? fmt(data.value) : '--'; el.weightValue.className = !ok ? 'error' : (stable && !stale ? '' : 'unstable'); } if (el.weightUnit) el.weightUnit.textContent = data.unit || ''; if (!ok) { if (el.scaleDot) el.scaleDot.className = 'dot off'; if (el.scaleState) el.scaleState.textContent = 'Cântar indisponibil'; if (el.stability) { el.stability.textContent = 'fără date'; el.stability.className = 'badge off'; } if (el.scaleError) el.scaleError.textContent = data.error || 'Cântarul nu poate fi citit.'; } else if (stale) { if (el.scaleDot) el.scaleDot.className = 'dot off'; if (el.scaleState) el.scaleState.textContent = 'Citire veche'; if (el.stability) { el.stability.textContent = 'citire veche'; el.stability.className = 'badge warn'; } if (el.scaleError) el.scaleError.textContent = ''; } else { if (el.scaleDot) el.scaleDot.className = 'dot ok'; if (el.scaleState) el.scaleState.textContent = 'Cântar conectat'; if (el.stability) { el.stability.textContent = stable ? 'stabil' : 'instabil'; el.stability.className = 'badge ' + (stable ? 'ok' : 'warn'); } if (el.scaleError) el.scaleError.textContent = ''; } if (el.scaleProfile) el.scaleProfile.textContent = (data.profile || activeProfile || 'fără profil') + (data.port ? ' · ' + data.port : ''); if (el.age) { el.age.textContent = typeof data.age_ms === 'number' ? (data.age_ms < 1000 ? '<1 s' : (data.age_ms / 1000).toFixed(1) + ' s') : ''; } if (el.raw) el.raw.textContent = data.raw_hex ? 'raw: ' + data.raw_hex.slice(0, 40) : ''; } function poll() { if (!scaleReady || polling || document.hidden) return; polling = true; var controller = new AbortController(); var timer = setTimeout(function () { controller.abort(); }, 6000); var params = new URLSearchParams(); if (activeProfile) params.set('profile', activeProfile); fetch('/api/weight?' + params.toString(), { headers: { Accept: 'application/json' }, cache: 'no-store', signal: controller.signal }) .then(function (response) { return response.json(); }) .then(applyReading) .catch(function (error) { if (error && error.name !== 'AbortError') { if (el.scaleDot) el.scaleDot.className = 'dot off'; if (el.scaleState) el.scaleState.textContent = 'Conexiune întreruptă'; } }) .finally(function () { clearTimeout(timer); polling = false; }); } // -- tastatură ----------------------------------------------------------- function onKeyDown(event) { if (event.key !== 'Enter' || event.ctrlKey || event.metaKey) return; var tag = (event.target && event.target.tagName || '').toLowerCase(); if (tag === 'textarea' || tag === 'button' || tag === 'a') return; if (!state) { event.preventDefault(); startPackage(); } else if (state.complete) { event.preventDefault(); startPackage(); } else if (state.step) { event.preventDefault(); acceptStep(); } } // -- start --------------------------------------------------------------- function schedulePoll() { clearTimeout(pollTimer); var delay = (state && state.open && state.step) ? POLL_ACTIVE_MS : POLL_IDLE_MS; pollTimer = setTimeout(function () { poll(); schedulePoll(); }, delay); } if (el.profileSelect) { el.profileSelect.addEventListener('change', function () { activeProfile = el.profileSelect.value; poll(); }); } document.addEventListener('keydown', onKeyDown); document.addEventListener('visibilitychange', function () { if (!document.hidden) poll(); }); render(); if (scaleReady) { poll(); schedulePoll(); } })();