Split 49 modules/suites into independent git repos; untrack from monorepo
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:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'})
|
||||
@@ -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',
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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,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();
|
||||
}
|
||||
})();
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
// // ...
|
||||
// }
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
from . import test_portal_dashboard
|
||||
from . import test_employee_portal_gating
|
||||
@@ -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', ''))
|
||||
@@ -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'])
|
||||
@@ -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>
|
||||
@@ -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}&filter_state=#{fk}&sort=#{sort}&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 + '&filter_state=' + filter_state + '&sort=date_desc&search=' + search"
|
||||
t-att-selected="sort == 'date_desc'">Newest first</option>
|
||||
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&filter_state=' + filter_state + '&sort=date_asc&search=' + search"
|
||||
t-att-selected="sort == 'date_asc'">Oldest first</option>
|
||||
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&filter_state=' + filter_state + '&sort=amount_desc&search=' + search"
|
||||
t-att-selected="sort == 'amount_desc'">Largest amount</option>
|
||||
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&filter_state=' + filter_state + '&sort=amount_asc&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&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>
|
||||
@@ -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>
|
||||
@@ -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 & 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 & 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>
|
||||
@@ -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>
|
||||
@@ -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]}&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') + '&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>
|
||||
@@ -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 '•'"/>
|
||||
<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">↪</span>
|
||||
<span>Sign Out</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -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&model=res.partner&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 & 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 & 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user