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);
|
||||
@@ -0,0 +1,518 @@
|
||||
// =============================================================================
|
||||
// Fusion Plating — Mobile QC Checklist styles
|
||||
// Copyright 2026 Nexa Systems Inc. · License OPL-1
|
||||
//
|
||||
// Built on the shop-floor design system tokens (_fp_shopfloor_tokens.scss).
|
||||
// Same language as Tablet Station / Plant Overview: no borders, shadow-
|
||||
// based elevation, 48 px touch targets, three-layer contrast.
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_qc {
|
||||
background-color: $fp-page;
|
||||
color: $fp-ink;
|
||||
min-height: 100vh;
|
||||
padding: $fp-space-4;
|
||||
font-family: $fp-font-stack;
|
||||
font-size: $fp-text-base;
|
||||
|
||||
// ---------- State ----------
|
||||
.o_fp_qc_state_loading,
|
||||
.o_fp_qc_state_error {
|
||||
max-width: 480px;
|
||||
margin: $fp-space-10 auto;
|
||||
@include fp-card($fp-elev-2);
|
||||
padding: $fp-space-7;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $fp-space-3;
|
||||
|
||||
.fa {
|
||||
font-size: $fp-text-2xl;
|
||||
color: $fp-ink-mute;
|
||||
}
|
||||
|
||||
p { color: $fp-ink-soft; margin: 0; }
|
||||
}
|
||||
|
||||
.o_fp_qc_state_error .fa { color: $fp-bad; }
|
||||
|
||||
// ---------- Header ----------
|
||||
.o_fp_qc_header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: $fp-space-4;
|
||||
margin-bottom: $fp-space-5;
|
||||
|
||||
.o_fp_qc_header_left {
|
||||
display: flex;
|
||||
gap: $fp-space-3;
|
||||
align-items: flex-start;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.o_fp_qc_back {
|
||||
width: $fp-touch-min;
|
||||
height: $fp-touch-min;
|
||||
border-radius: $fp-radius-md;
|
||||
background-color: $fp-card;
|
||||
box-shadow: $fp-elev-1;
|
||||
border: none;
|
||||
color: $fp-ink-soft;
|
||||
font-size: $fp-text-md;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: box-shadow $fp-dur $fp-ease;
|
||||
|
||||
@include fp-hover-only {
|
||||
&:hover { box-shadow: $fp-elev-2; }
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_qc_title_block {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.o_fp_qc_breadcrumb {
|
||||
color: $fp-ink-mute;
|
||||
font-size: $fp-text-sm;
|
||||
margin-bottom: $fp-space-1;
|
||||
}
|
||||
|
||||
.o_fp_qc_title {
|
||||
font-size: $fp-text-2xl;
|
||||
font-weight: $fp-weight-semibold;
|
||||
margin: 0 0 $fp-space-1 0;
|
||||
color: $fp-ink;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.o_fp_qc_sub {
|
||||
color: $fp-ink-mute;
|
||||
font-size: $fp-text-sm;
|
||||
}
|
||||
|
||||
.o_fp_qc_sep {
|
||||
margin: 0 $fp-space-2;
|
||||
color: $fp-ink-faint;
|
||||
}
|
||||
|
||||
.o_fp_qc_ref { font-weight: $fp-weight-medium; }
|
||||
}
|
||||
|
||||
.o_fp_qc_state_chip {
|
||||
padding: $fp-space-2 $fp-space-4;
|
||||
border-radius: $fp-radius-pill;
|
||||
font-size: $fp-text-sm;
|
||||
font-weight: $fp-weight-semibold;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
|
||||
&.o_fp_qc_chip_draft { @include fp-pill('--bs-info'); }
|
||||
&.o_fp_qc_chip_in_progress { @include fp-pill('--bs-warning'); }
|
||||
&.o_fp_qc_chip_passed { @include fp-pill('--bs-success'); }
|
||||
&.o_fp_qc_chip_failed { @include fp-pill('--bs-danger'); }
|
||||
&.o_fp_qc_chip_rework { @include fp-pill('--bs-secondary'); }
|
||||
}
|
||||
|
||||
// ---------- Progress card ----------
|
||||
.o_fp_qc_progress_card {
|
||||
@include fp-card($fp-elev-2);
|
||||
padding: $fp-space-5 $fp-space-6;
|
||||
margin-bottom: $fp-space-5;
|
||||
}
|
||||
|
||||
.o_fp_qc_progress_numbers {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: $fp-space-6;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: $fp-space-4;
|
||||
}
|
||||
|
||||
.o_fp_qc_progress_big {
|
||||
font-size: $fp-text-3xl;
|
||||
font-weight: $fp-weight-bold;
|
||||
color: $fp-accent;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.o_fp_qc_progress_break {
|
||||
display: flex;
|
||||
gap: $fp-space-6;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.o_fp_qc_counter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
|
||||
.o_fp_qc_counter_n {
|
||||
font-size: $fp-text-xl;
|
||||
font-weight: $fp-weight-bold;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.o_fp_qc_counter_l {
|
||||
font-size: $fp-text-xs;
|
||||
color: $fp-ink-mute;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
&.o_fp_qc_counter_pass .o_fp_qc_counter_n { color: $fp-ok; }
|
||||
&.o_fp_qc_counter_fail .o_fp_qc_counter_n { color: $fp-bad; }
|
||||
&.o_fp_qc_counter_pending .o_fp_qc_counter_n { color: $fp-ink-mute; }
|
||||
}
|
||||
|
||||
.o_fp_qc_progress_bar {
|
||||
height: 6px;
|
||||
background-color: $fp-card-soft;
|
||||
border-radius: $fp-radius-pill;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.o_fp_qc_progress_fill {
|
||||
height: 100%;
|
||||
background-color: $fp-accent;
|
||||
border-radius: $fp-radius-pill;
|
||||
transition: width $fp-dur $fp-ease;
|
||||
}
|
||||
|
||||
// ---------- Thickness card ----------
|
||||
.o_fp_qc_thickness_card {
|
||||
@include fp-card($fp-elev-1);
|
||||
padding: $fp-space-4 $fp-space-5;
|
||||
margin-bottom: $fp-space-5;
|
||||
}
|
||||
|
||||
.o_fp_qc_thickness_head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: $fp-space-4;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.o_fp_qc_thickness_title {
|
||||
font-size: $fp-text-md;
|
||||
font-weight: $fp-weight-semibold;
|
||||
|
||||
.fa { color: $fp-accent; margin-right: $fp-space-2; }
|
||||
}
|
||||
|
||||
.o_fp_qc_thickness_sub {
|
||||
font-size: $fp-text-sm;
|
||||
color: $fp-ink-mute;
|
||||
margin-top: $fp-space-1;
|
||||
}
|
||||
|
||||
// ---------- Checklist ----------
|
||||
.o_fp_qc_list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $fp-space-3;
|
||||
margin-bottom: $fp-space-6;
|
||||
}
|
||||
|
||||
.o_fp_qc_item {
|
||||
@include fp-card($fp-elev-1);
|
||||
overflow: hidden;
|
||||
transition: box-shadow $fp-dur $fp-ease,
|
||||
transform $fp-dur $fp-ease;
|
||||
|
||||
&.o_fp_qc_item_pass {
|
||||
// Left accent strip — subtle indicator that doesn't scream at you
|
||||
background:
|
||||
linear-gradient(to right, $fp-ok 4px, transparent 4px) $fp-card;
|
||||
}
|
||||
&.o_fp_qc_item_fail {
|
||||
background:
|
||||
linear-gradient(to right, $fp-bad 4px, transparent 4px) $fp-card;
|
||||
}
|
||||
&.o_fp_qc_item_na {
|
||||
background:
|
||||
linear-gradient(to right, $fp-ink-faint 4px, transparent 4px) $fp-card;
|
||||
}
|
||||
|
||||
&.o_fp_qc_item_open { box-shadow: $fp-elev-2; }
|
||||
}
|
||||
|
||||
.o_fp_qc_item_row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $fp-space-4;
|
||||
padding: $fp-space-4 $fp-space-5;
|
||||
min-height: $fp-touch-min + $fp-space-3;
|
||||
cursor: pointer;
|
||||
|
||||
@include fp-hover-only {
|
||||
&:hover { background-color: color-mix(in srgb, #{$fp-accent} 4%, transparent); }
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_qc_item_icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: $fp-radius-md;
|
||||
background-color: $fp-card-soft;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $fp-ink-soft;
|
||||
font-size: $fp-text-md;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.o_fp_qc_item_body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.o_fp_qc_item_name {
|
||||
font-size: $fp-text-md;
|
||||
font-weight: $fp-weight-medium;
|
||||
color: $fp-ink;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.o_fp_qc_item_optional {
|
||||
margin-left: $fp-space-2;
|
||||
font-size: $fp-text-xs;
|
||||
color: $fp-ink-mute;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.o_fp_qc_item_meta {
|
||||
display: flex;
|
||||
gap: $fp-space-3;
|
||||
align-items: center;
|
||||
margin-top: $fp-space-1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.o_fp_qc_item_value {
|
||||
font-size: $fp-text-sm;
|
||||
color: $fp-ink-soft;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.o_fp_qc_item_photo_ind {
|
||||
color: $fp-accent;
|
||||
font-size: $fp-text-sm;
|
||||
}
|
||||
|
||||
.o_fp_qc_badge {
|
||||
display: inline-block;
|
||||
padding: 2px $fp-space-2;
|
||||
font-size: $fp-text-xs;
|
||||
font-weight: $fp-weight-semibold;
|
||||
border-radius: $fp-radius-sm;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.o_fp_qc_badge_pass { @include fp-pill('--bs-success'); }
|
||||
.o_fp_qc_badge_fail { @include fp-pill('--bs-danger'); }
|
||||
.o_fp_qc_badge_na { @include fp-pill('--bs-secondary'); }
|
||||
.o_fp_qc_badge_pending { @include fp-pill('--bs-info'); }
|
||||
|
||||
.o_fp_qc_chevron {
|
||||
color: $fp-ink-mute;
|
||||
font-size: $fp-text-sm;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// ---------- Expanded detail ----------
|
||||
.o_fp_qc_item_detail {
|
||||
padding: $fp-space-4 $fp-space-5 $fp-space-5;
|
||||
border-top: 1px solid color-mix(in srgb, #{$fp-border} 60%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $fp-space-4;
|
||||
}
|
||||
|
||||
.o_fp_qc_guidance {
|
||||
background-color: $fp-card-soft;
|
||||
padding: $fp-space-3 $fp-space-4;
|
||||
border-radius: $fp-radius-md;
|
||||
color: $fp-ink-soft;
|
||||
font-size: $fp-text-sm;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.o_fp_qc_value_row,
|
||||
.o_fp_qc_notes_row,
|
||||
.o_fp_qc_photo_row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $fp-space-2;
|
||||
|
||||
label {
|
||||
font-size: $fp-text-xs;
|
||||
font-weight: $fp-weight-semibold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: $fp-ink-mute;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_qc_value_input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $fp-space-3;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
height: $fp-touch-min;
|
||||
padding: 0 $fp-space-4;
|
||||
font-size: $fp-text-lg;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background-color: $fp-card-soft;
|
||||
border: none;
|
||||
border-radius: $fp-radius-md;
|
||||
color: $fp-ink;
|
||||
|
||||
&:focus { @include fp-focus-ring; }
|
||||
}
|
||||
|
||||
.o_fp_qc_uom {
|
||||
color: $fp-ink-mute;
|
||||
font-size: $fp-text-md;
|
||||
min-width: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_qc_range {
|
||||
font-size: $fp-text-xs;
|
||||
color: $fp-ink-mute;
|
||||
}
|
||||
|
||||
.o_fp_qc_notes_row textarea {
|
||||
width: 100%;
|
||||
padding: $fp-space-3 $fp-space-4;
|
||||
font-size: $fp-text-base;
|
||||
background-color: $fp-card-soft;
|
||||
border: none;
|
||||
border-radius: $fp-radius-md;
|
||||
color: $fp-ink;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
|
||||
&:focus { @include fp-focus-ring; }
|
||||
}
|
||||
|
||||
.o_fp_qc_actions_row {
|
||||
display: flex;
|
||||
gap: $fp-space-3;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
// ---------- Buttons ----------
|
||||
.o_fp_qc_btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $fp-space-2;
|
||||
min-height: $fp-touch-min;
|
||||
padding: 0 $fp-space-5;
|
||||
font-size: $fp-text-md;
|
||||
font-weight: $fp-weight-semibold;
|
||||
border: none;
|
||||
border-radius: $fp-radius-md;
|
||||
cursor: pointer;
|
||||
transition: transform $fp-dur-fast $fp-ease,
|
||||
box-shadow $fp-dur $fp-ease,
|
||||
background-color $fp-dur $fp-ease;
|
||||
|
||||
&:active:not([disabled]) { transform: scale(0.97); }
|
||||
&[disabled] { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.fa { font-size: $fp-text-md; }
|
||||
}
|
||||
|
||||
.o_fp_qc_btn_primary {
|
||||
background-color: $fp-accent;
|
||||
color: white;
|
||||
box-shadow: $fp-elev-1;
|
||||
@include fp-hover-only {
|
||||
&:hover:not([disabled]) { box-shadow: $fp-elev-2; }
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_qc_btn_pass,
|
||||
.o_fp_qc_btn_pass_lg {
|
||||
background-color: $fp-ok;
|
||||
color: white;
|
||||
box-shadow: $fp-elev-1;
|
||||
@include fp-hover-only {
|
||||
&:hover:not([disabled]) { box-shadow: $fp-elev-2; }
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_qc_btn_fail,
|
||||
.o_fp_qc_btn_fail_lg {
|
||||
background-color: $fp-bad;
|
||||
color: white;
|
||||
box-shadow: $fp-elev-1;
|
||||
@include fp-hover-only {
|
||||
&:hover:not([disabled]) { box-shadow: $fp-elev-2; }
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_qc_btn_ghost,
|
||||
.o_fp_qc_btn_ghost_lg {
|
||||
background-color: $fp-card-soft;
|
||||
color: $fp-ink-soft;
|
||||
@include fp-hover-only {
|
||||
&:hover:not([disabled]) {
|
||||
background-color: color-mix(in srgb, #{$fp-ink-soft} 10%, $fp-card-soft);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_qc_btn_pass_lg,
|
||||
.o_fp_qc_btn_fail_lg,
|
||||
.o_fp_qc_btn_ghost_lg {
|
||||
flex: 1;
|
||||
min-height: 60px;
|
||||
font-size: $fp-text-lg;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
// ---------- Sign-off footer ----------
|
||||
.o_fp_qc_footer {
|
||||
position: sticky;
|
||||
bottom: $fp-space-4;
|
||||
background: color-mix(in srgb, $fp-page 85%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
padding: $fp-space-4;
|
||||
border-radius: $fp-radius-lg;
|
||||
box-shadow: $fp-elev-2;
|
||||
display: flex;
|
||||
gap: $fp-space-3;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
// ---------- Responsive ----------
|
||||
@media (max-width: 640px) {
|
||||
padding: $fp-space-3;
|
||||
|
||||
.o_fp_qc_header .o_fp_qc_title { font-size: $fp-text-xl; }
|
||||
.o_fp_qc_progress_big { font-size: $fp-text-2xl; }
|
||||
.o_fp_qc_footer {
|
||||
flex-direction: column;
|
||||
.o_fp_qc_btn_pass_lg,
|
||||
.o_fp_qc_btn_fail_lg,
|
||||
.o_fp_qc_btn_ghost_lg { width: 100%; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
-->
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_quality.FpQcChecklist">
|
||||
<div class="o_fp_qc">
|
||||
|
||||
<!-- ===== Loading / error ===== -->
|
||||
<t t-if="state.loading">
|
||||
<div class="o_fp_qc_state_loading">
|
||||
<i class="fa fa-spinner fa-spin"/>
|
||||
<span>Loading QC…</span>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-elif="state.error">
|
||||
<div class="o_fp_qc_state_error">
|
||||
<i class="fa fa-exclamation-triangle"/>
|
||||
<p><t t-esc="state.error"/></p>
|
||||
<button class="btn btn-primary" t-on-click="refresh">Retry</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-elif="state.check">
|
||||
<!-- ===== Header ===== -->
|
||||
<div class="o_fp_qc_header">
|
||||
<div class="o_fp_qc_header_left">
|
||||
<button class="o_fp_qc_back" t-on-click="openJob"
|
||||
t-if="state.check.job_id"
|
||||
title="Back to Job">
|
||||
<i class="fa fa-arrow-left"/>
|
||||
</button>
|
||||
<div class="o_fp_qc_title_block">
|
||||
<div class="o_fp_qc_breadcrumb">
|
||||
<span><t t-esc="state.check.job_name"/></span>
|
||||
<t t-if="state.check.partner_name">
|
||||
<span class="o_fp_qc_sep">·</span>
|
||||
<span><t t-esc="state.check.partner_name"/></span>
|
||||
</t>
|
||||
</div>
|
||||
<h1 class="o_fp_qc_title">
|
||||
<t t-esc="state.check.template_name or 'QC Checklist'"/>
|
||||
</h1>
|
||||
<div class="o_fp_qc_sub">
|
||||
<span class="o_fp_qc_ref"><t t-esc="state.check.name"/></span>
|
||||
<t t-if="state.check.inspector_name">
|
||||
<span class="o_fp_qc_sep">·</span>
|
||||
<span>Inspector: <t t-esc="state.check.inspector_name"/></span>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_qc_state_chip"
|
||||
t-att-class="'o_fp_qc_chip_' + state.check.state">
|
||||
<t t-esc="state.check.state.replace('_', ' ').toUpperCase()"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Progress ===== -->
|
||||
<div class="o_fp_qc_progress_card">
|
||||
<div class="o_fp_qc_progress_numbers">
|
||||
<div class="o_fp_qc_progress_big">
|
||||
<t t-esc="progressPercent"/>%
|
||||
</div>
|
||||
<div class="o_fp_qc_progress_break">
|
||||
<div class="o_fp_qc_counter o_fp_qc_counter_pass">
|
||||
<span class="o_fp_qc_counter_n">
|
||||
<t t-esc="state.check.lines_passed"/>
|
||||
</span>
|
||||
<span class="o_fp_qc_counter_l">Pass</span>
|
||||
</div>
|
||||
<div class="o_fp_qc_counter o_fp_qc_counter_fail">
|
||||
<span class="o_fp_qc_counter_n">
|
||||
<t t-esc="state.check.lines_failed"/>
|
||||
</span>
|
||||
<span class="o_fp_qc_counter_l">Fail</span>
|
||||
</div>
|
||||
<div class="o_fp_qc_counter o_fp_qc_counter_pending">
|
||||
<span class="o_fp_qc_counter_n">
|
||||
<t t-esc="state.check.lines_pending"/>
|
||||
</span>
|
||||
<span class="o_fp_qc_counter_l">Pending</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_qc_progress_bar">
|
||||
<div class="o_fp_qc_progress_fill"
|
||||
t-att-style="'width:' + progressPercent + '%'"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Thickness PDF (if required) ===== -->
|
||||
<t t-if="state.check.require_thickness_report_pdf or state.check.require_thickness_readings">
|
||||
<div class="o_fp_qc_thickness_card">
|
||||
<div class="o_fp_qc_thickness_head">
|
||||
<div>
|
||||
<div class="o_fp_qc_thickness_title">
|
||||
<i class="fa fa-bar-chart"/>
|
||||
Thickness Report
|
||||
</div>
|
||||
<div class="o_fp_qc_thickness_sub">
|
||||
<t t-if="state.check.has_thickness_pdf">
|
||||
PDF uploaded · <t t-esc="state.check.thickness_reading_count"/> reading(s) extracted
|
||||
</t>
|
||||
<t t-else="">
|
||||
Upload Fischerscope / XDAL 600 PDF export
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
<button class="o_fp_qc_btn o_fp_qc_btn_primary"
|
||||
t-on-click="triggerPdfUpload"
|
||||
t-att-disabled="state.saving">
|
||||
<i class="fa fa-upload"/>
|
||||
<t t-if="state.check.has_thickness_pdf">Replace PDF</t>
|
||||
<t t-else="">Upload PDF</t>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- ===== Checklist ===== -->
|
||||
<div class="o_fp_qc_list">
|
||||
<t t-foreach="state.lines" t-as="line" t-key="line.id">
|
||||
<div class="o_fp_qc_item"
|
||||
t-att-class="{
|
||||
'o_fp_qc_item_pass': line.result == 'pass',
|
||||
'o_fp_qc_item_fail': line.result == 'fail',
|
||||
'o_fp_qc_item_na': line.result == 'na',
|
||||
'o_fp_qc_item_pending': line.result == 'pending' or !line.result,
|
||||
'o_fp_qc_item_open': state.expandedLineId == line.id,
|
||||
}">
|
||||
<div class="o_fp_qc_item_row"
|
||||
t-on-click="() => this.toggleExpanded(line)">
|
||||
<div class="o_fp_qc_item_icon">
|
||||
<i class="fa" t-att-class="checkTypeIcon(line.check_type)"/>
|
||||
</div>
|
||||
<div class="o_fp_qc_item_body">
|
||||
<div class="o_fp_qc_item_name">
|
||||
<t t-esc="line.name"/>
|
||||
<t t-if="!line.required">
|
||||
<span class="o_fp_qc_item_optional">(optional)</span>
|
||||
</t>
|
||||
</div>
|
||||
<div class="o_fp_qc_item_meta">
|
||||
<span class="o_fp_qc_badge"
|
||||
t-att-class="resultBadgeClass(line.result)">
|
||||
<t t-esc="(line.result or 'pending').toUpperCase()"/>
|
||||
</span>
|
||||
<t t-if="line.requires_value and line.value">
|
||||
<span class="o_fp_qc_item_value">
|
||||
<t t-esc="line.value"/>
|
||||
<t t-esc="line.value_uom"/>
|
||||
</span>
|
||||
</t>
|
||||
<t t-if="line.requires_photo and line.has_photo">
|
||||
<span class="o_fp_qc_item_photo_ind">
|
||||
<i class="fa fa-camera"/>
|
||||
</span>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
<i class="o_fp_qc_chevron fa"
|
||||
t-att-class="state.expandedLineId == line.id ? 'fa-chevron-up' : 'fa-chevron-down'"/>
|
||||
</div>
|
||||
|
||||
<t t-if="state.expandedLineId == line.id">
|
||||
<div class="o_fp_qc_item_detail">
|
||||
<t t-if="line.description">
|
||||
<div class="o_fp_qc_guidance">
|
||||
<t t-esc="line.description"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="line.requires_value">
|
||||
<div class="o_fp_qc_value_row">
|
||||
<label>Measured Value</label>
|
||||
<div class="o_fp_qc_value_input">
|
||||
<input type="number" step="0.0001"
|
||||
t-att-value="line.value or ''"
|
||||
t-att-placeholder="line.value_uom or ''"
|
||||
t-on-input="(ev) => this.onValueInput(line, ev)"/>
|
||||
<span class="o_fp_qc_uom"><t t-esc="line.value_uom"/></span>
|
||||
</div>
|
||||
<t t-if="line.value_min or line.value_max">
|
||||
<div class="o_fp_qc_range">
|
||||
Range: <t t-esc="line.value_min"/> – <t t-esc="line.value_max"/>
|
||||
<t t-esc="line.value_uom"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="line.requires_photo">
|
||||
<div class="o_fp_qc_photo_row">
|
||||
<button class="o_fp_qc_btn o_fp_qc_btn_ghost"
|
||||
t-on-click="() => this.triggerPhoto(line)">
|
||||
<i class="fa fa-camera"/>
|
||||
<t t-if="line.has_photo">Replace photo</t>
|
||||
<t t-else="">Add photo</t>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<div class="o_fp_qc_notes_row">
|
||||
<label>Notes</label>
|
||||
<textarea rows="2"
|
||||
t-att-value="line.notes or ''"
|
||||
t-on-input="(ev) => this.onNotesInput(line, ev)"
|
||||
placeholder="Optional — anything the inspector saw that matters"/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_qc_actions_row">
|
||||
<button class="o_fp_qc_btn o_fp_qc_btn_pass"
|
||||
t-on-click="() => this.markLine(line, 'pass')"
|
||||
t-att-disabled="state.saving">
|
||||
<i class="fa fa-check"/>
|
||||
Pass
|
||||
</button>
|
||||
<button class="o_fp_qc_btn o_fp_qc_btn_fail"
|
||||
t-on-click="() => this.markLine(line, 'fail')"
|
||||
t-att-disabled="state.saving">
|
||||
<i class="fa fa-times"/>
|
||||
Fail
|
||||
</button>
|
||||
<t t-if="!line.required">
|
||||
<button class="o_fp_qc_btn o_fp_qc_btn_ghost"
|
||||
t-on-click="() => this.markLine(line, 'na')"
|
||||
t-att-disabled="state.saving">
|
||||
N/A
|
||||
</button>
|
||||
</t>
|
||||
<t t-if="line.result != 'pending'">
|
||||
<button class="o_fp_qc_btn o_fp_qc_btn_ghost"
|
||||
t-on-click="() => this.markLine(line, 'pending')"
|
||||
t-att-disabled="state.saving">
|
||||
Reset
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- ===== Sign-off bar ===== -->
|
||||
<t t-if="state.check.state != 'passed' and state.check.state != 'failed'">
|
||||
<div class="o_fp_qc_footer">
|
||||
<button class="o_fp_qc_btn o_fp_qc_btn_pass_lg"
|
||||
t-on-click="() => this.finalize('pass')"
|
||||
t-att-disabled="!canFinalize or state.saving">
|
||||
<i class="fa fa-check"/>
|
||||
<span>Sign Off — PASS</span>
|
||||
</button>
|
||||
<button class="o_fp_qc_btn o_fp_qc_btn_fail_lg"
|
||||
t-on-click="() => this.finalize('fail')"
|
||||
t-att-disabled="state.saving">
|
||||
<i class="fa fa-times"/>
|
||||
<span>Fail QC</span>
|
||||
</button>
|
||||
<button class="o_fp_qc_btn o_fp_qc_btn_ghost_lg"
|
||||
t-on-click="() => this.finalize('rework')"
|
||||
t-att-disabled="state.saving or !anyFailed">
|
||||
Send to Rework
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- ===== Hidden file inputs ===== -->
|
||||
<input type="file" t-ref="fileInput"
|
||||
accept="image/*" capture="environment"
|
||||
style="display:none"
|
||||
t-on-change="onPhotoSelected"/>
|
||||
<input type="file" t-ref="pdfInput"
|
||||
accept="application/pdf"
|
||||
style="display:none"
|
||||
t-on-change="onPdfSelected"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
Reference in New Issue
Block a user