c6d4260ede
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
273 lines
10 KiB
JavaScript
273 lines
10 KiB
JavaScript
/**
|
|
* 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 };
|