changes
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — Mobile QC Checklist (OWL backend client action)
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// Matches the existing Tablet Station / Plant Overview conventions:
|
||||
// * `static template` + `static props = ["*"]`
|
||||
// * Standalone rpc() from @web/core/network/rpc
|
||||
// * Design tokens from _fp_shopfloor_tokens.scss (no borders, shadow
|
||||
// elevation, 48 px touch targets)
|
||||
//
|
||||
// Invoked either via the MO "Open QC" smart-button (action_open_tablet)
|
||||
// or directly with `ir.actions.client` tag `fp_qc_checklist` and the
|
||||
// action's params.check_id.
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, onMounted, useRef } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class FpQcChecklist extends Component {
|
||||
static template = "fusion_plating_quality.FpQcChecklist";
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
this.fileInput = useRef("fileInput");
|
||||
this.pdfInput = useRef("pdfInput");
|
||||
this.photoLineId = null;
|
||||
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
saving: false,
|
||||
error: null,
|
||||
check: null,
|
||||
lines: [],
|
||||
expandedLineId: null,
|
||||
showFinalize: false,
|
||||
finalizeNotes: "",
|
||||
});
|
||||
|
||||
// action.params (from ir.actions.client) is the canonical
|
||||
// source; fall back to URL query params for deep-linking.
|
||||
const params = (this.props.action && this.props.action.params) || {};
|
||||
this.checkId = params.check_id || null;
|
||||
this.jobId = params.job_id || null;
|
||||
|
||||
onMounted(() => this.refresh());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Data
|
||||
// ------------------------------------------------------------------
|
||||
async refresh() {
|
||||
this.state.loading = true;
|
||||
this.state.error = null;
|
||||
try {
|
||||
const res = await rpc("/fp/qc/get", {
|
||||
check_id: this.checkId,
|
||||
job_id: this.jobId,
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.state.error = res.error === "no_qc"
|
||||
? "No QC checklist exists for this MO yet."
|
||||
: (res.error || "QC not found");
|
||||
return;
|
||||
}
|
||||
this.state.check = res.check;
|
||||
this.state.lines = res.lines || [];
|
||||
this.checkId = res.check.id;
|
||||
} catch (err) {
|
||||
this.state.error = err && err.message ? err.message : String(err);
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Line actions
|
||||
// ------------------------------------------------------------------
|
||||
async markLine(line, result) {
|
||||
if (this.state.saving) return;
|
||||
this.state.saving = true;
|
||||
try {
|
||||
const payload = {
|
||||
check_id: this.checkId,
|
||||
line_id: line.id,
|
||||
result,
|
||||
};
|
||||
if (line.requires_value) {
|
||||
payload.value = line.value;
|
||||
}
|
||||
if (line.notes !== undefined) payload.notes = line.notes;
|
||||
const res = await rpc("/fp/qc/line/mark", payload);
|
||||
if (!res.ok) {
|
||||
this.notification.add(res.error || "Mark failed", {
|
||||
type: "danger",
|
||||
title: line.name,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Merge updated line into state
|
||||
const idx = this.state.lines.findIndex((l) => l.id === line.id);
|
||||
if (idx >= 0) this.state.lines[idx] = res.line;
|
||||
this.state.check = res.check;
|
||||
this.notification.add(
|
||||
result === "pass" ? "Passed" : result === "fail" ? "Failed" : "Marked",
|
||||
{ type: result === "fail" ? "danger" : "success" },
|
||||
);
|
||||
} catch (err) {
|
||||
this.notification.add(
|
||||
err && err.message ? err.message : String(err),
|
||||
{ type: "danger" },
|
||||
);
|
||||
} finally {
|
||||
this.state.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Value input — debounced write on blur. Pending result stays until
|
||||
// operator taps pass/fail.
|
||||
onValueInput(line, ev) {
|
||||
const v = parseFloat(ev.target.value);
|
||||
line.value = isNaN(v) ? 0 : v;
|
||||
if (line.requires_value) {
|
||||
const inRange =
|
||||
(!line.value_min || line.value >= line.value_min) &&
|
||||
(!line.value_max || line.value <= line.value_max);
|
||||
line.value_in_range = inRange;
|
||||
}
|
||||
}
|
||||
|
||||
onNotesInput(line, ev) {
|
||||
line.notes = ev.target.value;
|
||||
}
|
||||
|
||||
toggleExpanded(line) {
|
||||
this.state.expandedLineId =
|
||||
this.state.expandedLineId === line.id ? null : line.id;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Photo upload
|
||||
// ------------------------------------------------------------------
|
||||
triggerPhoto(line) {
|
||||
this.photoLineId = line.id;
|
||||
if (this.fileInput.el) {
|
||||
this.fileInput.el.value = "";
|
||||
this.fileInput.el.click();
|
||||
}
|
||||
}
|
||||
|
||||
async onPhotoSelected(ev) {
|
||||
const file = ev.target.files && ev.target.files[0];
|
||||
if (!file || !this.photoLineId) return;
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("line_id", this.photoLineId);
|
||||
try {
|
||||
const resp = await fetch("/fp/qc/line/photo", {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const json = await resp.json();
|
||||
if (!json.ok) {
|
||||
this.notification.add(json.error || "Upload failed", {
|
||||
type: "danger",
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.notification.add("Photo uploaded", { type: "success" });
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
this.notification.add(
|
||||
err && err.message ? err.message : String(err),
|
||||
{ type: "danger" },
|
||||
);
|
||||
} finally {
|
||||
this.photoLineId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Fischerscope PDF upload
|
||||
// ------------------------------------------------------------------
|
||||
triggerPdfUpload() {
|
||||
if (this.pdfInput.el) {
|
||||
this.pdfInput.el.value = "";
|
||||
this.pdfInput.el.click();
|
||||
}
|
||||
}
|
||||
|
||||
async onPdfSelected(ev) {
|
||||
const file = ev.target.files && ev.target.files[0];
|
||||
if (!file) return;
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("check_id", this.checkId);
|
||||
try {
|
||||
this.state.saving = true;
|
||||
const resp = await fetch("/fp/qc/thickness_pdf", {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const json = await resp.json();
|
||||
if (!json.ok) {
|
||||
this.notification.add(json.error || "Upload failed", {
|
||||
type: "danger",
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.notification.add(
|
||||
`Uploaded — ${json.reading_count || 0} reading(s) extracted`,
|
||||
{ type: "success" },
|
||||
);
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
this.notification.add(
|
||||
err && err.message ? err.message : String(err),
|
||||
{ type: "danger" },
|
||||
);
|
||||
} finally {
|
||||
this.state.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Finalize
|
||||
// ------------------------------------------------------------------
|
||||
openFinalize() {
|
||||
this.state.showFinalize = true;
|
||||
this.state.finalizeNotes = this.state.check
|
||||
? this.state.check.notes || ""
|
||||
: "";
|
||||
}
|
||||
|
||||
closeFinalize() {
|
||||
this.state.showFinalize = false;
|
||||
}
|
||||
|
||||
async finalize(result) {
|
||||
try {
|
||||
this.state.saving = true;
|
||||
const res = await rpc("/fp/qc/finalize", {
|
||||
check_id: this.checkId,
|
||||
result,
|
||||
notes: this.state.finalizeNotes,
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.notification.add(res.error || "Finalize failed", {
|
||||
type: "danger",
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.state.check = res.check;
|
||||
this.state.showFinalize = false;
|
||||
this.notification.add(
|
||||
result === "pass"
|
||||
? "QC passed. MO can now be marked Done."
|
||||
: result === "fail"
|
||||
? "QC failed. Go to the MO to decide scrap/rework."
|
||||
: "QC flagged for rework.",
|
||||
{ type: result === "pass" ? "success" : "warning" },
|
||||
);
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
this.notification.add(
|
||||
err && err.message ? err.message : String(err),
|
||||
{ type: "danger" },
|
||||
);
|
||||
} finally {
|
||||
this.state.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Navigation
|
||||
// ------------------------------------------------------------------
|
||||
async openJob() {
|
||||
if (!this.state.check || !this.state.check.job_id) return;
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "fp.job",
|
||||
res_id: this.state.check.job_id,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Helpers used by the template
|
||||
// ------------------------------------------------------------------
|
||||
resultBadgeClass(result) {
|
||||
return {
|
||||
pass: "o_fp_qc_badge_pass",
|
||||
fail: "o_fp_qc_badge_fail",
|
||||
na: "o_fp_qc_badge_na",
|
||||
pending: "o_fp_qc_badge_pending",
|
||||
}[result || "pending"] || "o_fp_qc_badge_pending";
|
||||
}
|
||||
|
||||
checkTypeIcon(type) {
|
||||
return {
|
||||
visual: "fa-eye",
|
||||
dimensional: "fa-arrows-h",
|
||||
thickness: "fa-bar-chart",
|
||||
adhesion: "fa-link",
|
||||
hardness: "fa-diamond",
|
||||
salt_spray: "fa-tint",
|
||||
functional: "fa-cogs",
|
||||
other: "fa-circle-o",
|
||||
}[type] || "fa-circle-o";
|
||||
}
|
||||
|
||||
get progressPercent() {
|
||||
if (!this.state.check || !this.state.check.line_count) return 0;
|
||||
const done = this.state.check.lines_passed +
|
||||
this.state.check.lines_failed;
|
||||
return Math.round((done / this.state.check.line_count) * 100);
|
||||
}
|
||||
|
||||
get canFinalize() {
|
||||
if (!this.state.check) return false;
|
||||
if (["passed", "failed"].includes(this.state.check.state)) return false;
|
||||
// Required items must be resolved
|
||||
const pendingRequired = this.state.lines.filter(
|
||||
(l) => l.required && (l.result === "pending" || !l.result),
|
||||
);
|
||||
if (pendingRequired.length > 0) return false;
|
||||
// Thickness PDF requirement
|
||||
if (this.state.check.require_thickness_report_pdf &&
|
||||
!this.state.check.has_thickness_pdf) return false;
|
||||
// Thickness readings requirement
|
||||
if (this.state.check.require_thickness_readings &&
|
||||
this.state.check.thickness_reading_count === 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
get anyFailed() {
|
||||
return this.state.lines.some((l) => l.result === "fail");
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_qc_checklist", FpQcChecklist);
|
||||
Reference in New Issue
Block a user