Files
TieMeasureFlow/src/frontend/flask_app/static/js/task-results.js
T

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 };