folder rename
This commit is contained in:
42
fusion_plating/fusion_plating_portal/README.md
Normal file
42
fusion_plating/fusion_plating_portal/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 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.
|
||||
7
fusion_plating/fusion_plating_portal/__init__.py
Normal file
7
fusion_plating/fusion_plating_portal/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
# -*- 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
|
||||
77
fusion_plating/fusion_plating_portal/__manifest__.py
Normal file
77
fusion_plating/fusion_plating_portal/__manifest__.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# -*- 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.2.0.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_quote_request_views.xml',
|
||||
'views/fp_portal_dashboard.xml',
|
||||
'views/fp_portal_templates.xml',
|
||||
'views/fp_portal_configurator_templates.xml',
|
||||
'views/fp_portal_breadcrumbs.xml',
|
||||
'views/fp_menu.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_frontend': [
|
||||
'fusion_plating_portal/static/src/scss/fusion_plating_portal.scss',
|
||||
'fusion_plating_portal/static/src/js/fp_rfq_form.js',
|
||||
],
|
||||
},
|
||||
'demo': [
|
||||
'data/fp_demo_portal_data.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# -*- 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
|
||||
856
fusion_plating/fusion_plating_portal/controllers/portal.py
Normal file
856
fusion_plating/fusion_plating_portal/controllers/portal.py
Normal file
@@ -0,0 +1,856 @@
|
||||
# -*- 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 json
|
||||
|
||||
from odoo import _, http
|
||||
from odoo.exceptions import AccessError, MissingError
|
||||
from odoo.http import request
|
||||
from odoo.addons.portal.controllers.portal import (
|
||||
CustomerPortal,
|
||||
pager as portal_pager,
|
||||
)
|
||||
|
||||
|
||||
class FpCustomerPortal(CustomerPortal):
|
||||
"""Customer portal extension for Fusion Plating.
|
||||
|
||||
Adds a rich dashboard, Quote Requests with part lines, Jobs with
|
||||
segmented progress, Purchase Orders, Invoices, Shipping, and
|
||||
Certifications sections.
|
||||
"""
|
||||
|
||||
# ==========================================================================
|
||||
# Portal Home -- counters
|
||||
# ==========================================================================
|
||||
def _prepare_home_portal_values(self, counters):
|
||||
values = super()._prepare_home_portal_values(counters)
|
||||
partner = request.env.user.partner_id
|
||||
commercial = partner.commercial_partner_id
|
||||
|
||||
if 'fp_quote_request_count' in counters:
|
||||
values['fp_quote_request_count'] = request.env[
|
||||
'fusion.plating.quote.request'
|
||||
].search_count([('partner_id', 'child_of', commercial.id)])
|
||||
|
||||
if 'fp_portal_job_count' in counters:
|
||||
values['fp_portal_job_count'] = request.env[
|
||||
'fusion.plating.portal.job'
|
||||
].search_count([('partner_id', 'child_of', commercial.id)])
|
||||
|
||||
if 'fp_purchase_order_count' in counters:
|
||||
values['fp_purchase_order_count'] = request.env[
|
||||
'sale.order'
|
||||
].sudo().search_count([
|
||||
('partner_id', 'child_of', commercial.id),
|
||||
('state', '=', 'sale'),
|
||||
])
|
||||
|
||||
if 'fp_invoice_count' in counters:
|
||||
values['fp_invoice_count'] = request.env[
|
||||
'account.move'
|
||||
].sudo().search_count([
|
||||
('partner_id', 'child_of', commercial.id),
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
])
|
||||
|
||||
if 'fp_delivery_count' in counters:
|
||||
values['fp_delivery_count'] = request.env[
|
||||
'stock.picking'
|
||||
].sudo().search_count([
|
||||
('partner_id', 'child_of', commercial.id),
|
||||
('picking_type_code', '=', 'outgoing'),
|
||||
('state', '=', 'done'),
|
||||
])
|
||||
|
||||
if 'fp_certification_count' in counters:
|
||||
values['fp_certification_count'] = request.env[
|
||||
'fusion.plating.portal.job'
|
||||
].search_count([
|
||||
('partner_id', 'child_of', commercial.id),
|
||||
('coc_attachment_id', '!=', False),
|
||||
])
|
||||
|
||||
return values
|
||||
|
||||
# ==========================================================================
|
||||
# Helpers
|
||||
# ==========================================================================
|
||||
def _fp_quote_request_get_page_view_values(self, quote_request, access_token, **kwargs):
|
||||
values = {
|
||||
'page_name': 'fp_quote_request',
|
||||
'quote_request': quote_request,
|
||||
}
|
||||
return self._get_page_view_values(
|
||||
quote_request,
|
||||
access_token,
|
||||
values,
|
||||
'my_fp_quote_requests_history',
|
||||
False,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _fp_portal_job_get_page_view_values(self, job, access_token, **kwargs):
|
||||
values = {
|
||||
'page_name': 'fp_portal_job',
|
||||
'job': job,
|
||||
}
|
||||
return self._get_page_view_values(
|
||||
job,
|
||||
access_token,
|
||||
values,
|
||||
'my_fp_jobs_history',
|
||||
False,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _fp_get_partner_domain(self):
|
||||
"""Return base domain filtering to commercial partner tree."""
|
||||
partner = request.env.user.partner_id
|
||||
return partner.commercial_partner_id
|
||||
|
||||
# ==========================================================================
|
||||
# DASHBOARD
|
||||
# ==========================================================================
|
||||
@http.route(
|
||||
['/my/home'],
|
||||
type='http',
|
||||
auth='user',
|
||||
website=True,
|
||||
)
|
||||
def portal_my_home_dashboard(self, **kw):
|
||||
partner = request.env.user.partner_id
|
||||
commercial = partner.commercial_partner_id
|
||||
|
||||
# Recent quotes (5)
|
||||
Quote = request.env['fusion.plating.quote.request']
|
||||
quote_domain = [('partner_id', 'child_of', commercial.id)]
|
||||
recent_quotes = Quote.search(
|
||||
quote_domain, order='create_date desc', limit=5
|
||||
)
|
||||
quote_count = Quote.search_count(quote_domain)
|
||||
|
||||
# Recent POs (5) -- sale.order with state=sale
|
||||
SO = request.env['sale.order'].sudo()
|
||||
po_domain = [
|
||||
('partner_id', 'child_of', commercial.id),
|
||||
('state', '=', 'sale'),
|
||||
]
|
||||
recent_pos = SO.search(po_domain, order='date_order desc', limit=5)
|
||||
po_count = SO.search_count(po_domain)
|
||||
|
||||
# Recent jobs (5)
|
||||
Job = request.env['fusion.plating.portal.job']
|
||||
job_domain = [('partner_id', 'child_of', commercial.id)]
|
||||
recent_jobs = Job.search(
|
||||
job_domain, order='received_date desc, id desc', limit=5
|
||||
)
|
||||
job_count = Job.search_count(job_domain)
|
||||
|
||||
# Certifications (jobs with CoC)
|
||||
cert_domain = [
|
||||
('partner_id', 'child_of', commercial.id),
|
||||
('coc_attachment_id', '!=', False),
|
||||
]
|
||||
recent_certs = Job.search(
|
||||
cert_domain, order='actual_ship_date desc, id desc', limit=5
|
||||
)
|
||||
cert_count = Job.search_count(cert_domain)
|
||||
|
||||
# Deliveries (stock.picking outgoing done)
|
||||
Picking = request.env['stock.picking'].sudo()
|
||||
delivery_domain = [
|
||||
('partner_id', 'child_of', commercial.id),
|
||||
('picking_type_code', '=', 'outgoing'),
|
||||
('state', '=', 'done'),
|
||||
]
|
||||
recent_deliveries = Picking.search(
|
||||
delivery_domain, order='date_done desc', limit=5
|
||||
)
|
||||
delivery_count = Picking.search_count(delivery_domain)
|
||||
|
||||
# Invoices
|
||||
Invoice = request.env['account.move'].sudo()
|
||||
invoice_domain = [
|
||||
('partner_id', 'child_of', commercial.id),
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
]
|
||||
recent_invoices = Invoice.search(
|
||||
invoice_domain, order='invoice_date desc', limit=5
|
||||
)
|
||||
invoice_count = Invoice.search_count(invoice_domain)
|
||||
|
||||
values = {
|
||||
'page_name': 'fp_dashboard',
|
||||
'partner': partner,
|
||||
# Quotes
|
||||
'recent_quotes': recent_quotes,
|
||||
'quote_count': quote_count,
|
||||
# Purchase Orders
|
||||
'recent_pos': recent_pos,
|
||||
'po_count': po_count,
|
||||
# Jobs / Parts Portal
|
||||
'recent_jobs': recent_jobs,
|
||||
'job_count': job_count,
|
||||
# Certifications
|
||||
'recent_certs': recent_certs,
|
||||
'cert_count': cert_count,
|
||||
# Deliveries
|
||||
'recent_deliveries': recent_deliveries,
|
||||
'delivery_count': delivery_count,
|
||||
# Invoices
|
||||
'recent_invoices': recent_invoices,
|
||||
'invoice_count': invoice_count,
|
||||
}
|
||||
return request.render(
|
||||
'fusion_plating_portal.fp_portal_home_dashboard',
|
||||
values,
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# QUOTE REQUESTS -- list with tabs
|
||||
# ==========================================================================
|
||||
@http.route(
|
||||
['/my/quote_requests', '/my/quote_requests/page/<int:page>'],
|
||||
type='http',
|
||||
auth='user',
|
||||
website=True,
|
||||
)
|
||||
def portal_my_quote_requests(self, page=1, sortby=None, filterby=None, **kw):
|
||||
partner = request.env.user.partner_id
|
||||
commercial = partner.commercial_partner_id
|
||||
Quote = request.env['fusion.plating.quote.request']
|
||||
domain = [('partner_id', 'child_of', commercial.id)]
|
||||
|
||||
searchbar_sortings = {
|
||||
'date': {'label': _('Newest'), 'order': 'create_date desc'},
|
||||
'name': {'label': _('Reference'), 'order': 'name desc'},
|
||||
'state': {'label': _('Status'), 'order': 'state'},
|
||||
}
|
||||
if not sortby:
|
||||
sortby = 'date'
|
||||
order = searchbar_sortings[sortby]['order']
|
||||
|
||||
# Tab filters
|
||||
searchbar_filters = {
|
||||
'all': {'label': _('All'), 'domain': []},
|
||||
'active': {'label': _('Active'), 'domain': [
|
||||
('state', 'in', ['new', 'under_review', 'quoted']),
|
||||
]},
|
||||
'converted': {'label': _('Converted'), 'domain': [
|
||||
('state', '=', 'accepted'),
|
||||
]},
|
||||
'declined': {'label': _('Declined'), 'domain': [
|
||||
('state', 'in', ['declined', 'expired']),
|
||||
]},
|
||||
}
|
||||
if not filterby or filterby not in searchbar_filters:
|
||||
filterby = 'all'
|
||||
domain += searchbar_filters[filterby]['domain']
|
||||
|
||||
total = Quote.search_count(domain)
|
||||
pager = portal_pager(
|
||||
url='/my/quote_requests',
|
||||
url_args={'sortby': sortby, 'filterby': filterby},
|
||||
total=total,
|
||||
page=page,
|
||||
step=self._items_per_page,
|
||||
)
|
||||
quote_requests = Quote.search(
|
||||
domain,
|
||||
order=order,
|
||||
limit=self._items_per_page,
|
||||
offset=pager['offset'],
|
||||
)
|
||||
request.session['my_fp_quote_requests_history'] = quote_requests.ids[:100]
|
||||
|
||||
values = {
|
||||
'quote_requests': quote_requests,
|
||||
'page_name': 'fp_quote_requests',
|
||||
'pager': pager,
|
||||
'default_url': '/my/quote_requests',
|
||||
'searchbar_sortings': searchbar_sortings,
|
||||
'sortby': sortby,
|
||||
'searchbar_filters': searchbar_filters,
|
||||
'filterby': filterby,
|
||||
}
|
||||
return request.render(
|
||||
'fusion_plating_portal.portal_my_quote_requests',
|
||||
values,
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# QUOTE REQUESTS -- detail
|
||||
# ==========================================================================
|
||||
@http.route(
|
||||
['/my/quote_requests/<int:request_id>'],
|
||||
type='http',
|
||||
auth='user',
|
||||
website=True,
|
||||
)
|
||||
def portal_my_quote_request(self, request_id, access_token=None, **kw):
|
||||
try:
|
||||
quote_sudo = self._document_check_access(
|
||||
'fusion.plating.quote.request',
|
||||
request_id,
|
||||
access_token,
|
||||
)
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my')
|
||||
|
||||
values = self._fp_quote_request_get_page_view_values(
|
||||
quote_sudo, access_token, **kw
|
||||
)
|
||||
return request.render(
|
||||
'fusion_plating_portal.portal_my_quote_request',
|
||||
values,
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# QUOTE REQUESTS -- new form (enhanced)
|
||||
# ==========================================================================
|
||||
@http.route(
|
||||
['/my/quote_requests/new'],
|
||||
type='http',
|
||||
auth='user',
|
||||
website=True,
|
||||
)
|
||||
def portal_new_quote_request(self, **kw):
|
||||
partner = request.env.user.partner_id
|
||||
commercial = partner.commercial_partner_id
|
||||
|
||||
process_types = request.env['fusion.plating.process.type'].sudo().search(
|
||||
[('active', '=', True)]
|
||||
)
|
||||
|
||||
# Shipping addresses: child contacts of type 'delivery' or 'other'
|
||||
addresses = request.env['res.partner'].sudo().search([
|
||||
('parent_id', '=', commercial.id),
|
||||
('type', 'in', ['delivery', 'other', 'contact']),
|
||||
])
|
||||
|
||||
# Products available for this customer (all saleable products)
|
||||
products = request.env['product.product'].sudo().search([
|
||||
('sale_ok', '=', True),
|
||||
('active', '=', True),
|
||||
], limit=200, order='default_code, name')
|
||||
|
||||
values = {
|
||||
'page_name': 'fp_quote_request_new',
|
||||
'process_types': process_types,
|
||||
'partner': partner,
|
||||
'commercial': commercial,
|
||||
'addresses': addresses,
|
||||
'products': products,
|
||||
'error': kw.get('error'),
|
||||
'form_data': kw,
|
||||
}
|
||||
return request.render(
|
||||
'fusion_plating_portal.portal_new_quote_request_form',
|
||||
values,
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# QUOTE REQUESTS -- submit (enhanced for multi-part)
|
||||
# ==========================================================================
|
||||
@http.route(
|
||||
['/my/quote_requests/submit'],
|
||||
type='http',
|
||||
auth='user',
|
||||
website=True,
|
||||
methods=['POST'],
|
||||
csrf=True,
|
||||
)
|
||||
def portal_submit_quote_request(self, **post):
|
||||
partner = request.env.user.partner_id
|
||||
|
||||
# Basic validation: need at least one part description or a general description
|
||||
parts_json = post.get('parts_data', '[]')
|
||||
try:
|
||||
parts_data = json.loads(parts_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
parts_data = []
|
||||
|
||||
if not parts_data and not post.get('part_description'):
|
||||
return request.redirect(
|
||||
'/my/quote_requests/new?error=missing_description'
|
||||
)
|
||||
|
||||
process_type_ids = []
|
||||
raw_processes = request.httprequest.form.getlist('process_type_ids')
|
||||
for pt in raw_processes:
|
||||
try:
|
||||
process_type_ids.append(int(pt))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
try:
|
||||
quantity = int(post.get('quantity') or 0)
|
||||
except (TypeError, ValueError):
|
||||
quantity = 0
|
||||
|
||||
vals = {
|
||||
'partner_id': partner.id,
|
||||
'contact_name': post.get('contact_name') or partner.name,
|
||||
'contact_email': post.get('contact_email') or partner.email,
|
||||
'contact_phone': post.get('contact_phone') or partner.phone,
|
||||
'company_name': post.get('company_name')
|
||||
or (partner.parent_id.name if partner.parent_id else partner.name),
|
||||
'part_description': post.get('part_description'),
|
||||
'quantity': quantity,
|
||||
'special_instructions': post.get('special_instructions'),
|
||||
'state': 'new',
|
||||
'billing_same_as_shipping': bool(post.get('billing_same_as_shipping')),
|
||||
}
|
||||
|
||||
# Addresses
|
||||
if post.get('shipping_address_id'):
|
||||
try:
|
||||
vals['shipping_address_id'] = int(post['shipping_address_id'])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if post.get('billing_address_id') and not post.get('billing_same_as_shipping'):
|
||||
try:
|
||||
vals['billing_address_id'] = int(post['billing_address_id'])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
if post.get('target_delivery'):
|
||||
vals['target_delivery'] = post.get('target_delivery')
|
||||
if process_type_ids:
|
||||
vals['process_type_ids'] = [(6, 0, process_type_ids)]
|
||||
|
||||
quote = request.env['fusion.plating.quote.request'].sudo().create(vals)
|
||||
|
||||
# Create part lines from JSON data
|
||||
if parts_data:
|
||||
Line = request.env['fusion.plating.quote.request.line'].sudo()
|
||||
for idx, part in enumerate(parts_data):
|
||||
line_vals = {
|
||||
'request_id': quote.id,
|
||||
'sequence': (idx + 1) * 10,
|
||||
'part_number': part.get('part_number', ''),
|
||||
'quantity': int(part.get('quantity', 1) or 1),
|
||||
'count': int(part.get('count', 1) or 1),
|
||||
'description': part.get('description', ''),
|
||||
'spec_text': part.get('spec_text', ''),
|
||||
}
|
||||
if part.get('product_id'):
|
||||
try:
|
||||
line_vals['product_id'] = int(part['product_id'])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
Line.create(line_vals)
|
||||
|
||||
# Handle file uploads (general attachments on the quote)
|
||||
files = request.httprequest.files.getlist('drawing_attachments')
|
||||
attachment_ids = []
|
||||
for f in files:
|
||||
if not f or not f.filename:
|
||||
continue
|
||||
try:
|
||||
content = f.read()
|
||||
if not content:
|
||||
continue
|
||||
attachment = request.env['ir.attachment'].sudo().create({
|
||||
'name': f.filename,
|
||||
'datas': base64.b64encode(content),
|
||||
'res_model': 'fusion.plating.quote.request',
|
||||
'res_id': quote.id,
|
||||
'type': 'binary',
|
||||
})
|
||||
attachment_ids.append(attachment.id)
|
||||
except Exception:
|
||||
continue
|
||||
if attachment_ids:
|
||||
quote.sudo().write({
|
||||
'drawing_attachment_ids': [(6, 0, attachment_ids)],
|
||||
})
|
||||
|
||||
# Handle per-line file uploads (line_file_0, line_file_1, ...)
|
||||
for idx in range(len(parts_data)):
|
||||
line_files = request.httprequest.files.getlist('line_file_%d' % idx)
|
||||
line_att_ids = []
|
||||
for f in line_files:
|
||||
if not f or not f.filename:
|
||||
continue
|
||||
try:
|
||||
content = f.read()
|
||||
if not content:
|
||||
continue
|
||||
attachment = request.env['ir.attachment'].sudo().create({
|
||||
'name': f.filename,
|
||||
'datas': base64.b64encode(content),
|
||||
'res_model': 'fusion.plating.quote.request.line',
|
||||
'res_id': quote.line_ids[idx].id if idx < len(quote.line_ids) else 0,
|
||||
'type': 'binary',
|
||||
})
|
||||
line_att_ids.append(attachment.id)
|
||||
except Exception:
|
||||
continue
|
||||
if line_att_ids and idx < len(quote.line_ids):
|
||||
quote.line_ids[idx].sudo().write({
|
||||
'attachment_ids': [(6, 0, line_att_ids)],
|
||||
})
|
||||
|
||||
return request.redirect('/my/quote_requests/%s' % quote.id)
|
||||
|
||||
# ==========================================================================
|
||||
# JOBS -- list
|
||||
# ==========================================================================
|
||||
@http.route(
|
||||
['/my/jobs', '/my/jobs/page/<int:page>'],
|
||||
type='http',
|
||||
auth='user',
|
||||
website=True,
|
||||
)
|
||||
def portal_my_jobs(self, page=1, sortby=None, **kw):
|
||||
partner = request.env.user.partner_id
|
||||
commercial = partner.commercial_partner_id
|
||||
Job = request.env['fusion.plating.portal.job']
|
||||
domain = [('partner_id', 'child_of', commercial.id)]
|
||||
|
||||
searchbar_sortings = {
|
||||
'date': {'label': _('Newest'), 'order': 'received_date desc, id desc'},
|
||||
'name': {'label': _('Reference'), 'order': 'name desc'},
|
||||
'state': {'label': _('Status'), 'order': 'state'},
|
||||
}
|
||||
if not sortby:
|
||||
sortby = 'date'
|
||||
order = searchbar_sortings[sortby]['order']
|
||||
|
||||
total = Job.search_count(domain)
|
||||
pager = portal_pager(
|
||||
url='/my/jobs',
|
||||
url_args={'sortby': sortby},
|
||||
total=total,
|
||||
page=page,
|
||||
step=self._items_per_page,
|
||||
)
|
||||
jobs = Job.search(
|
||||
domain,
|
||||
order=order,
|
||||
limit=self._items_per_page,
|
||||
offset=pager['offset'],
|
||||
)
|
||||
request.session['my_fp_jobs_history'] = jobs.ids[:100]
|
||||
|
||||
values = {
|
||||
'jobs': jobs,
|
||||
'page_name': 'fp_jobs',
|
||||
'pager': pager,
|
||||
'default_url': '/my/jobs',
|
||||
'searchbar_sortings': searchbar_sortings,
|
||||
'sortby': sortby,
|
||||
}
|
||||
return request.render(
|
||||
'fusion_plating_portal.portal_my_jobs',
|
||||
values,
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# JOBS -- detail
|
||||
# ==========================================================================
|
||||
@http.route(
|
||||
['/my/jobs/<int:job_id>'],
|
||||
type='http',
|
||||
auth='user',
|
||||
website=True,
|
||||
)
|
||||
def portal_my_job(self, job_id, access_token=None, **kw):
|
||||
try:
|
||||
job_sudo = self._document_check_access(
|
||||
'fusion.plating.portal.job',
|
||||
job_id,
|
||||
access_token,
|
||||
)
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my')
|
||||
|
||||
values = self._fp_portal_job_get_page_view_values(
|
||||
job_sudo, access_token, **kw
|
||||
)
|
||||
values['progress_percent'] = job_sudo._progress_percent()
|
||||
return request.render(
|
||||
'fusion_plating_portal.portal_my_job',
|
||||
values,
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# JOBS -- download CoC
|
||||
# ==========================================================================
|
||||
@http.route(
|
||||
['/my/jobs/<int:job_id>/coc'],
|
||||
type='http',
|
||||
auth='user',
|
||||
website=True,
|
||||
)
|
||||
def portal_download_coc(self, job_id, access_token=None, **kw):
|
||||
try:
|
||||
job_sudo = self._document_check_access(
|
||||
'fusion.plating.portal.job',
|
||||
job_id,
|
||||
access_token,
|
||||
)
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my')
|
||||
|
||||
if not job_sudo.coc_attachment_id:
|
||||
return request.redirect('/my/jobs/%s' % job_id)
|
||||
|
||||
attachment = job_sudo.coc_attachment_id.sudo()
|
||||
return request.env['ir.binary']._get_stream_from(
|
||||
attachment, 'raw'
|
||||
).get_response(as_attachment=True)
|
||||
|
||||
# ==========================================================================
|
||||
# PURCHASE ORDERS -- list
|
||||
# ==========================================================================
|
||||
@http.route(
|
||||
['/my/purchase_orders', '/my/purchase_orders/page/<int:page>'],
|
||||
type='http',
|
||||
auth='user',
|
||||
website=True,
|
||||
)
|
||||
def portal_my_purchase_orders(self, page=1, sortby=None, **kw):
|
||||
partner = request.env.user.partner_id
|
||||
commercial = partner.commercial_partner_id
|
||||
SO = request.env['sale.order'].sudo()
|
||||
domain = [
|
||||
('partner_id', 'child_of', commercial.id),
|
||||
('state', '=', 'sale'),
|
||||
]
|
||||
|
||||
searchbar_sortings = {
|
||||
'date': {'label': _('Newest'), 'order': 'date_order desc'},
|
||||
'name': {'label': _('Reference'), 'order': 'name desc'},
|
||||
'amount': {'label': _('Amount'), 'order': 'amount_total desc'},
|
||||
}
|
||||
if not sortby:
|
||||
sortby = 'date'
|
||||
order = searchbar_sortings[sortby]['order']
|
||||
|
||||
total = SO.search_count(domain)
|
||||
pager = portal_pager(
|
||||
url='/my/purchase_orders',
|
||||
url_args={'sortby': sortby},
|
||||
total=total,
|
||||
page=page,
|
||||
step=self._items_per_page,
|
||||
)
|
||||
orders = SO.search(
|
||||
domain,
|
||||
order=order,
|
||||
limit=self._items_per_page,
|
||||
offset=pager['offset'],
|
||||
)
|
||||
|
||||
values = {
|
||||
'orders': orders,
|
||||
'page_name': 'fp_purchase_orders',
|
||||
'pager': pager,
|
||||
'default_url': '/my/purchase_orders',
|
||||
'searchbar_sortings': searchbar_sortings,
|
||||
'sortby': sortby,
|
||||
}
|
||||
return request.render(
|
||||
'fusion_plating_portal.portal_my_purchase_orders',
|
||||
values,
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# INVOICES -- list
|
||||
# ==========================================================================
|
||||
@http.route(
|
||||
['/my/fp_invoices', '/my/fp_invoices/page/<int:page>'],
|
||||
type='http',
|
||||
auth='user',
|
||||
website=True,
|
||||
)
|
||||
def portal_my_fp_invoices(self, page=1, sortby=None, **kw):
|
||||
partner = request.env.user.partner_id
|
||||
commercial = partner.commercial_partner_id
|
||||
Invoice = request.env['account.move'].sudo()
|
||||
domain = [
|
||||
('partner_id', 'child_of', commercial.id),
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
]
|
||||
|
||||
searchbar_sortings = {
|
||||
'date': {'label': _('Newest'), 'order': 'invoice_date desc'},
|
||||
'name': {'label': _('Reference'), 'order': 'name desc'},
|
||||
'amount': {'label': _('Amount'), 'order': 'amount_total desc'},
|
||||
'due': {'label': _('Due Date'), 'order': 'invoice_date_due asc'},
|
||||
}
|
||||
if not sortby:
|
||||
sortby = 'date'
|
||||
order = searchbar_sortings[sortby]['order']
|
||||
|
||||
total = Invoice.search_count(domain)
|
||||
pager = portal_pager(
|
||||
url='/my/fp_invoices',
|
||||
url_args={'sortby': sortby},
|
||||
total=total,
|
||||
page=page,
|
||||
step=self._items_per_page,
|
||||
)
|
||||
invoices = Invoice.search(
|
||||
domain,
|
||||
order=order,
|
||||
limit=self._items_per_page,
|
||||
offset=pager['offset'],
|
||||
)
|
||||
|
||||
values = {
|
||||
'invoices': invoices,
|
||||
'page_name': 'fp_invoices',
|
||||
'pager': pager,
|
||||
'default_url': '/my/fp_invoices',
|
||||
'searchbar_sortings': searchbar_sortings,
|
||||
'sortby': sortby,
|
||||
}
|
||||
return request.render(
|
||||
'fusion_plating_portal.portal_my_fp_invoices',
|
||||
values,
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# SHIPPING / DELIVERIES -- list
|
||||
# ==========================================================================
|
||||
@http.route(
|
||||
['/my/deliveries', '/my/deliveries/page/<int:page>'],
|
||||
type='http',
|
||||
auth='user',
|
||||
website=True,
|
||||
)
|
||||
def portal_my_deliveries(self, page=1, sortby=None, **kw):
|
||||
partner = request.env.user.partner_id
|
||||
commercial = partner.commercial_partner_id
|
||||
Picking = request.env['stock.picking'].sudo()
|
||||
domain = [
|
||||
('partner_id', 'child_of', commercial.id),
|
||||
('picking_type_code', '=', 'outgoing'),
|
||||
('state', '=', 'done'),
|
||||
]
|
||||
|
||||
searchbar_sortings = {
|
||||
'date': {'label': _('Newest'), 'order': 'date_done desc'},
|
||||
'name': {'label': _('Reference'), 'order': 'name desc'},
|
||||
}
|
||||
if not sortby:
|
||||
sortby = 'date'
|
||||
order = searchbar_sortings[sortby]['order']
|
||||
|
||||
total = Picking.search_count(domain)
|
||||
pager = portal_pager(
|
||||
url='/my/deliveries',
|
||||
url_args={'sortby': sortby},
|
||||
total=total,
|
||||
page=page,
|
||||
step=self._items_per_page,
|
||||
)
|
||||
deliveries = Picking.search(
|
||||
domain,
|
||||
order=order,
|
||||
limit=self._items_per_page,
|
||||
offset=pager['offset'],
|
||||
)
|
||||
|
||||
values = {
|
||||
'deliveries': deliveries,
|
||||
'page_name': 'fp_deliveries',
|
||||
'pager': pager,
|
||||
'default_url': '/my/deliveries',
|
||||
'searchbar_sortings': searchbar_sortings,
|
||||
'sortby': sortby,
|
||||
}
|
||||
return request.render(
|
||||
'fusion_plating_portal.portal_my_deliveries',
|
||||
values,
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# CERTIFICATIONS -- list
|
||||
# ==========================================================================
|
||||
@http.route(
|
||||
['/my/certifications', '/my/certifications/page/<int:page>'],
|
||||
type='http',
|
||||
auth='user',
|
||||
website=True,
|
||||
)
|
||||
def portal_my_certifications(self, page=1, sortby=None, **kw):
|
||||
partner = request.env.user.partner_id
|
||||
commercial = partner.commercial_partner_id
|
||||
Job = request.env['fusion.plating.portal.job']
|
||||
domain = [
|
||||
('partner_id', 'child_of', commercial.id),
|
||||
('coc_attachment_id', '!=', False),
|
||||
]
|
||||
|
||||
searchbar_sortings = {
|
||||
'date': {'label': _('Newest'), 'order': 'actual_ship_date desc, id desc'},
|
||||
'name': {'label': _('Job Reference'), 'order': 'name desc'},
|
||||
}
|
||||
if not sortby:
|
||||
sortby = 'date'
|
||||
order = searchbar_sortings[sortby]['order']
|
||||
|
||||
total = Job.search_count(domain)
|
||||
pager = portal_pager(
|
||||
url='/my/certifications',
|
||||
url_args={'sortby': sortby},
|
||||
total=total,
|
||||
page=page,
|
||||
step=self._items_per_page,
|
||||
)
|
||||
cert_jobs = Job.search(
|
||||
domain,
|
||||
order=order,
|
||||
limit=self._items_per_page,
|
||||
offset=pager['offset'],
|
||||
)
|
||||
|
||||
values = {
|
||||
'cert_jobs': cert_jobs,
|
||||
'page_name': 'fp_certifications',
|
||||
'pager': pager,
|
||||
'default_url': '/my/certifications',
|
||||
'searchbar_sortings': searchbar_sortings,
|
||||
'sortby': sortby,
|
||||
}
|
||||
return request.render(
|
||||
'fusion_plating_portal.portal_my_certifications',
|
||||
values,
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# JSONRPC -- product search for RFQ form
|
||||
# ==========================================================================
|
||||
@http.route(
|
||||
['/my/quote_requests/search_products'],
|
||||
type='jsonrpc',
|
||||
auth='user',
|
||||
website=True,
|
||||
)
|
||||
def portal_search_products(self, query='', **kw):
|
||||
"""Search products for the RFQ part number dropdown."""
|
||||
products = request.env['product.product'].sudo().search([
|
||||
('sale_ok', '=', True),
|
||||
('active', '=', True),
|
||||
'|', '|',
|
||||
('name', 'ilike', query),
|
||||
('default_code', 'ilike', query),
|
||||
('barcode', 'ilike', query),
|
||||
], limit=20, order='default_code, name')
|
||||
return [{
|
||||
'id': p.id,
|
||||
'name': p.name,
|
||||
'default_code': p.default_code or '',
|
||||
'display': '[%s] %s' % (p.default_code, p.name) if p.default_code else p.name,
|
||||
} for p in products]
|
||||
@@ -0,0 +1,311 @@
|
||||
# -*- 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,
|
||||
)
|
||||
return request.render('fusion_plating_portal.portal_configurator_landing', {
|
||||
'page_name': 'fp_configurator',
|
||||
'quotes': quotes,
|
||||
})
|
||||
|
||||
# ======================================================================
|
||||
# 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', 'manual'),
|
||||
'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),
|
||||
}
|
||||
# Handle file upload
|
||||
file_upload = kw.get('part_file')
|
||||
if file_upload and hasattr(file_upload, 'read'):
|
||||
file_data = file_upload.read()
|
||||
if file_data:
|
||||
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_id'] = attachment.id
|
||||
session_data['attachment_name'] = file_upload.filename
|
||||
fname = file_upload.filename.lower()
|
||||
if fname.endswith(('.stl', '.stp', '.step', '.iges', '.igs')):
|
||||
session_data['geometry_source'] = '3d_model'
|
||||
else:
|
||||
session_data['geometry_source'] = 'pdf_drawing'
|
||||
|
||||
# Try to calculate surface area for STL files
|
||||
if fname.endswith('.stl'):
|
||||
try:
|
||||
import io
|
||||
import trimesh
|
||||
mesh = trimesh.load(io.BytesIO(file_data), file_type='stl')
|
||||
# Convert mm^2 to 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('Could not auto-calculate STL surface area (trimesh not available).')
|
||||
|
||||
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'),
|
||||
]
|
||||
return request.render('fusion_plating_portal.portal_configurator_step1', {
|
||||
'page_name': 'fp_configurator',
|
||||
'materials': materials,
|
||||
})
|
||||
|
||||
# ======================================================================
|
||||
# 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')
|
||||
|
||||
coatings = request.env['fp.coating.config'].sudo().search(
|
||||
[('active', '=', True)], order='sequence',
|
||||
)
|
||||
return request.render('fusion_plating_portal.portal_configurator_step2', {
|
||||
'page_name': 'fp_configurator',
|
||||
'coatings': coatings,
|
||||
'session_data': session_data,
|
||||
})
|
||||
|
||||
# ======================================================================
|
||||
# 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['fp.coating.config'].sudo().browse(
|
||||
session_data['coating_config_id'],
|
||||
)
|
||||
if not coating.exists():
|
||||
return request.redirect('/my/configurator/coating')
|
||||
|
||||
# Calculate estimated price from pricing rules
|
||||
estimated_price = self._estimate_price(session_data, coating)
|
||||
|
||||
return request.render('fusion_plating_portal.portal_configurator_step3', {
|
||||
'page_name': 'fp_configurator',
|
||||
'session_data': session_data,
|
||||
'coating': coating,
|
||||
'estimated_price': estimated_price,
|
||||
})
|
||||
|
||||
# ======================================================================
|
||||
# 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['fp.coating.config'].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 coating process type
|
||||
if coating.exists() and coating.process_type_id:
|
||||
vals['process_type_ids'] = [(4, coating.process_type_id.id)]
|
||||
|
||||
quote = request.env['fusion.plating.quote.request'].sudo().create(vals)
|
||||
|
||||
# Attach uploaded file to the quote request
|
||||
attachment_id = session_data.get('attachment_id')
|
||||
if attachment_id:
|
||||
attachment = request.env['ir.attachment'].sudo().browse(attachment_id)
|
||||
if attachment.exists():
|
||||
attachment.write({
|
||||
'res_model': 'fusion.plating.quote.request',
|
||||
'res_id': quote.id,
|
||||
})
|
||||
quote.drawing_attachment_ids = [(4, attachment.id)]
|
||||
|
||||
# Clear session
|
||||
request.session.pop('fp_configurator', None)
|
||||
|
||||
return request.render('fusion_plating_portal.portal_configurator_success', {
|
||||
'page_name': 'fp_configurator',
|
||||
'quote': quote,
|
||||
})
|
||||
|
||||
# ======================================================================
|
||||
# Pricing helper
|
||||
# ======================================================================
|
||||
def _estimate_price(self, session_data, coating):
|
||||
"""Calculate estimated price range from pricing rules.
|
||||
|
||||
Returns a dict with ``min``, ``max``, and ``available`` keys.
|
||||
The range is deliberately wide (+/- 15-25%) because final quotes
|
||||
account for masking complexity, rack configuration, etc.
|
||||
"""
|
||||
rules = request.env['fp.pricing.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', '')
|
||||
cert_level = coating.certification_level if coating else 'commercial'
|
||||
|
||||
if not area or not rules:
|
||||
return {'min': 0, 'max': 0, 'available': False}
|
||||
|
||||
# Find best matching rule (same scoring as fp.quote.configurator)
|
||||
best = None
|
||||
best_score = -1
|
||||
for rule in rules:
|
||||
score = 0
|
||||
if rule.coating_config_id:
|
||||
if rule.coating_config_id.id != coating.id:
|
||||
continue
|
||||
score += 4
|
||||
if rule.substrate_material:
|
||||
if rule.substrate_material != substrate:
|
||||
continue
|
||||
score += 2
|
||||
if rule.certification_level:
|
||||
if rule.certification_level != cert_level:
|
||||
continue
|
||||
score += 1
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best = rule
|
||||
|
||||
if not best:
|
||||
return {'min': 0, 'max': 0, 'available': False}
|
||||
|
||||
# Calculate base price
|
||||
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
|
||||
|
||||
# Apply thickness factor (use min thickness from coating)
|
||||
thickness = coating.thickness_min or 1.0
|
||||
unit *= thickness * best.thickness_factor
|
||||
|
||||
base_total = unit * qty + best.setup_fee
|
||||
|
||||
# Apply minimum charge
|
||||
if best.minimum_charge and base_total < best.minimum_charge:
|
||||
base_total = best.minimum_charge
|
||||
|
||||
# Return a range (85% to 125%) to account for complexity, masking, etc.
|
||||
return {
|
||||
'min': round(base_total * 0.85, 2),
|
||||
'max': round(base_total * 1.25, 2),
|
||||
'available': True,
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?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>
|
||||
9
fusion_plating/fusion_plating_portal/models/__init__.py
Normal file
9
fusion_plating/fusion_plating_portal/models/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# -*- 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
|
||||
127
fusion_plating/fusion_plating_portal/models/fp_portal_job.py
Normal file
127
fusion_plating/fusion_plating_portal/models/fp_portal_job.py
Normal file
@@ -0,0 +1,127 @@
|
||||
# -*- 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,
|
||||
)
|
||||
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',
|
||||
)
|
||||
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)
|
||||
265
fusion_plating/fusion_plating_portal/models/fp_quote_request.py
Normal file
265
fusion_plating/fusion_plating_portal/models/fp_quote_request.py
Normal file
@@ -0,0 +1,265 @@
|
||||
# -*- 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 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',
|
||||
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=_(
|
||||
'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'})
|
||||
@@ -0,0 +1,59 @@
|
||||
# -*- 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',
|
||||
)
|
||||
45
fusion_plating/fusion_plating_portal/models/res_partner.py
Normal file
45
fusion_plating/fusion_plating_portal/models/res_partner.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# -*- 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='Plating Jobs',
|
||||
)
|
||||
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='Plating Job 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)
|
||||
@@ -0,0 +1,75 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,13 @@
|
||||
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_fusion_plating_operator,1,0,0,0
|
||||
access_fp_quote_request_supervisor,fp.quote.request.supervisor,model_fusion_plating_quote_request,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_quote_request_manager,fp.quote.request.manager,model_fusion_plating_quote_request,fusion_plating.group_fusion_plating_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_fusion_plating_operator,1,0,0,0
|
||||
access_fp_quote_request_line_supervisor,fp.quote.request.line.supervisor,model_fusion_plating_quote_request_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_quote_request_line_manager,fp.quote.request.line.manager,model_fusion_plating_quote_request_line,fusion_plating.group_fusion_plating_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_fusion_plating_operator,1,0,0,0
|
||||
access_fp_portal_job_supervisor,fp.portal.job.supervisor,model_fusion_plating_portal_job,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_portal_job_manager,fp.portal.job.manager,model_fusion_plating_portal_job,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
@@ -0,0 +1,320 @@
|
||||
/** @odoo-module **/
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
// Part of the Fusion Plating product family.
|
||||
|
||||
import { Interaction } from "@web/public/interaction";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
/**
|
||||
* Multi-part RFQ form interaction.
|
||||
*
|
||||
* Manages dynamic part rows in the quote request form: add/remove parts,
|
||||
* drag-drop file uploads per part, billing address toggle, and serialises
|
||||
* the parts data to a hidden JSON field before form submission.
|
||||
*/
|
||||
|
||||
function _el(tag, attrs, children) {
|
||||
const el = document.createElement(tag);
|
||||
if (attrs) {
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k === "className") {
|
||||
el.className = v;
|
||||
} else if (k === "textContent") {
|
||||
el.textContent = v;
|
||||
} else {
|
||||
el.setAttribute(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (children) {
|
||||
for (const child of children) {
|
||||
if (typeof child === "string") {
|
||||
el.appendChild(document.createTextNode(child));
|
||||
} else if (child) {
|
||||
el.appendChild(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function _icon(cls) {
|
||||
const i = document.createElement("i");
|
||||
i.className = cls;
|
||||
return i;
|
||||
}
|
||||
|
||||
|
||||
class FpRfqFormInteraction extends Interaction {
|
||||
static selector = "#fp_rfq_form";
|
||||
|
||||
setup() {
|
||||
this.partIndex = 0;
|
||||
|
||||
// Add first part row automatically
|
||||
this._addPartRow();
|
||||
|
||||
// Event listeners
|
||||
this.addListener("#fp_add_part_btn", "click", this._onAddPart);
|
||||
this.addListener("#billing_same_as_shipping", "change", this._onBillingSameToggle);
|
||||
this.addListener("#fp_rfq_form", "submit", this._onSubmit);
|
||||
this.addListener("#fp_parts_container", "click", this._onContainerClick);
|
||||
}
|
||||
|
||||
_onAddPart() {
|
||||
this._addPartRow();
|
||||
}
|
||||
|
||||
_onContainerClick(ev) {
|
||||
const removeBtn = ev.target.closest(".o_fp_remove_part");
|
||||
if (removeBtn) {
|
||||
const row = removeBtn.closest(".o_fp_part_row");
|
||||
if (row) {
|
||||
row.remove();
|
||||
this._renumberParts();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_onBillingSameToggle(ev) {
|
||||
const billingSelect = this.el.querySelector("#billing_address_id");
|
||||
if (billingSelect) {
|
||||
billingSelect.disabled = ev.target.checked;
|
||||
if (ev.target.checked) {
|
||||
billingSelect.value = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_addPartRow() {
|
||||
const container = this.el.querySelector("#fp_parts_container");
|
||||
if (!container) return;
|
||||
|
||||
const idx = this.partIndex++;
|
||||
const rowCount = container.querySelectorAll(".o_fp_part_row").length + 1;
|
||||
|
||||
// Build the part row using safe DOM methods
|
||||
const row = _el("div", { className: "o_fp_part_row", "data-part-idx": String(idx) });
|
||||
|
||||
// Header
|
||||
const header = _el("div", { className: "o_fp_part_row_header" }, [
|
||||
_el("span", { className: "o_fp_part_num", textContent: "Part #" + rowCount }),
|
||||
_el("span", { className: "o_fp_remove_part", title: "Remove this part" }, [
|
||||
_icon("fa fa-times"),
|
||||
]),
|
||||
]);
|
||||
row.appendChild(header);
|
||||
|
||||
// Row 1: Part Number, Qty, Count, Product
|
||||
const r1 = _el("div", { className: "row" });
|
||||
|
||||
// Part Number
|
||||
const c1 = _el("div", { className: "col-md-4 mb-2" }, [
|
||||
_el("label", { className: "form-label small", textContent: "Part Number" }),
|
||||
_el("input", {
|
||||
type: "text",
|
||||
className: "form-control form-control-sm fp_part_number",
|
||||
placeholder: "e.g. PN-12345",
|
||||
}),
|
||||
]);
|
||||
r1.appendChild(c1);
|
||||
|
||||
// Quantity
|
||||
const c2 = _el("div", { className: "col-md-2 mb-2" }, [
|
||||
_el("label", { className: "form-label small", textContent: "Quantity" }),
|
||||
_el("input", {
|
||||
type: "number",
|
||||
className: "form-control form-control-sm fp_part_qty",
|
||||
value: "1",
|
||||
min: "1",
|
||||
}),
|
||||
]);
|
||||
r1.appendChild(c2);
|
||||
|
||||
// Count
|
||||
const c3 = _el("div", { className: "col-md-2 mb-2" }, [
|
||||
_el("label", { className: "form-label small", textContent: "Count" }),
|
||||
_el("input", {
|
||||
type: "number",
|
||||
className: "form-control form-control-sm fp_part_count",
|
||||
value: "1",
|
||||
min: "1",
|
||||
}),
|
||||
]);
|
||||
r1.appendChild(c3);
|
||||
|
||||
// Product select
|
||||
const prodSelect = _el("select", { className: "form-select form-select-sm fp_part_product" }, [
|
||||
_el("option", { value: "", textContent: "-- Select --" }),
|
||||
]);
|
||||
const c4 = _el("div", { className: "col-md-4 mb-2" }, [
|
||||
_el("label", { className: "form-label small", textContent: "Product" }),
|
||||
prodSelect,
|
||||
]);
|
||||
r1.appendChild(c4);
|
||||
row.appendChild(r1);
|
||||
|
||||
// Row 2: Description
|
||||
const r2 = _el("div", { className: "row" }, [
|
||||
_el("div", { className: "col-md-12 mb-2" }, [
|
||||
_el("label", { className: "form-label small", textContent: "Description" }),
|
||||
_el("textarea", {
|
||||
className: "form-control form-control-sm fp_part_desc",
|
||||
rows: "2",
|
||||
placeholder: "Describe this part...",
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
row.appendChild(r2);
|
||||
|
||||
// Row 3: Spec Parameters
|
||||
const r3 = _el("div", { className: "row" }, [
|
||||
_el("div", { className: "col-md-12 mb-2" }, [
|
||||
_el("label", { className: "form-label small", textContent: "Spec Parameters" }),
|
||||
_el("textarea", {
|
||||
className: "form-control form-control-sm fp_part_spec",
|
||||
rows: "2",
|
||||
placeholder: "Spec details for this part...",
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
row.appendChild(r3);
|
||||
|
||||
// Row 4: File upload
|
||||
const fileInputName = "line_file_" + container.querySelectorAll(".o_fp_part_row").length;
|
||||
const fileInput = _el("input", {
|
||||
type: "file",
|
||||
name: fileInputName,
|
||||
multiple: "multiple",
|
||||
className: "d-none fp_line_file_input",
|
||||
});
|
||||
|
||||
const dropZone = _el("div", {
|
||||
className: "o_fp_file_drop_zone",
|
||||
"data-idx": String(idx),
|
||||
}, [
|
||||
_icon("fa fa-cloud-upload"),
|
||||
document.createTextNode(" "),
|
||||
_el("span", { textContent: "Drag files here or click to upload" }),
|
||||
fileInput,
|
||||
]);
|
||||
|
||||
const fileListEl = _el("div", { className: "fp_file_list small text-muted mt-1" });
|
||||
|
||||
const r4 = _el("div", { className: "row" }, [
|
||||
_el("div", { className: "col-md-12" }, [
|
||||
_el("label", { className: "form-label small", textContent: "Files" }),
|
||||
dropZone,
|
||||
fileListEl,
|
||||
]),
|
||||
]);
|
||||
row.appendChild(r4);
|
||||
|
||||
container.appendChild(row);
|
||||
|
||||
// Populate product dropdown
|
||||
this._populateProductDropdown(prodSelect);
|
||||
|
||||
// Set up drag-drop zone
|
||||
this._setupDropZone(dropZone, fileInput, fileListEl);
|
||||
}
|
||||
|
||||
_populateProductDropdown(selectEl) {
|
||||
// Clone options from the hidden source select rendered by QWeb
|
||||
const sourceSelect = document.getElementById("fp_products_source");
|
||||
if (sourceSelect) {
|
||||
for (const opt of sourceSelect.options) {
|
||||
const clone = opt.cloneNode(true);
|
||||
selectEl.appendChild(clone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_setupDropZone(zone, fileInput, fileListEl) {
|
||||
zone.addEventListener("click", (ev) => {
|
||||
if (ev.target !== fileInput) {
|
||||
fileInput.click();
|
||||
}
|
||||
});
|
||||
|
||||
zone.addEventListener("dragover", (ev) => {
|
||||
ev.preventDefault();
|
||||
zone.classList.add("o_fp_drag_over");
|
||||
});
|
||||
|
||||
zone.addEventListener("dragleave", () => {
|
||||
zone.classList.remove("o_fp_drag_over");
|
||||
});
|
||||
|
||||
zone.addEventListener("drop", (ev) => {
|
||||
ev.preventDefault();
|
||||
zone.classList.remove("o_fp_drag_over");
|
||||
if (ev.dataTransfer.files.length) {
|
||||
fileInput.files = ev.dataTransfer.files;
|
||||
this._updateFileList(fileInput, fileListEl);
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
this._updateFileList(fileInput, fileListEl);
|
||||
});
|
||||
}
|
||||
|
||||
_updateFileList(fileInput, fileListEl) {
|
||||
if (!fileListEl) return;
|
||||
const names = [];
|
||||
for (const f of fileInput.files) {
|
||||
names.push(f.name);
|
||||
}
|
||||
fileListEl.textContent = names.length ? names.join(", ") : "";
|
||||
}
|
||||
|
||||
_renumberParts() {
|
||||
const container = this.el.querySelector("#fp_parts_container");
|
||||
if (!container) return;
|
||||
const rows = container.querySelectorAll(".o_fp_part_row");
|
||||
rows.forEach((row, i) => {
|
||||
const numEl = row.querySelector(".o_fp_part_num");
|
||||
if (numEl) numEl.textContent = "Part #" + (i + 1);
|
||||
|
||||
const fileInput = row.querySelector(".fp_line_file_input");
|
||||
if (fileInput) fileInput.name = "line_file_" + i;
|
||||
});
|
||||
}
|
||||
|
||||
_onSubmit() {
|
||||
const container = this.el.querySelector("#fp_parts_container");
|
||||
const hiddenField = this.el.querySelector("#fp_parts_data");
|
||||
if (!container || !hiddenField) return;
|
||||
|
||||
const rows = container.querySelectorAll(".o_fp_part_row");
|
||||
const parts = [];
|
||||
|
||||
rows.forEach((row) => {
|
||||
const partNumber = (row.querySelector(".fp_part_number") || {}).value || "";
|
||||
const quantity = (row.querySelector(".fp_part_qty") || {}).value || "1";
|
||||
const count = (row.querySelector(".fp_part_count") || {}).value || "1";
|
||||
const description = (row.querySelector(".fp_part_desc") || {}).value || "";
|
||||
const specText = (row.querySelector(".fp_part_spec") || {}).value || "";
|
||||
const productId = (row.querySelector(".fp_part_product") || {}).value || "";
|
||||
|
||||
if (partNumber || description || productId) {
|
||||
parts.push({
|
||||
part_number: partNumber,
|
||||
quantity: quantity,
|
||||
count: count,
|
||||
description: description,
|
||||
spec_text: specText,
|
||||
product_id: productId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
hiddenField.value = JSON.stringify(parts);
|
||||
}
|
||||
}
|
||||
|
||||
registry
|
||||
.category("public.interactions")
|
||||
.add("fusion_plating_portal.rfq_form", FpRfqFormInteraction);
|
||||
@@ -0,0 +1,304 @@
|
||||
// =============================================================================
|
||||
// Fusion Plating -- Customer Portal styles
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// THEME AWARENESS
|
||||
// ---------------
|
||||
// This stylesheet ships with the website / portal frontend bundle. It NEVER
|
||||
// hardcodes hex values. Every colour comes from a Bootstrap CSS custom
|
||||
// property so the portal renders correctly in BOTH light and dark themes
|
||||
// without any duplication:
|
||||
//
|
||||
// surface: var(--bs-body-bg)
|
||||
// muted: var(--bs-secondary-bg)
|
||||
// foreground: var(--bs-body-color)
|
||||
// muted text: var(--bs-secondary-color)
|
||||
// border: var(--bs-border-color)
|
||||
// success: var(--bs-success)
|
||||
// info: var(--bs-info)
|
||||
// warning: var(--bs-warning)
|
||||
// danger: var(--bs-danger)
|
||||
//
|
||||
// Status tints use color-mix() against a theme token so a green dot is darker
|
||||
// on a light background and brighter on a dark background -- one rule, two
|
||||
// looks. We never use @media (prefers-color-scheme) or .o_dark overrides.
|
||||
// =============================================================================
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Local helper -- tint a semantic colour onto the current surface
|
||||
// -----------------------------------------------------------------------------
|
||||
@mixin fp-portal-tint($color-var, $amount: 14%) {
|
||||
background-color: color-mix(in srgb, var(#{$color-var}) #{$amount}, transparent);
|
||||
color: var(#{$color-var});
|
||||
border: 1px solid color-mix(in srgb, var(#{$color-var}) 35%, transparent);
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Generic portal card surface for plating-specific blocks
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_portal_card {
|
||||
background-color: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 10px;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--bs-primary) 40%, var(--bs-border-color));
|
||||
}
|
||||
|
||||
h6 {
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Dashboard layout
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_dashboard {
|
||||
.o_fp_dashboard_card {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--bs-primary) 40%, var(--bs-border-color));
|
||||
box-shadow: 0 2px 12px color-mix(in srgb, var(--bs-primary) 8%, transparent);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: var(--bs-body-bg);
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
padding: 0.75rem 1rem;
|
||||
|
||||
h6 {
|
||||
color: var(--bs-body-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
background-color: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-bottom: 0;
|
||||
font-size: 0.875rem;
|
||||
|
||||
thead th {
|
||||
border-bottom-width: 1px;
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--bs-secondary-color);
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 0.5rem 1rem;
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Segmented progress bar (Receiving / In Progress / Shipping)
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_seg_progress {
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
background: var(--bs-secondary-bg);
|
||||
|
||||
> div {
|
||||
transition: opacity 300ms ease;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Job state -- small coloured dot used in the jobs list table
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_portal_status_dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--bs-secondary-color);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--bs-secondary-color) 25%, transparent);
|
||||
vertical-align: middle;
|
||||
|
||||
&[data-state="received"] {
|
||||
background-color: var(--bs-info);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--bs-info) 25%, transparent);
|
||||
}
|
||||
&[data-state="in_progress"] {
|
||||
background-color: var(--bs-primary);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--bs-primary) 25%, transparent);
|
||||
}
|
||||
&[data-state="quality_check"] {
|
||||
background-color: var(--bs-warning);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--bs-warning) 25%, transparent);
|
||||
}
|
||||
&[data-state="ready_to_ship"] {
|
||||
background-color: var(--bs-secondary-color);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--bs-secondary-color) 25%, transparent);
|
||||
}
|
||||
&[data-state="shipped"],
|
||||
&[data-state="complete"] {
|
||||
background-color: var(--bs-success);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--bs-success) 25%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Job progress bar -- wraps Bootstrap .progress with state-aware fill colour
|
||||
// (kept for backwards compatibility on job detail page)
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_portal_progress {
|
||||
|
||||
.progress {
|
||||
background-color: color-mix(in srgb, var(--bs-secondary-color) 18%, transparent);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background-color: var(--bs-primary);
|
||||
color: var(--bs-body-bg);
|
||||
font-weight: 600;
|
||||
font-size: 0.72rem;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
&[data-state="received"] .progress-bar {
|
||||
background-color: var(--bs-info);
|
||||
}
|
||||
&[data-state="quality_check"] .progress-bar {
|
||||
background-color: var(--bs-warning);
|
||||
}
|
||||
&[data-state="ready_to_ship"] .progress-bar {
|
||||
background-color: color-mix(in srgb, var(--bs-success) 70%, var(--bs-warning));
|
||||
}
|
||||
&[data-state="shipped"] .progress-bar,
|
||||
&[data-state="complete"] .progress-bar {
|
||||
background-color: var(--bs-success);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// RFQ Form -- Part row card
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_part_row {
|
||||
background-color: var(--bs-body-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
position: relative;
|
||||
transition: border-color 120ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--bs-primary) 35%, var(--bs-border-color));
|
||||
}
|
||||
|
||||
.o_fp_part_row_header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
.o_fp_part_num {
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_remove_part {
|
||||
color: var(--bs-danger);
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 120ms ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Drag-drop file upload zone
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_file_drop_zone {
|
||||
border: 2px dashed var(--bs-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--bs-secondary-color);
|
||||
cursor: pointer;
|
||||
transition: border-color 150ms ease, background-color 150ms ease;
|
||||
|
||||
&:hover,
|
||||
&.o_fp_drag_over {
|
||||
border-color: var(--bs-primary);
|
||||
background-color: color-mix(in srgb, var(--bs-primary) 6%, transparent);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Portal form general
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_portal_form {
|
||||
.form-label {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Tab styling for quote request filter tabs
|
||||
// -----------------------------------------------------------------------------
|
||||
.nav-tabs {
|
||||
.nav-link {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Jobs list -- card-based layout
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_jobs_list {
|
||||
.o_fp_portal_card {
|
||||
.card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
25
fusion_plating/fusion_plating_portal/views/fp_menu.xml
Normal file
25
fusion_plating/fusion_plating_portal/views/fp_menu.xml
Normal file
@@ -0,0 +1,25 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,110 @@
|
||||
<?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">
|
||||
Parts Portal
|
||||
</li>
|
||||
|
||||
<!-- Job detail -->
|
||||
<li t-if="page_name == 'fp_portal_job'"
|
||||
class="breadcrumb-item">
|
||||
<a href="/my/jobs">Parts Portal</a>
|
||||
</li>
|
||||
<li t-if="page_name == 'fp_portal_job'"
|
||||
class="breadcrumb-item active"
|
||||
aria-current="page">
|
||||
<span t-out="job.name"/>
|
||||
</li>
|
||||
|
||||
<!-- Purchase Orders -->
|
||||
<li t-if="page_name == 'fp_purchase_orders'"
|
||||
class="breadcrumb-item active"
|
||||
aria-current="page">
|
||||
Purchase Orders
|
||||
</li>
|
||||
|
||||
<!-- Invoices -->
|
||||
<li t-if="page_name == 'fp_invoices'"
|
||||
class="breadcrumb-item active"
|
||||
aria-current="page">
|
||||
Invoices
|
||||
</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>
|
||||
@@ -0,0 +1,524 @@
|
||||
<?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="btn btn-primary btn-lg px-5">
|
||||
<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 Upload -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Part Drawing or 3D Model</label>
|
||||
<div class="o_fp_file_drop_zone p-4">
|
||||
<i class="fa fa-cloud-upload"/>
|
||||
<p class="mb-1 fw-semibold">
|
||||
Drag and drop your file here, or click to browse
|
||||
</p>
|
||||
<p class="small text-muted mb-2">
|
||||
Accepted: STL, STP, STEP, IGES, PDF (max 50 MB)
|
||||
</p>
|
||||
<input type="file" name="part_file" id="part_file"
|
||||
class="form-control"
|
||||
accept=".stl,.stp,.step,.iges,.igs,.pdf"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4"/>
|
||||
|
||||
<!-- Manual Measurements -->
|
||||
<h6 class="mb-3">
|
||||
<i class="fa fa-ruler-combined me-2"/>Manual Measurements
|
||||
<span class="text-muted small fw-normal ms-2">
|
||||
(if no 3D model uploaded)
|
||||
</span>
|
||||
</h6>
|
||||
|
||||
<input type="hidden" name="geometry_source" value="manual"/>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<label for="dimensions_length" class="form-label">Length (in)</label>
|
||||
<input type="number" step="0.001" min="0"
|
||||
id="dimensions_length" name="dimensions_length"
|
||||
class="form-control" placeholder="0.000"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="dimensions_width" class="form-label">Width (in)</label>
|
||||
<input type="number" step="0.001" min="0"
|
||||
id="dimensions_width" name="dimensions_width"
|
||||
class="form-control" placeholder="0.000"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="dimensions_height" class="form-label">Height (in)</label>
|
||||
<input type="number" step="0.001" min="0"
|
||||
id="dimensions_height" name="dimensions_height"
|
||||
class="form-control" placeholder="0.000"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="surface_area" class="form-label">
|
||||
Surface Area (sq in)
|
||||
<span class="text-muted small fw-normal ms-1">
|
||||
-- auto-calculated if STL uploaded
|
||||
</span>
|
||||
</label>
|
||||
<input type="number" step="0.0001" min="0"
|
||||
id="surface_area" name="surface_area"
|
||||
class="form-control" placeholder="0.0000"/>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="/my/configurator" class="btn btn-outline-secondary">
|
||||
<i class="fa fa-arrow-left me-1"/>Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn 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.process_type_id" class="small text-muted mb-1">
|
||||
<i class="fa fa-flask me-1"/>
|
||||
<t t-out="coat.process_type_id.name"/>
|
||||
</p>
|
||||
<p t-if="coat.spec_reference" class="small text-muted mb-1">
|
||||
<i class="fa fa-bookmark me-1"/>
|
||||
<t t-out="coat.spec_reference"/>
|
||||
</p>
|
||||
<p t-if="coat.thickness_min or coat.thickness_max" class="small text-muted mb-1">
|
||||
<i class="fa fa-arrows-v me-1"/>
|
||||
<t t-if="coat.thickness_min" t-out="coat.thickness_min"/>
|
||||
<t t-if="coat.thickness_min and coat.thickness_max"> - </t>
|
||||
<t t-if="coat.thickness_max" t-out="coat.thickness_max"/>
|
||||
<t t-out="coat.thickness_uom or 'mils'"/>
|
||||
</p>
|
||||
<p t-if="coat.certification_level and coat.certification_level != 'commercial'"
|
||||
class="small mb-0">
|
||||
<span class="badge text-bg-warning">
|
||||
<t t-out="dict(coat._fields['certification_level']._description_selection(coat.env)).get(coat.certification_level)"/>
|
||||
</span>
|
||||
</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="btn btn-outline-secondary">
|
||||
<i class="fa fa-arrow-left me-1"/>Back
|
||||
</a>
|
||||
<button type="submit" class="btn 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.spec_reference" class="row mb-3">
|
||||
<div class="col-sm-4 text-muted small fw-semibold">Spec</div>
|
||||
<div class="col-sm-8" t-out="coating.spec_reference"/>
|
||||
</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="btn btn-outline-secondary">
|
||||
<i class="fa fa-arrow-left me-1"/>Back
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary 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="btn btn-primary">
|
||||
<i class="fa fa-eye me-1"/>View Quote Request
|
||||
</a>
|
||||
<a href="/my/configurator" class="btn btn-outline-secondary">
|
||||
<i class="fa fa-plus me-1"/>Start Another
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,398 @@
|
||||
<?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 — 6-section grid -->
|
||||
<!-- ================================================================== -->
|
||||
<template id="fp_portal_home_dashboard" name="Plating Portal Dashboard">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="o_fp_dashboard mt-3">
|
||||
<!-- Welcome header -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h3 class="mb-1">
|
||||
Welcome back, <span t-out="partner.name"/>
|
||||
</h3>
|
||||
<p class="text-muted mb-0">
|
||||
Your plating portal dashboard — everything at a glance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions bar -->
|
||||
<div class="d-flex flex-wrap gap-2 mb-4">
|
||||
<a href="/my/configurator" class="btn btn-primary">
|
||||
<i class="fa fa-cog me-1"/>Get a Quote
|
||||
</a>
|
||||
<a href="/my/quote_requests/new" class="btn btn-outline-primary">
|
||||
<i class="fa fa-plus me-1"/>Request Quote
|
||||
</a>
|
||||
<a href="/my/quote_requests" class="btn btn-outline-secondary">
|
||||
<i class="fa fa-file-text-o me-1"/>My Quotes
|
||||
</a>
|
||||
<a href="/my/jobs" class="btn btn-outline-secondary">
|
||||
<i class="fa fa-cogs me-1"/>Parts Portal
|
||||
</a>
|
||||
<a href="/my/certifications" class="btn btn-outline-secondary">
|
||||
<i class="fa fa-certificate me-1"/>Certifications
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Grid -->
|
||||
<div class="row g-4">
|
||||
|
||||
<!-- ====== QUOTES SECTION ====== -->
|
||||
<div class="col-lg-6">
|
||||
<div class="o_fp_dashboard_card card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<i class="fa fa-file-text-o me-2"/>Quotes
|
||||
<span class="badge text-bg-primary ms-2" t-out="quote_count"/>
|
||||
</h6>
|
||||
<a href="/my/quote_requests" class="btn btn-sm btn-outline-primary">View All</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<t t-if="recent_quotes">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Reference</th>
|
||||
<th>Date</th>
|
||||
<th class="text-end">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="recent_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 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>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="p-4 text-center text-muted">
|
||||
<p class="mb-2">No quotes yet.</p>
|
||||
<a href="/my/quote_requests/new" class="btn btn-sm btn-primary">
|
||||
Submit Your First RFQ
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== PURCHASE ORDERS SECTION ====== -->
|
||||
<div class="col-lg-6">
|
||||
<div class="o_fp_dashboard_card card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<i class="fa fa-shopping-cart me-2"/>Purchase Orders
|
||||
<span class="badge text-bg-primary ms-2" t-out="po_count"/>
|
||||
</h6>
|
||||
<a href="/my/purchase_orders" class="btn btn-sm btn-outline-primary">View All</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<t t-if="recent_pos">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Order</th>
|
||||
<th>Date</th>
|
||||
<th class="text-end">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="recent_pos" t-as="po">
|
||||
<td t-out="po.name"/>
|
||||
<td>
|
||||
<span t-field="po.date_order" t-options='{"widget": "date"}'/>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span t-field="po.amount_total"
|
||||
t-options='{"widget": "monetary", "display_currency": po.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="p-4 text-center text-muted">
|
||||
No purchase orders yet.
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== PARTS PORTAL / JOBS SECTION ====== -->
|
||||
<div class="col-lg-6">
|
||||
<div class="o_fp_dashboard_card card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<i class="fa fa-cogs me-2"/>Parts Portal
|
||||
<span class="badge text-bg-primary ms-2" t-out="job_count"/>
|
||||
</h6>
|
||||
<a href="/my/jobs" class="btn btn-sm btn-outline-primary">View All</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<t t-if="recent_jobs">
|
||||
<div class="p-3">
|
||||
<t t-foreach="recent_jobs" t-as="job">
|
||||
<div class="o_fp_dashboard_job_row mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<a t-att-href="'/my/jobs/%s' % job.id"
|
||||
class="fw-semibold text-decoration-none"
|
||||
t-out="job.name"/>
|
||||
<span t-attf-class="badge #{
|
||||
'text-bg-info' if job.state == 'received' else
|
||||
'text-bg-primary' if job.state == 'in_progress' else
|
||||
'text-bg-warning' if job.state == 'quality_check' else
|
||||
'text-bg-secondary' if job.state == 'ready_to_ship' else
|
||||
'text-bg-success'}"
|
||||
t-out="dict(job._fields['state']._description_selection(job.env)).get(job.state)"/>
|
||||
</div>
|
||||
<!-- Segmented progress bar -->
|
||||
<div class="o_fp_seg_progress d-flex" style="height: 8px; border-radius: 4px; overflow: hidden;">
|
||||
<t t-set="pct" t-value="job._progress_percent()"/>
|
||||
<!-- Receiving segment (green, 0-20%) -->
|
||||
<div t-attf-class="o_fp_seg_receiving"
|
||||
t-attf-style="width: #{min(pct, 20)}%; background-color: var(--bs-success); opacity: #{1 if pct >= 1 else 0.3};"/>
|
||||
<!-- In Progress segment (orange, 20-70%) -->
|
||||
<div t-attf-class="o_fp_seg_progress_mid"
|
||||
t-attf-style="width: #{max(0, min(pct - 20, 50))}%; background-color: var(--bs-warning); opacity: #{1 if pct > 20 else 0.3};"/>
|
||||
<!-- Shipping segment (orange-green, 70-100%) -->
|
||||
<div t-attf-class="o_fp_seg_shipping"
|
||||
t-attf-style="width: #{max(0, min(pct - 70, 30))}%; background-color: var(--bs-info); opacity: #{1 if pct > 70 else 0.3};"/>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mt-1" style="font-size: 0.7rem;">
|
||||
<span class="text-muted">Receiving</span>
|
||||
<span class="text-muted">In Progress</span>
|
||||
<span class="text-muted">Shipping</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="p-4 text-center text-muted">
|
||||
No active jobs.
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== CERTIFICATIONS & QUALITY SECTION ====== -->
|
||||
<div class="col-lg-6">
|
||||
<div class="o_fp_dashboard_card card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<i class="fa fa-certificate me-2"/>Certifications & Quality
|
||||
<span class="badge text-bg-primary ms-2" t-out="cert_count"/>
|
||||
</h6>
|
||||
<a href="/my/certifications" class="btn btn-sm btn-outline-primary">View All</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<t t-if="recent_certs">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job</th>
|
||||
<th>Ship Date</th>
|
||||
<th class="text-end">CoC</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="recent_certs" 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 class="text-end">
|
||||
<a t-att-href="'/my/jobs/%s/coc' % cj.id"
|
||||
class="btn btn-sm btn-outline-success">
|
||||
<i class="fa fa-download me-1"/>Download
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="p-4 text-center text-muted">
|
||||
No certificates available yet.
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== SHIPPING / DELIVERIES SECTION ====== -->
|
||||
<div class="col-lg-6">
|
||||
<div class="o_fp_dashboard_card card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<i class="fa fa-truck me-2"/>Shipping
|
||||
<span class="badge text-bg-primary ms-2" t-out="delivery_count"/>
|
||||
</h6>
|
||||
<a href="/my/deliveries" class="btn btn-sm btn-outline-primary">View All</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<t t-if="recent_deliveries">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Packing Slip</th>
|
||||
<th>Date</th>
|
||||
<th class="text-end">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="recent_deliveries" t-as="dlv">
|
||||
<td t-out="dlv.name"/>
|
||||
<td>
|
||||
<span t-if="dlv.date_done"
|
||||
t-field="dlv.date_done"
|
||||
t-options='{"widget": "date"}'/>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span class="badge text-bg-success">Delivered</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="p-4 text-center text-muted">
|
||||
No deliveries yet.
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== INVOICES SECTION ====== -->
|
||||
<div class="col-lg-6">
|
||||
<div class="o_fp_dashboard_card card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<i class="fa fa-file-text me-2"/>Invoices
|
||||
<span class="badge text-bg-primary ms-2" t-out="invoice_count"/>
|
||||
</h6>
|
||||
<a href="/my/fp_invoices" class="btn btn-sm btn-outline-primary">View All</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<t t-if="recent_invoices">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Invoice</th>
|
||||
<th>Due Date</th>
|
||||
<th class="text-end">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="recent_invoices" t-as="inv">
|
||||
<td t-out="inv.name"/>
|
||||
<td>
|
||||
<span t-if="inv.invoice_date_due"
|
||||
t-field="inv.invoice_date_due"
|
||||
t-options='{"widget": "date"}'/>
|
||||
<span t-else="" class="text-muted">--</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span t-field="inv.amount_total"
|
||||
t-options='{"widget": "monetary", "display_currency": inv.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="p-4 text-center text-muted">
|
||||
No invoices yet.
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /row -->
|
||||
</div><!-- /o_fp_dashboard -->
|
||||
</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">Plating Jobs</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">Purchase Orders</t>
|
||||
<t t-set="url" t-value="'/my/purchase_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>
|
||||
@@ -0,0 +1,805 @@
|
||||
<?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 tabs (Active / Converted / Declined) -->
|
||||
<!-- ================================================================== -->
|
||||
<template id="portal_my_quote_requests" name="My Quote Requests">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
<t t-call="portal.portal_searchbar">
|
||||
<t t-set="title">Quote Requests</t>
|
||||
</t>
|
||||
|
||||
<!-- Tab navigation -->
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item" t-foreach="searchbar_filters" t-as="f">
|
||||
<a t-attf-class="nav-link #{'active' if filterby == f else ''}"
|
||||
t-attf-href="/my/quote_requests?filterby=#{f}&sortby=#{sortby}">
|
||||
<t t-out="searchbar_filters[f]['label']"/>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="d-flex justify-content-end mb-3">
|
||||
<a href="/my/quote_requests/new" class="btn btn-primary">
|
||||
<i class="fa fa-plus me-1"/>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>
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
<span t-attf-class="badge #{
|
||||
'text-bg-secondary' if quote_request.state == 'new' else
|
||||
'text-bg-info' if quote_request.state == 'under_review' else
|
||||
'text-bg-primary' if quote_request.state == 'quoted' else
|
||||
'text-bg-success' if quote_request.state == 'accepted' else
|
||||
'text-bg-danger' if quote_request.state == 'declined' else
|
||||
'text-bg-warning'} fs-6"
|
||||
t-out="dict(quote_request._fields['state']._description_selection(quote_request.env)).get(quote_request.state)"/>
|
||||
</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="badge text-bg-light border 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="btn btn-outline-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="btn btn-link">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary btn-lg" id="fp_submit_rfq">
|
||||
<i class="fa fa-paper-plane me-1"/>SUBMIT RFQ
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- JOBS — list with segmented progress bars -->
|
||||
<!-- ================================================================== -->
|
||||
<template id="portal_my_jobs" name="My Plating Jobs">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
<t t-call="portal.portal_searchbar">
|
||||
<t t-set="title">Parts Portal</t>
|
||||
</t>
|
||||
|
||||
<t t-if="not jobs">
|
||||
<div class="o_fp_portal_card card bg-body-tertiary border-0 p-4 text-center">
|
||||
<p class="text-muted mb-0">You have no plating jobs yet.</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="jobs">
|
||||
<div class="o_fp_jobs_list">
|
||||
<t t-foreach="jobs" t-as="job">
|
||||
<div class="card mb-3 o_fp_portal_card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<a t-att-href="'/my/jobs/%s' % job.id"
|
||||
class="fs-6 fw-semibold text-decoration-none"
|
||||
t-out="job.name"/>
|
||||
<div class="text-muted small">
|
||||
<span t-if="job.received_date">
|
||||
Received: <span t-field="job.received_date" t-options='{"widget": "date"}'/>
|
||||
</span>
|
||||
<span t-if="job.target_ship_date" class="ms-3">
|
||||
Target: <span t-field="job.target_ship_date" t-options='{"widget": "date"}'/>
|
||||
</span>
|
||||
<span class="ms-3">Qty: <span t-out="job.quantity"/></span>
|
||||
</div>
|
||||
</div>
|
||||
<span t-attf-class="badge #{
|
||||
'text-bg-info' if job.state == 'received' else
|
||||
'text-bg-primary' if job.state == 'in_progress' else
|
||||
'text-bg-warning' if job.state == 'quality_check' else
|
||||
'text-bg-secondary' if job.state == 'ready_to_ship' else
|
||||
'text-bg-success' if job.state == 'shipped' else
|
||||
'text-bg-success'}"
|
||||
t-out="dict(job._fields['state']._description_selection(job.env)).get(job.state)"/>
|
||||
</div>
|
||||
|
||||
<!-- Segmented progress bar -->
|
||||
<t t-set="pct" t-value="job._progress_percent()"/>
|
||||
<div class="o_fp_seg_progress d-flex" style="height: 10px; border-radius: 5px; overflow: hidden; background: var(--bs-secondary-bg);">
|
||||
<!-- Receiving segment (green) -->
|
||||
<div t-attf-style="width: 20%; opacity: #{1 if pct >= 10 else 0.2};"
|
||||
style="background-color: var(--bs-success);"/>
|
||||
<!-- In Progress segment (orange) -->
|
||||
<div t-attf-style="width: 50%; opacity: #{1 if pct >= 35 else 0.2};"
|
||||
style="background-color: var(--bs-warning); margin-left: 2px;"/>
|
||||
<!-- Shipping segment (blue/green) -->
|
||||
<div t-attf-style="width: 30%; opacity: #{1 if pct >= 80 else 0.2};"
|
||||
style="background-color: var(--bs-info); margin-left: 2px;"/>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mt-1" style="font-size: 0.7rem;">
|
||||
<span class="text-muted">Receiving</span>
|
||||
<span class="text-muted">In Progress</span>
|
||||
<span class="text-muted">Shipping</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- JOB — detail -->
|
||||
<!-- ================================================================== -->
|
||||
<template id="portal_my_job" name="My Plating Job">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="row mt-2 mb-4">
|
||||
<div class="col-12">
|
||||
<h3 class="mb-1">
|
||||
<span t-out="job.name"/>
|
||||
</h3>
|
||||
<p class="text-muted mb-0">
|
||||
Received
|
||||
<span t-if="job.received_date" t-field="job.received_date"
|
||||
t-options='{"widget": "date"}'/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Segmented progress bar -->
|
||||
<t t-set="pct" t-value="progress_percent"/>
|
||||
<div class="mb-4">
|
||||
<div class="o_fp_seg_progress d-flex" style="height: 14px; border-radius: 7px; overflow: hidden; background: var(--bs-secondary-bg);">
|
||||
<div t-attf-style="width: 20%; opacity: #{1 if pct >= 10 else 0.2};"
|
||||
style="background-color: var(--bs-success);"/>
|
||||
<div t-attf-style="width: 50%; opacity: #{1 if pct >= 35 else 0.2};"
|
||||
style="background-color: var(--bs-warning); margin-left: 2px;"/>
|
||||
<div t-attf-style="width: 30%; opacity: #{1 if pct >= 80 else 0.2};"
|
||||
style="background-color: var(--bs-info); margin-left: 2px;"/>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small text-muted mt-1">
|
||||
<span>Receiving</span>
|
||||
<span>In Progress</span>
|
||||
<span>QC</span>
|
||||
<span>Ready</span>
|
||||
<span>Shipped</span>
|
||||
<span>Complete</span>
|
||||
</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">Current Status</h6>
|
||||
<span t-attf-class="badge #{
|
||||
'text-bg-info' if job.state == 'received' else
|
||||
'text-bg-primary' if job.state == 'in_progress' else
|
||||
'text-bg-warning' if job.state == 'quality_check' else
|
||||
'text-bg-secondary' if job.state == 'ready_to_ship' else
|
||||
'text-bg-success' if job.state == 'shipped' else
|
||||
'text-bg-success'} fs-6"
|
||||
t-out="dict(job._fields['state']._description_selection(job.env)).get(job.state)"/>
|
||||
</div>
|
||||
<div class="col-md-6 text-md-end" t-if="job.target_ship_date">
|
||||
<h6 class="text-muted small text-uppercase">Target Ship Date</h6>
|
||||
<h5 class="mb-0">
|
||||
<span t-field="job.target_ship_date"
|
||||
t-options='{"widget": "date"}'/>
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<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="job.quantity"/>
|
||||
</div>
|
||||
<div t-if="job.actual_ship_date">
|
||||
<span class="text-muted small">Actual Ship Date:</span>
|
||||
<span t-field="job.actual_ship_date"
|
||||
t-options='{"widget": "date"}'/>
|
||||
</div>
|
||||
<div t-if="job.tracking_ref">
|
||||
<span class="text-muted small">Tracking:</span>
|
||||
<span t-out="job.tracking_ref"/>
|
||||
</div>
|
||||
<div t-if="job.invoice_ref">
|
||||
<span class="text-muted small">Invoice:</span>
|
||||
<span t-out="job.invoice_ref"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-4" t-if="job.process_type_ids">
|
||||
<h6 class="text-muted small text-uppercase">Processes</h6>
|
||||
<span t-foreach="job.process_type_ids" t-as="pt"
|
||||
class="badge text-bg-light border me-1" t-out="pt.name"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h6 class="text-muted small text-uppercase">Documents</h6>
|
||||
<div class="list-group">
|
||||
<a t-if="job.coc_attachment_id"
|
||||
t-att-href="'/my/jobs/%s/coc' % job.id"
|
||||
class="list-group-item list-group-item-action">
|
||||
<i class="fa fa-file-pdf-o me-2 text-muted"/>
|
||||
Certificate of Conformance
|
||||
<i class="fa fa-download float-end text-muted"/>
|
||||
</a>
|
||||
<a t-if="job.packing_list_attachment_id"
|
||||
t-att-href="'/web/content/%s?download=true' % job.packing_list_attachment_id.id"
|
||||
class="list-group-item list-group-item-action">
|
||||
<i class="fa fa-file-text-o me-2 text-muted"/>
|
||||
Packing List
|
||||
<i class="fa fa-download float-end text-muted"/>
|
||||
</a>
|
||||
<span t-if="not job.coc_attachment_id and not job.packing_list_attachment_id"
|
||||
class="text-muted small">No documents available yet.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4" t-if="job.notes">
|
||||
<h6 class="text-muted small text-uppercase">Notes</h6>
|
||||
<div class="border rounded p-3 bg-body">
|
||||
<span t-out="job.notes"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- PURCHASE ORDERS — list -->
|
||||
<!-- ================================================================== -->
|
||||
<template id="portal_my_purchase_orders" name="My Purchase Orders">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
<t t-call="portal.portal_searchbar">
|
||||
<t t-set="title">Purchase Orders</t>
|
||||
</t>
|
||||
|
||||
<t t-if="not orders">
|
||||
<div class="o_fp_portal_card card bg-body-tertiary border-0 p-4 text-center">
|
||||
<p class="text-muted mb-0">No purchase orders found.</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="orders" t-call="portal.portal_table">
|
||||
<thead>
|
||||
<tr class="active">
|
||||
<th>Order</th>
|
||||
<th>Date</th>
|
||||
<th class="text-end">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="orders" t-as="order">
|
||||
<td t-out="order.name"/>
|
||||
<td>
|
||||
<span t-field="order.date_order" t-options='{"widget": "date"}'/>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span t-field="order.amount_total"
|
||||
t-options='{"widget": "monetary", "display_currency": order.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- INVOICES — list -->
|
||||
<!-- ================================================================== -->
|
||||
<template id="portal_my_fp_invoices" name="My Invoices">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
<t t-call="portal.portal_searchbar">
|
||||
<t t-set="title">Invoices</t>
|
||||
</t>
|
||||
|
||||
<t t-if="not invoices">
|
||||
<div class="o_fp_portal_card card bg-body-tertiary border-0 p-4 text-center">
|
||||
<p class="text-muted mb-0">No invoices found.</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="invoices" t-call="portal.portal_table">
|
||||
<thead>
|
||||
<tr class="active">
|
||||
<th>Invoice</th>
|
||||
<th>Date</th>
|
||||
<th>Due Date</th>
|
||||
<th class="text-end">Amount Due</th>
|
||||
<th class="text-end">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="invoices" t-as="inv">
|
||||
<td t-out="inv.name"/>
|
||||
<td>
|
||||
<span t-if="inv.invoice_date"
|
||||
t-field="inv.invoice_date"
|
||||
t-options='{"widget": "date"}'/>
|
||||
</td>
|
||||
<td>
|
||||
<span t-if="inv.invoice_date_due"
|
||||
t-field="inv.invoice_date_due"
|
||||
t-options='{"widget": "date"}'/>
|
||||
<span t-else="" class="text-muted">--</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span t-field="inv.amount_residual"
|
||||
t-options='{"widget": "monetary", "display_currency": inv.currency_id}'/>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span t-field="inv.amount_total"
|
||||
t-options='{"widget": "monetary", "display_currency": inv.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- DELIVERIES / PACKING SLIPS — list -->
|
||||
<!-- ================================================================== -->
|
||||
<template id="portal_my_deliveries" name="My Deliveries">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
<t t-call="portal.portal_searchbar">
|
||||
<t t-set="title">Packing Slips / Deliveries</t>
|
||||
</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>Date</th>
|
||||
<th class="text-end">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="deliveries" t-as="dlv">
|
||||
<td t-out="dlv.name"/>
|
||||
<td>
|
||||
<span t-if="dlv.date_done"
|
||||
t-field="dlv.date_done"
|
||||
t-options='{"widget": "date"}'/>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span class="badge text-bg-success">Delivered</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- CERTIFICATIONS — list -->
|
||||
<!-- ================================================================== -->
|
||||
<template id="portal_my_certifications" name="My Certifications">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
<t t-call="portal.portal_searchbar">
|
||||
<t t-set="title">Certifications & Quality</t>
|
||||
</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>
|
||||
<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="badge text-bg-light border me-1" t-out="pt.name"/>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a t-att-href="'/my/jobs/%s/coc' % cj.id"
|
||||
class="btn btn-sm btn-outline-success">
|
||||
<i class="fa fa-download me-1"/>CoC
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,311 @@
|
||||
<?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="quoted_price" widget="monetary" optional="show"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
<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="quoted_price" widget="monetary"
|
||||
readonly="state in ('new','under_review')"/>
|
||||
<field name="currency_id" groups="base.group_multi_currency"/>
|
||||
<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="Plating Jobs"
|
||||
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="Plating Job">
|
||||
<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="Plating Jobs">
|
||||
<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">Plating Jobs</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>
|
||||
Reference in New Issue
Block a user