changes
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
/** @odoo-module **/
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
// Part of the Fusion Plating product family.
|
||||
|
||||
import { Interaction } from "@web/public/interaction";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
/**
|
||||
* Multi-part RFQ form interaction.
|
||||
*
|
||||
* Manages dynamic part rows in the quote request form: add/remove parts,
|
||||
* drag-drop file uploads per part, billing address toggle, and serialises
|
||||
* the parts data to a hidden JSON field before form submission.
|
||||
*/
|
||||
|
||||
function _el(tag, attrs, children) {
|
||||
const el = document.createElement(tag);
|
||||
if (attrs) {
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k === "className") {
|
||||
el.className = v;
|
||||
} else if (k === "textContent") {
|
||||
el.textContent = v;
|
||||
} else {
|
||||
el.setAttribute(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (children) {
|
||||
for (const child of children) {
|
||||
if (typeof child === "string") {
|
||||
el.appendChild(document.createTextNode(child));
|
||||
} else if (child) {
|
||||
el.appendChild(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function _icon(cls) {
|
||||
const i = document.createElement("i");
|
||||
i.className = cls;
|
||||
return i;
|
||||
}
|
||||
|
||||
|
||||
class FpRfqFormInteraction extends Interaction {
|
||||
static selector = "#fp_rfq_form";
|
||||
|
||||
setup() {
|
||||
this.partIndex = 0;
|
||||
|
||||
// Add first part row automatically
|
||||
this._addPartRow();
|
||||
|
||||
// Event listeners
|
||||
this.addListener("#fp_add_part_btn", "click", this._onAddPart);
|
||||
this.addListener("#billing_same_as_shipping", "change", this._onBillingSameToggle);
|
||||
this.addListener("#fp_rfq_form", "submit", this._onSubmit);
|
||||
this.addListener("#fp_parts_container", "click", this._onContainerClick);
|
||||
}
|
||||
|
||||
_onAddPart() {
|
||||
this._addPartRow();
|
||||
}
|
||||
|
||||
_onContainerClick(ev) {
|
||||
const removeBtn = ev.target.closest(".o_fp_remove_part");
|
||||
if (removeBtn) {
|
||||
const row = removeBtn.closest(".o_fp_part_row");
|
||||
if (row) {
|
||||
row.remove();
|
||||
this._renumberParts();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_onBillingSameToggle(ev) {
|
||||
const billingSelect = this.el.querySelector("#billing_address_id");
|
||||
if (billingSelect) {
|
||||
billingSelect.disabled = ev.target.checked;
|
||||
if (ev.target.checked) {
|
||||
billingSelect.value = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_addPartRow() {
|
||||
const container = this.el.querySelector("#fp_parts_container");
|
||||
if (!container) return;
|
||||
|
||||
const idx = this.partIndex++;
|
||||
const rowCount = container.querySelectorAll(".o_fp_part_row").length + 1;
|
||||
|
||||
// Build the part row using safe DOM methods
|
||||
const row = _el("div", { className: "o_fp_part_row", "data-part-idx": String(idx) });
|
||||
|
||||
// Header
|
||||
const header = _el("div", { className: "o_fp_part_row_header" }, [
|
||||
_el("span", { className: "o_fp_part_num", textContent: "Part #" + rowCount }),
|
||||
_el("span", { className: "o_fp_remove_part", title: "Remove this part" }, [
|
||||
_icon("fa fa-times"),
|
||||
]),
|
||||
]);
|
||||
row.appendChild(header);
|
||||
|
||||
// Row 1: Part Number, Qty, Count, Product
|
||||
const r1 = _el("div", { className: "row" });
|
||||
|
||||
// Part Number
|
||||
const c1 = _el("div", { className: "col-md-4 mb-2" }, [
|
||||
_el("label", { className: "form-label small", textContent: "Part Number" }),
|
||||
_el("input", {
|
||||
type: "text",
|
||||
className: "form-control form-control-sm fp_part_number",
|
||||
placeholder: "e.g. PN-12345",
|
||||
}),
|
||||
]);
|
||||
r1.appendChild(c1);
|
||||
|
||||
// Quantity
|
||||
const c2 = _el("div", { className: "col-md-2 mb-2" }, [
|
||||
_el("label", { className: "form-label small", textContent: "Quantity" }),
|
||||
_el("input", {
|
||||
type: "number",
|
||||
className: "form-control form-control-sm fp_part_qty",
|
||||
value: "1",
|
||||
min: "1",
|
||||
}),
|
||||
]);
|
||||
r1.appendChild(c2);
|
||||
|
||||
// Count
|
||||
const c3 = _el("div", { className: "col-md-2 mb-2" }, [
|
||||
_el("label", { className: "form-label small", textContent: "Count" }),
|
||||
_el("input", {
|
||||
type: "number",
|
||||
className: "form-control form-control-sm fp_part_count",
|
||||
value: "1",
|
||||
min: "1",
|
||||
}),
|
||||
]);
|
||||
r1.appendChild(c3);
|
||||
|
||||
// Product select
|
||||
const prodSelect = _el("select", { className: "form-select form-select-sm fp_part_product" }, [
|
||||
_el("option", { value: "", textContent: "-- Select --" }),
|
||||
]);
|
||||
const c4 = _el("div", { className: "col-md-4 mb-2" }, [
|
||||
_el("label", { className: "form-label small", textContent: "Product" }),
|
||||
prodSelect,
|
||||
]);
|
||||
r1.appendChild(c4);
|
||||
row.appendChild(r1);
|
||||
|
||||
// Row 2: Description
|
||||
const r2 = _el("div", { className: "row" }, [
|
||||
_el("div", { className: "col-md-12 mb-2" }, [
|
||||
_el("label", { className: "form-label small", textContent: "Description" }),
|
||||
_el("textarea", {
|
||||
className: "form-control form-control-sm fp_part_desc",
|
||||
rows: "2",
|
||||
placeholder: "Describe this part...",
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
row.appendChild(r2);
|
||||
|
||||
// Row 3: Spec Parameters
|
||||
const r3 = _el("div", { className: "row" }, [
|
||||
_el("div", { className: "col-md-12 mb-2" }, [
|
||||
_el("label", { className: "form-label small", textContent: "Spec Parameters" }),
|
||||
_el("textarea", {
|
||||
className: "form-control form-control-sm fp_part_spec",
|
||||
rows: "2",
|
||||
placeholder: "Spec details for this part...",
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
row.appendChild(r3);
|
||||
|
||||
// Row 4: File upload
|
||||
const fileInputName = "line_file_" + container.querySelectorAll(".o_fp_part_row").length;
|
||||
const fileInput = _el("input", {
|
||||
type: "file",
|
||||
name: fileInputName,
|
||||
multiple: "multiple",
|
||||
className: "d-none fp_line_file_input",
|
||||
});
|
||||
|
||||
const dropZone = _el("div", {
|
||||
className: "o_fp_file_drop_zone",
|
||||
"data-idx": String(idx),
|
||||
}, [
|
||||
_icon("fa fa-cloud-upload"),
|
||||
document.createTextNode(" "),
|
||||
_el("span", { textContent: "Drag files here or click to upload" }),
|
||||
fileInput,
|
||||
]);
|
||||
|
||||
const fileListEl = _el("div", { className: "fp_file_list small text-muted mt-1" });
|
||||
|
||||
const r4 = _el("div", { className: "row" }, [
|
||||
_el("div", { className: "col-md-12" }, [
|
||||
_el("label", { className: "form-label small", textContent: "Files" }),
|
||||
dropZone,
|
||||
fileListEl,
|
||||
]),
|
||||
]);
|
||||
row.appendChild(r4);
|
||||
|
||||
container.appendChild(row);
|
||||
|
||||
// Populate product dropdown
|
||||
this._populateProductDropdown(prodSelect);
|
||||
|
||||
// Set up drag-drop zone
|
||||
this._setupDropZone(dropZone, fileInput, fileListEl);
|
||||
}
|
||||
|
||||
_populateProductDropdown(selectEl) {
|
||||
// Clone options from the hidden source select rendered by QWeb
|
||||
const sourceSelect = document.getElementById("fp_products_source");
|
||||
if (sourceSelect) {
|
||||
for (const opt of sourceSelect.options) {
|
||||
const clone = opt.cloneNode(true);
|
||||
selectEl.appendChild(clone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_setupDropZone(zone, fileInput, fileListEl) {
|
||||
zone.addEventListener("click", (ev) => {
|
||||
if (ev.target !== fileInput) {
|
||||
fileInput.click();
|
||||
}
|
||||
});
|
||||
|
||||
zone.addEventListener("dragover", (ev) => {
|
||||
ev.preventDefault();
|
||||
zone.classList.add("o_fp_drag_over");
|
||||
});
|
||||
|
||||
zone.addEventListener("dragleave", () => {
|
||||
zone.classList.remove("o_fp_drag_over");
|
||||
});
|
||||
|
||||
zone.addEventListener("drop", (ev) => {
|
||||
ev.preventDefault();
|
||||
zone.classList.remove("o_fp_drag_over");
|
||||
if (ev.dataTransfer.files.length) {
|
||||
fileInput.files = ev.dataTransfer.files;
|
||||
this._updateFileList(fileInput, fileListEl);
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
this._updateFileList(fileInput, fileListEl);
|
||||
});
|
||||
}
|
||||
|
||||
_updateFileList(fileInput, fileListEl) {
|
||||
if (!fileListEl) return;
|
||||
const names = [];
|
||||
for (const f of fileInput.files) {
|
||||
names.push(f.name);
|
||||
}
|
||||
fileListEl.textContent = names.length ? names.join(", ") : "";
|
||||
}
|
||||
|
||||
_renumberParts() {
|
||||
const container = this.el.querySelector("#fp_parts_container");
|
||||
if (!container) return;
|
||||
const rows = container.querySelectorAll(".o_fp_part_row");
|
||||
rows.forEach((row, i) => {
|
||||
const numEl = row.querySelector(".o_fp_part_num");
|
||||
if (numEl) numEl.textContent = "Part #" + (i + 1);
|
||||
|
||||
const fileInput = row.querySelector(".fp_line_file_input");
|
||||
if (fileInput) fileInput.name = "line_file_" + i;
|
||||
});
|
||||
}
|
||||
|
||||
_onSubmit() {
|
||||
const container = this.el.querySelector("#fp_parts_container");
|
||||
const hiddenField = this.el.querySelector("#fp_parts_data");
|
||||
if (!container || !hiddenField) return;
|
||||
|
||||
const rows = container.querySelectorAll(".o_fp_part_row");
|
||||
const parts = [];
|
||||
|
||||
rows.forEach((row) => {
|
||||
const partNumber = (row.querySelector(".fp_part_number") || {}).value || "";
|
||||
const quantity = (row.querySelector(".fp_part_qty") || {}).value || "1";
|
||||
const count = (row.querySelector(".fp_part_count") || {}).value || "1";
|
||||
const description = (row.querySelector(".fp_part_desc") || {}).value || "";
|
||||
const specText = (row.querySelector(".fp_part_spec") || {}).value || "";
|
||||
const productId = (row.querySelector(".fp_part_product") || {}).value || "";
|
||||
|
||||
if (partNumber || description || productId) {
|
||||
parts.push({
|
||||
part_number: partNumber,
|
||||
quantity: quantity,
|
||||
count: count,
|
||||
description: description,
|
||||
spec_text: specText,
|
||||
product_id: productId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
hiddenField.value = JSON.stringify(parts);
|
||||
}
|
||||
}
|
||||
|
||||
registry
|
||||
.category("public.interactions")
|
||||
.add("fusion_plating_portal.rfq_form", FpRfqFormInteraction);
|
||||
@@ -0,0 +1,304 @@
|
||||
// =============================================================================
|
||||
// Fusion Plating -- Customer Portal styles
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// THEME AWARENESS
|
||||
// ---------------
|
||||
// This stylesheet ships with the website / portal frontend bundle. It NEVER
|
||||
// hardcodes hex values. Every colour comes from a Bootstrap CSS custom
|
||||
// property so the portal renders correctly in BOTH light and dark themes
|
||||
// without any duplication:
|
||||
//
|
||||
// surface: var(--bs-body-bg)
|
||||
// muted: var(--bs-secondary-bg)
|
||||
// foreground: var(--bs-body-color)
|
||||
// muted text: var(--bs-secondary-color)
|
||||
// border: var(--bs-border-color)
|
||||
// success: var(--bs-success)
|
||||
// info: var(--bs-info)
|
||||
// warning: var(--bs-warning)
|
||||
// danger: var(--bs-danger)
|
||||
//
|
||||
// Status tints use color-mix() against a theme token so a green dot is darker
|
||||
// on a light background and brighter on a dark background -- one rule, two
|
||||
// looks. We never use @media (prefers-color-scheme) or .o_dark overrides.
|
||||
// =============================================================================
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Local helper -- tint a semantic colour onto the current surface
|
||||
// -----------------------------------------------------------------------------
|
||||
@mixin fp-portal-tint($color-var, $amount: 14%) {
|
||||
background-color: color-mix(in srgb, var(#{$color-var}) #{$amount}, transparent);
|
||||
color: var(#{$color-var});
|
||||
border: 1px solid color-mix(in srgb, var(#{$color-var}) 35%, transparent);
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Generic portal card surface for plating-specific blocks
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_portal_card {
|
||||
background-color: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 10px;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--bs-primary) 40%, var(--bs-border-color));
|
||||
}
|
||||
|
||||
h6 {
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Dashboard layout
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_dashboard {
|
||||
.o_fp_dashboard_card {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--bs-primary) 40%, var(--bs-border-color));
|
||||
box-shadow: 0 2px 12px color-mix(in srgb, var(--bs-primary) 8%, transparent);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: var(--bs-body-bg);
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
padding: 0.75rem 1rem;
|
||||
|
||||
h6 {
|
||||
color: var(--bs-body-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
background-color: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-bottom: 0;
|
||||
font-size: 0.875rem;
|
||||
|
||||
thead th {
|
||||
border-bottom-width: 1px;
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--bs-secondary-color);
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 0.5rem 1rem;
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Segmented progress bar (Receiving / In Progress / Shipping)
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_seg_progress {
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
background: var(--bs-secondary-bg);
|
||||
|
||||
> div {
|
||||
transition: opacity 300ms ease;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Job state -- small coloured dot used in the jobs list table
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_portal_status_dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--bs-secondary-color);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--bs-secondary-color) 25%, transparent);
|
||||
vertical-align: middle;
|
||||
|
||||
&[data-state="received"] {
|
||||
background-color: var(--bs-info);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--bs-info) 25%, transparent);
|
||||
}
|
||||
&[data-state="in_progress"] {
|
||||
background-color: var(--bs-primary);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--bs-primary) 25%, transparent);
|
||||
}
|
||||
&[data-state="quality_check"] {
|
||||
background-color: var(--bs-warning);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--bs-warning) 25%, transparent);
|
||||
}
|
||||
&[data-state="ready_to_ship"] {
|
||||
background-color: var(--bs-secondary-color);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--bs-secondary-color) 25%, transparent);
|
||||
}
|
||||
&[data-state="shipped"],
|
||||
&[data-state="complete"] {
|
||||
background-color: var(--bs-success);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--bs-success) 25%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Job progress bar -- wraps Bootstrap .progress with state-aware fill colour
|
||||
// (kept for backwards compatibility on job detail page)
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_portal_progress {
|
||||
|
||||
.progress {
|
||||
background-color: color-mix(in srgb, var(--bs-secondary-color) 18%, transparent);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background-color: var(--bs-primary);
|
||||
color: var(--bs-body-bg);
|
||||
font-weight: 600;
|
||||
font-size: 0.72rem;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
&[data-state="received"] .progress-bar {
|
||||
background-color: var(--bs-info);
|
||||
}
|
||||
&[data-state="quality_check"] .progress-bar {
|
||||
background-color: var(--bs-warning);
|
||||
}
|
||||
&[data-state="ready_to_ship"] .progress-bar {
|
||||
background-color: color-mix(in srgb, var(--bs-success) 70%, var(--bs-warning));
|
||||
}
|
||||
&[data-state="shipped"] .progress-bar,
|
||||
&[data-state="complete"] .progress-bar {
|
||||
background-color: var(--bs-success);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// RFQ Form -- Part row card
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_part_row {
|
||||
background-color: var(--bs-body-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
position: relative;
|
||||
transition: border-color 120ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--bs-primary) 35%, var(--bs-border-color));
|
||||
}
|
||||
|
||||
.o_fp_part_row_header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
.o_fp_part_num {
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_remove_part {
|
||||
color: var(--bs-danger);
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 120ms ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Drag-drop file upload zone
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_file_drop_zone {
|
||||
border: 2px dashed var(--bs-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--bs-secondary-color);
|
||||
cursor: pointer;
|
||||
transition: border-color 150ms ease, background-color 150ms ease;
|
||||
|
||||
&:hover,
|
||||
&.o_fp_drag_over {
|
||||
border-color: var(--bs-primary);
|
||||
background-color: color-mix(in srgb, var(--bs-primary) 6%, transparent);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Portal form general
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_portal_form {
|
||||
.form-label {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Tab styling for quote request filter tabs
|
||||
// -----------------------------------------------------------------------------
|
||||
.nav-tabs {
|
||||
.nav-link {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Jobs list -- card-based layout
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_jobs_list {
|
||||
.o_fp_portal_card {
|
||||
.card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user