/** * Task results (#53 #54 #55) - Alpine component for the tasks that record a result. * * - «check» task (#54): the operator answers OK or NOK, with a note (mandatory for NOK). * - «note» task with `fields` (#55): one input per field, an optional attachment * (image / PDF) for the fields that ask for it, outcome 'done'. * * The same component serves the task being executed (one form) and the panel shown * at the end of production (one form per at_close task), so the field rules live here * once. The page sets `window.__taskResultsCfg` (urls + labels) and the component's * `ctx` (production run id + callbacks) from its x-init, the way visionExecute() does. * * Plain functions only (no framework imports) so tests/js/test_task_results.js can * run them under node with a stubbed fetch. */ function taskResultForm() { return { // Set by the page's x-init: () => ({ runId: int|null, done: () => void }) ctx: null, forms: [], working: false, error: '', // true once a result was recorded during this page view (shows «Prosegui») justRecorded: false, // Panel open state (used by the end-of-production panel only) open: false, cfg() { return window.__taskResultsCfg || { labels: {} }; }, label(key, fallback) { return (this.cfg().labels && this.cfg().labels[key]) || fallback; }, runId() { const c = this.ctx ? this.ctx() : null; return c && c.runId ? c.runId : null; }, /* One form per task: values start from the field's default, the way the maker set it. */ makeForm(task) { const fields = Array.isArray(task.fields) ? task.fields : []; const values = {}; fields.forEach((f) => { values[f.key] = (f.default === null || f.default === undefined) ? '' : String(f.default); }); return { task_id: task.id, title: task.title || '', directive: task.directive || '', type: task.task_type, fields: fields, values: values, files: {}, fileNames: {}, note: '', outcome: '', error: '', result: null, recorded: false, }; }, /* The single form of the task on screen. */ initForTask(task) { if (!task) return; const isCheck = task.task_type === 'check'; const isNoteWithFields = task.task_type === 'note' && Array.isArray(task.fields) && task.fields.length > 0; if (!isCheck && !isNoteWithFields) return; this.forms = [this.makeForm(task)]; this.loadLast(); }, pickFile(form, key, ev) { const f = ev && ev.target && ev.target.files ? ev.target.files[0] : null; if (f) { form.files[key] = f; form.fileNames[key] = f.name; } else { delete form.files[key]; form.fileNames[key] = ''; } }, csrf() { const m = typeof document !== 'undefined' ? document.querySelector('meta[name=csrf-token]') : null; return (m && m.content) || ''; }, /* Fetch + JSON with the page's usual error reading: the detail of the server is what the operator sees; an HTML answer (expired CSRF / session) means «reload». */ async call(url, opts) { let resp; try { resp = await fetch(url, opts); } catch (e) { return { ok: false, detail: this.label('connection', 'Errore di connessione') }; } const data = await resp.json().catch(() => null); if (!data || data.csrf_expired || data.session_expired) { return { ok: false, detail: this.label('sessionExpired', 'Sessione scaduta: ricaricare la pagina') }; } if (!resp.ok) { return { ok: false, detail: data.detail || this.label('serverError', 'Errore di comunicazione con il server') }; } return { ok: true, data: data }; }, /* The last result of this task: in the run under way if there is one, otherwise the one given in this browser session (no run = nothing on the server to ask). Only shown: the buttons stay active, a loop gives a new result every cycle. */ async loadLast() { const form = this.forms[0]; if (!form) return; const rid = this.runId(); if (rid && this.cfg().listUrl) { const r = await this.call( this.cfg().listUrl + '?production_run_id=' + encodeURIComponent(rid) + '&task_id=' + encodeURIComponent(form.task_id)); if (r.ok && Array.isArray(r.data) && r.data.length) { form.result = r.data[r.data.length - 1]; return; } } try { const raw = window.sessionStorage.getItem('tmf-task-result-' + form.task_id); if (raw && !rid) form.result = JSON.parse(raw); } catch (e) { /* storage may be unavailable: the result is simply not shown */ } }, /* Validation before anything goes on the wire. Returns the message or ''. */ validate(form, outcome) { if (form.type === 'check') { if (outcome === 'nok' && !(form.note || '').trim()) { return this.label('nokNeedsNote', 'Per un NOK serve la nota'); } return ''; } for (const f of form.fields) { const v = form.values[f.key]; if (f.type === 'number' && v !== '' && v !== null && v !== undefined && !isFinite(Number(String(v).replace(',', '.')))) { return this.label('notANumber', 'Valore non numerico') + ': ' + f.label; } } return ''; }, /* Upload the attachments of a form, then POST the result. Returns true when the result is recorded (forms already recorded are skipped). */ async record(form, outcome) { if (form.recorded) return true; form.error = this.validate(form, outcome); if (form.error) return false; const attachments = []; for (const f of form.fields) { const file = form.files[f.key]; if (!f.attachment || !file) continue; const fd = new FormData(); fd.append('file', file, file.name); const up = await this.call(this.cfg().uploadUrl, { method: 'POST', headers: { 'X-CSRFToken': this.csrf() }, body: fd, }); if (!up.ok) { form.error = up.detail; return false; } // The backend answers {file_path, ...}: that path is what the result keeps. attachments.push({ key: f.key, path: up.data.file_path || up.data.path, name: file.name }); } let values = null; if (form.type === 'note' && form.fields.length) { values = {}; form.fields.forEach((f) => { const v = form.values[f.key]; if (v === '' || v === null || v === undefined) return; values[f.key] = f.type === 'number' ? String(v).replace(',', '.') : String(v); }); } const body = { task_id: form.task_id, production_run_id: this.runId(), outcome: form.type === 'check' ? outcome : 'done', note: (form.note || '').trim() || null, values: values, attachments: attachments.length ? attachments : null, }; const r = await this.call(this.cfg().saveUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrf() }, body: JSON.stringify(body), }); if (!r.ok) { form.error = r.detail; return false; } form.result = r.data; form.recorded = true; try { window.sessionStorage.setItem('tmf-task-result-' + form.task_id, JSON.stringify(r.data)); } catch (e) { /* storage may be unavailable */ } return true; }, /* Task on screen: OK / NOK button, or «Completato» of a note with fields. */ async submit(outcome) { const form = this.forms[0]; if (!form || this.working) return; this.working = true; this.error = ''; const ok = await this.record(form, outcome); this.working = false; if (!ok) return; this.justRecorded = true; this.proceed(); }, /* Hand over to the page: close the cycle / go on. Also bound to «Prosegui», for when that step failed after the result was already recorded. */ proceed() { const c = this.ctx ? this.ctx() : null; if (c && c.done) c.done(); }, /* The result as the operator reads it. */ resultLabel(result) { if (!result) return ''; if (result.outcome === 'ok') return this.label('outcomeOk', 'OK'); if (result.outcome === 'nok') return this.label('outcomeNok', 'NOK'); return this.label('outcomeDone', 'Registrato'); }, // ---- End of production panel (#55) ---- /* Open the panel for the at_close tasks of the run. A task that already has a result in this run (page reloaded in between) is not asked again. */ async openClose(ids) { this.error = ''; this.working = true; this.open = true; this.forms = []; const rid = this.runId(); let done = []; if (rid && this.cfg().listUrl) { const r = await this.call(this.cfg().listUrl + '?production_run_id=' + encodeURIComponent(rid)); if (r.ok && Array.isArray(r.data)) done = r.data.map((x) => x.task_id); } const forms = []; for (const id of ids) { const r = await this.call(this.cfg().taskUrlTpl.replace('/0', '/' + id)); if (!r.ok) { this.error = r.detail; this.working = false; return; } const form = this.makeForm(r.data); if (done.indexOf(id) !== -1) form.recorded = true; forms.push(form); } this.forms = forms; this.working = false; // Nothing left to ask: straight on to the capoturno. if (forms.every((f) => f.recorded)) this.finishClose(); }, cancelClose() { this.open = false; this.forms = []; this.error = ''; }, async submitClose() { if (this.working) return; this.working = true; this.error = ''; let allOk = true; for (const form of this.forms) { // Keep going after a failure: each form shows its own error, and the ones // already recorded are not sent again on the next try. if (!(await this.record(form, 'done'))) allOk = false; } this.working = false; if (allOk) this.finishClose(); }, finishClose() { this.open = false; this.proceed(); }, }; } if (typeof module !== 'undefined') module.exports = { taskResultForm };