Split 49 modules/suites into independent git repos; untrack from monorepo
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled

Each top-level module/suite folder is now its own private repo on GitHub
(gsinghpal/<name>) and gitea (admin/<name>), with a fresh single initial
commit. The monorepo no longer tracks them (added to .gitignore + git rm
--cached); working-tree files are retained on disk and managed in their
own repos. The monorepo keeps shared root files (CLAUDE.md, docs/, scripts/,
tools/, AGENTS.md, WIP/obsolete dirs) and full history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-07 01:54:34 -04:00
parent 2a7b315e98
commit a66cdefc01
6740 changed files with 51 additions and 1277207 deletions

View File

@@ -1,42 +0,0 @@
# Fusion Plating — Customer Portal
Part of the Fusion Plating product family by Nexa Systems Inc.
Customer-facing portal that extends `fusion_plating` with a self-service area
inside Odoo's standard portal layout. Customers can:
- Submit Requests for Quote (RFQ) with drawings, target dates, and notes
- Track production jobs through Received → In Progress → QC → Ready → Shipped → Complete
- Download Certificates of Conformance (CoC) and packing lists
- Reference shipment tracking numbers and invoice references
## Models
| Model | Purpose |
|-------|---------|
| `fusion.plating.quote.request` | Customer-submitted RFQ |
| `fusion.plating.portal.job` | Lightweight portal-facing job summary |
| `res.partner` (extended) | Adds portal-enabled flag and counts |
## Routes
| Route | Purpose |
|-------|---------|
| `/my/quote_requests` | List quote requests |
| `/my/quote_requests/<id>` | RFQ detail |
| `/my/quote_requests/new` | New RFQ form |
| `/my/quote_requests/submit` | RFQ form submission |
| `/my/jobs` | List jobs |
| `/my/jobs/<id>` | Job detail |
| `/my/jobs/<id>/coc` | Download CoC PDF |
## Conventions
- New `res.partner` fields prefixed `x_fc_*`.
- All portal pages extend `portal.portal_layout`.
- SCSS theme-aware: uses Bootstrap CSS variables only, no hex values.
- Routes are `type='http'` (not the deprecated `type='json'`).
## License
OPL-1 (Odoo Proprietary License v1.0). Copyright 2026 Nexa Systems Inc.

View File

@@ -1,7 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from . import controllers
from . import models

View File

@@ -1,96 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
{
'name': 'Fusion Plating - Customer Portal',
'version': '19.0.4.5.0',
'category': 'Manufacturing/Plating',
'summary': 'Customer-facing portal for plating shops: online RFQ, job status, '
'CoC downloads, invoice access.',
'description': """
Fusion Plating - Customer Portal
================================
Part of the Fusion Plating product family by Nexa Systems Inc.
This module extends the Odoo portal with plating-specific customer-facing
features so a shop's customers can self-serve common requests:
* Online Request for Quote (RFQ) submission with drawing uploads
* Track production job status (received → in process → quality → shipped)
* Download Certificates of Conformance (CoC) for completed jobs
* Reference invoice numbers and tracking shipments
* Submit complaints and follow up on resolution
Design principles
-----------------
1. Extends, never modifies, the fusion_plating core.
2. Reuses Odoo's standard portal layout, breadcrumbs, and pager.
3. Theme-aware: respects portal light/dark theme via Bootstrap CSS variables.
4. No client-specific strings; all labels are translatable.
5. Works on both Odoo Community and Enterprise editions.
Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
""",
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'maintainer': 'Nexa Systems Inc.',
'support': 'support@nexasystems.ca',
'license': 'OPL-1',
'price': 0.00,
'currency': 'CAD',
'depends': [
'fusion_plating',
'fusion_plating_configurator',
'portal',
'website',
'mail',
'sale_management',
'account',
'stock',
],
'data': [
'security/fp_portal_security.xml',
'security/ir.model.access.csv',
'data/fp_sequence_data.xml',
'views/fp_portal_shell.xml',
'views/fp_portal_macros.xml',
'views/fp_quote_request_views.xml',
'views/fp_portal_dashboard.xml',
'views/fp_portal_templates.xml',
'views/fp_portal_account_summary.xml', # NEW - Task 10
'views/fp_portal_configurator_templates.xml',
'views/fp_portal_breadcrumbs.xml',
'views/fp_sale_order_portal.xml',
'views/fp_menu.xml',
],
'assets': {
'web.assets_frontend': [
# Tokens MUST be first so every later file sees the variables.
'fusion_plating_portal/static/src/scss/_fp_portal_tokens.scss',
# Phase 1 - button system
'fusion_plating_portal/static/src/scss/fp_portal_buttons.scss',
# Phase 2 - badges, cards, stepper, dashboard layout
'fusion_plating_portal/static/src/scss/fp_portal_badges.scss',
'fusion_plating_portal/static/src/scss/fp_portal_cards.scss',
'fusion_plating_portal/static/src/scss/fp_portal_stepper.scss',
'fusion_plating_portal/static/src/scss/fp_portal_dashboard.scss',
'fusion_plating_portal/static/src/scss/fp_portal_timeline.scss',
'fusion_plating_portal/static/src/scss/fp_portal_sidebar.scss', # NEW - Task 5
# Catch-all legacy rules (last)
'fusion_plating_portal/static/src/scss/fusion_plating_portal.scss',
'fusion_plating_portal/static/src/js/fp_rfq_form.js',
'fusion_plating_portal/static/src/js/fp_portal_sidebar.js', # NEW - Task 5
'fusion_plating_portal/static/src/js/fp_portal_account_summary.js', # NEW - Task 10 fix
'fusion_plating_portal/static/src/js/fp_portal_list_search.js', # list search + sort
],
},
'demo': [
'data/fp_demo_portal_data.xml',
],
'installable': True,
'application': False,
'auto_install': False,
}

View File

@@ -1,7 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from . import portal
from . import portal_configurator

File diff suppressed because it is too large Load Diff

View File

@@ -1,342 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import base64
import logging
from odoo import _, http
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal
_logger = logging.getLogger(__name__)
class FpPortalConfigurator(CustomerPortal):
"""Self-service configurator wizard on the customer portal.
Three-step flow:
1. Upload part (3D / PDF) or enter manual measurements
2. Select a coating configuration
3. View estimated price range and submit quote request
"""
# ======================================================================
# Landing - start new or view past requests
# ======================================================================
@http.route('/my/configurator', type='http', auth='user', website=True)
def portal_configurator_landing(self, **kw):
"""Landing page -- start new quote or view past requests."""
partner = request.env.user.partner_id
quotes = request.env['fusion.plating.quote.request'].sudo().search(
[('partner_id', 'child_of', partner.commercial_partner_id.id)],
order='create_date desc', limit=10,
)
values = self._prepare_portal_layout_values()
values.update({
'page_name': 'fp_configurator',
'quotes': quotes,
})
return request.render('fusion_plating_portal.portal_configurator_landing', values)
# ======================================================================
# Step 1 - Upload part or enter manual measurements
# ======================================================================
@http.route(
'/my/configurator/new', type='http', auth='user', website=True,
methods=['GET', 'POST'], csrf=True,
)
def portal_configurator_step1(self, **kw):
"""Step 1: upload part or enter manual measurements."""
if request.httprequest.method == 'POST':
# Save step 1 data to session
session_data = {
'part_name': kw.get('part_name', ''),
'part_number': kw.get('part_number', ''),
'substrate_material': kw.get('substrate_material', 'steel'),
'geometry_source': kw.get('geometry_source', 'upload'),
# Manual measurements are HIDDEN in the template per customer
# feedback 2026-05-17 - kept as hidden 0s so the rest of the
# flow doesn't error on missing keys. Backend computes them
# if/when needed.
'surface_area': float(kw.get('surface_area', 0) or 0),
'dimensions_length': float(kw.get('dimensions_length', 0) or 0),
'dimensions_width': float(kw.get('dimensions_width', 0) or 0),
'dimensions_height': float(kw.get('dimensions_height', 0) or 0),
# Multi-upload: customer may submit drawing + 3D + others.
# Collect IDs in a list so they all flow through to the RFQ.
'attachment_ids': [],
'attachment_names': [],
}
def _save_upload(file_upload, label):
"""Persist a single uploaded file and append to session lists.
Returns the attachment record or None."""
if not (file_upload and hasattr(file_upload, 'read')):
return None
file_data = file_upload.read()
if not file_data:
return None
attachment = request.env['ir.attachment'].sudo().create({
'name': file_upload.filename,
'datas': base64.b64encode(file_data),
'res_model': 'fusion.plating.quote.request',
'type': 'binary',
})
session_data['attachment_ids'].append(attachment.id)
session_data['attachment_names'].append(
'%s (%s)' % (file_upload.filename, label),
)
# STL surface-area auto-calc (silent - backend value only,
# not surfaced in UI per the hidden-measurements decision).
fname = file_upload.filename.lower()
if fname.endswith('.stl'):
try:
import io
import trimesh
mesh = trimesh.load(io.BytesIO(file_data), file_type='stl')
# mm^2 -> sq in (1 sq in = 645.16 mm^2)
session_data['surface_area'] = round(mesh.area / 645.16, 4)
session_data['auto_calculated'] = True
except Exception:
_logger.info('STL surface-area auto-calc skipped (trimesh missing or bad mesh).')
return attachment
_save_upload(kw.get('part_drawing'), 'drawing')
_save_upload(kw.get('part_3d_model'), '3D model')
# Back-compat: legacy single-file input still accepted.
_save_upload(kw.get('part_file'), 'attachment')
request.session['fp_configurator'] = session_data
return request.redirect('/my/configurator/coating')
# GET -- show form
materials = [
('aluminium', 'Aluminium'),
('steel', 'Steel'),
('stainless', 'Stainless Steel'),
('copper', 'Copper'),
('titanium', 'Titanium'),
('other', 'Other'),
]
values = self._prepare_portal_layout_values()
values.update({
'page_name': 'fp_configurator',
'materials': materials,
})
return request.render('fusion_plating_portal.portal_configurator_step1', values)
# ======================================================================
# Step 2 - Select coating configuration
# ======================================================================
@http.route(
'/my/configurator/coating', type='http', auth='user', website=True,
methods=['GET', 'POST'], csrf=True,
)
def portal_configurator_step2(self, **kw):
"""Step 2: select coating configuration."""
session_data = request.session.get('fp_configurator', {})
if not session_data:
return request.redirect('/my/configurator/new')
if request.httprequest.method == 'POST':
coating_id = int(kw.get('coating_config_id', 0))
quantity = int(kw.get('quantity', 1) or 1)
session_data['coating_config_id'] = coating_id
session_data['quantity'] = quantity
request.session['fp_configurator'] = session_data
return request.redirect('/my/configurator/estimate')
# fp.coating.config retired post-Sub-11. Use process.type as the
# customer-facing coating picker - its records (Hard Chrome, EN
# Low-Phos, etc.) are exactly what a customer would select from.
coatings = request.env['fusion.plating.process.type'].sudo().search(
[('active', '=', True)], order='sequence, name',
)
values = self._prepare_portal_layout_values()
values.update({
'page_name': 'fp_configurator',
'coatings': coatings,
'session_data': session_data,
})
return request.render('fusion_plating_portal.portal_configurator_step2', values)
# ======================================================================
# Step 3 - Estimate & submit
# ======================================================================
@http.route('/my/configurator/estimate', type='http', auth='user', website=True)
def portal_configurator_step3(self, **kw):
"""Step 3: show estimated price and submit."""
session_data = request.session.get('fp_configurator', {})
if not session_data or not session_data.get('coating_config_id'):
return request.redirect('/my/configurator/new')
coating = request.env['fusion.plating.process.type'].sudo().browse(
session_data['coating_config_id'],
)
if not coating.exists():
return request.redirect('/my/configurator/coating')
# Estimate price from pricing rules. Best-effort: returns
# {'available': False} when rules don't match or process.type
# lacks the data the rules expect (post-coating-retire transition).
try:
estimated_price = self._estimate_price(session_data, coating)
except Exception:
_logger.info('Skipping price estimate - pricing helper unavailable.', exc_info=True)
estimated_price = {'min': 0, 'max': 0, 'available': False}
values = self._prepare_portal_layout_values()
values.update({
'page_name': 'fp_configurator',
'session_data': session_data,
'coating': coating,
'estimated_price': estimated_price,
})
return request.render('fusion_plating_portal.portal_configurator_step3', values)
# ======================================================================
# Submit - create quote request
# ======================================================================
@http.route(
'/my/configurator/submit', type='http', auth='user', website=True,
methods=['POST'], csrf=True,
)
def portal_configurator_submit(self, **kw):
"""Submit quote request from configurator."""
session_data = request.session.get('fp_configurator', {})
if not session_data or not session_data.get('coating_config_id'):
return request.redirect('/my/configurator/new')
partner = request.env.user.partner_id
coating = request.env['fusion.plating.process.type'].sudo().browse(
session_data['coating_config_id'],
)
# Build part description HTML
part_desc = '<p><strong>%s</strong></p>' % (
session_data.get('part_name', '') or 'Unnamed Part',
)
if session_data.get('part_number'):
part_desc += '<p>Part Number: %s</p>' % session_data['part_number']
part_desc += '<p>Material: %s</p>' % session_data.get('substrate_material', '')
if session_data.get('surface_area'):
part_desc += '<p>Surface Area: %s sq in</p>' % session_data['surface_area']
dims = []
for dim_key, dim_label in [
('dimensions_length', 'L'), ('dimensions_width', 'W'), ('dimensions_height', 'H'),
]:
val = session_data.get(dim_key, 0)
if val:
dims.append('%s: %s in' % (dim_label, val))
if dims:
part_desc += '<p>Dimensions: %s</p>' % ', '.join(dims)
if coating.exists():
part_desc += '<p>Coating: %s</p>' % coating.name
vals = {
'partner_id': partner.id,
'contact_name': partner.name,
'contact_email': partner.email,
'contact_phone': partner.phone or '',
'company_name': partner.parent_id.name if partner.parent_id else partner.name,
'part_description': part_desc,
'quantity': session_data.get('quantity', 1),
'special_instructions': kw.get('special_instructions', ''),
}
# Link the selected process type (coating IS the process type now
# that fp.coating.config is retired).
if coating.exists():
vals['process_type_ids'] = [(4, coating.id)]
quote = request.env['fusion.plating.quote.request'].sudo().create(vals)
# Re-key uploaded attachments onto the new quote request so they
# appear on its chatter. Multi-upload - customer may have sent
# both a drawing and a 3D model (or more).
att_ids = session_data.get('attachment_ids') or []
if not att_ids and session_data.get('attachment_id'):
# Back-compat for old session shape (single attachment_id key).
att_ids = [session_data['attachment_id']]
for att_id in att_ids:
attachment = request.env['ir.attachment'].sudo().browse(att_id)
if attachment.exists():
attachment.write({
'res_model': 'fusion.plating.quote.request',
'res_id': quote.id,
})
if 'drawing_attachment_ids' in quote._fields:
quote.drawing_attachment_ids = [(4, attachment.id)]
# Clear session
request.session.pop('fp_configurator', None)
values = self._prepare_portal_layout_values()
values.update({
'page_name': 'fp_configurator',
'quote': quote,
})
return request.render('fusion_plating_portal.portal_configurator_success', values)
# ======================================================================
# Pricing helper
# ======================================================================
def _estimate_price(self, session_data, coating):
"""Best-effort price estimate. Returns {'min', 'max', 'available'}.
Post-coating-retire (Sub-11) the rule schema still references
fp.coating.config; ``coating`` here is now a process.type record.
That means rules with a ``coating_config_id`` set won't match
and we silently fall through to {'available': False} - which
the template renders as 'Quote will be priced by EN Plating'.
Estimate is non-essential; final price comes from EN's review.
"""
Rule = request.env.get('fp.pricing.rule')
if Rule is None:
return {'min': 0, 'max': 0, 'available': False}
rules = Rule.sudo().search([('active', '=', True)], order='sequence')
area = float(session_data.get('surface_area', 0))
qty = int(session_data.get('quantity', 1))
substrate = session_data.get('substrate_material', '')
if not area or not rules:
return {'min': 0, 'max': 0, 'available': False}
best = None
best_score = -1
for rule in rules:
score = 0
# Skip any rule keyed to coating_config - model is gone.
if 'coating_config_id' in rule._fields and rule.coating_config_id:
continue
if getattr(rule, 'substrate_material', None):
if rule.substrate_material != substrate:
continue
score += 2
if score > best_score:
best_score = score
best = rule
if not best:
return {'min': 0, 'max': 0, 'available': False}
if best.pricing_method == 'per_sqin':
unit = area * best.base_rate
elif best.pricing_method == 'per_sqft':
unit = (area / 144.0) * best.base_rate
elif best.pricing_method == 'per_piece':
unit = best.base_rate
else:
unit = best.base_rate
base_total = unit * qty + (best.setup_fee or 0)
if best.minimum_charge and base_total < best.minimum_charge:
base_total = best.minimum_charge
return {
'min': round(base_total * 0.85, 2),
'max': round(base_total * 1.25, 2),
'available': True,
}

View File

@@ -1,93 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc. - DEMO DATA (temporary)
Remove this file and its manifest entry before production release.
-->
<odoo noupdate="1">
<!-- ========== QUOTE REQUESTS ========== -->
<record id="demo_quote_request_001" model="fusion.plating.quote.request">
<field name="name">RFQ-2026-0041</field>
<field name="partner_id" ref="fusion_plating.demo_partner_aeroparts"/>
<field name="contact_name">Sarah Chen</field>
<field name="contact_email">sarah@aeroparts.ca</field>
<field name="contact_phone">905-555-0142</field>
<field name="company_name">AeroParts Canada Inc.</field>
<field name="quantity">250</field>
<field name="target_delivery" eval="(datetime.datetime.today() + timedelta(days=30)).strftime('%Y-%m-%d')"/>
<field name="state">new</field>
<field name="part_description" type="html"><p>Electroless nickel plating (mid-phosphorus) on aluminium 6061-T6 brackets per AMS 2404. Thickness 0.0005" +/- 0.0001". Parts are 4" x 2" x 0.25" - drawings attached. Require CoC with each shipment.</p></field>
<field name="special_instructions" type="html"><p>Customer requires lot traceability and material certificates. Parts must be individually bagged after plating.</p></field>
</record>
<record id="demo_quote_request_002" model="fusion.plating.quote.request">
<field name="name">RFQ-2026-0042</field>
<field name="partner_id" ref="fusion_plating.demo_partner_precision"/>
<field name="contact_name">Mike Thompson</field>
<field name="contact_email">mike@precisionmfg.ca</field>
<field name="contact_phone">416-555-0198</field>
<field name="company_name">Precision Manufacturing Ltd.</field>
<field name="quantity">100</field>
<field name="target_delivery" eval="(datetime.datetime.today() + timedelta(days=21)).strftime('%Y-%m-%d')"/>
<field name="state">under_review</field>
<field name="part_description" type="html"><p>Hard chrome plating on 4140 steel hydraulic cylinder rods. OD plating only, 0.002" per side. 12" length x 1.5" diameter. Spec: AMS 2460.</p></field>
</record>
<record id="demo_quote_request_003" model="fusion.plating.quote.request">
<field name="name">RFQ-2026-0043</field>
<field name="partner_id" ref="fusion_plating.demo_partner_aeroparts"/>
<field name="contact_name">Lisa Park</field>
<field name="contact_email">lisa@aeroparts.ca</field>
<field name="company_name">AeroParts Canada Inc.</field>
<field name="quantity">500</field>
<field name="state">quoted</field>
<field name="quoted_price">2450.00</field>
<field name="quote_sent_date" eval="(datetime.datetime.now() - timedelta(days=3)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="part_description" type="html"><p>Type II anodize (Class 1, clear) on 7075-T6 aluminium fasteners per MIL-A-8625. Thickness 0.0002"-0.0007". Small parts, bulk processing acceptable.</p></field>
</record>
<!-- ========== PORTAL JOBS ========== -->
<record id="demo_portal_job_001" model="fusion.plating.portal.job">
<field name="name">PJ-2026-0101</field>
<field name="partner_id" ref="fusion_plating.demo_partner_aeroparts"/>
<field name="state">received</field>
<field name="received_date" eval="(datetime.datetime.today() - timedelta(days=1)).strftime('%Y-%m-%d')"/>
<field name="target_ship_date" eval="(datetime.datetime.today() + timedelta(days=10)).strftime('%Y-%m-%d')"/>
<field name="quantity">50</field>
<field name="notes" type="html"><p>EN mid-phos plating on aluminium brackets. Parts received and inspected - ready for processing.</p></field>
</record>
<record id="demo_portal_job_002" model="fusion.plating.portal.job">
<field name="name">PJ-2026-0102</field>
<field name="partner_id" ref="fusion_plating.demo_partner_precision"/>
<field name="state">in_progress</field>
<field name="received_date" eval="(datetime.datetime.today() - timedelta(days=5)).strftime('%Y-%m-%d')"/>
<field name="target_ship_date" eval="(datetime.datetime.today() + timedelta(days=5)).strftime('%Y-%m-%d')"/>
<field name="quantity">100</field>
<field name="notes" type="html"><p>Hard chrome plating on hydraulic rods. Parts currently in the chrome tank - expected completion tomorrow.</p></field>
</record>
<record id="demo_portal_job_003" model="fusion.plating.portal.job">
<field name="name">PJ-2026-0103</field>
<field name="partner_id" ref="fusion_plating.demo_partner_aeroparts"/>
<field name="state">quality_check</field>
<field name="received_date" eval="(datetime.datetime.today() - timedelta(days=8)).strftime('%Y-%m-%d')"/>
<field name="target_ship_date" eval="(datetime.datetime.today() + timedelta(days=2)).strftime('%Y-%m-%d')"/>
<field name="quantity">200</field>
<field name="notes" type="html"><p>Type II anodize on fasteners. Plating complete - in final QC thickness and adhesion checks.</p></field>
</record>
<record id="demo_portal_job_004" model="fusion.plating.portal.job">
<field name="name">PJ-2026-0104</field>
<field name="partner_id" ref="fusion_plating.demo_partner_precision"/>
<field name="state">shipped</field>
<field name="received_date" eval="(datetime.datetime.today() - timedelta(days=14)).strftime('%Y-%m-%d')"/>
<field name="target_ship_date" eval="(datetime.datetime.today() - timedelta(days=1)).strftime('%Y-%m-%d')"/>
<field name="actual_ship_date" eval="(datetime.datetime.today() - timedelta(days=1)).strftime('%Y-%m-%d')"/>
<field name="quantity">75</field>
<field name="tracking_ref">CANPAR-7742891035</field>
<field name="invoice_ref">INV-2026-0318</field>
<field name="notes" type="html"><p>Black oxide on steel brackets. Shipped on time with CoC and packing list.</p></field>
</record>
</odoo>

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo noupdate="1">
<record id="seq_fp_quote_request" model="ir.sequence">
<field name="name">Fusion Plating: Quote Request</field>
<field name="code">fusion.plating.quote.request</field>
<field name="prefix">RFQ/%(year)s/</field>
<field name="padding">4</field>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -1,9 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from . import fp_quote_request
from . import fp_quote_request_line
from . import fp_portal_job
from . import res_partner

View File

@@ -1,374 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import _, api, fields, models
class FpPortalJob(models.Model):
"""Lightweight portal-facing view of a production job.
This is intentionally a simple, decoupled model - it does NOT replace any
real job/MO model from process packs (e.g. fusion_plating_process_en).
Instead, the shop populates this once per job (manually or via a small
sync rule from the real job) so the customer sees a clean, sanitised
summary on the portal without exposing internal records.
Each portal job carries the headline state, target/actual ship dates,
optional CoC + packing list attachments, and a tracking reference.
"""
_name = 'fusion.plating.portal.job'
_description = 'Fusion Plating - Portal Job'
_inherit = ['portal.mixin', 'mail.thread']
_order = 'received_date desc, id desc'
name = fields.Char(
string='Job Reference',
required=True,
copy=False,
tracking=True,
)
partner_id = fields.Many2one(
'res.partner',
string='Customer',
required=True,
index=True,
tracking=True,
)
state = fields.Selection(
[
('received', 'Received'),
('in_progress', 'In Progress'),
('quality_check', 'Quality Check'),
('ready_to_ship', 'Ready to Ship'),
('shipped', 'Shipped'),
('complete', 'Complete'),
],
string='Status',
default='received',
required=True,
tracking=True,
)
received_date = fields.Date(
string='Received Date',
default=fields.Date.context_today,
tracking=True,
)
target_ship_date = fields.Date(
string='Target Ship Date',
tracking=True,
)
actual_ship_date = fields.Date(
string='Actual Ship Date',
tracking=True,
)
# Per-stage Datetime timestamps for the customer-facing timeline.
# Snapshotted by write()/create() on state changes (idempotent).
received_at = fields.Datetime(
string='Received Timestamp',
readonly=True,
help='Auto-set when state first reaches received.',
)
in_progress_started_at = fields.Datetime(
string='In Progress Started At',
readonly=True,
)
qc_started_at = fields.Datetime(
string='QC Started At',
readonly=True,
)
ready_to_ship_at = fields.Datetime(
string='Ready to Ship At',
readonly=True,
)
shipped_at = fields.Datetime(
string='Shipped At',
readonly=True,
)
process_type_ids = fields.Many2many(
'fusion.plating.process.type',
'fp_portal_job_process_type_rel',
'job_id',
'process_type_id',
string='Processes',
)
quantity = fields.Integer(
string='Quantity',
default=1,
)
tracking_ref = fields.Char(
string='Tracking Reference',
)
x_fc_tracking_url = fields.Char(
string='Tracking URL',
compute='_compute_x_fc_tracking_url',
help='Resolved carrier tracking URL with the tracking number '
'substituted. Used by the portal template to render the '
'tracking_ref as a clickable link. Walks portal job → '
'fp.job → sale_order → fp.receiving → carrier.',
)
@api.depends('tracking_ref')
def _compute_x_fc_tracking_url(self):
Job = self.env.get('fp.job')
for rec in self:
url = ''
if rec.tracking_ref and Job is not None:
job = Job.sudo().search(
[('portal_job_id', '=', rec.id)], limit=1,
)
so = job.sale_order_id if job else False
recv = (
so.x_fc_receiving_ids[:1]
if so and 'x_fc_receiving_ids' in so._fields else False
)
carrier = (
recv.x_fc_carrier_id
if recv and 'x_fc_carrier_id' in recv._fields else False
)
tpl = (carrier.tracking_url or '') if carrier else ''
if tpl:
placeholder = '<shipmenttrackingnumber>'
if placeholder in tpl:
url = tpl.replace(placeholder, rec.tracking_ref)
else:
url = tpl + rec.tracking_ref
rec.x_fc_tracking_url = url
# ---- Tracking history exposure ----------------------------------------
# Pulls fusion.tracking.event records from the outbound shipment linked
# via fp.job → fp.receiving → x_fc_outbound_shipment_id. Used by the
# portal job page to render a timeline of carrier scan events.
x_fc_tracking_event_ids = fields.Many2many(
'fusion.tracking.event',
string='Tracking Events',
compute='_compute_x_fc_tracking_event_ids',
)
@api.depends('tracking_ref')
def _compute_x_fc_tracking_event_ids(self):
Job = self.env.get('fp.job')
Event = self.env.get('fusion.tracking.event')
empty = self.env['fusion.tracking.event'] if Event is not None else None
for rec in self:
events = empty
if Event is not None and Job is not None and rec.tracking_ref:
job = Job.sudo().search(
[('portal_job_id', '=', rec.id)], limit=1,
)
so = job.sale_order_id if job else False
recv = (
so.x_fc_receiving_ids[:1]
if so and 'x_fc_receiving_ids' in so._fields else False
)
ship = (
recv.x_fc_outbound_shipment_id
if recv and 'x_fc_outbound_shipment_id' in recv._fields
else False
)
if ship:
events = ship.tracking_event_ids.sorted(
key=lambda e: e.event_datetime or fields.Datetime.now(),
reverse=True,
)
rec.x_fc_tracking_event_ids = events
coc_attachment_id = fields.Many2one(
'ir.attachment',
string='Certificate of Conformance',
ondelete='set null',
)
packing_list_attachment_id = fields.Many2one(
'ir.attachment',
string='Packing List',
ondelete='set null',
)
invoice_ref = fields.Char(
string='Invoice Reference',
)
notes = fields.Html(
string='Customer-Visible Notes',
)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
)
# ==========================================================================
# Portal access
# ==========================================================================
def _compute_access_url(self):
super()._compute_access_url()
for rec in self:
rec.access_url = '/my/jobs/%s' % rec.id
# ==========================================================================
# Helpers
# ==========================================================================
@api.model
def _state_progress_map(self):
"""Return a dict mapping state -> progress percent for the portal bar."""
return {
'received': 10,
'in_progress': 35,
'quality_check': 60,
'ready_to_ship': 80,
'shipped': 95,
'complete': 100,
}
def _progress_percent(self):
self.ensure_one()
return self._state_progress_map().get(self.state, 0)
# ------------------------------------------------------------------
# Customer-visible process steps
#
# Walks the linked production's recipe tree and returns only the
# nodes the recipe author marked `customer_visible=True`. Used by
# the portal job page so internal QC / setup / handling steps stay
# hidden from the customer while the substantive process steps are
# surfaced.
# ------------------------------------------------------------------
def get_customer_visible_steps(self):
"""Return [{'name': str, 'icon': str, 'depth': int}] for portal display."""
self.ensure_one()
Production = self.env.get('mrp.production')
if Production is None:
return []
mo = Production.sudo().search(
[('x_fc_portal_job_id', '=', self.id)], limit=1,
)
if not mo or not mo.x_fc_recipe_id:
return []
result = []
def walk(node, depth):
for child in node.child_ids.sorted('sequence'):
if not child.customer_visible:
# Hidden node - and its sub-tree is also hidden,
# because if you're skipping the parent the kids
# never make sense in isolation.
continue
result.append({
'name': child.name,
'icon': child.icon or 'fa-cog',
'depth': depth,
'node_type': child.node_type,
})
walk(child, depth + 1)
walk(mo.x_fc_recipe_id, 0)
return result
# ==========================================================================
# State recompute - single source of truth derived from upstream models
# ==========================================================================
# The portal state should ALWAYS reflect the real shop-floor state of the
# linked fp.job(s), the outbound shipment(s), and the customer invoice.
# Earlier paths wrote state directly from each event hook (tracking number
# arrived → 'shipped'; invoice posted → 'complete') which drifted out of
# sync the moment any of those events fired before the job was actually
# done - e.g. a FedEx label booked early would promote portal state to
# 'shipped' even though the WO was still in 'confirmed'. The helper below
# is the new single source of truth; the hooks now delegate to it.
def _fp_recompute_portal_state(self):
"""Derive portal state from fp.job + shipment + invoice and write
it back if it differs. Safe to call from any sync hook; only
writes when the target state actually changes."""
Job = self.env.get('fp.job')
if Job is None:
return
for portal in self:
jobs = Job.sudo().search([('portal_job_id', '=', portal.id)])
if not jobs:
# No linked job - leave manual edits alone.
continue
all_done = all(j.state == 'done' for j in jobs)
any_in_progress = any(
j.state in ('in_progress', 'done') for j in jobs
)
# Walk SO → fp.receiving → fusion.shipment for shipment status.
ship_delivered = False
ship_in_transit = False
for j in jobs:
so = j.sale_order_id
if not so or 'x_fc_receiving_ids' not in so._fields:
continue
for recv in so.x_fc_receiving_ids:
ship = (
recv.x_fc_outbound_shipment_id
if 'x_fc_outbound_shipment_id' in recv._fields
else False
)
if not ship:
continue
if ship.status == 'delivered':
ship_delivered = True
elif ship.status == 'shipped':
ship_in_transit = True
# Invoice signal - any posted customer invoice on the SO.
invoiced = False
for j in jobs:
so = j.sale_order_id
if not so:
continue
if any(
m.state == 'posted' and m.move_type in ('out_invoice', 'out_refund')
for m in so.invoice_ids
):
invoiced = True
break
# Resolve target state.
if all_done and invoiced and ship_delivered:
target = 'complete'
elif all_done and (ship_delivered or ship_in_transit):
target = 'shipped'
elif all_done:
target = 'ready_to_ship'
elif any_in_progress:
target = 'in_progress'
else:
target = 'received'
if portal.state != target:
portal.sudo().write({'state': target})
# ==========================================================================
# Per-stage timestamp snapshots
# ==========================================================================
_STATE_TO_TS_FIELD = {
'received': 'received_at',
'in_progress': 'in_progress_started_at',
'quality_check': 'qc_started_at',
'ready_to_ship': 'ready_to_ship_at',
'shipped': 'shipped_at',
}
def write(self, vals):
if 'state' in vals:
ts_field = self._STATE_TO_TS_FIELD.get(vals['state'])
if ts_field:
now = fields.Datetime.now()
# Snapshot the timestamp only for records that don't have it yet,
# so re-transitioning to the same state doesn't overwrite history.
for rec in self:
if not rec[ts_field]:
super(FpPortalJob, rec).write({ts_field: now})
return super().write(vals)
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
now = fields.Datetime.now()
for rec in records:
ts_field = self._STATE_TO_TS_FIELD.get(rec.state)
if ts_field and not rec[ts_field]:
super(FpPortalJob, rec).write({ts_field: now})
return records

View File

@@ -1,266 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from markupsafe import Markup
from odoo import _, api, fields, models
class FpQuoteRequest(models.Model):
"""Customer-submitted Request for Quote (RFQ).
The RFQ is the entry point for new business through the customer portal.
A customer fills out the public form (logged in), uploads any drawings,
and submits - the record lands in the shop's backend in state ``new``.
The shop reviews, prices, and either quotes (``quoted``), declines, or
lets the request expire. The portal mixin gives each request a stable
access token URL so quote PDFs can be linked from chatter.
"""
_name = 'fusion.plating.quote.request'
_description = 'Fusion Plating - Quote Request'
_inherit = ['portal.mixin', 'mail.thread', 'mail.activity.mixin']
_order = 'create_date desc, id desc'
name = fields.Char(
string='Reference',
required=True,
copy=False,
readonly=True,
default=lambda self: _('New'),
tracking=True,
)
partner_id = fields.Many2one(
'res.partner',
string='Customer',
required=True,
index=True,
tracking=True,
)
contact_name = fields.Char(
string='Contact Name',
tracking=True,
)
contact_email = fields.Char(
string='Contact Email',
tracking=True,
)
contact_phone = fields.Char(
string='Contact Phone',
)
company_name = fields.Char(
string='Company',
)
part_description = fields.Html(
string='Part Description',
)
process_type_ids = fields.Many2many(
'fusion.plating.process.type',
'fp_quote_request_process_type_rel',
'request_id',
'process_type_id',
string='Requested Processes',
)
quantity = fields.Integer(
string='Quantity',
default=1,
)
target_delivery = fields.Date(
string='Target Delivery',
)
special_instructions = fields.Html(
string='Special Instructions',
)
drawing_attachment_ids = fields.Many2many(
'ir.attachment',
'fp_quote_request_attachment_rel',
'request_id',
'attachment_id',
string='Drawings & Attachments',
)
state = fields.Selection(
[
('new', 'New'),
('under_review', 'Under Review'),
('quoted', 'Quoted'),
('accepted', 'Accepted'),
('declined', 'Declined'),
('expired', 'Expired'),
],
string='Status',
default='new',
tracking=True,
required=True,
)
quoted_price = fields.Monetary(
string='Quoted Price',
currency_field='currency_id',
tracking=True,
)
currency_id = fields.Many2one(
'res.currency',
string='Currency',
required=True,
default=lambda self: self.env.company.currency_id,
)
quoted_by_id = fields.Many2one(
'res.users',
string='Quoted By',
tracking=True,
)
quote_sent_date = fields.Datetime(
string='Quote Sent',
tracking=True,
)
customer_response_date = fields.Datetime(
string='Customer Responded',
tracking=True,
)
line_ids = fields.One2many(
'fusion.plating.quote.request.line',
'request_id',
string='Part Lines',
)
shipping_address_id = fields.Many2one(
'res.partner',
string='Shipping Address',
)
billing_address_id = fields.Many2one(
'res.partner',
string='Billing Address',
)
billing_same_as_shipping = fields.Boolean(
string='Billing Same as Shipping',
default=True,
)
notes_internal = fields.Html(
string='Internal Notes',
help='Visible to shop users only - never shown on the customer portal.',
)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
)
# ==========================================================================
# ORM
# ==========================================================================
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name') or vals.get('name') == _('New'):
seq = self.env['ir.sequence'].next_by_code(
'fusion.plating.quote.request'
)
vals['name'] = seq or _('New')
return super().create(vals_list)
# ==========================================================================
# Portal access
# ==========================================================================
def _compute_access_url(self):
super()._compute_access_url()
for rec in self:
rec.access_url = '/my/quote_requests/%s' % rec.id
# ==========================================================================
# Actions
# ==========================================================================
def action_mark_under_review(self):
self.write({'state': 'under_review'})
def action_send_quote(self):
self.write({
'state': 'quoted',
'quote_sent_date': fields.Datetime.now(),
'quoted_by_id': self.env.user.id,
})
def action_mark_accepted(self):
self.write({
'state': 'accepted',
'customer_response_date': fields.Datetime.now(),
})
# ------------------------------------------------------------------
# GAP 1: Quote → Sale Order
# ------------------------------------------------------------------
def action_create_sale_order(self):
"""Create a sale order from this accepted quote request.
Populates SO lines from the quote request lines (if any) or
from the legacy single-part fields. Returns the SO action so
the user lands on the new order.
"""
self.ensure_one()
SaleOrder = self.env['sale.order']
SaleOrderLine = self.env['sale.order.line']
so_vals = {
'partner_id': self.partner_id.id,
'origin': self.name,
'company_id': self.company_id.id,
'note': self.special_instructions or '',
}
if self.shipping_address_id:
so_vals['partner_shipping_id'] = self.shipping_address_id.id
if self.billing_address_id:
so_vals['partner_invoice_id'] = self.billing_address_id.id
so = SaleOrder.create(so_vals)
# Create SO lines from quote lines
if self.line_ids:
for line in self.line_ids:
product = line.product_id
if not product:
continue
SaleOrderLine.create({
'order_id': so.id,
'product_id': product.id,
'product_uom_qty': line.quantity or 1,
'name': line.description or product.display_name,
'price_unit': self.quoted_price / max(len(self.line_ids), 1) if self.quoted_price else product.list_price,
})
elif self.quantity and self.quoted_price:
# Fallback: create a generic service line from the old single-part fields
generic_product = self.env.ref(
'fusion_plating_portal.product_plating_service',
raise_if_not_found=False,
)
SaleOrderLine.create({
'order_id': so.id,
'product_id': generic_product.id if generic_product else False,
'product_uom_qty': self.quantity,
'name': self.part_description or 'Plating Service',
'price_unit': self.quoted_price,
})
# Link back
self.write({'state': 'accepted'})
self.message_post(body=Markup(_(
'Sale Order <a href="/odoo/sales/%(so_id)s">%(so_name)s</a> created.'
)) % {'so_id': so.id, 'so_name': so.name})
return {
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'res_id': so.id,
'view_mode': 'form',
'target': 'current',
}
def action_mark_declined(self):
self.write({
'state': 'declined',
'customer_response_date': fields.Datetime.now(),
})
def action_mark_expired(self):
self.write({'state': 'expired'})

View File

@@ -1,59 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class FpQuoteRequestLine(models.Model):
"""Individual part line on a customer-submitted RFQ.
A quote request can contain multiple parts, each with its own
part number, quantity, description, and file attachments.
"""
_name = 'fusion.plating.quote.request.line'
_description = 'Fusion Plating - Quote Request Line'
_order = 'sequence, id'
request_id = fields.Many2one(
'fusion.plating.quote.request',
string='Quote Request',
required=True,
ondelete='cascade',
index=True,
)
sequence = fields.Integer(
string='Sequence',
default=10,
)
product_id = fields.Many2one(
'product.product',
string='Part',
)
part_number = fields.Char(
string='Part Number',
)
quantity = fields.Integer(
string='Quantity',
default=1,
)
count = fields.Integer(
string='Count',
default=1,
help='Number of pieces per quantity unit.',
)
description = fields.Text(
string='Description',
)
spec_text = fields.Text(
string='Spec Parameters',
help='Customer specification parameters for this part.',
)
attachment_ids = fields.Many2many(
'ir.attachment',
'fp_quote_request_line_attachment_rel',
'line_id',
'attachment_id',
string='Files',
)

View File

@@ -1,45 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
class ResPartner(models.Model):
_inherit = 'res.partner'
x_fc_portal_enabled = fields.Boolean(
string='Plating Portal Access',
default=False,
help='Allow this customer to see Plating quote requests and jobs '
'in their portal.',
)
x_fc_quote_request_ids = fields.One2many(
'fusion.plating.quote.request',
'partner_id',
string='Quote Requests',
)
x_fc_portal_job_ids = fields.One2many(
'fusion.plating.portal.job',
'partner_id',
string='Work Orders',
)
x_fc_quote_request_count = fields.Integer(
string='Quote Request Count',
compute='_compute_x_fc_quote_request_count',
)
x_fc_portal_job_count = fields.Integer(
string='Work Order Count',
compute='_compute_x_fc_portal_job_count',
)
@api.depends('x_fc_quote_request_ids')
def _compute_x_fc_quote_request_count(self):
for partner in self:
partner.x_fc_quote_request_count = len(partner.x_fc_quote_request_ids)
@api.depends('x_fc_portal_job_ids')
def _compute_x_fc_portal_job_count(self):
for partner in self:
partner.x_fc_portal_job_count = len(partner.x_fc_portal_job_ids)

View File

@@ -1,75 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<!-- ================================================================== -->
<!-- RECORD RULES -->
<!-- Customers (portal users) only see THEIR OWN quote requests + jobs. -->
<!-- Internal shop users (Operator+) see everything they're entitled to.-->
<!-- ================================================================== -->
<!-- Quote Request: portal users see only their own -->
<record id="fp_quote_request_portal_rule" model="ir.rule">
<field name="name">Plating Quote Request: portal - own company</field>
<field name="model_id" ref="model_fusion_plating_quote_request"/>
<field name="domain_force">[('partner_id','child_of', user.commercial_partner_id.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Quote Request: internal shop users - all -->
<record id="fp_quote_request_internal_rule" model="ir.rule">
<field name="name">Plating Quote Request: internal shop users</field>
<field name="model_id" ref="model_fusion_plating_quote_request"/>
<field name="domain_force">[(1,'=',1)]</field>
<field name="groups" eval="[(4, ref('fusion_plating.group_fusion_plating_operator'))]"/>
</record>
<!-- Quote Request Line: portal users see only their own (via parent) -->
<record id="fp_quote_request_line_portal_rule" model="ir.rule">
<field name="name">Plating Quote Request Line: portal - own company</field>
<field name="model_id" ref="model_fusion_plating_quote_request_line"/>
<field name="domain_force">[('request_id.partner_id','child_of', user.commercial_partner_id.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Quote Request Line: internal shop users - all -->
<record id="fp_quote_request_line_internal_rule" model="ir.rule">
<field name="name">Plating Quote Request Line: internal shop users</field>
<field name="model_id" ref="model_fusion_plating_quote_request_line"/>
<field name="domain_force">[(1,'=',1)]</field>
<field name="groups" eval="[(4, ref('fusion_plating.group_fusion_plating_operator'))]"/>
</record>
<!-- Portal Job: portal users see only their own -->
<record id="fp_portal_job_portal_rule" model="ir.rule">
<field name="name">Plating Portal Job: portal - own company</field>
<field name="model_id" ref="model_fusion_plating_portal_job"/>
<field name="domain_force">[('partner_id','child_of', user.commercial_partner_id.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Portal Job: internal shop users - all -->
<record id="fp_portal_job_internal_rule" model="ir.rule">
<field name="name">Plating Portal Job: internal shop users</field>
<field name="model_id" ref="model_fusion_plating_portal_job"/>
<field name="domain_force">[(1,'=',1)]</field>
<field name="groups" eval="[(4, ref('fusion_plating.group_fusion_plating_operator'))]"/>
</record>
</odoo>

View File

@@ -1,13 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fp_quote_request_portal,fp.quote.request.portal,model_fusion_plating_quote_request,base.group_portal,1,0,1,0
access_fp_quote_request_operator,fp.quote.request.operator,model_fusion_plating_quote_request,fusion_plating.group_fp_technician,1,0,0,0
access_fp_quote_request_supervisor,fp.quote.request.supervisor,model_fusion_plating_quote_request,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
access_fp_quote_request_manager,fp.quote.request.manager,model_fusion_plating_quote_request,fusion_plating.group_fp_manager,1,1,1,1
access_fp_quote_request_line_portal,fp.quote.request.line.portal,model_fusion_plating_quote_request_line,base.group_portal,1,0,1,0
access_fp_quote_request_line_operator,fp.quote.request.line.operator,model_fusion_plating_quote_request_line,fusion_plating.group_fp_technician,1,0,0,0
access_fp_quote_request_line_supervisor,fp.quote.request.line.supervisor,model_fusion_plating_quote_request_line,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
access_fp_quote_request_line_manager,fp.quote.request.line.manager,model_fusion_plating_quote_request_line,fusion_plating.group_fp_manager,1,1,1,1
access_fp_portal_job_portal,fp.portal.job.portal,model_fusion_plating_portal_job,base.group_portal,1,0,0,0
access_fp_portal_job_operator,fp.portal.job.operator,model_fusion_plating_portal_job,fusion_plating.group_fp_technician,1,0,0,0
access_fp_portal_job_supervisor,fp.portal.job.supervisor,model_fusion_plating_portal_job,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
access_fp_portal_job_manager,fp.portal.job.manager,model_fusion_plating_portal_job,fusion_plating.group_fp_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fp_quote_request_portal fp.quote.request.portal model_fusion_plating_quote_request base.group_portal 1 0 1 0
3 access_fp_quote_request_operator fp.quote.request.operator model_fusion_plating_quote_request fusion_plating.group_fp_technician 1 0 0 0
4 access_fp_quote_request_supervisor fp.quote.request.supervisor model_fusion_plating_quote_request fusion_plating.group_fp_shop_manager_v2 1 1 1 0
5 access_fp_quote_request_manager fp.quote.request.manager model_fusion_plating_quote_request fusion_plating.group_fp_manager 1 1 1 1
6 access_fp_quote_request_line_portal fp.quote.request.line.portal model_fusion_plating_quote_request_line base.group_portal 1 0 1 0
7 access_fp_quote_request_line_operator fp.quote.request.line.operator model_fusion_plating_quote_request_line fusion_plating.group_fp_technician 1 0 0 0
8 access_fp_quote_request_line_supervisor fp.quote.request.line.supervisor model_fusion_plating_quote_request_line fusion_plating.group_fp_shop_manager_v2 1 1 1 0
9 access_fp_quote_request_line_manager fp.quote.request.line.manager model_fusion_plating_quote_request_line fusion_plating.group_fp_manager 1 1 1 1
10 access_fp_portal_job_portal fp.portal.job.portal model_fusion_plating_portal_job base.group_portal 1 0 0 0
11 access_fp_portal_job_operator fp.portal.job.operator model_fusion_plating_portal_job fusion_plating.group_fp_technician 1 0 0 0
12 access_fp_portal_job_supervisor fp.portal.job.supervisor model_fusion_plating_portal_job fusion_plating.group_fp_shop_manager_v2 1 1 1 0
13 access_fp_portal_job_manager fp.portal.job.manager model_fusion_plating_portal_job fusion_plating.group_fp_manager 1 1 1 1

View File

@@ -1,25 +0,0 @@
/**
* Fusion Plating - Portal Account Summary
* Wires the sort dropdown change event to navigate to the option's value
* (which is a fully-formed /my/account_summary URL). Replaces an inline
* `onchange` attribute on the <select> so the template stays CSP-clean.
*/
(function () {
"use strict";
function init() {
document.querySelectorAll(".o_fp_account_summary select.o_fp_sort_select").forEach(function (sel) {
sel.addEventListener("change", function () {
if (sel.value) {
window.location.href = sel.value;
}
});
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

View File

@@ -1,92 +0,0 @@
/**
* Fusion Plating - portal list search + filter UI helper.
*
* Provides client-side, real-time multi-keyword filtering for any portal
* list page that opts in via the markup contract below. Pure vanilla JS,
* no framework, no debounce (client-side filter is <1ms even on 500 rows).
*
* Markup contract:
* - One <input class="o_fp_list_search" data-fp-target="<container-selector>"/>
* - One container matching <container-selector> with attribute data-fp-filterable
* whose direct children are the filterable rows (e.g. <tr> in a tbody,
* or <div class="o_fp_job_card_wrap"> children of #fp_jobs_list).
* - Optional: <span class="o_fp_list_search_count"/> updates with N visible
* of M total when a filter is active. Empty (no text) when search is empty.
*
* Each row's textContent is matched against the user's keywords using a
* lowercase AND across whitespace-split tokens. Extra non-visible search
* terms can be added per row as <span class="d-none" data-fp-search="..."/>.
*
* Sort dropdown: any <select class="o_fp_sort_select"> on the page navigates
* to the selected option's value URL on change. This file wires ALL such
* selects on any page, so the scope is not limited to .o_fp_account_summary.
* fp_portal_account_summary.js limits itself to .o_fp_account_summary scope
* to avoid double-firing on the Account Summary page. These two files are
* safe to coexist as long as Account Summary wraps its select inside the
* .o_fp_account_summary container (which it does).
*/
(function () {
"use strict";
function initSearch() {
document.querySelectorAll("input.o_fp_list_search").forEach(function (input) {
var targetSelector = input.getAttribute("data-fp-target");
if (!targetSelector) { return; }
var container = document.querySelector(targetSelector);
if (!container) { return; }
var countEl = document.querySelector(".o_fp_list_search_count");
var totalRows = container.children.length;
function applyFilter() {
var raw = (input.value || "").trim().toLowerCase();
var tokens = raw.split(/\s+/).filter(Boolean);
var visible = 0;
Array.prototype.forEach.call(container.children, function (row) {
var text = (row.textContent || "").toLowerCase();
var match = tokens.length === 0 || tokens.every(function (t) {
return text.indexOf(t) !== -1;
});
row.style.display = match ? "" : "none";
if (match) { visible++; }
});
if (countEl) {
countEl.textContent = tokens.length === 0
? ""
: visible + " of " + totalRows + " matching";
// Toggle visibility
countEl.classList.toggle("d-none", tokens.length === 0);
}
}
input.addEventListener("input", applyFilter);
// Run once on load in case the input has a prefilled value
applyFilter();
});
}
function initSortSelects() {
// Wire ALL .o_fp_sort_select dropdowns that are NOT inside
// .o_fp_account_summary (that page has its own handler in
// fp_portal_account_summary.js to avoid double-firing).
document.querySelectorAll(".o_fp_sort_select").forEach(function (sel) {
if (sel.closest(".o_fp_account_summary")) { return; }
sel.addEventListener("change", function () {
if (sel.value) {
window.location.href = sel.value;
}
});
});
}
function init() {
initSearch();
initSortSelects();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

View File

@@ -1,59 +0,0 @@
/**
* Fusion Plating - Portal sidebar hamburger toggle.
* Vanilla JS - no OWL / no jQuery. Loaded on every /my/* page.
* Below 768px the sidebar is translateX(-100%); toggling
* .o_fp_open on both sidebar + backdrop shows/hides it.
*/
(function () {
"use strict";
function init() {
var sidebar = document.querySelector(".o_fp_portal_sidebar");
var hamburger = document.querySelector(".o_fp_portal_hamburger");
var backdrop = document.querySelector(".o_fp_portal_backdrop");
if (!sidebar || !hamburger || !backdrop) {
return; // sidebar not on this page (logged-out, error pages, etc.)
}
function toggleOpen(force) {
var willOpen = (typeof force === "boolean")
? force
: !sidebar.classList.contains("o_fp_open");
sidebar.classList.toggle("o_fp_open", willOpen);
backdrop.classList.toggle("o_fp_open", willOpen);
}
hamburger.addEventListener("click", function (e) {
e.preventDefault();
toggleOpen();
});
backdrop.addEventListener("click", function () {
toggleOpen(false);
});
// Close when navigating to a sidebar link on mobile
sidebar.querySelectorAll("a.o_fp_sidebar_item").forEach(function (a) {
a.addEventListener("click", function () {
if (window.innerWidth < 769) {
toggleOpen(false);
}
});
});
// Resize safety: closing the drawer when the user crosses the desktop
// breakpoint prevents the backdrop's display:block from leaking onto
// the desktop layout. SCSS scopes the sidebar's drawer transform to
// @media (max-width: 768px), but the backdrop's display rule isn't
// media-scoped (intentionally - the JS owns that lifecycle).
window.addEventListener("resize", function () {
if (window.innerWidth > 768 && sidebar.classList.contains("o_fp_open")) {
toggleOpen(false);
}
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

View File

@@ -1,320 +0,0 @@
/** @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);

View File

@@ -1,87 +0,0 @@
// ============================================================================
// Fusion Plating - Customer Portal · Design Tokens
// Brand palette pulled from enplating.com live CSS (2026-05-17).
// Loaded first in web.assets_frontend so every later SCSS file sees these.
// Per Odoo 19 SCSS rules (CLAUDE.md rule 8/9): no @import; tokens are SCSS
// variables that downstream files reference directly, NOT CSS custom props.
// ============================================================================
// Brand palette
$fp-teal-light: #2eaf93;
$fp-teal: #1a6b59;
$fp-teal-dark: #0e3d2f;
$fp-teal-deep: #0a3528;
$fp-mint: #cbf3e6;
$fp-mint-pastel: #f0fdf9;
$fp-aqua: #9ae5d4;
// Surfaces
$fp-page-bg: #f8fafb;
$fp-section-bg: #f3f7f6;
$fp-card-bg: #ffffff;
$fp-card-border: #e5e7eb;
$fp-card-border-dark: #d1d5db;
// Text
$fp-text: #111827;
$fp-text-body: #374151;
$fp-muted: #6b7280;
$fp-muted-light: #9ca3af;
$fp-disabled: #d1d5db;
// Status (functional, NOT brand)
$fp-amber: #f59e0b;
$fp-amber-bg: #fef3c7;
$fp-amber-text: #92400e;
$fp-success: #22c55e;
$fp-success-text: #15803d;
$fp-success-bg: #f0fdf4;
$fp-danger: #ef4444;
$fp-danger-dark: #b91c1c;
$fp-danger-bg: #fef2f2;
// Gradients
$fp-gradient-primary: linear-gradient(135deg, $fp-teal-light 0%, $fp-teal 100%);
$fp-gradient-danger: linear-gradient(135deg, $fp-danger 0%, $fp-danger-dark 100%);
$fp-gradient-mint: linear-gradient(135deg, $fp-mint-pastel 0%, $fp-mint 100%);
$fp-gradient-icon: linear-gradient(135deg, $fp-mint 0%, $fp-aqua 100%);
$fp-gradient-secondary: linear-gradient(180deg, #fff 0%, $fp-section-bg 100%);
$fp-gradient-tab: linear-gradient(180deg, $fp-section-bg 0%, $fp-mint 100%);
// Shadows
$fp-shadow-card: 0 1px 2px rgba(0, 0, 0, .03);
$fp-shadow-card-hover: 0 1px 3px rgba(0, 0, 0, .04), 0 4px 12px rgba(0, 0, 0, .04);
$fp-shadow-button: 0 1px 3px rgba(26, 107, 89, .25), 0 4px 12px rgba(26, 107, 89, .18);
$fp-shadow-button-hover:0 2px 4px rgba(26, 107, 89, .30), 0 6px 16px rgba(26, 107, 89, .22);
$fp-shadow-danger: 0 1px 3px rgba(185, 28, 28, .25), 0 4px 12px rgba(185, 28, 28, .15);
$fp-glow-ring-teal: 0 0 0 4px rgba(46, 175, 147, .20);
$fp-glow-ring-amber: 0 0 0 4px rgba(245, 158, 11, .20);
// Geometry
$fp-radius-pill: 9999px;
$fp-radius-card: 14px;
$fp-radius-button: 9px;
$fp-radius-chip: 8px;
$fp-radius-icon: 7px;
$fp-radius-tile: 11px;
// Spacing scale (rem)
$fp-space-1: .25rem;
$fp-space-2: .5rem;
$fp-space-3: .7rem;
$fp-space-4: 1rem;
$fp-space-5: 1.25rem;
$fp-space-6: 1.5rem;
// Typography
$fp-font: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
$fp-font-mono: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, monospace;
// Dark-mode placeholder - DEFERRED per spec.
// When implementing, branch on $o-webclient-color-scheme per CLAUDE.md rule 9.
// Example pattern (do NOT enable now):
// @if $o-webclient-color-scheme == dark {
// $fp-page-bg: #0e1f1b !global;
// $fp-card-bg: #1a2b27 !global;
// // ...
// }

View File

@@ -1,65 +0,0 @@
// ============================================================================
// Fusion Plating - Portal · Status badges
// Pill with coloured dot + soft glow halo. Maps directly to fp.portal.job.state
// (and similar enum fields on quote / invoice / delivery).
// ============================================================================
.o_fp_badge {
display: inline-flex;
align-items: center;
gap: .4rem;
padding: .25rem .7rem;
border-radius: $fp-radius-pill;
font-family: $fp-font;
font-size: .7rem;
font-weight: 600;
line-height: 1.1;
white-space: nowrap;
.o_fp_badge_dot {
width: 7px;
height: 7px;
border-radius: $fp-radius-pill;
flex-shrink: 0;
}
}
// State mapping - extend with `class="o_fp_badge o_fp_badge_<state>"`.
.o_fp_badge_received,
.o_fp_badge_new {
background: $fp-section-bg;
color: $fp-text-body;
.o_fp_badge_dot { background: $fp-muted; }
}
.o_fp_badge_in_progress,
.o_fp_badge_quoted {
background: $fp-mint;
color: $fp-teal-dark;
.o_fp_badge_dot { background: $fp-teal; box-shadow: 0 0 0 3px rgba(26, 107, 89, .18); }
}
.o_fp_badge_quality_check,
.o_fp_badge_under_review {
background: $fp-amber-bg;
color: $fp-amber-text;
.o_fp_badge_dot { background: $fp-amber; box-shadow: 0 0 0 3px rgba(245, 158, 11, .18); }
}
.o_fp_badge_ready_to_ship,
.o_fp_badge_accepted,
.o_fp_badge_paid {
background: $fp-success-bg;
color: $fp-success-text;
.o_fp_badge_dot { background: $fp-success; }
}
.o_fp_badge_shipped,
.o_fp_badge_complete {
background: $fp-success-bg;
color: $fp-success-text;
.o_fp_badge_dot { background: $fp-success; }
}
.o_fp_badge_declined,
.o_fp_badge_overdue,
.o_fp_badge_hold {
background: $fp-danger-bg;
color: $fp-danger-dark;
.o_fp_badge_dot { background: $fp-danger; }
}

View File

@@ -1,120 +0,0 @@
// ============================================================================
// Fusion Plating - Portal · Button system
// Gradient primary CTA, soft secondary, ghost tertiary, gradient danger.
// All states use class hooks under .o_fp_btn_* so they don't fight Bootstrap.
// ============================================================================
.o_fp_btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: $fp-space-2;
// Match Odoo's standard Bootstrap button rhythm (38px tall).
padding: .5rem 1rem;
border-radius: $fp-radius-button;
font-family: $fp-font;
font-size: 1rem;
font-weight: 500;
line-height: 1.4;
border: none;
cursor: pointer;
text-decoration: none;
transition: transform .08s ease, box-shadow .15s ease;
user-select: none;
&:focus-visible {
outline: 2px solid $fp-teal;
outline-offset: 2px;
}
&:active { transform: translateY(1px); }
&:disabled,
&.disabled {
opacity: .55;
cursor: not-allowed;
pointer-events: none;
}
}
// PRIMARY - gradient teal CTA
.o_fp_btn_primary {
@extend .o_fp_btn;
background: $fp-gradient-primary;
color: #fff;
box-shadow: $fp-shadow-button;
text-shadow: 0 1px 0 rgba(0, 0, 0, .08);
&:hover { box-shadow: $fp-shadow-button-hover; color: #fff; }
}
// SECONDARY - outlined, very subtle gradient
.o_fp_btn_secondary {
@extend .o_fp_btn;
background: $fp-gradient-secondary;
color: $fp-teal;
border: 1px solid $fp-card-border-dark;
box-shadow: 0 1px 2px rgba(0, 0, 0, .04);
&:hover { background: $fp-section-bg; color: $fp-teal-dark; }
}
// GHOST - text-only with subtle hover
.o_fp_btn_ghost {
@extend .o_fp_btn;
background: transparent;
color: $fp-teal;
font-weight: 500;
&:hover { background: rgba(46, 175, 147, .08); color: $fp-teal-dark; }
}
// DANGER - gradient red
.o_fp_btn_danger {
@extend .o_fp_btn;
background: $fp-gradient-danger;
color: #fff;
box-shadow: $fp-shadow-danger;
&:hover { color: #fff; }
}
// MINT-PILL - soft branded "view all" affordance
.o_fp_btn_mint {
@extend .o_fp_btn;
background: $fp-gradient-mint;
color: $fp-teal;
border: 1px solid $fp-aqua;
font-weight: 600;
&:hover { color: $fp-teal-dark; }
}
// Size modifiers - match Bootstrap btn-sm / btn-lg sizing
.o_fp_btn_sm { padding: .25rem .5rem; font-size: .875rem; }
.o_fp_btn_lg { padding: .5rem 1rem; font-size: 1.25rem; }
// ============================================================================
// Globally suppress browser-default underline-on-hover for portal
// surfaces. Bootstrap's Reboot puts `a:hover { text-decoration: underline }`
// on every anchor; for our flat-aesthetic chips / cards / pill buttons that
// reads as a buggy visual artifact. Hover signal lives in color + shadow
// instead. Specificity is high enough to win without !important.
// ============================================================================
.o_fp_btn,
.o_fp_btn_primary,
.o_fp_btn_secondary,
.o_fp_btn_ghost,
.o_fp_btn_danger,
.o_fp_btn_mint,
.o_fp_dashboard a,
.o_fp_job_detail a,
.o_fp_job_card,
.o_fp_doc_chip,
.o_fp_doc_row,
.o_fp_panel_view_all,
.o_fp_panel_inline_cta,
.o_fp_kpi_hint,
.o_fp_view_all a,
.o_fp_status_tab,
.o_fp_related_links a {
text-decoration: none;
&:hover,
&:focus,
&:active {
text-decoration: none;
}
}

View File

@@ -1,161 +0,0 @@
// ============================================================================
// Fusion Plating - Portal · Card shells + KPI tiles + doc chips
// ============================================================================
// Generic card shell
.o_fp_card {
background: $fp-card-bg;
border: 1px solid $fp-card-border;
border-radius: $fp-radius-card;
padding: $fp-space-5;
box-shadow: $fp-shadow-card;
}
.o_fp_card_compact {
@extend .o_fp_card;
padding: $fp-space-3 $fp-space-4;
border-radius: $fp-radius-tile;
}
.o_fp_card_hoverable {
transition: box-shadow .15s ease, transform .08s ease;
&:hover {
box-shadow: $fp-shadow-card-hover;
transform: translateY(-1px);
}
}
// KPI tile (the 4-tile strip across the top of the dashboard)
.o_fp_kpi_tile {
@extend .o_fp_card_compact;
.o_fp_kpi_label {
font-size: .66rem;
color: $fp-muted;
text-transform: uppercase;
letter-spacing: .05em;
font-weight: 600;
margin-bottom: .2rem;
}
.o_fp_kpi_value {
font-size: 1.5rem;
font-weight: 700;
color: $fp-text;
line-height: 1;
}
.o_fp_kpi_hint {
font-size: .7rem;
margin-top: .2rem;
color: $fp-muted;
display: block;
text-decoration: none;
&.o_fp_hint_action {
color: $fp-teal;
font-weight: 500;
}
&.o_fp_hint_success { color: $fp-success-text; font-weight: 500; }
&.o_fp_hint_warn { color: $fp-amber-text; font-weight: 500; }
}
// Highlighted KPI (the In-Flight Jobs hero metric)
&.o_fp_kpi_hero {
background: $fp-gradient-mint;
border-color: $fp-aqua;
.o_fp_kpi_label,
.o_fp_kpi_value {
color: $fp-teal-dark;
}
}
}
// Doc chip (compact attachment pill)
.o_fp_doc_chip {
display: inline-flex;
align-items: center;
gap: .35rem;
padding: .25rem .55rem;
background: $fp-section-bg;
color: $fp-teal;
border: 1px solid $fp-card-border;
border-radius: $fp-radius-chip;
font-size: .7rem;
font-weight: 500;
text-decoration: none;
&:hover {
background: $fp-mint;
color: $fp-teal-dark;
}
&.o_fp_doc_chip_pending {
background: $fp-card-bg;
color: $fp-muted-light;
border: 1px dashed $fp-card-border-dark;
cursor: default;
}
}
// Document row (used inside grouped doc panel)
.o_fp_doc_row {
display: flex;
align-items: center;
padding: .55rem .7rem;
background: $fp-page-bg;
border-radius: $fp-radius-chip;
margin-bottom: .4rem;
text-decoration: none;
transition: background .12s ease;
&:hover { background: $fp-section-bg; }
.o_fp_doc_icon {
width: 32px;
height: 32px;
border-radius: $fp-radius-icon;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: .85rem;
margin-right: .7rem;
flex-shrink: 0;
}
.o_fp_doc_meta { flex: 1; min-width: 0; }
.o_fp_doc_name {
font-size: .84rem;
color: $fp-text;
font-weight: 500;
}
.o_fp_doc_sub {
font-size: .7rem;
color: $fp-muted-light;
}
.o_fp_doc_action {
color: $fp-teal;
font-size: .74rem;
text-decoration: none;
font-weight: 500;
padding: .25rem .5rem;
}
// Icon color variants - tint per doc category
.o_fp_doc_icon_input { background: #eff6ff; color: #1e40af; }
.o_fp_doc_icon_drawing { background: $fp-success-bg; color: $fp-success-text; }
.o_fp_doc_icon_spec { background: $fp-amber-bg; color: $fp-amber-text; }
.o_fp_doc_icon_quality { background: $fp-mint; color: $fp-teal-dark; }
.o_fp_doc_icon_shipping { background: $fp-mint-pastel; color: $fp-teal; }
.o_fp_doc_icon_pending { background: $fp-section-bg; color: $fp-muted-light; }
// Pending state for not-yet-generated docs
&.o_fp_doc_row_pending {
background: $fp-card-bg;
border: 1px dashed $fp-card-border;
opacity: .9;
cursor: default;
.o_fp_doc_name, .o_fp_doc_sub { color: $fp-muted-light; }
}
}
// Doc group label
.o_fp_doc_group_label {
font-size: .7rem;
color: $fp-muted;
text-transform: uppercase;
letter-spacing: .04em;
font-weight: 600;
margin-bottom: .45rem;
}

View File

@@ -1,290 +0,0 @@
// ============================================================================
// Fusion Plating - Portal · Dashboard layout
// Jobs-forward grid: welcome strip → KPI tile row → hero jobs section →
// secondary panel strip.
// ============================================================================
.o_fp_dashboard {
background: $fp-page-bg;
padding: $fp-space-6;
border-radius: $fp-radius-card;
border: 1px solid $fp-card-border;
font-family: $fp-font;
}
.o_fp_welcome {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: $fp-space-4;
flex-wrap: wrap;
gap: $fp-space-3;
.o_fp_welcome_title {
font-size: 1.15rem;
font-weight: 600;
color: $fp-text;
margin-bottom: .18rem;
}
.o_fp_welcome_sub {
font-size: .82rem;
color: $fp-muted;
}
}
.o_fp_kpi_row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $fp-space-3;
margin-bottom: $fp-space-5;
@media (max-width: 768px) {
grid-template-columns: repeat(2, 1fr);
}
}
.o_fp_jobs_hero {
margin-bottom: $fp-space-5;
.o_fp_section_header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $fp-space-3;
.o_fp_section_title {
font-weight: 600;
font-size: 1rem;
color: $fp-text;
}
}
// Status filter tabs on the jobs hero
.o_fp_status_tabs {
display: flex;
gap: .35rem;
font-size: .74rem;
align-items: center;
background: $fp-card-bg;
border: 1px solid $fp-card-border;
border-radius: $fp-radius-button;
padding: .2rem;
.o_fp_status_tab {
padding: .25rem .6rem;
border-radius: 6px;
color: $fp-muted;
cursor: pointer;
text-decoration: none;
&.active {
background: $fp-gradient-tab;
color: $fp-teal-dark;
font-weight: 600;
}
}
}
.o_fp_view_all {
text-align: center;
padding: .45rem;
a { @extend .o_fp_btn_mint; }
}
}
// Job card: outer wrap is a plain div so we can place an interactive
// actions footer (Repeat Order form, doc download links) as a SIBLING
// of the main anchor - forms inside anchors are invalid HTML and
// browser-buggy. Hover/lift effect lives on the wrap; click target is
// the inner .o_fp_job_card_main anchor only.
.o_fp_job_card {
@extend .o_fp_card;
padding: $fp-space-4;
border-radius: $fp-radius-tile;
margin-bottom: $fp-space-3;
box-shadow: $fp-shadow-card;
transition: box-shadow .15s ease, transform .08s ease, border-color .15s ease;
&:hover {
box-shadow: $fp-shadow-card-hover;
border-color: $fp-aqua;
transform: translateY(-1px);
}
}
.o_fp_job_card_main {
display: block;
color: inherit;
text-decoration: none;
&:hover,
&:focus-visible {
color: inherit;
text-decoration: none;
}
&:focus-visible {
outline: 2px solid $fp-teal;
outline-offset: 2px;
}
}
.o_fp_job_header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: .55rem;
gap: .65rem;
.o_fp_job_ref {
font-weight: 600;
color: $fp-text;
font-size: .98rem;
}
.o_fp_job_meta {
color: $fp-muted;
font-size: .8rem;
margin-left: .55rem;
}
}
// Part name / number row under the header
.o_fp_job_part {
color: $fp-text-body;
font-size: .78rem;
margin-bottom: .2rem;
.o_fp_job_part_icon { color: $fp-muted-light; margin-right: .3rem; }
}
// Shipping address row
.o_fp_job_ship {
color: $fp-muted;
font-size: .76rem;
margin-bottom: $fp-space-3;
.o_fp_job_ship_icon { color: $fp-muted-light; margin-right: .3rem; }
}
// Actions footer - siblings of the main anchor. Doc download chips on
// the left, Repeat Order on the right. Border-top visually separates.
.o_fp_job_card_actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: $fp-space-3;
margin-top: $fp-space-3;
padding-top: .7rem;
border-top: 1px solid $fp-section-bg;
flex-wrap: wrap;
}
.o_fp_job_card_docs {
display: flex;
align-items: center;
gap: .4rem;
flex-wrap: wrap;
}
// Compact icon-only download chip. Tooltip via `title` attr.
.o_fp_doc_quick_btn {
display: inline-flex;
align-items: center;
gap: .25rem;
padding: .3rem .55rem;
background: $fp-section-bg;
color: $fp-teal;
border: 1px solid $fp-card-border;
border-radius: $fp-radius-chip;
font-size: .72rem;
font-weight: 500;
text-decoration: none;
transition: background .12s ease, color .12s ease;
&:hover {
background: $fp-mint;
color: $fp-teal-dark;
text-decoration: none;
}
&.o_fp_doc_quick_btn_pending {
background: $fp-card-bg;
color: $fp-muted-light;
border: 1px dashed $fp-card-border-dark;
cursor: not-allowed;
pointer-events: none;
}
}
// Legacy: kept for any place still rendering chips below the stepper.
.o_fp_job_docs {
display: flex;
flex-wrap: wrap;
gap: .35rem;
margin-top: $fp-space-3;
padding-top: .6rem;
border-top: 1px solid $fp-section-bg;
}
.o_fp_secondary_panels {
display: grid;
// Auto-fit so 5 panels arrange nicely as 3+2 / 2+2+1 / 1 column at
// various widths instead of overflowing or cramping.
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: $fp-space-3;
.o_fp_panel {
@extend .o_fp_card_compact;
.o_fp_panel_title {
font-weight: 600;
font-size: .82rem;
color: $fp-text;
margin-bottom: .5rem;
display: flex;
align-items: center;
gap: .4rem;
.o_fp_panel_icon {
background: $fp-gradient-icon;
color: $fp-teal-dark;
width: 24px;
height: 24px;
border-radius: $fp-radius-icon;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: .78rem;
}
.o_fp_panel_view_all {
margin-left: auto;
font-size: .7rem;
color: $fp-teal;
text-decoration: none;
font-weight: 500;
&:hover { color: $fp-teal-dark; }
}
}
.o_fp_panel_row {
font-size: .72rem;
color: $fp-muted;
margin-top: .2rem;
&:first-of-type { margin-top: 0; }
}
.o_fp_panel_inline_cta {
margin-left: .35rem;
color: $fp-teal;
text-decoration: none;
font-weight: 500;
&:hover { color: $fp-teal-dark; }
}
}
}
// Filter pills used by Account Summary (also reusable elsewhere)
.o_fp_filter_pill {
display: inline-block;
padding: .25rem .75rem;
border-radius: $fp-radius-pill;
background: $fp-section-bg;
color: $fp-muted;
font-size: .8rem;
text-decoration: none;
transition: background .12s ease, color .12s ease;
&:hover { background: $fp-mint; color: $fp-teal-dark; text-decoration: none; }
&.o_fp_filter_pill_active {
background: $fp-gradient-primary;
color: #fff;
font-weight: 600;
}
}

View File

@@ -1,190 +0,0 @@
// ============================================================================
// Fusion Plating - Portal · Sidebar shell
// Sticky 240px left rail wrapping every /my/* page. Grouped sections
// (Dashboard / ACTIVITY / DOCUMENTS / ACCOUNT). Active page = mint
// gradient fill + brand teal left bar. Below 768px collapses to a
// hamburger drawer with backdrop.
// ============================================================================
.o_fp_portal_shell {
display: grid;
grid-template-columns: 240px 1fr;
gap: $fp-space-5;
background: $fp-page-bg;
min-height: calc(100vh - 80px);
padding: $fp-space-4;
@media (max-width: 768px) {
grid-template-columns: 1fr;
gap: 0;
padding: $fp-space-3;
}
}
// Internal staff (employee portal) - no customer sidebar. Collapse the grid
// to a single column so page content isn't pushed right by the now-empty
// 240px sidebar track.
.o_fp_portal_shell--no-sidebar {
grid-template-columns: 1fr;
gap: 0;
}
.o_fp_portal_sidebar {
position: sticky;
top: $fp-space-4;
background: $fp-card-bg;
border: 1px solid $fp-card-border;
border-radius: $fp-radius-card;
padding: .85rem .5rem;
box-shadow: $fp-shadow-card;
font-family: $fp-font;
align-self: start;
.o_fp_sidebar_header {
padding: .45rem .9rem .7rem;
font-size: .62rem;
color: $fp-muted;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
border-bottom: 1px solid $fp-section-bg;
}
.o_fp_sidebar_section_label {
padding: .85rem .9rem .25rem;
font-size: .62rem;
color: $fp-muted-light;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
}
.o_fp_sidebar_item {
display: flex;
align-items: center;
gap: .55rem;
padding: .5rem .9rem;
margin: .05rem .15rem;
color: $fp-text-body;
font-size: .85rem;
text-decoration: none;
border-radius: 6px;
border-left: 3px solid transparent;
transition: background .12s ease, color .12s ease;
&:hover {
background: $fp-section-bg;
color: $fp-teal-dark;
text-decoration: none;
}
&.o_fp_sidebar_active {
background: linear-gradient(90deg, $fp-mint 0%, $fp-mint-pastel 100%);
color: $fp-teal-dark;
font-weight: 600;
border-left: 3px solid $fp-teal;
}
.o_fp_sidebar_icon {
width: 1.15rem;
text-align: center;
flex-shrink: 0;
}
}
.o_fp_sidebar_footer {
border-top: 1px solid $fp-section-bg;
margin: .7rem .15rem 0;
padding-top: .5rem;
}
// Mobile: slide-in drawer
@media (max-width: 768px) {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 280px;
z-index: 1040;
transform: translateX(-100%);
transition: transform .2s ease;
border-radius: 0;
border-top: none;
border-bottom: none;
border-left: none;
margin: 0;
&.o_fp_open {
transform: translateX(0);
}
}
}
// Mobile hamburger button (above main content, hidden on desktop)
.o_fp_portal_hamburger {
display: none;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
background: $fp-card-bg;
border: 1px solid $fp-card-border;
border-radius: $fp-radius-button;
color: $fp-teal;
margin-bottom: $fp-space-3;
cursor: pointer;
transition: background .12s ease;
&:hover { background: $fp-section-bg; }
@media (max-width: 768px) {
display: inline-flex;
}
}
// Backdrop behind the open mobile drawer
.o_fp_portal_backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(15, 30, 30, .35);
z-index: 1030;
&.o_fp_open {
display: block;
}
}
.o_fp_portal_main {
// Stretches with the grid row so the right column matches the
// sidebar's height on short pages (empty list states, statements
// tab, etc.) - uniform visual rhythm.
min-height: 100%;
// Bootstrap tables can grow wider than the grid track without this;
// min-width: 0 lets the flex/grid child shrink and lets overflow-x
// on inner .table-responsive containers do their job on Safari.
min-width: 0;
// Neutralise Odoo's container pt-3 + templates' mt-3 on the first
// child so the right column's top aligns flush with the sidebar's
// top edge. !important is required because Bootstrap 5 spacing
// utilities (.pt-3, .mt-3) ship with !important by default - without
// matching specificity Bootstrap wins and the right column sits
// ~32px (pt-3 + mt-3) lower than the sidebar.
#wrap > .container {
padding-top: 0 !important;
}
#wrap > .container > :first-child {
margin-top: 0 !important;
}
// Odoo's portal.portal_searchbar still renders an inline breadcrumb
// inside its navbar when the page sets breadcrumbs_searchbar=True.
// Now that our shell forces the outer breadcrumb to ALWAYS render
// (via the fp_portal_shell xpath above), the inline copy would be
// a visible duplicate. Hide it; the navbar's other content (title,
// sort/filter dropdowns) stays visible.
.o_portal_navbar > .breadcrumb,
.o_portal_navbar > ol.breadcrumb {
display: none;
}
}

View File

@@ -1,132 +0,0 @@
// ============================================================================
// Fusion Plating - Portal · Numbered stepper
// Horizontal circle+line stepper for job progress on dashboard cards.
// 5 steps fixed (Received / Inspected / Plating / QC / Ship) by default;
// macro accepts variable step count.
// ============================================================================
.o_fp_stepper {
display: flex;
align-items: center;
// Bottom space for the absolutely-positioned labels below each circle.
// ~2.2rem covers two short lines (title + time_label).
margin-bottom: 2.4rem;
// Each unit holds one circle + its label. The label is absolutely
// positioned (see .o_fp_step_label below) so its horizontal centre
// lines up with the circle's centre even when text is wider than 24px.
.o_fp_step_unit {
position: relative;
flex-shrink: 0;
width: 24px;
height: 24px;
}
.o_fp_step_circle {
width: 24px;
height: 24px;
border-radius: $fp-radius-pill;
display: flex;
align-items: center;
justify-content: center;
font-family: $fp-font;
font-size: .65rem;
font-weight: 700;
background: $fp-card-bg;
border: 1.5px solid $fp-card-border;
color: $fp-muted-light;
}
.o_fp_step_done {
background: $fp-gradient-primary;
color: #fff;
border: none;
box-shadow: 0 1px 2px rgba(26, 107, 89, .25);
}
.o_fp_step_active {
background: $fp-card-bg;
color: $fp-teal;
border: 2.5px solid $fp-teal;
box-shadow: $fp-glow-ring-teal;
animation: fp-pulse-teal 1.8s ease-in-out infinite;
}
.o_fp_step_active_warn {
// Used when the active step is in QC (amber)
background: $fp-card-bg;
color: $fp-amber-text;
border: 2.5px solid $fp-amber;
box-shadow: $fp-glow-ring-amber;
animation: fp-pulse-amber 1.8s ease-in-out infinite;
}
// Connector lines that flex-grow to fill the row between circles.
// MUST stay nested inside .o_fp_stepper or flex:1 doesn't apply
// (and the circles cluster on the left with no gaps).
.o_fp_step_line {
flex: 1;
height: 2px;
margin: 0 3px;
background: $fp-card-border;
&.o_fp_step_line_done { background: $fp-teal; }
&.o_fp_step_line_warn { background: $fp-amber; }
}
// Label centred on its circle via absolute positioning. Wider text
// ("Inspected") overflows equally left + right instead of pushing
// the column or sitting in a separate flex slot.
.o_fp_step_label {
position: absolute;
top: calc(100% + .45rem);
left: 50%;
transform: translateX(-50%);
text-align: center;
white-space: nowrap;
font-size: .68rem;
.o_fp_step_label_title {
color: $fp-muted-light;
font-weight: 500;
}
.o_fp_step_label_time {
color: $fp-disabled;
font-size: .6rem;
}
&.o_fp_step_label_done {
.o_fp_step_label_title { color: $fp-text-body; }
.o_fp_step_label_time { color: $fp-muted-light; }
}
&.o_fp_step_label_active {
.o_fp_step_label_title { color: $fp-teal; font-weight: 700; }
.o_fp_step_label_time { color: $fp-teal; }
}
&.o_fp_step_label_active_warn {
.o_fp_step_label_title { color: $fp-amber-text; font-weight: 700; }
.o_fp_step_label_time { color: $fp-amber-text; }
}
}
}
// Pulsing glow for the active step indicator. Kept subtle - the ring
// breathes in width + fades; the inner dot stays still. Two color
// variants (teal for normal flow, amber for QC) so the warn state
// retains its meaning. Defined here, used in both fp_portal_stepper.scss
// and fp_portal_timeline.scss.
@keyframes fp-pulse-teal {
0%, 100% { box-shadow: 0 0 0 4px rgba(46, 175, 147, 0.20); }
50% { box-shadow: 0 0 0 9px rgba(46, 175, 147, 0.06); }
}
@keyframes fp-pulse-amber {
0%, 100% { box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.20); }
50% { box-shadow: 0 0 0 9px rgba(245, 158, 11, 0.06); }
}
// Accessibility: kill the animation for users who've opted out of motion.
@media (prefers-reduced-motion: reduce) {
.o_fp_step_active,
.o_fp_step_active_warn,
.o_fp_timeline_active .o_fp_timeline_dot {
animation: none;
}
}
// Legacy .o_fp_step_labels container removed - labels are now nested
// inside each .o_fp_step_unit (see above) so they centre on their circle.

View File

@@ -1,164 +0,0 @@
// ============================================================================
// Fusion Plating - Portal · Vertical timeline (job detail page)
// ============================================================================
.o_fp_timeline {
position: relative;
// Spine (gray default)
&::before {
content: '';
position: absolute;
left: 9px;
top: 10px;
bottom: 10px;
width: 2px;
background: $fp-card-border;
}
// Active portion (filled to height of completed stages, set inline by template)
.o_fp_timeline_spine_active {
position: absolute;
left: 9px;
top: 10px;
width: 2px;
background: $fp-teal;
// height set inline via style attribute
}
.o_fp_timeline_item {
position: relative;
padding-left: 2rem;
padding-bottom: 1.1rem;
&:last-child { padding-bottom: 0; }
.o_fp_timeline_dot {
position: absolute;
left: 0;
top: 0;
width: 20px;
height: 20px;
border-radius: $fp-radius-pill;
background: $fp-card-bg;
border: 2px solid $fp-card-border;
}
&.o_fp_timeline_done .o_fp_timeline_dot {
background: $fp-gradient-primary;
border: none;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: .65rem;
font-weight: 700;
}
&.o_fp_timeline_active .o_fp_timeline_dot {
background: $fp-card-bg;
border: 2.5px solid $fp-teal;
box-shadow: $fp-glow-ring-teal;
// Pulsing glow defined in fp_portal_stepper.scss (@keyframes
// fp-pulse-teal) - reused here so the active timeline dot
// breathes in sync with the stepper circle on /my/home.
animation: fp-pulse-teal 1.8s ease-in-out infinite;
}
.o_fp_timeline_title {
font-size: .92rem;
color: $fp-text;
font-weight: 500;
line-height: 1.2;
}
&.o_fp_timeline_active .o_fp_timeline_title {
color: $fp-teal;
font-weight: 700;
font-size: .95rem;
}
&.o_fp_timeline_pending .o_fp_timeline_title {
color: $fp-muted;
}
.o_fp_timeline_time {
font-size: .78rem;
color: $fp-muted;
margin-top: .2rem;
}
&.o_fp_timeline_active .o_fp_timeline_time { color: $fp-teal; }
&.o_fp_timeline_pending .o_fp_timeline_time { color: $fp-disabled; }
.o_fp_timeline_note {
font-size: .74rem;
color: $fp-text-body;
margin-top: .35rem;
padding: .4rem .6rem;
background: $fp-page-bg;
border-radius: 6px;
display: inline-block;
&.o_fp_timeline_note_success { background: $fp-success-bg; color: $fp-success-text; }
&.o_fp_timeline_note_active { background: #eff6ff; color: $fp-teal-dark; line-height: 1.4; }
}
}
}
// Detail-page outer wrapper
.o_fp_job_detail {
background: $fp-page-bg;
padding: $fp-space-6;
border-radius: $fp-radius-card;
border: 1px solid $fp-card-border;
font-family: $fp-font;
.o_fp_job_detail_hero {
@extend .o_fp_card;
margin-bottom: $fp-space-5;
padding-bottom: $fp-space-4;
h2 { margin: 0 0 .35rem 0; font-size: 1.5rem; color: $fp-text; font-weight: 600; }
.o_fp_detail_label {
font-size: .7rem;
color: $fp-muted;
letter-spacing: .05em;
text-transform: uppercase;
font-weight: 500;
margin-bottom: .25rem;
}
.o_fp_detail_subtitle {
color: $fp-text-body;
font-size: .92rem;
margin-bottom: .7rem;
}
.o_fp_detail_facts {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
color: $fp-text-body;
font-size: .82rem;
.o_fp_fact_label { color: $fp-muted-light; }
.o_fp_fact_value { color: $fp-text; font-weight: 600; }
}
}
.o_fp_job_detail_grid {
display: grid;
grid-template-columns: 1.1fr 1fr;
gap: $fp-space-5;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
.o_fp_job_detail_footer {
@extend .o_fp_card;
margin-top: $fp-space-5;
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
.o_fp_related_links {
font-size: .82rem;
color: $fp-text-body;
a { color: $fp-teal; text-decoration: none; margin: 0 .55rem; }
a.disabled { color: $fp-muted-light; }
}
}
}

View File

@@ -1,128 +0,0 @@
// =============================================================================
// Fusion Plating - Customer Portal · Legacy catch-all
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// This file holds the rules that pre-date the 2026-05-17 dashboard redesign
// AND are still referenced by templates that the redesign did NOT touch
// (RFQ wizard part rows, file drop zone, configurator forms, empty-state
// cards on the secondary portal pages). The redesigned surfaces use the
// new component partials (_fp_portal_tokens, fp_portal_buttons, etc.).
//
// THEME AWARENESS: rules in this file still use Bootstrap CSS custom
// properties (var(--bs-*)) so they adapt to both light and dark portal
// themes. New component partials use the brand token system instead.
// =============================================================================
// -----------------------------------------------------------------------------
// Generic portal card surface - used by empty-state messages on the secondary
// portal pages (quote requests, POs, invoices, certifications) and by the
// configurator coating cards. Replaced by .o_fp_card on the redesigned
// surfaces (/my/home, /my/jobs, /my/jobs/<id>).
// -----------------------------------------------------------------------------
.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);
}
}
// -----------------------------------------------------------------------------
// RFQ Form - Part row card (dynamically inserted by fp_rfq_form.js)
// -----------------------------------------------------------------------------
.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 (RFQ + configurator)
// -----------------------------------------------------------------------------
.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 (form-label sizing)
// -----------------------------------------------------------------------------
.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;
}
}

View File

@@ -1,2 +0,0 @@
from . import test_portal_dashboard
from . import test_employee_portal_gating

View File

@@ -1,60 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1.
from odoo.tests import HttpCase, tagged
@tagged('post_install', '-at_install', 'fp_portal')
class TestEmployeePortalGating(HttpCase):
"""Internal staff get the clean employee experience (no customer sidebar,
redirected off the customer dashboard); customers are untouched."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.customer_partner = cls.env['res.partner'].create({
'name': 'Gating Customer Co.',
'email': 'gating_customer@example.com',
})
cls.customer_user = cls.env['res.users'].create({
'name': 'Gating Portal User',
'login': 'gating_portal_user',
'password': 'gating_portal_user',
'partner_id': cls.customer_partner.id,
'group_ids': [(6, 0, [cls.env.ref('base.group_portal').id])],
})
def test_customer_sees_sidebar_on_home(self):
"""A share/portal user still gets the FP customer sidebar shell."""
self.assertTrue(self.customer_user.share)
self.authenticate('gating_portal_user', 'gating_portal_user')
r = self.url_open('/my/home')
self.assertEqual(r.status_code, 200)
self.assertIn('o_fp_portal_sidebar', r.text,
"customer should still see the FP sidebar shell")
def test_internal_employee_redirected_to_clock(self):
"""An internal user with an employee record is bounced to /my/clock.
Only meaningful when fusion_clock is installed (the redirect guard
checks for its x_fclk_enable_clock field, so it never sends anyone to
a non-existent /my/clock). Skip otherwise.
"""
if 'hr.employee' not in self.env:
self.skipTest('hr not installed')
if 'x_fclk_enable_clock' not in self.env['hr.employee']._fields:
self.skipTest('fusion_clock not installed - redirect intentionally inert')
internal = self.env['res.users'].create({
'name': 'Shop Hand',
'login': 'gating_shop_hand',
'password': 'gating_shop_hand',
'group_ids': [(6, 0, [self.env.ref('base.group_user').id])],
})
self.assertFalse(internal.share)
self.env['hr.employee'].create({'name': 'Shop Hand', 'user_id': internal.id})
self.authenticate('gating_shop_hand', 'gating_shop_hand')
# Don't follow the redirect - just assert we're bounced toward /my/clock.
r = self.url_open('/my/home', allow_redirects=False)
self.assertIn(r.status_code, (301, 302, 303, 307, 308))
self.assertIn('/my/clock', r.headers.get('Location', ''))

View File

@@ -1,270 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1.
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install', 'fp_portal')
class TestPortalDashboard(TransactionCase):
"""Welcome-line summary counts for the redesigned /my/home."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({
'name': 'Test Customer Co.',
'email': 'test@example.com',
})
cls.portal_user = cls.env['res.users'].create({
'name': 'Portal Tester',
'login': 'portal_tester',
'partner_id': cls.partner.id,
'group_ids': [(6, 0, [cls.env.ref('base.group_portal').id])],
})
Job = cls.env['fusion.plating.portal.job']
# 2 active, 1 ready_to_ship, 1 shipped (should not count as active)
cls.job_received = Job.create({
'name': 'WO-TEST-001', 'partner_id': cls.partner.id, 'state': 'received'})
cls.job_in_progress = Job.create({
'name': 'WO-TEST-002', 'partner_id': cls.partner.id, 'state': 'in_progress'})
cls.job_ready = Job.create({
'name': 'WO-TEST-003', 'partner_id': cls.partner.id, 'state': 'ready_to_ship'})
cls.job_shipped = Job.create({
'name': 'WO-TEST-004', 'partner_id': cls.partner.id, 'state': 'shipped'})
# 1 quoted RFQ (counts as awaiting_review), 1 new (does not count)
Quote = cls.env['fusion.plating.quote.request']
cls.quote_quoted = Quote.create({
'name': 'QR-TEST-001', 'partner_id': cls.partner.id, 'state': 'quoted'})
cls.quote_new = Quote.create({
'name': 'QR-TEST-002', 'partner_id': cls.partner.id, 'state': 'new'})
def test_welcome_counts_separates_active_from_ready_from_review(self):
"""The 3 welcome-line numbers split correctly across states."""
commercial = self.partner.commercial_partner_id
active = self.env['fusion.plating.portal.job'].search_count([
('partner_id', 'child_of', commercial.id),
('state', 'in', ['received', 'in_progress', 'quality_check']),
])
awaiting_review = self.env['fusion.plating.quote.request'].search_count([
('partner_id', 'child_of', commercial.id),
('state', '=', 'quoted'),
])
ready_to_ship = self.env['fusion.plating.portal.job'].search_count([
('partner_id', 'child_of', commercial.id),
('state', '=', 'ready_to_ship'),
])
self.assertEqual(active, 2)
self.assertEqual(awaiting_review, 1)
self.assertEqual(ready_to_ship, 1)
def test_state_change_snapshots_timestamp(self):
"""write({'state': 'in_progress'}) sets in_progress_started_at."""
from odoo import fields as odoo_fields
Job = self.env['fusion.plating.portal.job']
job = Job.create({
'name': 'WO-TS-001',
'partner_id': self.partner.id,
'state': 'received',
})
self.assertTrue(job.received_at, 'received_at set on create')
before = odoo_fields.Datetime.now()
job.state = 'in_progress'
self.assertTrue(job.in_progress_started_at, 'in_progress_started_at set')
self.assertGreaterEqual(job.in_progress_started_at, before)
# received_at must not be overwritten when state advances
self.assertTrue(job.received_at)
def test_stage_timeline_for_job_in_quality_check(self):
"""Timeline returns 5 entries aligned with dashboard stepper labels."""
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
Job = self.env['fusion.plating.portal.job']
job = Job.create({
'name': 'WO-TL-001',
'partner_id': self.partner.id,
'state': 'received',
})
job.state = 'in_progress'
job.state = 'quality_check'
timeline = FpCustomerPortal()._fp_get_stage_timeline(job)
self.assertEqual(len(timeline), 5)
labels = [s['label'] for s in timeline]
# Must match dashboard stepper labels in fp_portal_dashboard.xml
self.assertEqual(labels, ['Received', 'Inspected', 'Plating', 'QC', 'Shipped'])
statuses = [s['status'] for s in timeline]
# state=quality_check -> step_idx=3 -> QC active, 3 prior done, 1 after pending
self.assertEqual(statuses, ['done', 'done', 'done', 'active', 'pending'])
# Done + active stages have started_at set
self.assertIsNotNone(timeline[0]['started_at'], 'Received timestamp')
self.assertIsNotNone(timeline[1]['started_at'], 'Inspected timestamp')
self.assertIsNotNone(timeline[2]['started_at'], 'Plating timestamp')
self.assertIsNotNone(timeline[3]['started_at'], 'QC active timestamp')
# Pending stage has no started_at
self.assertIsNone(timeline[4]['started_at'], 'Shipped pending - no timestamp')
def test_stage_timeline_complete_state_marks_all_done(self):
"""state='complete' shows all 5 stages done (no active)."""
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
Job = self.env['fusion.plating.portal.job']
job = Job.create({
'name': 'WO-TL-002',
'partner_id': self.partner.id,
'state': 'received',
})
for s in ('in_progress', 'quality_check', 'ready_to_ship', 'shipped', 'complete'):
job.state = s
timeline = FpCustomerPortal()._fp_get_stage_timeline(job)
statuses = [t['status'] for t in timeline]
self.assertEqual(statuses, ['done', 'done', 'done', 'done', 'done'])
def test_group_documents_returns_5_groups(self):
"""V1 doc grouping returns 5 groups: From You / Specs / Work Order /
Quality / Shipping. Quality populated when CoC set."""
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
Job = self.env['fusion.plating.portal.job']
att = self.env['ir.attachment'].create({
'name': 'CoC_WO-TEST.pdf',
'datas': b'',
'res_model': 'fusion.plating.portal.job',
})
job = Job.create({
'name': 'WO-DOC-001',
'partner_id': self.partner.id,
'state': 'shipped',
'coc_attachment_id': att.id,
})
groups = FpCustomerPortal()._fp_group_documents(job)
self.assertEqual(len(groups), 5)
keys = [g['key'] for g in groups]
self.assertEqual(keys, ['from_you', 'specs', 'work_order', 'quality', 'shipping'])
# Quality group has the CoC populated (not pending)
quality = next(g for g in groups if g['key'] == 'quality')
self.assertTrue(any(d['label'] == 'Certificate of Conformance' and not d.get('pending')
for d in quality['docs']))
# From You is a placeholder when no SO link (test job has no x_fc_job_id)
from_you = next(g for g in groups if g['key'] == 'from_you')
self.assertTrue(all(d.get('pending') for d in from_you['docs']))
# Work Order is placeholder without a backend fp.job link
wo = next(g for g in groups if g['key'] == 'work_order')
self.assertTrue(all(d.get('pending') for d in wo['docs']))
def test_account_summary_partitions_invoices_and_credits(self):
"""Account Summary helper splits posted moves by move_type."""
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
# fp_from_so_invoice=True bypasses the fusion_plating_jobs enforcement
# that normally requires invoices to originate from a Sale Order.
# payment_term is required by fusion_plating_invoicing's action_post gate.
# Both are test-data scaffolding only; they do not affect what is tested.
pt = self.env.ref('account.account_payment_term_immediate')
Move = self.env['account.move'].with_context(fp_from_so_invoice=True)
inv = Move.create({
'partner_id': self.partner.id,
'move_type': 'out_invoice',
'invoice_date': '2026-05-01',
'invoice_payment_term_id': pt.id,
'invoice_line_ids': [(0, 0, {
'name': 'Test plating',
'quantity': 1,
'price_unit': 250.00,
})],
})
inv.action_post()
cm = Move.create({
'partner_id': self.partner.id,
'move_type': 'out_refund',
'invoice_date': '2026-05-02',
'invoice_payment_term_id': pt.id,
'invoice_line_ids': [(0, 0, {
'name': 'Test credit',
'quantity': 1,
'price_unit': 50.00,
})],
})
cm.action_post()
controller = FpCustomerPortal()
data = controller._fp_account_summary_data(
self.partner.commercial_partner_id,
tab='invoices',
filter_state='all',
search='',
sort='date_desc',
page=1,
)
# Tab=invoices -> only out_invoice
names = data['records'].mapped('name')
self.assertIn(inv.name, names)
self.assertNotIn(cm.name, names)
data = controller._fp_account_summary_data(
self.partner.commercial_partner_id,
tab='credit_memos',
filter_state='all',
search='',
sort='date_desc',
page=1,
)
names = data['records'].mapped('name')
self.assertIn(cm.name, names)
self.assertNotIn(inv.name, names)
def test_account_summary_open_balance_sums_residuals(self):
"""Open Balance pill = sum of amount_residual across open invoices."""
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
pt = self.env.ref('account.account_payment_term_immediate')
Move = self.env['account.move'].with_context(fp_from_so_invoice=True)
inv = Move.create({
'partner_id': self.partner.id,
'move_type': 'out_invoice',
'invoice_date': '2026-05-01',
'invoice_payment_term_id': pt.id,
'invoice_line_ids': [(0, 0, {
'name': 'Open inv',
'quantity': 1,
'price_unit': 750.00,
})],
})
inv.action_post()
controller = FpCustomerPortal()
open_balance = controller._fp_account_summary_open_balance(
self.partner.commercial_partner_id,
)
# The 750 invoice has amount_residual = 750 until paid
self.assertEqual(open_balance, 750.00)
def test_account_summary_search_matches_name_and_ref(self):
"""Search box filters by invoice number OR customer PO (ref)."""
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
pt = self.env.ref('account.account_payment_term_immediate')
Move = self.env['account.move'].with_context(fp_from_so_invoice=True)
inv = Move.create({
'partner_id': self.partner.id,
'move_type': 'out_invoice',
'invoice_date': '2026-05-01',
'invoice_payment_term_id': pt.id,
'ref': 'PO-CUSTOMER-99999',
'invoice_line_ids': [(0, 0, {
'name': 'Sale',
'quantity': 1,
'price_unit': 100.0,
})],
})
inv.action_post()
controller = FpCustomerPortal()
# Search by ref (customer PO)
data = controller._fp_account_summary_data(
self.partner.commercial_partner_id,
tab='invoices', filter_state='all',
search='99999', sort='date_desc', page=1,
)
self.assertIn(inv, data['records'])
# Search that matches nothing
data = controller._fp_account_summary_data(
self.partner.commercial_partner_id,
tab='invoices', filter_state='all',
search='zzznotfoundzzz', sort='date_desc', page=1,
)
self.assertNotIn(inv, data['records'])

View File

@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<!-- ================================================================== -->
<!-- Portal-facing records live under the unified Sales menu defined -->
<!-- by fusion_plating_configurator. -->
<!-- ================================================================== -->
<menuitem id="menu_fp_quote_requests"
name="Quote Requests"
parent="fusion_plating_configurator.menu_fp_sales"
action="action_fp_quote_request"
sequence="50"/>
<menuitem id="menu_fp_portal_jobs"
name="Portal Jobs"
parent="fusion_plating_configurator.menu_fp_sales"
action="action_fp_portal_job"
sequence="60"/>
</odoo>

View File

@@ -1,157 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
-->
<odoo>
<template id="portal_my_account_summary" name="Account Summary">
<t t-call="portal.portal_layout">
<div class="o_fp_account_summary">
<!-- Page header: title + Open Balance pill -->
<div class="d-flex justify-content-between align-items-baseline mb-3">
<h3 class="mb-0" style="color: var(--fp-text, #111827)">Account Summary</h3>
<div class="o_fp_badge o_fp_badge_paid" t-if="open_balance">
<span class="o_fp_badge_dot"/>
Open Balance:
<span t-out="open_balance_display"/>
</div>
<span class="o_fp_badge" t-else=""
style="background:#f3f7f6;color:#374151">
Open Balance: $0.00
</span>
</div>
<!-- Tab strip -->
<ul class="nav nav-tabs mb-3" role="tablist">
<t t-foreach="tabs" t-as="tab_entry">
<li class="nav-item">
<a t-attf-href="/my/account_summary?tab=#{tab_entry[0]}"
t-attf-class="nav-link #{'active' if active_tab == tab_entry[0] else ''}"
t-out="tab_entry[1]"/>
</li>
</t>
</ul>
<!-- Filter pills + search + sort -->
<t t-if="active_tab != 'statements'">
<div class="d-flex flex-wrap align-items-center gap-3 mb-3">
<div class="d-flex align-items-center gap-2">
<span class="text-muted small">Showing:</span>
<t t-foreach="['open', 'closed', 'all']" t-as="fk">
<a t-attf-href="/my/account_summary?tab=#{active_tab}&amp;filter_state=#{fk}&amp;sort=#{sort}&amp;search=#{search}"
t-attf-class="o_fp_filter_pill #{'o_fp_filter_pill_active' if filter_state == fk else ''}"
t-out="fk.capitalize()"/>
</t>
</div>
<form method="GET" action="/my/account_summary" class="d-flex gap-1 ms-auto m-0">
<input type="hidden" name="tab" t-att-value="active_tab"/>
<input type="hidden" name="filter_state" t-att-value="filter_state"/>
<input type="hidden" name="sort" t-att-value="sort"/>
<input type="text" name="search" t-att-value="search"
placeholder="Search invoice # or PO #"
class="form-control form-control-sm"
style="max-width: 260px"/>
<button type="submit" class="o_fp_btn_secondary o_fp_btn_sm">Search</button>
</form>
<select class="form-select form-select-sm o_fp_sort_select" style="max-width: 200px">
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&amp;filter_state=' + filter_state + '&amp;sort=date_desc&amp;search=' + search"
t-att-selected="sort == 'date_desc'">Newest first</option>
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&amp;filter_state=' + filter_state + '&amp;sort=date_asc&amp;search=' + search"
t-att-selected="sort == 'date_asc'">Oldest first</option>
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&amp;filter_state=' + filter_state + '&amp;sort=amount_desc&amp;search=' + search"
t-att-selected="sort == 'amount_desc'">Largest amount</option>
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&amp;filter_state=' + filter_state + '&amp;sort=amount_asc&amp;search=' + search"
t-att-selected="sort == 'amount_asc'">Smallest amount</option>
</select>
</div>
</t>
<!-- Table -->
<t t-if="active_tab == 'statements'">
<div class="o_fp_card text-center text-muted" style="padding: 2rem">
<p>Monthly statements coming soon.</p>
<p class="small">
For a copy in the meantime, contact your sales rep at EN Plating.
</p>
</div>
</t>
<t t-elif="not records">
<div class="o_fp_card text-center text-muted" style="padding: 1.5rem">
<t t-if="search">No results for "<t t-out="search"/>".</t>
<t t-else="">No records in this tab.</t>
</div>
</t>
<t t-else="">
<div class="o_fp_card" style="padding: 0; overflow: hidden">
<table class="table mb-0">
<thead>
<tr>
<th>#</th>
<th>Status</th>
<th>Posted On</th>
<th>PO #</th>
<th>Due Date</th>
<th class="text-end">Balance</th>
<th class="text-end">View PDF</th>
</tr>
</thead>
<tbody>
<tr t-foreach="records" t-as="move">
<td t-out="move.name"/>
<td>
<t t-if="move.amount_residual == 0">
<span class="o_fp_badge o_fp_badge_paid"><span class="o_fp_badge_dot"/>Closed</span>
</t>
<t t-else="">
<span class="o_fp_badge o_fp_badge_in_progress"><span class="o_fp_badge_dot"/>Open</span>
</t>
</td>
<td>
<span t-if="move.invoice_date"
t-field="move.invoice_date"
t-options='{"widget": "date"}'/>
</td>
<td t-out="move.ref or ''"/>
<td>
<span t-if="move.invoice_date_due"
t-field="move.invoice_date_due"
t-options='{"widget": "date"}'/>
</td>
<td class="text-end">
<span t-field="move.amount_residual"
t-options='{"widget": "monetary", "display_currency": move.currency_id}'/>
</td>
<td class="text-end">
<a t-attf-href="/my/invoices/#{move.id}?report_type=pdf&amp;download=true"
class="o_fp_btn_ghost o_fp_btn_sm">View PDF</a>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pager -->
<div class="d-flex justify-content-between align-items-center mt-3"
t-if="pager and pager.get('page_count', 0) > 1">
<div class="text-muted small">
Showing
<t t-out="pager['offset'] + 1"/>-<t t-out="min(pager['offset'] + 10, total)"/>
of <t t-out="total"/>
</div>
<ul class="pagination mb-0">
<t t-foreach="pager.get('pages', [])" t-as="p">
<li t-attf-class="page-item #{'active' if p['num'] == pager['page']['num'] else ''}">
<a class="page-link" t-att-href="p['url']" t-out="p['num']"/>
</li>
</t>
</ul>
</div>
</t>
</div>
</t>
</template>
</odoo>

View File

@@ -1,103 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<!-- ================================================================== -->
<!-- Breadcrumb additions for plating portal pages. -->
<!-- Each <li> we add gets picked up by portal.portal_breadcrumbs. -->
<!-- ================================================================== -->
<template id="portal_breadcrumbs_plating"
inherit_id="portal.portal_breadcrumbs"
priority="40">
<xpath expr="//ol[hasclass('o_portal_submenu')]" position="inside">
<!-- Dashboard -->
<li t-if="page_name == 'fp_dashboard'"
class="breadcrumb-item active"
aria-current="page">
Dashboard
</li>
<!-- Configurator -->
<li t-if="page_name == 'fp_configurator'"
class="breadcrumb-item active"
aria-current="page">
Get a Quote
</li>
<!-- Quote Requests list -->
<li t-if="page_name == 'fp_quote_requests'"
class="breadcrumb-item active"
aria-current="page">
Quote Requests
</li>
<!-- Quote Request detail -->
<li t-if="page_name == 'fp_quote_request'"
class="breadcrumb-item">
<a href="/my/quote_requests">Quote Requests</a>
</li>
<li t-if="page_name == 'fp_quote_request'"
class="breadcrumb-item active"
aria-current="page">
<span t-out="quote_request.name"/>
</li>
<!-- Quote Request new -->
<li t-if="page_name == 'fp_quote_request_new'"
class="breadcrumb-item">
<a href="/my/quote_requests">Quote Requests</a>
</li>
<li t-if="page_name == 'fp_quote_request_new'"
class="breadcrumb-item active"
aria-current="page">
New
</li>
<!-- Jobs list -->
<li t-if="page_name == 'fp_jobs'"
class="breadcrumb-item active"
aria-current="page">
Work Orders
</li>
<!-- Job detail -->
<li t-if="page_name == 'fp_portal_job'"
class="breadcrumb-item">
<a href="/my/jobs">Work Orders</a>
</li>
<li t-if="page_name == 'fp_portal_job'"
class="breadcrumb-item active"
aria-current="page">
<span t-out="job.name"/>
</li>
<!-- Account Summary -->
<li t-if="page_name == 'fp_account_summary'"
class="breadcrumb-item active"
aria-current="page">
Account Summary
</li>
<!-- Deliveries / Packing Slips -->
<li t-if="page_name == 'fp_deliveries'"
class="breadcrumb-item active"
aria-current="page">
Packing Slips
</li>
<!-- Certifications -->
<li t-if="page_name == 'fp_certifications'"
class="breadcrumb-item active"
aria-current="page">
Certifications
</li>
</xpath>
</template>
</odoo>

View File

@@ -1,493 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Portal Configurator Templates: 3-step self-service quoting wizard.
-->
<odoo>
<!-- ================================================================== -->
<!-- REUSABLE: Progress Bar (Step 1 / 2 / 3) -->
<!-- ================================================================== -->
<template id="portal_configurator_progress" name="Configurator Progress Bar">
<div class="d-flex align-items-center justify-content-center mb-4">
<t t-foreach="[('1', 'Upload Part'), ('2', 'Select Coating'), ('3', 'Review &amp; Submit')]" t-as="step_item">
<t t-set="step_num" t-value="step_item[0]"/>
<t t-set="step_label" t-value="step_item[1]"/>
<div class="d-flex align-items-center">
<div t-attf-class="rounded-circle d-flex align-items-center justify-content-center fw-bold
#{'bg-primary text-white' if current_step == step_num else
'bg-success text-white' if int(current_step) > int(step_num) else
'bg-body-tertiary text-muted'}"
style="width: 32px; height: 32px; font-size: 0.85rem;">
<t t-if="int(current_step) > int(step_num)">
<i class="fa fa-check"/>
</t>
<t t-else="">
<t t-out="step_num"/>
</t>
</div>
<span t-attf-class="ms-2 small fw-semibold
#{'text-primary' if current_step == step_num else
'text-success' if int(current_step) > int(step_num) else
'text-muted'}"
t-out="step_label"/>
</div>
<t t-if="step_num != '3'">
<div class="mx-3" style="width: 40px; height: 2px; background: var(--bs-border-color);"/>
</t>
</t>
</div>
</template>
<!-- ================================================================== -->
<!-- LANDING PAGE -->
<!-- ================================================================== -->
<template id="portal_configurator_landing" name="Configurator Landing">
<t t-call="portal.portal_layout">
<div class="o_fp_portal_form mt-3">
<!-- Hero card -->
<div class="card mb-4" style="border: 2px solid var(--bs-primary); border-radius: 12px;">
<div class="card-body text-center py-5">
<i class="fa fa-cog fa-3x mb-3" style="color: var(--bs-primary);"/>
<h3 class="mb-2">Get a Quote</h3>
<p class="text-muted mb-4" style="max-width: 500px; margin: 0 auto;">
Use our configurator to upload your part, select a coating, and
receive an estimated price range in minutes.
</p>
<a href="/my/configurator/new" class="o_fp_btn_primary o_fp_btn_lg">
<i class="fa fa-play me-2"/>Start Configurator
</a>
</div>
</div>
<!-- Recent quote requests -->
<t t-if="quotes">
<h5 class="mb-3">Recent Quote Requests</h5>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Reference</th>
<th>Submitted</th>
<th>Quantity</th>
<th class="text-end">Status</th>
</tr>
</thead>
<tbody>
<tr t-foreach="quotes" t-as="qr">
<td>
<a t-att-href="'/my/quote_requests/%s' % qr.id"
t-out="qr.name"/>
</td>
<td>
<span t-field="qr.create_date" t-options='{"widget": "date"}'/>
</td>
<td t-out="qr.quantity"/>
<td class="text-end">
<span t-attf-class="badge #{
'text-bg-secondary' if qr.state == 'new' else
'text-bg-info' if qr.state == 'under_review' else
'text-bg-primary' if qr.state == 'quoted' else
'text-bg-success' if qr.state == 'accepted' else
'text-bg-danger' if qr.state == 'declined' else
'text-bg-warning'}"
t-out="dict(qr._fields['state']._description_selection(qr.env)).get(qr.state)"/>
</td>
</tr>
</tbody>
</table>
</div>
</t>
</div>
</t>
</template>
<!-- ================================================================== -->
<!-- STEP 1 - Upload Part / Manual Measurements -->
<!-- ================================================================== -->
<template id="portal_configurator_step1" name="Configurator Step 1 - Upload Part">
<t t-call="portal.portal_layout">
<div class="o_fp_portal_form mt-3" style="max-width: 720px; margin: 0 auto;">
<!-- Progress bar -->
<t t-set="current_step" t-value="'1'"/>
<t t-call="fusion_plating_portal.portal_configurator_progress"/>
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fa fa-cube me-2"/>Part Information
</h5>
</div>
<div class="card-body">
<form method="POST" action="/my/configurator/new"
enctype="multipart/form-data">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<!-- Part Name & Number -->
<div class="row mb-3">
<div class="col-md-6">
<label for="part_name" class="form-label">Part Name *</label>
<input type="text" id="part_name" name="part_name"
class="form-control" required="required"
placeholder="e.g. Bearing Housing"/>
</div>
<div class="col-md-6">
<label for="part_number" class="form-label">Part Number</label>
<input type="text" id="part_number" name="part_number"
class="form-control"
placeholder="e.g. BH-2024-001"/>
</div>
</div>
<!-- Substrate Material -->
<div class="mb-3">
<label for="substrate_material" class="form-label">Substrate Material *</label>
<select id="substrate_material" name="substrate_material"
class="form-select" required="required">
<t t-foreach="materials" t-as="mat">
<option t-att-value="mat[0]" t-out="mat[1]"/>
</t>
</select>
</div>
<!-- File Uploads - separate drawing + 3D model.
Customer can upload either or both. STL gets
trimesh surface-area auto-calc server-side
(not shown to customer - backend uses it for
future pricing). -->
<div class="row">
<div class="col-md-6 mb-4">
<label class="form-label">Drawing (PDF)</label>
<div class="o_fp_file_drop_zone p-3">
<i class="fa fa-file-pdf-o"/>
<p class="mb-1 fw-semibold">PDF drawing</p>
<p class="small text-muted mb-2">
2D / dimensioned drawing
</p>
<input type="file" name="part_drawing" id="part_drawing"
class="form-control"
accept=".pdf"/>
</div>
</div>
<div class="col-md-6 mb-4">
<label class="form-label">3D Model</label>
<div class="o_fp_file_drop_zone p-3">
<i class="fa fa-cube"/>
<p class="mb-1 fw-semibold">STL / STP / STEP / IGES</p>
<p class="small text-muted mb-2">
Optional - speeds up estimation
</p>
<input type="file" name="part_3d_model" id="part_3d_model"
class="form-control"
accept=".stl,.stp,.step,.iges,.igs"/>
</div>
</div>
</div>
<!-- Manual measurements hidden per customer-feedback 2026-05-17:
backend computes these (or doesn't) - not the
customer's job. Fields kept as hidden inputs at 0
so the controller doesn't error on missing keys. -->
<input type="hidden" name="geometry_source" value="upload"/>
<input type="hidden" name="dimensions_length" value="0"/>
<input type="hidden" name="dimensions_width" value="0"/>
<input type="hidden" name="dimensions_height" value="0"/>
<input type="hidden" name="surface_area" value="0"/>
<!-- Navigation -->
<div class="d-flex justify-content-between">
<a href="/my/configurator" class="o_fp_btn_secondary">
<i class="fa fa-arrow-left me-1"/>Cancel
</a>
<button type="submit" class="o_fp_btn_primary">
Next: Select Coating
<i class="fa fa-arrow-right ms-1"/>
</button>
</div>
</form>
</div>
</div>
</div>
</t>
</template>
<!-- ================================================================== -->
<!-- STEP 2 - Select Coating Configuration -->
<!-- ================================================================== -->
<template id="portal_configurator_step2" name="Configurator Step 2 - Select Coating">
<t t-call="portal.portal_layout">
<div class="o_fp_portal_form mt-3" style="max-width: 900px; margin: 0 auto;">
<!-- Progress bar -->
<t t-set="current_step" t-value="'2'"/>
<t t-call="fusion_plating_portal.portal_configurator_progress"/>
<form method="POST" action="/my/configurator/coating">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="coating_config_id" id="coating_config_id" value="0"/>
<!-- Part summary -->
<div class="alert alert-info d-flex align-items-center mb-4" role="alert">
<i class="fa fa-cube me-3 fa-lg"/>
<div>
<strong t-out="session_data.get('part_name', 'Part')"/>
<span t-if="session_data.get('part_number')"
class="text-muted ms-2">
(<t t-out="session_data.get('part_number')"/>)
</span>
<span class="ms-3 badge text-bg-secondary" t-out="session_data.get('substrate_material', '')"/>
<span t-if="session_data.get('surface_area')" class="ms-2 small">
<t t-out="session_data.get('surface_area')"/> sq in
</span>
</div>
</div>
<!-- Coating cards grid -->
<h5 class="mb-3">Select a Coating Configuration</h5>
<t t-if="not coatings">
<div class="alert alert-warning">
No coating configurations are available. Please contact us directly.
</div>
</t>
<div class="row g-3 mb-4">
<t t-foreach="coatings" t-as="coat">
<div class="col-md-6 col-lg-4">
<div class="card h-100 o_fp_portal_card o_fp_coating_card"
style="cursor: pointer; transition: border-color 150ms, box-shadow 150ms;"
t-att-data-coating-id="coat.id"
t-attf-onclick="
document.querySelectorAll('.o_fp_coating_card').forEach(c => {
c.style.borderColor = '';
c.style.boxShadow = '';
});
this.style.borderColor = 'var(--bs-primary)';
this.style.boxShadow = '0 0 0 2px var(--bs-primary)';
document.getElementById('coating_config_id').value = this.dataset.coatingId;
">
<div class="card-body">
<h6 class="card-title mb-2" style="color: var(--bs-body-color);">
<t t-out="coat.name"/>
</h6>
<p t-if="coat.code" class="small text-muted mb-1">
<i class="fa fa-tag me-1"/>
<t t-out="coat.code"/>
</p>
<p t-if="coat.process_family" class="small text-muted mb-1">
<i class="fa fa-flask me-1"/>
<t t-out="dict(coat._fields['process_family']._description_selection(coat.env)).get(coat.process_family)"/>
</p>
<p t-if="coat.description" class="small text-muted mt-2 mb-0"
t-out="coat.description"/>
</div>
</div>
</div>
</t>
</div>
<!-- Quantity -->
<div class="row mb-4">
<div class="col-md-4">
<label for="quantity" class="form-label fw-semibold">Quantity</label>
<input type="number" id="quantity" name="quantity"
class="form-control" min="1" value="1" required="required"/>
</div>
</div>
<!-- Navigation -->
<div class="d-flex justify-content-between">
<a href="/my/configurator/new" class="o_fp_btn_secondary">
<i class="fa fa-arrow-left me-1"/>Back
</a>
<button type="submit" class="o_fp_btn_primary">
Next: View Estimate
<i class="fa fa-arrow-right ms-1"/>
</button>
</div>
</form>
</div>
</t>
</template>
<!-- ================================================================== -->
<!-- STEP 3 - Estimate & Submit -->
<!-- ================================================================== -->
<template id="portal_configurator_step3" name="Configurator Step 3 - Estimate &amp; Submit">
<t t-call="portal.portal_layout">
<div class="o_fp_portal_form mt-3" style="max-width: 720px; margin: 0 auto;">
<!-- Progress bar -->
<t t-set="current_step" t-value="'3'"/>
<t t-call="fusion_plating_portal.portal_configurator_progress"/>
<!-- Summary card -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">
<i class="fa fa-clipboard me-2"/>Quote Summary
</h5>
</div>
<div class="card-body">
<!-- Part details -->
<div class="row mb-3">
<div class="col-sm-4 text-muted small fw-semibold">Part</div>
<div class="col-sm-8">
<strong t-out="session_data.get('part_name', '')"/>
<span t-if="session_data.get('part_number')" class="text-muted ms-1">
(<t t-out="session_data.get('part_number')"/>)
</span>
</div>
</div>
<div class="row mb-3">
<div class="col-sm-4 text-muted small fw-semibold">Material</div>
<div class="col-sm-8" t-out="session_data.get('substrate_material', '')"/>
</div>
<div t-if="session_data.get('surface_area')" class="row mb-3">
<div class="col-sm-4 text-muted small fw-semibold">Surface Area</div>
<div class="col-sm-8">
<t t-out="session_data.get('surface_area')"/> sq in
<span t-if="session_data.get('auto_calculated')"
class="badge text-bg-info ms-2">Auto-calculated from STL</span>
</div>
</div>
<div t-if="session_data.get('attachment_name')" class="row mb-3">
<div class="col-sm-4 text-muted small fw-semibold">Uploaded File</div>
<div class="col-sm-8">
<i class="fa fa-paperclip me-1"/>
<t t-out="session_data.get('attachment_name')"/>
</div>
</div>
<hr/>
<!-- Coating details -->
<div class="row mb-3">
<div class="col-sm-4 text-muted small fw-semibold">Coating</div>
<div class="col-sm-8">
<strong t-out="coating.name"/>
</div>
</div>
<div t-if="coating.code" class="row mb-3">
<div class="col-sm-4 text-muted small fw-semibold">Code</div>
<div class="col-sm-8" t-out="coating.code"/>
</div>
<div class="row mb-3">
<div class="col-sm-4 text-muted small fw-semibold">Quantity</div>
<div class="col-sm-8" t-out="session_data.get('quantity', 1)"/>
</div>
<hr/>
<!-- Estimated Price -->
<div class="text-center py-3">
<t t-if="estimated_price.get('available')">
<p class="text-muted small mb-2">Estimated Price Range</p>
<p class="display-6 fw-bold mb-1" style="color: var(--bs-primary);">
$<t t-out="'{:,.2f}'.format(estimated_price['min'])"/>
<span class="text-muted mx-2" style="font-size: 0.6em;">to</span>
$<t t-out="'{:,.2f}'.format(estimated_price['max'])"/>
</p>
<p class="text-muted small mb-0">
Final pricing depends on masking complexity, batch size, and inspection requirements.
</p>
</t>
<t t-else="">
<div class="alert alert-secondary mb-0">
<i class="fa fa-info-circle me-2"/>
We could not calculate an automatic estimate for this configuration.
Our team will provide a detailed quote after reviewing your request.
</div>
</t>
</div>
</div>
</div>
<!-- Submit form -->
<div class="card">
<div class="card-body">
<form method="POST" action="/my/configurator/submit">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="mb-3">
<label for="special_instructions" class="form-label fw-semibold">
Special Instructions
<span class="text-muted small fw-normal">(optional)</span>
</label>
<textarea id="special_instructions" name="special_instructions"
class="form-control" rows="3"
placeholder="Masking requirements, delivery preferences, certifications needed, etc."/>
</div>
<div class="alert alert-light border small mb-4">
<i class="fa fa-clock-o me-1"/>
Our team will review your request and provide a detailed quote
within 24 hours (business days).
</div>
<!-- Navigation -->
<div class="d-flex justify-content-between">
<a href="/my/configurator/coating" class="o_fp_btn_secondary">
<i class="fa fa-arrow-left me-1"/>Back
</a>
<button type="submit" class="o_fp_btn_primary o_fp_btn_lg">
<i class="fa fa-paper-plane me-2"/>Submit Quote Request
</button>
</div>
</form>
</div>
</div>
</div>
</t>
</template>
<!-- ================================================================== -->
<!-- SUCCESS PAGE -->
<!-- ================================================================== -->
<template id="portal_configurator_success" name="Configurator - Quote Submitted">
<t t-call="portal.portal_layout">
<div class="o_fp_portal_form mt-3" style="max-width: 600px; margin: 0 auto;">
<div class="card text-center py-5">
<div class="card-body">
<div class="mb-3">
<i class="fa fa-check-circle fa-4x" style="color: var(--bs-success);"/>
</div>
<h3 class="mb-3">Quote Request Submitted</h3>
<p class="text-muted mb-1">
Your request has been received with reference:
</p>
<p class="fw-bold fs-5 mb-4" style="color: var(--bs-primary);">
<t t-out="quote.name"/>
</p>
<p class="text-muted small mb-4">
Our estimating team will review your part details and coating
selection, then send you a detailed quote within 24 hours
(business days). You can track the status from your portal.
</p>
<div class="d-flex justify-content-center gap-3">
<a t-att-href="'/my/quote_requests/%s' % quote.id"
class="o_fp_btn_primary">
<i class="fa fa-eye me-1"/>View Quote Request
</a>
<a href="/my/configurator" class="o_fp_btn_secondary">
<i class="fa fa-plus me-1"/>Start Another
</a>
</div>
</div>
</div>
</div>
</t>
</template>
</odoo>

View File

@@ -1,244 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<!-- ================================================================== -->
<!-- Portal Home Dashboard - jobs-forward layout (v19.0.3.1.0 redesign) -->
<!-- ================================================================== -->
<template id="fp_portal_home_dashboard" name="Plating Portal Dashboard">
<t t-call="portal.portal_layout">
<div class="o_fp_dashboard mt-3">
<!-- Welcome strip -->
<div class="o_fp_welcome">
<div>
<div class="o_fp_welcome_title">
Welcome back, <span t-out="partner.name"/>
</div>
<div class="o_fp_welcome_sub">
<t t-out="active_job_count"/> active job<t t-if="active_job_count != 1">s</t>
<t t-if="awaiting_review_count"> · <t t-out="awaiting_review_count"/> awaiting your review</t>
<t t-if="ready_to_ship_count"> · <t t-out="ready_to_ship_count"/> ready to ship</t>
</div>
</div>
<a href="/my/configurator" class="o_fp_btn_primary">
<i class="fa fa-plus"/> Get a Quote
</a>
</div>
<!-- KPI tiles -->
<div class="o_fp_kpi_row">
<div class="o_fp_kpi_tile">
<div class="o_fp_kpi_label">Open Quotes</div>
<div class="o_fp_kpi_value" t-out="quote_count"/>
<a href="/my/quote_requests" class="o_fp_kpi_hint o_fp_hint_action">View quotes →</a>
</div>
<div class="o_fp_kpi_tile">
<div class="o_fp_kpi_label">Active Sales Orders</div>
<div class="o_fp_kpi_value" t-out="po_count"/>
<a href="/my/orders" class="o_fp_kpi_hint">View orders →</a>
</div>
<div class="o_fp_kpi_tile o_fp_kpi_hero">
<div class="o_fp_kpi_label">In-Flight Jobs</div>
<div class="o_fp_kpi_value" t-out="active_job_count"/>
<t t-if="ready_to_ship_count">
<div class="o_fp_kpi_hint o_fp_hint_success">
<t t-out="ready_to_ship_count"/> ready to ship ✓
</div>
</t>
</div>
<div class="o_fp_kpi_tile">
<div class="o_fp_kpi_label">Invoices</div>
<div class="o_fp_kpi_value" t-out="invoice_count"/>
<a href="/my/fp_invoices" class="o_fp_kpi_hint">View invoices →</a>
</div>
</div>
<!-- Active jobs hero -->
<div class="o_fp_jobs_hero">
<div class="o_fp_section_header">
<div class="o_fp_section_title">Active Work Orders</div>
<a href="/my/jobs" class="o_fp_btn_ghost">All Jobs →</a>
</div>
<t t-if="recent_jobs">
<t t-foreach="recent_jobs[:3]" t-as="job">
<t t-call="fusion_plating_portal.fp_portal_job_card">
<t t-set="job" t-value="job"/>
</t>
</t>
<t t-if="job_count > 3">
<div class="o_fp_view_all">
<a href="/my/jobs">View all <t t-out="job_count"/> jobs →</a>
</div>
</t>
</t>
<t t-else="">
<div class="o_fp_card text-center text-muted">
<p class="mb-2">No active jobs yet.</p>
<a href="/my/configurator" class="o_fp_btn_primary o_fp_btn_sm">+ Get Your First Quote</a>
</div>
</t>
</div>
<!-- Secondary panels -->
<div class="o_fp_secondary_panels">
<!-- Quote Requests -->
<div class="o_fp_panel">
<div class="o_fp_panel_title">
<span class="o_fp_panel_icon">📄</span> Recent Quote Requests
<a href="/my/quote_requests" class="o_fp_panel_view_all">View all →</a>
</div>
<t t-if="recent_quotes">
<t t-foreach="recent_quotes[:3]" t-as="qr">
<div class="o_fp_panel_row">
<a t-att-href="'/my/quote_requests/%s' % qr.id" class="text-decoration-none" t-out="qr.name"/>
<t t-if="qr.create_date"> · <span t-field="qr.create_date" t-options='{"widget": "date"}'/></t>
</div>
</t>
</t>
<t t-else="">
<div class="o_fp_panel_row text-muted">
No quotes yet.
<a href="/my/configurator" class="o_fp_panel_inline_cta">Get a quote →</a>
</div>
</t>
</div>
<!-- Sales Orders -->
<div class="o_fp_panel">
<div class="o_fp_panel_title">
<span class="o_fp_panel_icon">🛒</span> Recent Sales Orders
<a href="/my/orders" class="o_fp_panel_view_all">View all →</a>
</div>
<t t-if="recent_pos">
<t t-foreach="recent_pos[:3]" t-as="po">
<div class="o_fp_panel_row">
<span t-out="po.name"/>
<t t-if="po.amount_total"> · <span t-field="po.amount_total" t-options='{"widget": "monetary", "display_currency": po.currency_id}'/></t>
</div>
</t>
</t>
<t t-else="">
<div class="o_fp_panel_row text-muted">No sales orders yet.</div>
</t>
</div>
<!-- Certifications -->
<div class="o_fp_panel">
<div class="o_fp_panel_title">
<span class="o_fp_panel_icon">📑</span> Recent Certifications
<a href="/my/certifications" class="o_fp_panel_view_all">View all →</a>
</div>
<t t-if="recent_certs">
<t t-foreach="recent_certs[:3]" t-as="cert">
<div class="o_fp_panel_row">
<a t-att-href="'/my/jobs/%s' % cert.id" class="text-decoration-none">
CoC <span t-out="cert.name"/>
</a>
<t t-if="cert.actual_ship_date"> · <span t-field="cert.actual_ship_date" t-options='{"widget": "date"}'/></t>
</div>
</t>
</t>
<t t-else="">
<div class="o_fp_panel_row text-muted">No certifications yet.</div>
</t>
</div>
<!-- Packing Slips / Deliveries -->
<div class="o_fp_panel">
<div class="o_fp_panel_title">
<span class="o_fp_panel_icon">📦</span> Recent Packing Slips
<a href="/my/deliveries" class="o_fp_panel_view_all">View all →</a>
</div>
<t t-if="recent_deliveries">
<t t-foreach="recent_deliveries[:3]" t-as="d">
<div class="o_fp_panel_row">
<span t-out="d.name"/>
<t t-if="d.date_done"> · <span t-field="d.date_done" t-options='{"widget": "date"}'/></t>
</div>
</t>
</t>
<t t-else="">
<div class="o_fp_panel_row text-muted">No deliveries yet.</div>
</t>
</div>
<!-- Invoices -->
<div class="o_fp_panel">
<div class="o_fp_panel_title">
<span class="o_fp_panel_icon">💰</span> Recent Invoices
<a href="/my/fp_invoices" class="o_fp_panel_view_all">View all →</a>
</div>
<t t-if="recent_invoices">
<t t-foreach="recent_invoices[:3]" t-as="inv">
<div class="o_fp_panel_row">
<a t-att-href="'/my/fp_invoices/%s' % inv.id" class="text-decoration-none" t-out="inv.name"/>
<t t-if="inv.amount_total"> · <span t-field="inv.amount_total" t-options='{"widget": "monetary", "display_currency": inv.currency_id}'/></t>
<t t-if="inv.payment_state == 'paid'"> · <span class="o_fp_badge o_fp_badge_paid"><span class="o_fp_badge_dot"/>Paid</span></t>
</div>
</t>
</t>
<t t-else="">
<div class="o_fp_panel_row text-muted">No invoices yet.</div>
</t>
</div>
</div>
</div>
</t>
</template>
<!-- ================================================================== -->
<!-- Override portal home to add sidebar badge counts -->
<!-- ================================================================== -->
<template id="portal_my_home_plating"
name="Portal My Home -- Plating"
inherit_id="portal.portal_my_home"
customize_show="True"
priority="40">
<xpath expr="//div[hasclass('o_portal_docs')]" position="inside">
<t t-call="portal.portal_docs_entry">
<t t-set="title">Get a Quote</t>
<t t-set="url" t-value="'/my/configurator'"/>
<t t-set="placeholder_count" t-value="'fp_quote_request_count'"/>
</t>
<t t-call="portal.portal_docs_entry">
<t t-set="title">Quote Requests</t>
<t t-set="url" t-value="'/my/quote_requests'"/>
<t t-set="placeholder_count" t-value="'fp_quote_request_count'"/>
</t>
<t t-call="portal.portal_docs_entry">
<t t-set="title">Work Orders</t>
<t t-set="url" t-value="'/my/jobs'"/>
<t t-set="placeholder_count" t-value="'fp_portal_job_count'"/>
</t>
<t t-call="portal.portal_docs_entry">
<t t-set="title">Sales Orders</t>
<t t-set="url" t-value="'/my/orders'"/>
<t t-set="placeholder_count" t-value="'fp_purchase_order_count'"/>
</t>
<t t-call="portal.portal_docs_entry">
<t t-set="title">Invoices</t>
<t t-set="url" t-value="'/my/fp_invoices'"/>
<t t-set="placeholder_count" t-value="'fp_invoice_count'"/>
</t>
<t t-call="portal.portal_docs_entry">
<t t-set="title">Packing Slips</t>
<t t-set="url" t-value="'/my/deliveries'"/>
<t t-set="placeholder_count" t-value="'fp_delivery_count'"/>
</t>
<t t-call="portal.portal_docs_entry">
<t t-set="title">Certifications</t>
<t t-set="url" t-value="'/my/certifications'"/>
<t t-set="placeholder_count" t-value="'fp_certification_count'"/>
</t>
</xpath>
</template>
</odoo>

View File

@@ -1,293 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Shared QWeb macros for the customer portal redesign.
Every template should t-call these instead of inlining stepper/badge/doc HTML.
-->
<odoo>
<!-- ================================================================== -->
<!-- Status badge - pass state (string) and label (string) -->
<!-- ================================================================== -->
<template id="fp_portal_status_badge" name="Portal: Status Badge">
<span t-attf-class="o_fp_badge o_fp_badge_#{state}">
<span class="o_fp_badge_dot"/>
<t t-out="label"/>
</span>
</template>
<!-- ================================================================== -->
<!-- Numbered horizontal stepper - pass `steps` list of dicts: -->
<!-- {label, status: 'done'|'active'|'pending', time_label} -->
<!-- active_state: 'normal' (teal) or 'warn' (amber) -->
<!-- ================================================================== -->
<template id="fp_portal_stepper" name="Portal: Numbered Stepper">
<t t-set="active_state" t-value="active_state or 'normal'"/>
<div class="o_fp_stepper">
<t t-foreach="steps" t-as="step">
<!-- Unit = circle + its label stacked. Label is absolutely
positioned below the circle (in SCSS) so its horizontal
centre lines up with the circle no matter how wide the
text is - fixes the column-vs-edge distribution
mismatch we had with a separate labels row. -->
<div class="o_fp_step_unit">
<div t-attf-class="o_fp_step_circle #{
'o_fp_step_done' if step['status'] == 'done' else
(('o_fp_step_active_warn' if active_state == 'warn' else 'o_fp_step_active') if step['status'] == 'active' else '')
}">
<t t-if="step['status'] == 'done'"></t>
<t t-elif="step['status'] in ('active', 'pending')">
<t t-out="step_index + 1"/>
</t>
</div>
<div t-attf-class="o_fp_step_label #{
'o_fp_step_label_done' if step['status'] == 'done' else
(('o_fp_step_label_active_warn' if active_state == 'warn' else 'o_fp_step_label_active') if step['status'] == 'active' else '')
}">
<div class="o_fp_step_label_title" t-out="step['label']"/>
<div class="o_fp_step_label_time" t-out="step.get('time_label') or ''"/>
</div>
</div>
<t t-if="not step_last">
<div t-attf-class="o_fp_step_line #{
'o_fp_step_line_done' if step['status'] == 'done' else
('o_fp_step_line_warn' if active_state == 'warn' and step['status'] == 'active' else '')
}"/>
</t>
</t>
</div>
</template>
<!-- ================================================================== -->
<!-- Doc chip (compact) - pass doc dict {icon, label, url, pending} -->
<!-- ================================================================== -->
<template id="fp_portal_doc_chip" name="Portal: Doc Chip">
<t t-if="doc.get('pending')">
<span class="o_fp_doc_chip o_fp_doc_chip_pending">
<t t-out="doc.get('icon') or '📑'"/>
<span t-out="doc['label']"/> · pending
</span>
</t>
<t t-else="">
<a t-att-href="doc['url']" class="o_fp_doc_chip">
<t t-out="doc.get('icon') or '📄'"/>
<span t-out="doc['label']"/>
</a>
</t>
</template>
<!-- ================================================================== -->
<!-- Job card - shared between /my/home dashboard and /my/jobs list. -->
<!-- Pass `job` (fusion.plating.portal.job). Renders a wrap div with -->
<!-- inner anchor (whole card click target = detail page) and a sibling -->
<!-- actions footer (doc download chips + Repeat Order form). Forms -->
<!-- live OUTSIDE the anchor because forms-inside-anchors is invalid -->
<!-- HTML and clicks would otherwise double-fire. -->
<!-- ================================================================== -->
<template id="fp_portal_job_card" name="Portal: Job Card">
<!-- Pull related backend records inline (cards are 3-5 per page,
query cost is fine). Each `t-set` is a no-op if the field
chain breaks. -->
<t t-set="backend_job" t-value="job.x_fc_job_id if 'x_fc_job_id' in job._fields else False"/>
<t t-set="so" t-value="backend_job.sale_order_id if backend_job and 'sale_order_id' in backend_job._fields else False"/>
<t t-set="part" t-value="backend_job.part_catalog_id if backend_job and 'part_catalog_id' in backend_job._fields else False"/>
<t t-set="ship_to" t-value="so.partner_shipping_id if so else False"/>
<t t-set="picking" t-value="so.picking_ids.filtered(lambda p: p.state == 'done')[:1] if so and 'picking_ids' in so._fields else False"/>
<!-- Stepper state mapping (same as detail page).
received -> idx 0; in_progress -> 2; quality_check -> 3;
ready_to_ship -> 4; shipped / complete -> 5 (all done). -->
<t t-set="state_to_idx" t-value="{'received': 0, 'in_progress': 2, 'quality_check': 3, 'ready_to_ship': 4, 'shipped': 5, 'complete': 5}"/>
<t t-set="state_idx" t-value="state_to_idx.get(job.state, 0)"/>
<t t-set="steps" t-value="[
{'label': 'Received', 'status': 'done' if state_idx > 0 else 'active', 'time_label': ''},
{'label': 'Inspected', 'status': 'done' if state_idx > 1 else ('active' if state_idx == 1 else 'pending'), 'time_label': ''},
{'label': 'Plating', 'status': 'done' if state_idx > 2 else ('active' if state_idx == 2 else 'pending'), 'time_label': ''},
{'label': 'QC', 'status': 'done' if state_idx > 3 else ('active' if state_idx == 3 else 'pending'), 'time_label': ''},
{'label': 'Ship', 'status': 'done' if state_idx > 4 else ('active' if state_idx == 4 else 'pending'), 'time_label': ''},
]"/>
<t t-set="active_state" t-value="'warn' if job.state == 'quality_check' else 'normal'"/>
<div class="o_fp_job_card">
<a t-att-href="'/my/jobs/%s' % job.id" class="o_fp_job_card_main">
<div class="o_fp_job_header">
<div>
<span class="o_fp_job_ref" t-out="job.name"/>
<span class="o_fp_job_meta">
<t t-if="job.quantity"><t t-out="job.quantity"/> units</t>
<t t-if="job.target_ship_date"> · ETA <span t-field="job.target_ship_date" t-options='{"widget": "date"}'/></t>
</span>
</div>
<t t-call="fusion_plating_portal.fp_portal_status_badge">
<t t-set="state" t-value="job.state"/>
<t t-set="label" t-value="dict(job._fields['state']._description_selection(job.env)).get(job.state)"/>
</t>
</div>
<!-- Part info: prefer the part catalog ref, fall back to
process types listed on the portal job. -->
<t t-if="part">
<div class="o_fp_job_part">
<span class="o_fp_job_part_icon">📦</span>
<t t-if="part.part_number"><b t-out="part.part_number"/> · </t>
<t t-out="part.name or part.display_name"/>
</div>
</t>
<t t-elif="job.process_type_ids">
<div class="o_fp_job_part">
<span class="o_fp_job_part_icon">📦</span>
<t t-out="', '.join(job.process_type_ids.mapped('name'))"/>
</div>
</t>
<!-- Shipping address: customer may have multiple sites; the
SO carries which one this job ships to. -->
<t t-if="ship_to and ship_to.id != job.partner_id.commercial_partner_id.id">
<div class="o_fp_job_ship">
<span class="o_fp_job_ship_icon">📍</span>
Ship to: <t t-out="ship_to.name"/>
<t t-if="ship_to.city"> · <t t-out="ship_to.city"/></t>
</div>
</t>
<t t-call="fusion_plating_portal.fp_portal_stepper"/>
</a>
<!-- Actions footer: doc quick-downloads + Repeat Order. Lives
OUTSIDE the .o_fp_job_card_main anchor so the form button
doesn't double-fire navigation + form submission. -->
<div class="o_fp_job_card_actions">
<div class="o_fp_job_card_docs">
<t t-if="so">
<a t-attf-href="/my/jobs/#{job.id}/so_confirmation"
class="o_fp_doc_quick_btn" title="Sales Order Confirmation">
📄 SO
</a>
</t>
<t t-if="backend_job">
<a t-attf-href="/my/jobs/#{job.id}/wo_detail"
class="o_fp_doc_quick_btn" title="Work Order Detail">
🛠 WO
</a>
</t>
<t t-if="job.coc_attachment_id">
<a t-attf-href="/my/jobs/#{job.id}/coc"
class="o_fp_doc_quick_btn" title="Certificate of Conformance">
📑 CoC
</a>
</t>
<t t-else="">
<span class="o_fp_doc_quick_btn o_fp_doc_quick_btn_pending"
title="Will appear after QC completes">
📑 CoC
</span>
</t>
<t t-if="job.packing_list_attachment_id">
<a t-attf-href="/web/content/#{job.packing_list_attachment_id.id}?download=true"
class="o_fp_doc_quick_btn" title="Packing Slip">
📦 Packing
</a>
</t>
</div>
<form t-attf-action="/my/jobs/#{job.id}/repeat" method="post" class="m-0">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<button type="submit" class="o_fp_btn_secondary o_fp_btn_sm">
<i class="fa fa-repeat"/> Repeat Order
</button>
</form>
</div>
</div>
</template>
<!-- ================================================================== -->
<!-- Reusable filter-pill + search + sort strip for portal list pages. -->
<!-- Render with t-call="fusion_plating_portal.fp_portal_list_controls" -->
<!-- and t-set the following vars in the call: -->
<!-- filters : list of (key, label) tuples for the pills -->
<!-- active_filter : the current filter key -->
<!-- sorts : list of (key, label) tuples for the sort dropdown-->
<!-- active_sort : the current sort key -->
<!-- search : current search string (for prefilling the input) -->
<!-- url : base path (e.g. '/my/jobs') -->
<!-- extra_qs : optional extra query-string suffix -->
<!-- target : CSS selector of the data-fp-filterable container -->
<!-- result_total : total record count (for the ">500" clip notice) -->
<!-- clipped : True if results were capped at 500 -->
<!-- ================================================================== -->
<template id="fp_portal_list_controls" name="FP Portal List Controls">
<div class="d-flex flex-wrap align-items-center gap-3 mb-3 o_fp_list_controls">
<!-- Filter pills -->
<div class="d-flex align-items-center gap-2" t-if="filters">
<span class="text-muted small">Showing:</span>
<t t-foreach="filters" t-as="f">
<a t-attf-href="#{url}?filter_state=#{f[0]}&amp;sortby=#{active_sort}#{extra_qs or ''}"
t-attf-class="o_fp_filter_pill #{'o_fp_filter_pill_active' if active_filter == f[0] else ''}"
t-out="f[1]"/>
</t>
</div>
<!-- Search input - real-time client-side filtering, no submit -->
<div class="ms-auto d-flex align-items-center gap-2"
t-att-style="'flex: 1 1 auto; max-width: 360px;' + ('' if filters else 'margin-left: 0 !important')">
<input type="search"
class="form-control form-control-sm o_fp_list_search"
placeholder="Search…"
t-att-value="search or ''"
t-att-data-fp-target="target"
autocomplete="off"/>
</div>
<!-- Sort dropdown - navigates on change (wired by fp_portal_list_search.js) -->
<select class="form-select form-select-sm o_fp_sort_select" style="max-width: 180px" t-if="sorts">
<t t-foreach="sorts" t-as="s">
<option t-att-value="url + '?filter_state=' + (active_filter or 'all') + '&amp;sortby=' + s[0] + (extra_qs or '')"
t-att-selected="active_sort == s[0]"
t-out="s[1]"/>
</t>
</select>
</div>
<!-- Clip notice: shown server-side when >500 records were found -->
<div class="o_fp_list_search_meta small text-muted mb-2" t-if="clipped">
Showing latest 500 of <span t-out="result_total"/> - refine your filter to narrow further.
</div>
<!-- Live count: shown by JS while the user is typing in the search box -->
<div class="o_fp_list_search_count small text-muted mb-2 d-none"/>
</template>
<!-- ================================================================== -->
<!-- Doc group (detail page) - pass label + docs list of dicts: -->
<!-- {label, sub, url, icon_class, pending} -->
<!-- ================================================================== -->
<template id="fp_portal_doc_group" name="Portal: Doc Group">
<div class="o_fp_doc_group" style="margin-bottom: 1.1rem">
<div class="o_fp_doc_group_label" t-out="group_label"/>
<t t-foreach="docs" t-as="doc">
<t t-if="doc.get('pending')">
<div class="o_fp_doc_row o_fp_doc_row_pending">
<span t-attf-class="o_fp_doc_icon o_fp_doc_icon_pending">📑</span>
<div class="o_fp_doc_meta">
<div class="o_fp_doc_name" t-out="doc['label']"/>
<div class="o_fp_doc_sub" t-out="doc.get('sub') or ''"/>
</div>
<span style="color: #cbd5e1; font-size: .72rem">-</span>
</div>
</t>
<t t-else="">
<a t-att-href="doc['url']" class="o_fp_doc_row">
<span t-attf-class="o_fp_doc_icon #{doc.get('icon_class') or 'o_fp_doc_icon_input'}">
<t t-out="doc.get('icon') or '📄'"/>
</span>
<div class="o_fp_doc_meta">
<div class="o_fp_doc_name" t-out="doc['label']"/>
<div class="o_fp_doc_sub" t-out="doc.get('sub') or ''"/>
</div>
<span class="o_fp_doc_action">↓ Download</span>
</a>
</t>
</t>
</div>
</template>
</odoo>

View File

@@ -1,119 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Wraps every /my/* page (FP custom + Odoo default) in the new
sidebar shell. Inherits portal.portal_layout so we don't have
to edit every individual page template.
Implementation note (Approach D, $0 re-emit):
The plan originally proposed injecting an unbalanced main opening
tag in one xpath block and its closing tag in another. QWeb parses
each xpath payload as an independent XML fragment, so unbalanced
tags are rejected at load time.
Instead we use position="replace" on //div[@id='wrap'] with $0
inside the replacement payload. $0 is supported by Odoo 19's
Python view inheritance engine (tools/template_inheritance.py,
lines 162-169): any element whose text content is the literal
string "$0" has its text cleared and the deep-copied original node
appended as a child. This produces a fully balanced replacement tree
that nests the original #wrap (and all its Odoo-managed content)
inside our .o_fp_portal_main element.
Verified from portal_templates.xml line 155:
div id="wrap" class="o_portal_wrap"
div class="container pt-3 pb-5"
t t-out="0" (Odoo content slot)
/div
/div
-->
<odoo>
<!-- ================================================================== -->
<!-- Inherit portal.portal_layout to wrap content in sidebar shell -->
<!-- ================================================================== -->
<template id="fp_portal_shell"
name="FP Portal Shell - Sidebar Wrap"
inherit_id="portal.portal_layout"
priority="50">
<!-- Force Odoo's outer breadcrumb container to render even when a page
sets breadcrumbs_searchbar=True (e.g. /my/orders, /my/invoices, and
other stock Odoo list pages). Keeps the breadcrumb consistently above
our shell, never inside the right column. We still respect
no_breadcrumbs and my_details (used by /my/account). -->
<xpath expr="//div[hasclass('o_portal') and hasclass('container') and hasclass('mt-3')]" position="attributes">
<attribute name="t-if">not no_breadcrumbs and not my_details</attribute>
</xpath>
<!--
Replace #wrap entirely. The $0 text node inside
<main class="o_fp_portal_main"> causes Odoo's inheritance
engine to re-emit the original #wrap div (with all its
children) at that position. Every existing portal page
continues to render correctly because Odoo's <t t-out="0"/>
content slot inside #wrap is preserved verbatim.
-->
<xpath expr="//div[@id='wrap']" position="replace">
<div t-attf-class="o_fp_portal_shell#{'' if (fp_show_customer_sidebar if fp_show_customer_sidebar is defined else True) else ' o_fp_portal_shell--no-sidebar'}">
<!-- Sidebar chrome only for customers (share users). Internal
staff get the clean employee experience with no sidebar. -->
<t t-if="fp_show_customer_sidebar if fp_show_customer_sidebar is defined else True">
<!-- Mobile hamburger (shown only below 768px via SCSS) -->
<button type="button"
class="o_fp_portal_hamburger d-md-none"
aria-label="Open navigation">
<i class="fa fa-bars"/>
</button>
<!-- Backdrop for mobile drawer (hidden by default) -->
<div class="o_fp_portal_backdrop"/>
<!-- Sidebar navigation component -->
<t t-call="fusion_plating_portal.fp_portal_sidebar"/>
</t>
<!-- Main content area - original #wrap re-emitted here via $0 -->
<main class="o_fp_portal_main">$0</main>
</div>
</xpath>
</template>
<!-- ================================================================== -->
<!-- Sidebar template - rendered by fp_portal_shell -->
<!-- ================================================================== -->
<template id="fp_portal_sidebar" name="FP Portal Sidebar">
<aside class="o_fp_portal_sidebar">
<!-- Partner display name header -->
<div class="o_fp_sidebar_header">
<t t-out="fp_partner_display_name or 'My Account'"/>
</div>
<!-- Navigation items, walked from the Python-side data structure.
fp_sidebar_items is injected by the controller mixin in Task 4.
Guards here handle the case where Task 4 hasn't deployed yet. -->
<t t-foreach="fp_sidebar_items or []" t-as="entry">
<!-- Section labels render as non-link headers -->
<t t-if="entry.get('type') == 'section_label'">
<div class="o_fp_sidebar_section_label" t-out="entry.get('label', '')"/>
</t>
<!-- Items render as anchor links -->
<t t-elif="entry.get('type') == 'item'">
<a t-att-href="entry.get('url', '#')"
t-attf-class="o_fp_sidebar_item #{'o_fp_sidebar_active' if entry.get('active') else ''}">
<span class="o_fp_sidebar_icon" t-out="entry.get('icon') or '&#x2022;'"/>
<span t-out="entry.get('label', '')"/>
</a>
</t>
</t>
<!-- Footer: sign out link always present -->
<div class="o_fp_sidebar_footer">
<a href="/web/session/logout?redirect=/" class="o_fp_sidebar_item">
<span class="o_fp_sidebar_icon">&#x21AA;</span>
<span>Sign Out</span>
</a>
</div>
</aside>
</template>
</odoo>

View File

@@ -1,822 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<!-- ================================================================== -->
<!-- QUOTE REQUESTS - list with filter pills + real-time search -->
<!-- ================================================================== -->
<template id="portal_my_quote_requests" name="My Quote Requests">
<t t-call="portal.portal_layout">
<t t-call="portal.portal_searchbar">
<t t-set="title">Quote Requests</t>
</t>
<!-- Filter pills + search + sort strip -->
<t t-call="fusion_plating_portal.fp_portal_list_controls">
<t t-set="filters" t-value="filters"/>
<t t-set="active_filter" t-value="filter_state"/>
<t t-set="sorts" t-value="sorts"/>
<t t-set="active_sort" t-value="sortby"/>
<t t-set="search" t-value="search"/>
<t t-set="url" t-value="url"/>
<t t-set="extra_qs" t-value="extra_qs"/>
<t t-set="target" t-value="target"/>
<t t-set="result_total" t-value="result_total"/>
<t t-set="clipped" t-value="clipped"/>
</t>
<div class="d-flex justify-content-end mb-3">
<a href="/my/quote_requests/new" class="o_fp_btn_primary">
<i class="fa fa-plus"/> New Quote Request
</a>
</div>
<t t-if="not quote_requests">
<div class="o_fp_portal_card card bg-body-tertiary border-0 p-4 text-center">
<p class="text-muted mb-2">No quote requests found for this filter.</p>
<p class="small text-muted mb-0">
Click "New Quote Request" above to send your first RFQ.
</p>
</div>
</t>
<t t-if="quote_requests" t-call="portal.portal_table">
<thead>
<tr class="active">
<th>Reference</th>
<th>Submitted</th>
<th>Parts</th>
<th>Target Delivery</th>
<th class="text-end">Status</th>
</tr>
</thead>
<tbody class="o_fp_qr_filterable">
<tr t-foreach="quote_requests" t-as="qr">
<td>
<a t-att-href="'/my/quote_requests/%s' % qr.id"
t-out="qr.name"/>
</td>
<td>
<span t-field="qr.create_date" t-options='{"widget": "date"}'/>
</td>
<td>
<span t-out="len(qr.line_ids) or qr.quantity"/>
</td>
<td>
<span t-if="qr.target_delivery"
t-field="qr.target_delivery"
t-options='{"widget": "date"}'/>
<span t-else="" class="text-muted">--</span>
</td>
<td class="text-end">
<t t-call="fusion_plating_portal.fp_portal_status_badge">
<t t-set="state" t-value="qr.state"/>
<t t-set="label" t-value="dict(qr._fields['state']._description_selection(qr.env)).get(qr.state)"/>
</t>
</td>
<!-- Hidden search fields: contact, part numbers, descriptions -->
<td class="d-none" aria-hidden="true">
<span t-out="qr.contact_name or ''"/>
<span t-out="qr.contact_email or ''"/>
<t t-foreach="qr.line_ids" t-as="ln">
<span t-out="ln.part_number or ''"/>
<span t-out="ln.description or ''"/>
<span t-if="ln.product_id" t-out="ln.product_id.default_code or ''"/>
</t>
</td>
</tr>
</tbody>
</t>
</t>
</template>
<!-- ================================================================== -->
<!-- QUOTE REQUEST - detail -->
<!-- ================================================================== -->
<template id="portal_my_quote_request" name="My Quote Request">
<t t-call="portal.portal_layout">
<t t-set="o_portal_fullwidth_alert" groups="fusion_plating.group_fusion_plating_operator">
<t t-call="portal.portal_back_in_edit_mode">
<t t-set="backend_url"
t-value="'/odoo/action-base.action_partner_form#id=%s&amp;model=res.partner&amp;view_type=form' % quote_request.partner_id.id"/>
</t>
</t>
<div class="row mt-2 mb-4">
<div class="col-12">
<h3 class="mb-1">
<span t-out="quote_request.name"/>
</h3>
<p class="text-muted mb-0">
Submitted
<span t-field="quote_request.create_date" t-options='{"widget": "date"}'/>
</p>
</div>
</div>
<div class="card bg-body-tertiary border-0 mb-4 o_fp_portal_card">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-muted small text-uppercase">Status</h6>
<t t-call="fusion_plating_portal.fp_portal_status_badge">
<t t-set="state" t-value="quote_request.state"/>
<t t-set="label" t-value="dict(quote_request._fields['state']._description_selection(quote_request.env)).get(quote_request.state)"/>
</t>
</div>
<div class="col-md-6 text-md-end">
<t t-if="quote_request.state == 'quoted' and quote_request.quoted_price">
<h6 class="text-muted small text-uppercase">Quoted Price</h6>
<h4 class="mb-0">
<span t-field="quote_request.quoted_price"
t-options='{"widget": "monetary", "display_currency": quote_request.currency_id}'/>
</h4>
</t>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-4">
<h6 class="text-muted small text-uppercase">Contact</h6>
<div t-if="quote_request.contact_name" t-out="quote_request.contact_name"/>
<div t-if="quote_request.contact_email"
class="text-muted small" t-out="quote_request.contact_email"/>
<div t-if="quote_request.contact_phone"
class="text-muted small" t-out="quote_request.contact_phone"/>
</div>
<div class="col-md-6 mb-4">
<h6 class="text-muted small text-uppercase">Details</h6>
<div>
<span class="text-muted small">Quantity:</span>
<span t-out="quote_request.quantity"/>
</div>
<div t-if="quote_request.target_delivery">
<span class="text-muted small">Target Delivery:</span>
<span t-field="quote_request.target_delivery"
t-options='{"widget": "date"}'/>
</div>
</div>
</div>
<!-- Addresses -->
<div class="row" t-if="quote_request.shipping_address_id or quote_request.billing_address_id">
<div class="col-md-6 mb-4" t-if="quote_request.shipping_address_id">
<h6 class="text-muted small text-uppercase">Shipping Address</h6>
<div t-out="quote_request.shipping_address_id.contact_address"/>
</div>
<div class="col-md-6 mb-4" t-if="quote_request.billing_address_id">
<h6 class="text-muted small text-uppercase">Billing Address</h6>
<div t-out="quote_request.billing_address_id.contact_address"/>
</div>
</div>
<!-- Part Lines -->
<div class="mb-4" t-if="quote_request.line_ids">
<h6 class="text-muted small text-uppercase">Parts</h6>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>#</th>
<th>Part Number</th>
<th>Description</th>
<th class="text-center">Qty</th>
<th class="text-center">Count</th>
<th>Files</th>
</tr>
</thead>
<tbody>
<tr t-foreach="quote_request.line_ids" t-as="line">
<td t-out="line_index + 1"/>
<td>
<span t-if="line.product_id" t-out="line.product_id.default_code or line.product_id.name"/>
<span t-elif="line.part_number" t-out="line.part_number"/>
<span t-else="" class="text-muted">--</span>
</td>
<td t-out="line.description or '--'"/>
<td class="text-center" t-out="line.quantity"/>
<td class="text-center" t-out="line.count"/>
<td>
<t t-foreach="line.attachment_ids" t-as="att">
<a t-att-href="'/web/content/%s?download=true' % att.id" class="me-2">
<i class="fa fa-paperclip"/> <span t-out="att.name"/>
</a>
</t>
<span t-if="not line.attachment_ids" class="text-muted">--</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="mb-4" t-if="quote_request.process_type_ids">
<h6 class="text-muted small text-uppercase">Requested Processes</h6>
<span t-foreach="quote_request.process_type_ids" t-as="pt"
class="o_fp_doc_chip me-1" t-out="pt.name"/>
</div>
<div class="mb-4" t-if="quote_request.part_description">
<h6 class="text-muted small text-uppercase">Part Description</h6>
<div class="border rounded p-3 bg-body">
<span t-out="quote_request.part_description"/>
</div>
</div>
<div class="mb-4" t-if="quote_request.special_instructions">
<h6 class="text-muted small text-uppercase">Special Instructions</h6>
<div class="border rounded p-3 bg-body">
<span t-out="quote_request.special_instructions"/>
</div>
</div>
<div class="mb-4" t-if="quote_request.drawing_attachment_ids">
<h6 class="text-muted small text-uppercase">Attachments</h6>
<ul class="list-unstyled">
<li t-foreach="quote_request.drawing_attachment_ids" t-as="att">
<a t-att-href="'/web/content/%s?download=true' % att.id">
<i class="fa fa-paperclip me-1"/>
<span t-out="att.name"/>
</a>
</li>
</ul>
</div>
</t>
</template>
<!-- ================================================================== -->
<!-- QUOTE REQUEST - new form (enhanced with multi-part, addresses) -->
<!-- ================================================================== -->
<template id="portal_new_quote_request_form" name="New Quote Request">
<t t-call="portal.portal_layout">
<div class="row mt-2 mb-4">
<div class="col-12">
<h3>New Quote Request</h3>
<p class="text-muted">
Fill out the form below and our shop team will follow up with a quote.
</p>
</div>
</div>
<div t-if="error" class="alert alert-warning">
<t t-if="error == 'missing_description'">
Please add at least one part or describe the part you'd like quoted.
</t>
<t t-else="">
There was a problem submitting your request. Please try again.
</t>
</div>
<form action="/my/quote_requests/submit"
method="POST"
enctype="multipart/form-data"
class="o_fp_portal_form"
id="fp_rfq_form">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="parts_data" id="fp_parts_data" value="[]"/>
<!-- Hidden select with all available products for JS to clone into part rows -->
<select id="fp_products_source" class="d-none">
<t t-foreach="products" t-as="p">
<option t-att-value="p.id">
<t t-if="p.default_code">[<t t-out="p.default_code"/>] </t><t t-out="p.name"/>
</option>
</t>
</select>
<!-- Contact Info -->
<h5 class="mb-3">Contact Information</h5>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="contact_name">Contact Name</label>
<input type="text" class="form-control" id="contact_name"
name="contact_name" t-att-value="partner.name"/>
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="contact_email">Contact Email</label>
<input type="email" class="form-control" id="contact_email"
name="contact_email" t-att-value="partner.email"/>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="contact_phone">Contact Phone</label>
<input type="text" class="form-control" id="contact_phone"
name="contact_phone" t-att-value="partner.phone"/>
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="company_name">Company</label>
<input type="text" class="form-control" id="company_name"
name="company_name"
t-att-value="partner.parent_id.name or partner.name"/>
</div>
</div>
<!-- Addresses -->
<h5 class="mb-3 mt-4">Addresses</h5>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="shipping_address_id">Shipping Address</label>
<select class="form-select" id="shipping_address_id" name="shipping_address_id">
<option value="">-- Select Address --</option>
<t t-foreach="addresses" t-as="addr">
<option t-att-value="addr.id">
<t t-out="addr.name"/>
<t t-if="addr.city"> - <t t-out="addr.city"/></t>
<t t-if="addr.street"> (<t t-out="addr.street"/>)</t>
</option>
</t>
<option value="new">+ New Address</option>
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="billing_address_id">Billing Address</label>
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input" id="billing_same_as_shipping"
name="billing_same_as_shipping" value="1" checked="checked"/>
<label class="form-check-label" for="billing_same_as_shipping">
Same as shipping
</label>
</div>
<select class="form-select" id="billing_address_id" name="billing_address_id"
disabled="disabled">
<option value="">-- Select Address --</option>
<t t-foreach="addresses" t-as="addr">
<option t-att-value="addr.id">
<t t-out="addr.name"/>
<t t-if="addr.city"> - <t t-out="addr.city"/></t>
</option>
</t>
</select>
</div>
</div>
<!-- Parts Section -->
<h5 class="mb-3 mt-4">Parts <span class="text-danger">*</span></h5>
<div id="fp_parts_container">
<!-- Part rows will be added by JS -->
</div>
<button type="button" class="o_fp_btn_secondary mb-3" id="fp_add_part_btn">
<i class="fa fa-plus me-1"/> ADD ANOTHER PART
</button>
<!-- General Description (fallback) -->
<div class="mb-3">
<label class="form-label" for="part_description">
General Part Description
</label>
<textarea class="form-control" id="part_description"
name="part_description" rows="3"
placeholder="Describe the part(s) if not using the part lines above"/>
</div>
<!-- Process Types -->
<div class="mb-3" t-if="process_types">
<label class="form-label">Requested Processes</label>
<div class="row">
<div class="col-md-6" t-foreach="process_types" t-as="pt">
<div class="form-check">
<input type="checkbox"
class="form-check-input"
t-attf-id="process_type_#{pt.id}"
name="process_type_ids"
t-att-value="pt.id"/>
<label class="form-check-label"
t-attf-for="process_type_#{pt.id}"
t-out="pt.name"/>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="quantity">Total Quantity</label>
<input type="number" class="form-control" id="quantity"
name="quantity" min="1" value="1"/>
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="target_delivery">Target Delivery</label>
<input type="date" class="form-control" id="target_delivery"
name="target_delivery"/>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="special_instructions">Special Instructions</label>
<textarea class="form-control" id="special_instructions"
name="special_instructions" rows="3"/>
</div>
<div class="mb-3">
<label class="form-label" for="drawing_attachments">General Drawings &amp; Attachments</label>
<input type="file" class="form-control" id="drawing_attachments"
name="drawing_attachments" multiple="multiple"/>
<div class="form-text">Upload PDF, DWG, STEP, or image files.</div>
</div>
<div class="d-flex justify-content-between mt-4">
<a href="/my/quote_requests" class="o_fp_btn_ghost">Cancel</a>
<button type="submit" class="o_fp_btn_primary o_fp_btn_lg" id="fp_submit_rfq">
<i class="fa fa-paper-plane"/> SUBMIT RFQ
</button>
</div>
</form>
</t>
</template>
<!-- ================================================================== -->
<!-- JOBS - list with filter pills + real-time search (cards layout) -->
<!-- ================================================================== -->
<template id="portal_my_jobs" name="My Work Orders">
<t t-call="portal.portal_layout">
<t t-call="portal.portal_searchbar">
<t t-set="title">Work Orders</t>
</t>
<!-- Filter pills + search + sort strip -->
<t t-call="fusion_plating_portal.fp_portal_list_controls">
<t t-set="filters" t-value="filters"/>
<t t-set="active_filter" t-value="filter_state"/>
<t t-set="sorts" t-value="sorts"/>
<t t-set="active_sort" t-value="sortby"/>
<t t-set="search" t-value="search"/>
<t t-set="url" t-value="url"/>
<t t-set="extra_qs" t-value="extra_qs"/>
<t t-set="target" t-value="target"/>
<t t-set="result_total" t-value="result_total"/>
<t t-set="clipped" t-value="clipped"/>
</t>
<t t-if="not jobs">
<div class="o_fp_card text-center text-muted">
<p class="mb-2">You have no plating jobs yet.</p>
<a href="/my/configurator" class="o_fp_btn_primary o_fp_btn_sm">+ Get Your First Quote</a>
</div>
</t>
<t t-if="jobs">
<!-- id="fp_jobs_list" is the data-fp-target for the search JS -->
<div class="o_fp_dashboard" id="fp_jobs_list">
<t t-foreach="jobs" t-as="job">
<!-- Wrapper div is the filterable row unit.
Hidden span carries extra search terms that
are not visible in the card UI. -->
<div class="o_fp_job_card_wrap">
<t t-call="fusion_plating_portal.fp_portal_job_card">
<t t-set="job" t-value="job"/>
</t>
<!-- Extra hidden search terms for this card -->
<t t-set="_backend_job" t-value="job.x_fc_job_id if 'x_fc_job_id' in job._fields else False"/>
<t t-set="_so" t-value="_backend_job.sale_order_id if _backend_job and 'sale_order_id' in _backend_job._fields else False"/>
<t t-set="_part" t-value="_backend_job.part_catalog_id if _backend_job and 'part_catalog_id' in _backend_job._fields else False"/>
<span class="d-none" aria-hidden="true">
<t t-if="_part">
<t t-out="_part.part_number or ''"/>
<t t-out="_part.name or ''"/>
</t>
<t t-if="_so">
<t t-out="_so.name or ''"/>
<t t-out="_so.client_order_ref or ''"/>
</t>
<t t-out="job.notes or ''"/>
</span>
</div>
</t>
</div>
</t>
</t>
</template>
<!-- ================================================================== -->
<!-- JOB - detail -->
<!-- ================================================================== -->
<template id="portal_my_job" name="My Work Order">
<t t-call="portal.portal_layout">
<div class="o_fp_job_detail">
<!-- Hero header: WO ref + part + ship-to + key facts -->
<t t-set="backend_job" t-value="job.x_fc_job_id if 'x_fc_job_id' in job._fields else False"/>
<t t-set="so" t-value="backend_job.sale_order_id if backend_job and 'sale_order_id' in backend_job._fields else False"/>
<t t-set="part" t-value="backend_job.part_catalog_id if backend_job and 'part_catalog_id' in backend_job._fields else False"/>
<t t-set="ship_to" t-value="so.partner_shipping_id if so else False"/>
<div class="o_fp_job_detail_hero">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
<div>
<div class="o_fp_detail_label">Work Order</div>
<h2><span t-out="job.name"/></h2>
<t t-if="part">
<div class="o_fp_detail_subtitle">
<t t-if="part.part_number"><b t-out="part.part_number"/> · </t>
<t t-out="part.name or part.display_name"/>
</div>
</t>
<t t-elif="job.process_type_ids">
<div class="o_fp_detail_subtitle">
<span t-out="', '.join(job.process_type_ids.mapped('name'))"/>
</div>
</t>
<div class="o_fp_detail_facts">
<div t-if="job.quantity">
<span class="o_fp_fact_label">Qty </span>
<span class="o_fp_fact_value" t-out="job.quantity"/>
</div>
<div t-if="job.received_date">
<span class="o_fp_fact_label">Received </span>
<span class="o_fp_fact_value" t-field="job.received_date" t-options='{"widget": "date"}'/>
</div>
<div t-if="job.target_ship_date">
<span class="o_fp_fact_label">ETA </span>
<span class="o_fp_fact_value" t-field="job.target_ship_date" t-options='{"widget": "date"}'/>
</div>
<div t-if="job.tracking_ref">
<span class="o_fp_fact_label">Tracking </span>
<span class="o_fp_fact_value">
<a t-if="job.x_fc_tracking_url"
t-att-href="job.x_fc_tracking_url"
target="_blank"
rel="noopener noreferrer">
<t t-out="job.tracking_ref"/>
</a>
<t t-else="">
<t t-out="job.tracking_ref"/>
</t>
</span>
</div>
<div t-if="ship_to and ship_to.id != job.partner_id.commercial_partner_id.id">
<span class="o_fp_fact_label">Ship to </span>
<span class="o_fp_fact_value">
<t t-out="ship_to.name"/><t t-if="ship_to.city"> · <t t-out="ship_to.city"/></t>
</span>
</div>
</div>
</div>
<div class="d-flex flex-column align-items-end gap-2">
<t t-call="fusion_plating_portal.fp_portal_status_badge">
<t t-set="state" t-value="job.state"/>
<t t-set="label" t-value="dict(job._fields['state']._description_selection(job.env)).get(job.state)"/>
</t>
</div>
</div>
</div>
<!-- Two-column grid: timeline | docs -->
<div class="o_fp_job_detail_grid">
<!-- Timeline -->
<div class="o_fp_card">
<div class="d-flex justify-content-between align-items-center mb-3">
<div style="font-weight:600;color:#111827;font-size:1rem">Progress</div>
<span style="font-size:.7rem;color:#6b7280">
<t t-out="progress_percent"/>% complete
</span>
</div>
<div class="o_fp_timeline">
<div class="o_fp_timeline_spine_active" t-attf-style="height: #{timeline_spine_pct}%"/>
<t t-foreach="stage_timeline" t-as="step">
<div t-attf-class="o_fp_timeline_item o_fp_timeline_#{step['status']}">
<div class="o_fp_timeline_dot">
<t t-if="step['status'] == 'done'"></t>
</div>
<div class="o_fp_timeline_title" t-out="step['label']"/>
<div class="o_fp_timeline_time" t-out="step['time_label']"/>
</div>
</t>
</div>
</div>
<!-- Documents -->
<div class="o_fp_card">
<div class="d-flex justify-content-between align-items-center mb-3">
<div style="font-weight:600;color:#111827;font-size:1rem">Documents</div>
</div>
<t t-foreach="doc_groups" t-as="group">
<t t-call="fusion_plating_portal.fp_portal_doc_group">
<t t-set="group_label" t-value="group['label']"/>
<t t-set="docs" t-value="group['docs']"/>
</t>
</t>
</div>
</div>
<!-- Tracking history (if shipment has events) -->
<div t-if="job.x_fc_tracking_event_ids" class="o_fp_card"
style="margin-top:1.25rem">
<div class="d-flex justify-content-between align-items-center mb-3">
<div style="font-weight:600;color:#111827;font-size:1rem">
Tracking History
</div>
<span t-if="job.tracking_ref"
style="font-size:.75rem;color:#6b7280;font-family:monospace">
<a t-if="job.x_fc_tracking_url"
t-att-href="job.x_fc_tracking_url"
target="_blank" rel="noopener noreferrer">
<t t-out="job.tracking_ref"/>
</a>
<t t-else="" t-out="job.tracking_ref"/>
</span>
</div>
<div class="o_fp_timeline">
<t t-foreach="job.x_fc_tracking_event_ids" t-as="evt">
<div class="o_fp_timeline_item o_fp_timeline_done">
<div class="o_fp_timeline_dot"></div>
<div class="o_fp_timeline_title"
t-out="evt.event_description or 'Tracking update'"/>
<div class="o_fp_timeline_time">
<t t-if="evt.event_datetime"
t-out="evt.event_datetime"
t-options='{"widget": "datetime"}'/>
<t t-elif="evt.event_date"
t-out="evt.event_date"
t-options='{"widget": "date"}'/>
<t t-if="evt.event_site">
<span style="color:#9ca3af"> ·
<t t-out="evt.event_site"/>
<t t-if="evt.event_province">,
<t t-out="evt.event_province"/>
</t>
</span>
</t>
</div>
</div>
</t>
</div>
</div>
<!-- Customer notes (if any) -->
<div t-if="job.notes" class="o_fp_card" style="margin-top:1.25rem">
<div style="font-weight:600;color:#111827;font-size:1rem;margin-bottom:.6rem">Notes</div>
<div t-out="job.notes"/>
</div>
<!-- Footer -->
<div class="o_fp_job_detail_footer">
<div class="o_fp_related_links">
<span style="color:#9ca3af">Related:</span>
<a t-if="job.invoice_ref" href="#" t-out="'Invoice ' + job.invoice_ref"/>
<a t-else="" class="disabled">Invoice (pending)</a>
</div>
<div class="d-flex gap-2 align-items-center">
<!-- POST-only form so the action is intentional -->
<form t-attf-action="/my/jobs/#{job.id}/repeat" method="post" class="m-0">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<button type="submit" class="o_fp_btn_primary">
<i class="fa fa-repeat"/> Repeat Order
</button>
</form>
<a href="/my/jobs" class="o_fp_btn_secondary">← Back to all jobs</a>
</div>
</div>
</div>
</t>
</template>
<!-- ================================================================== -->
<!-- DELIVERIES / PACKING SLIPS - list with search + sort -->
<!-- ================================================================== -->
<template id="portal_my_deliveries" name="My Deliveries">
<t t-call="portal.portal_layout">
<t t-call="portal.portal_searchbar">
<t t-set="title">Packing Slips / Deliveries</t>
</t>
<!-- Search + sort strip (no filter pills - all rows are delivered) -->
<t t-call="fusion_plating_portal.fp_portal_list_controls">
<t t-set="filters" t-value="filters"/>
<t t-set="active_filter" t-value="filter_state"/>
<t t-set="sorts" t-value="sorts"/>
<t t-set="active_sort" t-value="sortby"/>
<t t-set="search" t-value="search"/>
<t t-set="url" t-value="url"/>
<t t-set="extra_qs" t-value="extra_qs"/>
<t t-set="target" t-value="target"/>
<t t-set="result_total" t-value="result_total"/>
<t t-set="clipped" t-value="clipped"/>
</t>
<t t-if="not deliveries">
<div class="o_fp_portal_card card bg-body-tertiary border-0 p-4 text-center">
<p class="text-muted mb-0">No deliveries found.</p>
</div>
</t>
<t t-if="deliveries" t-call="portal.portal_table">
<thead>
<tr class="active">
<th>Reference</th>
<th>Origin</th>
<th>Date</th>
<th class="text-end">Status</th>
</tr>
</thead>
<tbody class="o_fp_deliveries_filterable">
<tr t-foreach="deliveries" t-as="dlv">
<td t-out="dlv.name"/>
<td>
<span t-out="dlv.origin or ''"/>
</td>
<td>
<span t-if="dlv.date_done"
t-field="dlv.date_done"
t-options='{"widget": "date"}'/>
</td>
<td class="text-end">
<span class="o_fp_badge o_fp_badge_shipped">
<span class="o_fp_badge_dot"/>Delivered
</span>
</td>
<!-- Hidden: partner ref / customer PO on the origin SO -->
<td class="d-none" aria-hidden="true">
<t t-if="dlv.sale_id">
<span t-out="dlv.sale_id.name or ''"/>
<span t-out="dlv.sale_id.client_order_ref or ''"/>
</t>
</td>
</tr>
</tbody>
</t>
</t>
</template>
<!-- ================================================================== -->
<!-- CERTIFICATIONS - list with search + sort -->
<!-- ================================================================== -->
<template id="portal_my_certifications" name="My Certifications">
<t t-call="portal.portal_layout">
<t t-call="portal.portal_searchbar">
<t t-set="title">Certifications &amp; Quality</t>
</t>
<!-- Search + sort strip (no filter pills - all certs are terminal) -->
<t t-call="fusion_plating_portal.fp_portal_list_controls">
<t t-set="filters" t-value="filters"/>
<t t-set="active_filter" t-value="filter_state"/>
<t t-set="sorts" t-value="sorts"/>
<t t-set="active_sort" t-value="sortby"/>
<t t-set="search" t-value="search"/>
<t t-set="url" t-value="url"/>
<t t-set="extra_qs" t-value="extra_qs"/>
<t t-set="target" t-value="target"/>
<t t-set="result_total" t-value="result_total"/>
<t t-set="clipped" t-value="clipped"/>
</t>
<t t-if="not cert_jobs">
<div class="o_fp_portal_card card bg-body-tertiary border-0 p-4 text-center">
<p class="text-muted mb-0">No certificates available yet.</p>
</div>
</t>
<t t-if="cert_jobs" t-call="portal.portal_table">
<thead>
<tr class="active">
<th>Job</th>
<th>Ship Date</th>
<th>Processes</th>
<th class="text-end">Download</th>
</tr>
</thead>
<tbody class="o_fp_certs_filterable">
<tr t-foreach="cert_jobs" t-as="cj">
<td>
<a t-att-href="'/my/jobs/%s' % cj.id" t-out="cj.name"/>
</td>
<td>
<span t-if="cj.actual_ship_date"
t-field="cj.actual_ship_date"
t-options='{"widget": "date"}'/>
<span t-else="" class="text-muted">--</span>
</td>
<td>
<span t-foreach="cj.process_type_ids" t-as="pt"
class="o_fp_doc_chip me-1" t-out="pt.name"/>
</td>
<td class="text-end">
<a t-att-href="'/my/jobs/%s/coc' % cj.id"
class="o_fp_btn_secondary o_fp_btn_sm">
<i class="fa fa-download"/> CoC
</a>
</td>
<!-- Hidden: part name, customer PO from the backend job -->
<td class="d-none" aria-hidden="true">
<t t-set="_bj" t-value="cj.x_fc_job_id if 'x_fc_job_id' in cj._fields else False"/>
<t t-set="_so" t-value="_bj.sale_order_id if _bj and 'sale_order_id' in _bj._fields else False"/>
<t t-set="_part" t-value="_bj.part_catalog_id if _bj and 'part_catalog_id' in _bj._fields else False"/>
<t t-if="_part">
<span t-out="_part.part_number or ''"/>
<span t-out="_part.name or ''"/>
</t>
<t t-if="_so">
<span t-out="_so.client_order_ref or ''"/>
</t>
</td>
</tr>
</tbody>
</t>
</t>
</template>
</odoo>

View File

@@ -1,313 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<!-- ================================================================== -->
<!-- Quote Request - list -->
<!-- ================================================================== -->
<record id="view_fp_quote_request_list" model="ir.ui.view">
<field name="name">fp.quote.request.list</field>
<field name="model">fusion.plating.quote.request</field>
<field name="arch" type="xml">
<list string="Quote Requests"
decoration-info="state == 'new'"
decoration-warning="state == 'under_review'"
decoration-success="state == 'accepted'"
decoration-muted="state in ('declined','expired')">
<field name="name"/>
<field name="partner_id"/>
<field name="contact_name" optional="show"/>
<field name="quantity"/>
<field name="target_delivery"/>
<field name="currency_id" column_invisible="1"/>
<field name="quoted_price" widget="monetary"
options="{'currency_field': 'currency_id'}" optional="show" sum="Total"/>
<field name="state" widget="badge"
decoration-info="state == 'new'"
decoration-warning="state == 'under_review'"
decoration-primary="state == 'quoted'"
decoration-success="state == 'accepted'"
decoration-danger="state == 'declined'"
decoration-muted="state == 'expired'"/>
<field name="create_date" optional="show"/>
</list>
</field>
</record>
<!-- ================================================================== -->
<!-- Quote Request - form -->
<!-- ================================================================== -->
<record id="view_fp_quote_request_form" model="ir.ui.view">
<field name="name">fp.quote.request.form</field>
<field name="model">fusion.plating.quote.request</field>
<field name="arch" type="xml">
<form string="Quote Request">
<header>
<button name="action_mark_under_review" string="Start Review" type="object"
class="oe_highlight" invisible="state != 'new'"/>
<button name="action_send_quote" string="Send Quote" type="object"
class="oe_highlight"
invisible="state not in ('new','under_review')"/>
<button name="action_mark_accepted" string="Mark Accepted" type="object"
invisible="state != 'quoted'"/>
<button name="action_create_sale_order" string="Create Sale Order" type="object"
class="oe_highlight"
invisible="state != 'accepted'"/>
<button name="action_mark_declined" string="Mark Declined" type="object"
invisible="state != 'quoted'"/>
<button name="action_mark_expired" string="Mark Expired" type="object"
invisible="state in ('accepted','declined','expired')"/>
<field name="state" widget="statusbar"
statusbar_visible="new,under_review,quoted,accepted"/>
</header>
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group>
<field name="partner_id"/>
<field name="contact_name"/>
<field name="contact_email"/>
<field name="contact_phone"/>
<field name="company_name"/>
</group>
<group>
<field name="quantity"/>
<field name="target_delivery"/>
<field name="process_type_ids" widget="many2many_tags"/>
<field name="currency_id" groups="base.group_multi_currency"/>
<field name="quoted_price" widget="monetary"
options="{'currency_field': 'currency_id'}"
readonly="state in ('new','under_review')"/>
<field name="quoted_by_id" readonly="1"/>
<field name="quote_sent_date" readonly="1"/>
<field name="customer_response_date" readonly="1"/>
</group>
</group>
<group>
<group>
<field name="shipping_address_id"/>
<field name="billing_same_as_shipping"/>
<field name="billing_address_id"
invisible="billing_same_as_shipping"/>
</group>
<group>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Part Lines">
<field name="line_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="product_id"/>
<field name="part_number"/>
<field name="quantity"/>
<field name="count"/>
<field name="description"/>
<field name="spec_text" optional="hide"/>
<field name="attachment_ids" widget="many2many_binary"/>
</list>
</field>
</page>
<page string="Part Description">
<field name="part_description"/>
</page>
<page string="Special Instructions">
<field name="special_instructions"/>
</page>
<page string="Attachments">
<field name="drawing_attachment_ids" widget="many2many_binary"/>
</page>
<page string="Internal Notes">
<field name="notes_internal"/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ================================================================== -->
<!-- Quote Request - search -->
<!-- ================================================================== -->
<record id="view_fp_quote_request_search" model="ir.ui.view">
<field name="name">fp.quote.request.search</field>
<field name="model">fusion.plating.quote.request</field>
<field name="arch" type="xml">
<search string="Quote Requests">
<field name="name"/>
<field name="partner_id"/>
<field name="contact_name"/>
<field name="company_name"/>
<separator/>
<filter string="New" name="new" domain="[('state','=','new')]"/>
<filter string="Under Review" name="review" domain="[('state','=','under_review')]"/>
<filter string="Quoted" name="quoted" domain="[('state','=','quoted')]"/>
<filter string="Accepted" name="accepted" domain="[('state','=','accepted')]"/>
<separator/>
<group>
<filter string="Customer" name="group_partner" context="{'group_by':'partner_id'}"/>
<filter string="Status" name="group_state" context="{'group_by':'state'}"/>
</group>
</search>
</field>
</record>
<!-- ================================================================== -->
<!-- Quote Request - action -->
<!-- ================================================================== -->
<record id="action_fp_quote_request" model="ir.actions.act_window">
<field name="name">Quote Requests</field>
<field name="res_model">fusion.plating.quote.request</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_quote_request_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No quote requests yet
</p>
<p>
Customers can submit Requests for Quote (RFQ) from the portal at
<code>/my/quote_requests</code>. Once submitted, they appear here
for your team to review and price.
</p>
</field>
</record>
<!-- ================================================================== -->
<!-- Portal Job - list -->
<!-- ================================================================== -->
<record id="view_fp_portal_job_list" model="ir.ui.view">
<field name="name">fp.portal.job.list</field>
<field name="model">fusion.plating.portal.job</field>
<field name="arch" type="xml">
<list string="Work Orders"
decoration-info="state == 'received'"
decoration-success="state == 'complete'">
<field name="name"/>
<field name="partner_id"/>
<field name="received_date"/>
<field name="target_ship_date"/>
<field name="actual_ship_date" optional="hide"/>
<field name="quantity"/>
<field name="state" widget="badge"
decoration-info="state == 'received'"
decoration-primary="state == 'in_progress'"
decoration-warning="state == 'quality_check'"
decoration-success="state in ('shipped','complete')"/>
</list>
</field>
</record>
<!-- ================================================================== -->
<!-- Portal Job - form -->
<!-- ================================================================== -->
<record id="view_fp_portal_job_form" model="ir.ui.view">
<field name="name">fp.portal.job.form</field>
<field name="model">fusion.plating.portal.job</field>
<field name="arch" type="xml">
<form string="Work Order">
<header>
<field name="state" widget="statusbar"
statusbar_visible="received,in_progress,quality_check,ready_to_ship,shipped,complete"/>
</header>
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name"/></h1>
</div>
<group>
<group>
<field name="partner_id"/>
<field name="quantity"/>
<field name="process_type_ids" widget="many2many_tags"/>
</group>
<group>
<field name="received_date"/>
<field name="target_ship_date"/>
<field name="actual_ship_date"/>
<field name="tracking_ref"/>
<field name="invoice_ref"/>
</group>
</group>
<group string="Documents">
<field name="coc_attachment_id"/>
<field name="packing_list_attachment_id"/>
</group>
<group>
<field name="company_id" groups="base.group_multi_company"/>
</group>
<notebook>
<page string="Customer-Visible Notes">
<field name="notes"/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ================================================================== -->
<!-- Portal Job - search -->
<!-- ================================================================== -->
<record id="view_fp_portal_job_search" model="ir.ui.view">
<field name="name">fp.portal.job.search</field>
<field name="model">fusion.plating.portal.job</field>
<field name="arch" type="xml">
<search string="Work Orders">
<field name="name"/>
<field name="partner_id"/>
<field name="invoice_ref"/>
<separator/>
<filter string="Received" name="received" domain="[('state','=','received')]"/>
<filter string="In Progress" name="in_progress" domain="[('state','=','in_progress')]"/>
<filter string="Quality Check" name="quality_check" domain="[('state','=','quality_check')]"/>
<filter string="Ready to Ship" name="ready_to_ship" domain="[('state','=','ready_to_ship')]"/>
<filter string="Shipped" name="shipped" domain="[('state','=','shipped')]"/>
<filter string="Complete" name="complete" domain="[('state','=','complete')]"/>
<separator/>
<group>
<filter string="Customer" name="group_partner" context="{'group_by':'partner_id'}"/>
<filter string="Status" name="group_state" context="{'group_by':'state'}"/>
</group>
</search>
</field>
</record>
<!-- ================================================================== -->
<!-- Portal Job - action -->
<!-- ================================================================== -->
<record id="action_fp_portal_job" model="ir.actions.act_window">
<field name="name">Work Orders</field>
<field name="res_model">fusion.plating.portal.job</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_portal_job_search"/>
</record>
<!-- ================================================================== -->
<!-- res.partner - extend form to surface portal flag + counts -->
<!-- ================================================================== -->
<record id="view_partner_form_fp_portal" model="ir.ui.view">
<field name="name">res.partner.form.fp.portal</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<xpath expr="//sheet" position="inside">
<group string="Plating Portal" name="fp_portal_group">
<field name="x_fc_portal_enabled"/>
<field name="x_fc_quote_request_count" readonly="1"/>
<field name="x_fc_portal_job_count" readonly="1"/>
</group>
</xpath>
</field>
</record>
</odoo>

View File

@@ -1,98 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Adds a leading "Part #" column to the customer portal's Sales Order
products table. Reads sale.order.line.x_fc_part_catalog_id.part_number
(defined in fusion_plating_configurator). The existing second column
keeps line.name - the customer-facing description.
Layout after this inherit:
| Part # | Description | Quantity | Unit Price | [Disc] | [Taxes] | Amount |
-->
<odoo>
<!-- ================================================================== -->
<!-- /my/orders - inject filter strip + data-fp-filterable before the -->
<!-- Odoo portal table so real-time search works client-side. -->
<!-- Sort dropdown reuses Odoo's existing sortby param; filter pills -->
<!-- link to URL params that Odoo's stock route honours natively. -->
<!-- ================================================================== -->
<template id="portal_my_orders_fp_search"
inherit_id="sale.portal_my_orders"
priority="50">
<!-- Inject the controls strip right after the portal_searchbar call
and before the "no orders" alert / portal_table. The anchor is
the <div t-if="not orders"> alert - we inject before it. -->
<xpath expr="//div[@t-if='not orders']" position="before">
<t t-call="fusion_plating_portal.fp_portal_list_controls">
<t t-set="filters" t-value="False"/>
<t t-set="active_filter" t-value="'all'"/>
<t t-set="sorts" t-value="False"/>
<t t-set="active_sort" t-value="'date'"/>
<t t-set="search" t-value="''"/>
<t t-set="url" t-value="'/my/orders'"/>
<t t-set="extra_qs" t-value="''"/>
<!-- Odoo's portal.portal_table emits a <table class="o_portal_my_doc_table">
so we don't need to add our own id; the JS just needs a stable selector. -->
<t t-set="target" t-value="'.o_portal_my_doc_table tbody'"/>
<t t-set="result_total" t-value="len(orders) if orders else 0"/>
<t t-set="clipped" t-value="False"/>
</t>
</xpath>
</template>
<!-- ================================================================== -->
<!-- Sale order portal content: add Part # column -->
<!-- ================================================================== -->
<template id="sale_order_portal_content_fp_part_column"
inherit_id="sale.sale_order_portal_content">
<!-- ============================================================ -->
<!-- Header: Part # before "Products" -->
<!-- ============================================================ -->
<xpath expr="//th[@id='product_name_header']" position="before">
<th class="text-start" id="product_part_header">Part #</th>
</xpath>
<!-- ============================================================ -->
<!-- Product rows: Part # cell before the description cell. -->
<!-- Reads from x_fc_part_catalog_id; service / freight / display -->
<!-- lines with no part catalog show an empty cell. -->
<!-- ============================================================ -->
<xpath expr="//td[@name='td_product_name']" position="before">
<td name="td_product_part" t-att-class="padding_class">
<span t-if="'x_fc_part_catalog_id' in line._fields and line.x_fc_part_catalog_id"
t-out="line.x_fc_part_catalog_id.part_number"/>
</td>
</xpath>
<!-- Combo header rows: empty Part # cell (the part lives on the
child lines, not the combo header). -->
<xpath expr="//td[@name='td_combo_name']" position="before">
<td name="td_combo_part" t-att-class="padding_class"/>
</xpath>
<!-- Collapsed section group rows: empty Part # cell. -->
<xpath expr="//td[@name='td_section_group_name']" position="before">
<td name="td_section_group_part"/>
</xpath>
<!-- ============================================================ -->
<!-- Section / subsection rows: extend the name-cell colspan by -->
<!-- one so the new Part # column is absorbed into the section -->
<!-- title band (sections span every column before the section -->
<!-- total cell). The "not show_section_total" branch already -->
<!-- uses 99 so no change needed there. -->
<!-- ============================================================ -->
<xpath expr="//t[@t-set='section_name_colspan'][1]" position="attributes">
<attribute name="t-value">4 + (1 if display_discount else 0) + (1 if display_taxes else 0)</attribute>
</xpath>
</template>
</odoo>