changes
This commit is contained in:
4
fusion_quotations/__init__.py
Normal file
4
fusion_quotations/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
61
fusion_quotations/__manifest__.py
Normal file
61
fusion_quotations/__manifest__.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'Fusion Quotation Builder',
|
||||
'version': '19.0.2.0.0',
|
||||
'category': 'Sales',
|
||||
'summary': 'Multi-Equipment Assessment & Automatic Quotation Generation',
|
||||
'description': """
|
||||
Guided equipment assessment form with automatic quotation generation.
|
||||
Supports multiple equipment types (wheelchairs, stair lifts, porch lifts, etc.)
|
||||
with dynamic form steps per Configuration Flow.
|
||||
- Dynamic equipment type registry
|
||||
- Config Flow-driven form steps (customizable per equipment type)
|
||||
- Configurable sections (Frame, Cushion, Backrest, etc.)
|
||||
- Admin-managed product options per section
|
||||
- Auto-upcharge rules based on measurements
|
||||
- Multi-step portal form for sales reps
|
||||
- ADP prescription measurements capture
|
||||
- Automatic draft sale order generation with correct ADP device codes
|
||||
""",
|
||||
'author': 'Nexa Systems Inc.',
|
||||
'website': 'https://nexasystems.ca',
|
||||
'license': 'OPL-1',
|
||||
'depends': [
|
||||
'base',
|
||||
'sale',
|
||||
'sale_management',
|
||||
'portal',
|
||||
'website',
|
||||
'mail',
|
||||
'fusion_claims',
|
||||
'fusion_authorizer_portal',
|
||||
],
|
||||
'data': [
|
||||
'security/security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/equipment_type_data.xml',
|
||||
'views/wc_section_views.xml',
|
||||
'views/wc_upcharge_rule_views.xml',
|
||||
'views/wc_assessment_views.xml',
|
||||
'views/wc_config_flow_views.xml',
|
||||
'views/equipment_type_views.xml',
|
||||
'views/menus.xml',
|
||||
'views/portal_quotation_templates.xml',
|
||||
'data/section_seed_data.xml',
|
||||
'data/upcharge_rules_data.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_frontend': [
|
||||
'fusion_quotations/static/src/css/quotation_form.css',
|
||||
'fusion_quotations/static/src/js/quotation_form.js',
|
||||
],
|
||||
'web.assets_backend': [
|
||||
'fusion_quotations/static/src/scss/flow_designer.scss',
|
||||
'fusion_quotations/static/src/js/flow_designer/flow_designer_action.js',
|
||||
'fusion_quotations/static/src/xml/flow_designer_templates.xml',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
}
|
||||
4
fusion_quotations/controllers/__init__.py
Normal file
4
fusion_quotations/controllers/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import portal_quotation
|
||||
from . import quotation_api
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
536
fusion_quotations/controllers/portal_quotation.py
Normal file
536
fusion_quotations/controllers/portal_quotation.py
Normal file
@@ -0,0 +1,536 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import http, _
|
||||
from odoo.http import request
|
||||
from odoo.addons.portal.controllers.portal import CustomerPortal
|
||||
import json
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QuotationPortal(CustomerPortal):
|
||||
|
||||
def _prepare_home_portal_values(self, counters):
|
||||
values = super()._prepare_home_portal_values(counters)
|
||||
if 'wc_assessment_count' in counters:
|
||||
Assessment = request.env['fusion.wc.assessment'].sudo()
|
||||
values['wc_assessment_count'] = Assessment.search_count([
|
||||
('sales_rep_id', '=', request.env.user.id),
|
||||
])
|
||||
return values
|
||||
|
||||
# =========================================================================
|
||||
# ASSESSMENT LIST
|
||||
# =========================================================================
|
||||
@http.route('/my/quotation/builder', type='http', auth='user', website=True)
|
||||
def quotation_builder_list(self, **kw):
|
||||
Assessment = request.env['fusion.wc.assessment'].sudo()
|
||||
assessments = Assessment.search(
|
||||
[('sales_rep_id', '=', request.env.user.id)],
|
||||
order='create_date desc',
|
||||
limit=50,
|
||||
)
|
||||
# Equipment types with active flows — for "New Assessment" dropdown
|
||||
Flow = request.env['fusion.wc.config.flow'].sudo()
|
||||
active_flow_etypes = Flow.search([
|
||||
('state', '=', 'active'),
|
||||
]).mapped('equipment_type')
|
||||
all_equip_types = request.env['fusion.equipment.type'].sudo().search(
|
||||
[('active', '=', True)], order='sequence')
|
||||
equipment_types = all_equip_types.filtered(
|
||||
lambda et: et.code in active_flow_etypes)
|
||||
|
||||
# Build lookup dict: code → name for template display
|
||||
equip_type_map = {et.code: et.name for et in all_equip_types}
|
||||
|
||||
return request.render('fusion_quotations.portal_quotation_list', {
|
||||
'assessments': assessments,
|
||||
'equipment_types': equipment_types,
|
||||
'equip_type_map': equip_type_map,
|
||||
'page_name': 'wc_assessments',
|
||||
})
|
||||
|
||||
# =========================================================================
|
||||
# HELPER: build template context values
|
||||
# =========================================================================
|
||||
def _get_form_context(self, assessment=None, requested_equipment_type=None):
|
||||
"""Return common template context for the assessment form.
|
||||
|
||||
Args:
|
||||
assessment: existing assessment record, or None for new.
|
||||
requested_equipment_type: equipment type code from query param
|
||||
(only used when assessment is None — new form).
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
sections = request.env['fusion.wc.section'].sudo().search(
|
||||
[('active', '=', True), ('parent_id', '=', False)],
|
||||
order='sequence',
|
||||
)
|
||||
countries = request.env['res.country'].sudo().search([])
|
||||
provinces = request.env['res.country.state'].sudo().search(
|
||||
[('country_id.code', '=', 'CA')])
|
||||
|
||||
# Equipment types — only show types that have at least one active config flow
|
||||
Flow = request.env['fusion.wc.config.flow'].sudo()
|
||||
all_equip_types = request.env['fusion.equipment.type'].sudo().search(
|
||||
[('active', '=', True)], order='sequence')
|
||||
active_flow_etypes = Flow.search([
|
||||
('state', '=', 'active'),
|
||||
]).mapped('equipment_type')
|
||||
equipment_types = all_equip_types.filtered(
|
||||
lambda et: et.code in active_flow_etypes)
|
||||
|
||||
# ── Flow steps ──
|
||||
# Determine the equipment type: assessment field > query param > first available > fallback
|
||||
FlowStep = request.env['fusion.wc.config.flow.step'].sudo()
|
||||
if assessment:
|
||||
equipment_type = assessment.equipment_type
|
||||
elif requested_equipment_type and requested_equipment_type in active_flow_etypes:
|
||||
equipment_type = requested_equipment_type
|
||||
elif active_flow_etypes:
|
||||
# Default to first available type with an active flow
|
||||
first_type = equipment_types[:1]
|
||||
equipment_type = first_type.code if first_type else 'manual_wheelchair'
|
||||
else:
|
||||
equipment_type = 'manual_wheelchair'
|
||||
|
||||
flow = Flow.search([
|
||||
('equipment_type', '=', equipment_type),
|
||||
('state', '=', 'active'),
|
||||
], limit=1)
|
||||
|
||||
# Auto-create default steps for active flows that have none
|
||||
if flow and not flow.step_ids:
|
||||
try:
|
||||
flow.action_create_default_steps()
|
||||
except Exception:
|
||||
_logger.warning("Failed to auto-create steps for flow %s", flow.id)
|
||||
|
||||
flow_steps = flow.step_ids.sorted('sequence') if flow else FlowStep.browse()
|
||||
|
||||
# If no flow found, try any active flow as fallback
|
||||
if not flow_steps:
|
||||
flow = Flow.search([('state', '=', 'active')], limit=1)
|
||||
if flow:
|
||||
if not flow.step_ids:
|
||||
try:
|
||||
flow.action_create_default_steps()
|
||||
except Exception:
|
||||
pass
|
||||
flow_steps = flow.step_ids.sorted('sequence')
|
||||
|
||||
# Final fallback: SimpleNamespace objects matching step record interface
|
||||
if not flow_steps:
|
||||
flow_steps = [
|
||||
SimpleNamespace(id=0, name='Client & Equipment', step_type='client_info',
|
||||
icon='fa-user', section_code='', fields_json='', help_text='', is_required=True),
|
||||
SimpleNamespace(id=0, name='Measurements', step_type='measurements',
|
||||
icon='fa-ruler', section_code='', fields_json='', help_text='', is_required=True),
|
||||
SimpleNamespace(id=0, name='Frame', step_type='product_select',
|
||||
icon='fa-wheelchair', section_code='frame', fields_json='', help_text='', is_required=True),
|
||||
SimpleNamespace(id=0, name='Seating', step_type='options',
|
||||
icon='fa-chair', section_code='seating', fields_json='', help_text='', is_required=True),
|
||||
SimpleNamespace(id=0, name='Options', step_type='options',
|
||||
icon='fa-list', section_code='mw_adp_options,mw_accessories', fields_json='', help_text='', is_required=True),
|
||||
SimpleNamespace(id=0, name='Review', step_type='review',
|
||||
icon='fa-clipboard', section_code='', fields_json='', help_text='', is_required=True),
|
||||
]
|
||||
|
||||
# Parse fields_json for dynamic measurement/custom steps
|
||||
step_fields = {}
|
||||
for step in flow_steps:
|
||||
fj = step.fields_json if hasattr(step, 'fields_json') else ''
|
||||
if fj:
|
||||
try:
|
||||
step_fields[step.id] = json.loads(fj)
|
||||
except (ValueError, TypeError):
|
||||
step_fields[step.id] = []
|
||||
|
||||
# Pre-build a dict of field values so QWeb can look them up by name
|
||||
field_values = {}
|
||||
if assessment:
|
||||
# Standard wheelchair measurement fields
|
||||
for fname in [
|
||||
'seat_width', 'seat_depth', 'finished_seat_to_floor_height',
|
||||
'back_cane_height', 'finished_back_height',
|
||||
'finished_leg_rest_length', 'client_weight',
|
||||
'seat_width_unit', 'seat_depth_unit', 'seat_to_floor_unit',
|
||||
'cane_height_unit', 'back_height_unit', 'leg_rest_unit',
|
||||
'client_weight_unit',
|
||||
]:
|
||||
field_values[fname] = assessment[fname] or ''
|
||||
|
||||
# Equipment-specific data from JSON (for non-wheelchair types)
|
||||
if assessment.equipment_data_json:
|
||||
try:
|
||||
equipment_data = json.loads(assessment.equipment_data_json)
|
||||
if isinstance(equipment_data, dict):
|
||||
field_values.update(equipment_data)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return {
|
||||
'assessment': assessment,
|
||||
'sections': sections,
|
||||
'countries': countries,
|
||||
'provinces': provinces,
|
||||
'field_values': field_values,
|
||||
'equipment_types': equipment_types,
|
||||
'equipment_type': equipment_type,
|
||||
'flow': flow,
|
||||
'flow_steps': flow_steps,
|
||||
'step_fields': step_fields,
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# NEW ASSESSMENT FORM
|
||||
# =========================================================================
|
||||
@http.route('/my/quotation/builder/new', type='http', auth='user', website=True)
|
||||
def quotation_builder_new(self, **kw):
|
||||
ctx = self._get_form_context(
|
||||
assessment=None,
|
||||
requested_equipment_type=kw.get('equipment_type'),
|
||||
)
|
||||
ctx['page_name'] = 'wc_assessment_new'
|
||||
return request.render('fusion_quotations.portal_quotation_form', ctx)
|
||||
|
||||
# =========================================================================
|
||||
# EDIT EXISTING ASSESSMENT
|
||||
# =========================================================================
|
||||
@http.route('/my/quotation/builder/<int:assessment_id>/edit',
|
||||
type='http', auth='user', website=True)
|
||||
def quotation_builder_edit(self, assessment_id, **kw):
|
||||
Assessment = request.env['fusion.wc.assessment'].sudo()
|
||||
assessment = Assessment.browse(assessment_id)
|
||||
|
||||
if not assessment.exists() or assessment.sales_rep_id.id != request.env.user.id:
|
||||
return request.redirect('/my/quotation/builder')
|
||||
|
||||
ctx = self._get_form_context(assessment=assessment)
|
||||
ctx['page_name'] = 'wc_assessment_edit'
|
||||
return request.render('fusion_quotations.portal_quotation_form', ctx)
|
||||
|
||||
# =========================================================================
|
||||
# SAVE ASSESSMENT (FINAL SUBMIT)
|
||||
# =========================================================================
|
||||
@http.route('/my/quotation/builder/save', type='http', auth='user',
|
||||
website=True, methods=['POST'], csrf=True)
|
||||
def quotation_builder_save(self, **post):
|
||||
Assessment = request.env['fusion.wc.assessment'].sudo()
|
||||
assessment_id = int(post.get('assessment_id') or 0)
|
||||
|
||||
vals = self._extract_assessment_vals(post)
|
||||
|
||||
if assessment_id:
|
||||
assessment = Assessment.browse(assessment_id)
|
||||
if assessment.exists() and assessment.sales_rep_id.id == request.env.user.id:
|
||||
assessment.write(vals)
|
||||
else:
|
||||
vals['sales_rep_id'] = request.env.user.id
|
||||
assessment = Assessment.create(vals)
|
||||
|
||||
# Process selected items (lines)
|
||||
self._process_assessment_lines(assessment, post)
|
||||
|
||||
# Check if user wants to generate quotation
|
||||
if post.get('action') == 'generate':
|
||||
try:
|
||||
assessment.action_generate_quotation()
|
||||
return request.redirect(
|
||||
f'/my/quotation/builder/{assessment.id}/edit?success=quotation_generated')
|
||||
except Exception as e:
|
||||
_logger.error("Quotation generation failed: %s", str(e))
|
||||
return request.redirect(
|
||||
f'/my/quotation/builder/{assessment.id}/edit?error={str(e)}')
|
||||
|
||||
return request.redirect(
|
||||
f'/my/quotation/builder/{assessment.id}/edit?success=saved')
|
||||
|
||||
# Known POST keys that map to standard assessment fields (not dynamic)
|
||||
_KNOWN_POST_KEYS = {
|
||||
'csrf_token', 'assessment_id', 'current_step', 'action', 'access_token',
|
||||
'line_product_ids', 'line_section_ids', 'line_build_types',
|
||||
'line_quantities', 'line_rationales',
|
||||
'partner_id', 'create_new_partner',
|
||||
'client_first_name', 'client_last_name', 'client_phone', 'client_email',
|
||||
'client_street', 'client_street2', 'client_city', 'client_state_id',
|
||||
'client_zip', 'client_country_id', 'client_dob', 'client_health_card',
|
||||
'equipment_type', 'wheelchair_type', 'powerchair_type',
|
||||
'client_type', 'build_type', 'reason_for_application', 'authorizer_id',
|
||||
'seat_width', 'seat_depth', 'finished_seat_to_floor_height',
|
||||
'back_cane_height', 'finished_back_height', 'finished_leg_rest_length',
|
||||
'client_weight',
|
||||
'seat_width_unit', 'seat_depth_unit', 'seat_to_floor_unit',
|
||||
'cane_height_unit', 'back_height_unit', 'leg_rest_unit',
|
||||
'client_weight_unit',
|
||||
'frame_product_tmpl_id', 'frame_product_id', 'frame_notes', 'notes',
|
||||
}
|
||||
|
||||
def _extract_assessment_vals(self, post):
|
||||
"""Extract assessment field values from POST data."""
|
||||
vals = {}
|
||||
|
||||
# Client
|
||||
partner_id = post.get('partner_id')
|
||||
if partner_id and partner_id != '0':
|
||||
vals['partner_id'] = int(partner_id)
|
||||
vals['create_new_partner'] = False
|
||||
else:
|
||||
vals['create_new_partner'] = True
|
||||
vals['client_first_name'] = post.get('client_first_name', '')
|
||||
vals['client_last_name'] = post.get('client_last_name', '')
|
||||
vals['client_phone'] = post.get('client_phone', '')
|
||||
vals['client_email'] = post.get('client_email', '')
|
||||
vals['client_street'] = post.get('client_street', '')
|
||||
vals['client_street2'] = post.get('client_street2', '')
|
||||
vals['client_city'] = post.get('client_city', '')
|
||||
state_id = post.get('client_state_id')
|
||||
if state_id:
|
||||
vals['client_state_id'] = int(state_id)
|
||||
vals['client_zip'] = post.get('client_zip', '')
|
||||
country_id = post.get('client_country_id')
|
||||
if country_id:
|
||||
vals['client_country_id'] = int(country_id)
|
||||
dob = post.get('client_dob')
|
||||
if dob:
|
||||
vals['client_dob'] = dob
|
||||
|
||||
vals['client_health_card'] = post.get('client_health_card', '')
|
||||
|
||||
# Equipment
|
||||
vals['equipment_type'] = post.get('equipment_type', 'manual_wheelchair')
|
||||
vals['wheelchair_type'] = post.get('wheelchair_type', '')
|
||||
vals['powerchair_type'] = post.get('powerchair_type', '')
|
||||
vals['client_type'] = post.get('client_type', 'reg')
|
||||
vals['build_type'] = post.get('build_type', 'modular')
|
||||
vals['reason_for_application'] = post.get('reason_for_application', '')
|
||||
|
||||
# Authorizer
|
||||
authorizer_id = post.get('authorizer_id')
|
||||
if authorizer_id:
|
||||
vals['authorizer_id'] = int(authorizer_id)
|
||||
|
||||
# Measurements
|
||||
for field in ['seat_width', 'seat_depth', 'finished_seat_to_floor_height',
|
||||
'back_cane_height', 'finished_back_height',
|
||||
'finished_leg_rest_length', 'client_weight']:
|
||||
val = post.get(field)
|
||||
if val:
|
||||
try:
|
||||
vals[field] = float(val)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Measurement units
|
||||
for field in ['seat_width_unit', 'seat_depth_unit', 'seat_to_floor_unit',
|
||||
'cane_height_unit', 'back_height_unit', 'leg_rest_unit',
|
||||
'client_weight_unit']:
|
||||
val = post.get(field)
|
||||
if val:
|
||||
vals[field] = val
|
||||
|
||||
# Frame
|
||||
frame_tmpl = post.get('frame_product_tmpl_id')
|
||||
if frame_tmpl:
|
||||
vals['frame_product_tmpl_id'] = int(frame_tmpl)
|
||||
frame_product = post.get('frame_product_id')
|
||||
if frame_product:
|
||||
vals['frame_product_id'] = int(frame_product)
|
||||
vals['frame_notes'] = post.get('frame_notes', '')
|
||||
vals['notes'] = post.get('notes', '')
|
||||
|
||||
# Current step
|
||||
step = post.get('current_step')
|
||||
if step:
|
||||
vals['current_step'] = int(step)
|
||||
|
||||
# ── Dynamic / equipment-specific fields → equipment_data_json ──
|
||||
# Collect any POST keys not in the known set — these come from
|
||||
# dynamic measurement / custom steps defined via fields_json
|
||||
equipment_data = {}
|
||||
for key, value in post.items():
|
||||
if key not in self._KNOWN_POST_KEYS and not key.startswith('section_'):
|
||||
# Skip empty values
|
||||
if value:
|
||||
equipment_data[key] = value
|
||||
if equipment_data:
|
||||
# Merge with existing equipment_data_json if editing
|
||||
assessment_id = int(post.get('assessment_id') or 0)
|
||||
if assessment_id:
|
||||
assessment = request.env['fusion.wc.assessment'].sudo().browse(assessment_id)
|
||||
if assessment.exists() and assessment.equipment_data_json:
|
||||
try:
|
||||
existing = json.loads(assessment.equipment_data_json)
|
||||
if isinstance(existing, dict):
|
||||
existing.update(equipment_data)
|
||||
equipment_data = existing
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
vals['equipment_data_json'] = json.dumps(equipment_data)
|
||||
|
||||
return vals
|
||||
|
||||
def _process_assessment_lines(self, assessment, post):
|
||||
"""Process selected products from the form into assessment lines."""
|
||||
AssessmentLine = request.env['fusion.wc.assessment.line'].sudo()
|
||||
|
||||
# Remove existing non-upcharge lines (will be recreated from form)
|
||||
assessment.line_ids.filtered(lambda l: not l.is_upcharge).unlink()
|
||||
|
||||
# Parse lines from POST data
|
||||
# Format: line_product_ids = comma-separated product IDs
|
||||
# line_sections = comma-separated section IDs (parallel array)
|
||||
# line_build_types = comma-separated build types (parallel array)
|
||||
line_products = post.get('line_product_ids', '').split(',')
|
||||
line_sections = post.get('line_section_ids', '').split(',')
|
||||
line_build_types = post.get('line_build_types', '').split(',')
|
||||
line_quantities = post.get('line_quantities', '').split(',')
|
||||
line_rationales = post.get('line_rationales', '').split('|||')
|
||||
|
||||
for idx, tmpl_id_str in enumerate(line_products):
|
||||
if not tmpl_id_str.strip():
|
||||
continue
|
||||
try:
|
||||
tmpl_id = int(tmpl_id_str.strip())
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
# Resolve product template → first active variant
|
||||
tmpl = request.env['product.template'].sudo().browse(tmpl_id)
|
||||
if not tmpl.exists():
|
||||
continue
|
||||
product = tmpl.product_variant_ids[:1]
|
||||
if not product:
|
||||
continue
|
||||
|
||||
section_id = False
|
||||
if idx < len(line_sections) and line_sections[idx].strip():
|
||||
try:
|
||||
section_id = int(line_sections[idx].strip())
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
build_type = False
|
||||
if idx < len(line_build_types) and line_build_types[idx].strip():
|
||||
build_type = line_build_types[idx].strip()
|
||||
|
||||
quantity = 1.0
|
||||
if idx < len(line_quantities) and line_quantities[idx].strip():
|
||||
try:
|
||||
quantity = float(line_quantities[idx].strip())
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
rationale = ''
|
||||
if idx < len(line_rationales):
|
||||
rationale = line_rationales[idx].strip()
|
||||
|
||||
tmpl = product.product_tmpl_id
|
||||
AssessmentLine.create({
|
||||
'assessment_id': assessment.id,
|
||||
'section_id': section_id,
|
||||
'product_id': product.id,
|
||||
'product_name': product.display_name,
|
||||
'quantity': quantity,
|
||||
'adp_device_code': tmpl.x_fc_adp_device_code or '',
|
||||
'adp_price': tmpl.x_fc_adp_price or 0.0,
|
||||
'unit_price': product.lst_price,
|
||||
'build_type': build_type if build_type in ('modular', 'custom_fabricated') else False,
|
||||
'clinical_rationale': rationale,
|
||||
})
|
||||
|
||||
|
||||
class QuotationPublic(http.Controller):
|
||||
"""Public (token-based) access to the assessment form — no login required."""
|
||||
|
||||
def _validate_token(self, token):
|
||||
"""Look up and validate an assessment by its access token."""
|
||||
assessment = request.env['fusion.wc.assessment'].sudo().search([
|
||||
('access_token', '=', token),
|
||||
], limit=1)
|
||||
if not assessment:
|
||||
return None, 'not_found'
|
||||
if assessment.state == 'cancelled':
|
||||
return assessment, 'cancelled'
|
||||
return assessment, 'ok'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public form — full portal chrome
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/quotation/form/<string:token>', type='http',
|
||||
auth='public', website=True, sitemap=False)
|
||||
def public_form_view(self, token, **kw):
|
||||
"""Render the assessment form for public (unauthenticated) users."""
|
||||
assessment, status = self._validate_token(token)
|
||||
if status != 'ok':
|
||||
return request.render('fusion_quotations.public_form_invalid', {
|
||||
'status': status,
|
||||
})
|
||||
|
||||
portal_ctrl = QuotationPortal()
|
||||
ctx = portal_ctrl._get_form_context(
|
||||
assessment=assessment,
|
||||
requested_equipment_type=kw.get('equipment_type'),
|
||||
)
|
||||
ctx['page_name'] = 'wc_assessment_public'
|
||||
ctx['is_public'] = True
|
||||
ctx['access_token'] = token
|
||||
return request.render('fusion_quotations.portal_quotation_form', ctx)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Embeddable form — no portal chrome, iframe-friendly
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/quotation/form/<string:token>/embed', type='http',
|
||||
auth='public', website=True, sitemap=False)
|
||||
def embed_form_view(self, token, **kw):
|
||||
"""Render a minimal, iframe-embeddable version of the form."""
|
||||
assessment, status = self._validate_token(token)
|
||||
if status != 'ok':
|
||||
return request.render('fusion_quotations.public_form_invalid', {
|
||||
'status': status,
|
||||
})
|
||||
|
||||
portal_ctrl = QuotationPortal()
|
||||
ctx = portal_ctrl._get_form_context(assessment=assessment)
|
||||
ctx['page_name'] = 'wc_assessment_embed'
|
||||
ctx['is_public'] = True
|
||||
ctx['is_embed'] = True
|
||||
ctx['access_token'] = token
|
||||
resp = request.render('fusion_quotations.portal_quotation_form_embed', ctx)
|
||||
resp.headers['X-Frame-Options'] = 'ALLOWALL'
|
||||
resp.headers['Content-Security-Policy'] = ''
|
||||
return resp
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public form save (POST)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/quotation/form/<string:token>/save', type='http',
|
||||
auth='public', website=True, methods=['POST'],
|
||||
csrf=True, sitemap=False)
|
||||
def public_form_save(self, token, **post):
|
||||
"""Save form data submitted from the public/embed form."""
|
||||
assessment, status = self._validate_token(token)
|
||||
if status != 'ok' or not assessment:
|
||||
return request.redirect('/')
|
||||
|
||||
if assessment.state not in ('draft', 'review'):
|
||||
return request.redirect(f'/quotation/form/{token}?error=readonly')
|
||||
|
||||
portal_ctrl = QuotationPortal()
|
||||
vals = portal_ctrl._extract_assessment_vals(post)
|
||||
assessment.sudo().write(vals)
|
||||
portal_ctrl._process_assessment_lines(assessment, post)
|
||||
|
||||
if post.get('action') == 'generate':
|
||||
try:
|
||||
assessment.sudo().action_generate_quotation()
|
||||
return request.redirect(
|
||||
f'/quotation/form/{token}?success=quotation_generated')
|
||||
except Exception as e:
|
||||
_logger.error("Public quotation generation failed: %s", str(e))
|
||||
return request.redirect(
|
||||
f'/quotation/form/{token}?error={str(e)}')
|
||||
|
||||
return request.redirect(f'/quotation/form/{token}?success=saved')
|
||||
388
fusion_quotations/controllers/quotation_api.py
Normal file
388
fusion_quotations/controllers/quotation_api.py
Normal file
@@ -0,0 +1,388 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
import json
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QuotationAPI(http.Controller):
|
||||
|
||||
@http.route('/my/quotation/api/search_clients', type='json',
|
||||
auth='user', methods=['POST'])
|
||||
def search_clients(self, query='', limit=20, **kw):
|
||||
"""Search existing clients by name, phone, or health card."""
|
||||
if not query or len(query) < 2:
|
||||
return []
|
||||
|
||||
Partner = request.env['res.partner'].sudo()
|
||||
domain = [
|
||||
'|',
|
||||
('name', 'ilike', query),
|
||||
('phone', 'ilike', query),
|
||||
]
|
||||
partners = Partner.search(domain, limit=limit, order='name')
|
||||
|
||||
return [{
|
||||
'id': p.id,
|
||||
'name': p.name,
|
||||
'phone': p.phone or '',
|
||||
'email': p.email or '',
|
||||
'street': p.street or '',
|
||||
'city': p.city or '',
|
||||
} for p in partners]
|
||||
|
||||
@http.route('/my/quotation/api/search_products', type='json',
|
||||
auth='user', methods=['POST'])
|
||||
def search_products(self, query='', section_code=None, limit=20, **kw):
|
||||
"""Search product templates (parent products), optionally filtered by section.
|
||||
Returns templates with variant_count so the UI can show a configurator
|
||||
when multiple variants exist.
|
||||
"""
|
||||
if not query or len(query) < 2:
|
||||
return []
|
||||
|
||||
Template = request.env['product.template'].sudo()
|
||||
domain = [
|
||||
('sale_ok', '=', True),
|
||||
'|', '|',
|
||||
('name', 'ilike', query),
|
||||
('default_code', 'ilike', query),
|
||||
('x_fc_adp_device_code', 'ilike', query),
|
||||
]
|
||||
|
||||
# If section specified, filter by category
|
||||
if section_code:
|
||||
Section = request.env['fusion.wc.section'].sudo()
|
||||
section = Section.search([('code', '=', section_code)], limit=1)
|
||||
if section and section.product_category_id:
|
||||
domain.append(('categ_id', 'child_of', section.product_category_id.id))
|
||||
|
||||
templates = Template.search(domain, limit=limit, order='name')
|
||||
|
||||
results = []
|
||||
for t in templates:
|
||||
variant_count = len(t.product_variant_ids)
|
||||
has_configurable = bool(
|
||||
t.attribute_line_ids and variant_count > 1
|
||||
)
|
||||
# For single-variant templates, get the variant ID directly
|
||||
single_variant_id = (
|
||||
t.product_variant_ids[0].id
|
||||
if variant_count == 1 else False
|
||||
)
|
||||
results.append({
|
||||
'id': t.id,
|
||||
'name': t.name,
|
||||
'default_code': t.default_code or '',
|
||||
'adp_device_code': t.x_fc_adp_device_code or '',
|
||||
'adp_price': t.x_fc_adp_price or 0.0,
|
||||
'list_price': t.list_price,
|
||||
'variant_count': variant_count,
|
||||
'has_configurable_attributes': has_configurable,
|
||||
'single_variant_id': single_variant_id,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
@http.route('/my/quotation/api/get_product_attributes', type='json',
|
||||
auth='user', methods=['POST'])
|
||||
def get_product_attributes(self, template_id=None, **kw):
|
||||
"""Given a product template ID, return its configurable attributes
|
||||
with available values for each.
|
||||
"""
|
||||
if not template_id:
|
||||
return []
|
||||
|
||||
Template = request.env['product.template'].sudo()
|
||||
tmpl = Template.browse(int(template_id))
|
||||
if not tmpl.exists():
|
||||
return []
|
||||
|
||||
attributes = []
|
||||
for line in tmpl.attribute_line_ids:
|
||||
values = []
|
||||
for val in line.value_ids:
|
||||
values.append({
|
||||
'id': val.id,
|
||||
'name': val.name,
|
||||
})
|
||||
attributes.append({
|
||||
'attribute_id': line.attribute_id.id,
|
||||
'attribute_name': line.attribute_id.name,
|
||||
'values': values,
|
||||
})
|
||||
|
||||
return attributes
|
||||
|
||||
@http.route('/my/quotation/api/resolve_variant', type='json',
|
||||
auth='user', methods=['POST'])
|
||||
def resolve_variant(self, template_id=None, attribute_value_ids=None, **kw):
|
||||
"""Given a template ID and list of selected attribute value IDs,
|
||||
find and return the matching product.product variant.
|
||||
"""
|
||||
if not template_id or not attribute_value_ids:
|
||||
return {'error': 'Missing template_id or attribute_value_ids'}
|
||||
|
||||
Template = request.env['product.template'].sudo()
|
||||
tmpl = Template.browse(int(template_id))
|
||||
if not tmpl.exists():
|
||||
return {'error': 'Template not found'}
|
||||
|
||||
# Convert to set for matching
|
||||
selected_set = set(int(v) for v in attribute_value_ids)
|
||||
|
||||
# Search through variants to find the one matching all selected values
|
||||
for variant in tmpl.product_variant_ids:
|
||||
variant_values = set()
|
||||
for ptav in variant.product_template_attribute_value_ids:
|
||||
variant_values.add(ptav.product_attribute_value_id.id)
|
||||
|
||||
if variant_values == selected_set:
|
||||
return {
|
||||
'id': variant.id,
|
||||
'name': variant.display_name,
|
||||
'default_code': variant.default_code or '',
|
||||
'list_price': variant.lst_price,
|
||||
}
|
||||
|
||||
# No exact match found — return first variant as fallback
|
||||
fallback = tmpl.product_variant_ids[:1]
|
||||
if fallback:
|
||||
return {
|
||||
'id': fallback.id,
|
||||
'name': fallback.display_name,
|
||||
'default_code': fallback.default_code or '',
|
||||
'list_price': fallback.lst_price,
|
||||
'warning': 'No exact variant match found, using default variant.',
|
||||
}
|
||||
|
||||
return {'error': 'No variants found for this template'}
|
||||
|
||||
@http.route('/my/quotation/api/get_section_options', type='json',
|
||||
auth='user', methods=['POST'])
|
||||
def get_section_options(self, section_code=None, build_type=None, **kw):
|
||||
"""Get standard options for a section."""
|
||||
if not section_code:
|
||||
return []
|
||||
|
||||
Section = request.env['fusion.wc.section'].sudo()
|
||||
section = Section.search([('code', '=', section_code)], limit=1)
|
||||
if not section:
|
||||
return []
|
||||
|
||||
domain = [('section_id', '=', section.id), ('is_standard', '=', True)]
|
||||
if build_type and build_type in ('modular', 'custom_fabricated'):
|
||||
domain.append(('available_build_types', 'in', [build_type, 'both']))
|
||||
|
||||
options = request.env['fusion.wc.section.option'].sudo().search(
|
||||
domain, order='sequence')
|
||||
|
||||
return [{
|
||||
'id': o.id,
|
||||
'product_tmpl_id': o.product_tmpl_id.id,
|
||||
'product_name': o.product_tmpl_id.display_name,
|
||||
'variant_count': o.variant_count,
|
||||
'adp_device_code': o.adp_device_code or '',
|
||||
'adp_price': o.adp_price or 0.0,
|
||||
'list_price': o.list_price or 0.0,
|
||||
'requires_clinical_rationale': o.requires_clinical_rationale,
|
||||
'available_build_types': o.available_build_types,
|
||||
} for o in options]
|
||||
|
||||
@http.route('/my/quotation/api/check_upcharges', type='json',
|
||||
auth='user', methods=['POST'])
|
||||
def check_upcharges(self, assessment_data=None, **kw):
|
||||
"""Check which upcharge rules would trigger for given measurements.
|
||||
Returns list of upcharges WITHOUT creating any records.
|
||||
"""
|
||||
if not assessment_data:
|
||||
return []
|
||||
|
||||
rules = request.env['fusion.wc.upcharge.rule'].sudo().search([
|
||||
('active', '=', True),
|
||||
])
|
||||
|
||||
equipment_type = assessment_data.get('equipment_type', 'manual_wheelchair')
|
||||
triggered = []
|
||||
exclusive_triggered = {}
|
||||
|
||||
# Normalize measurements
|
||||
def get_value(field, unit_field, default_unit='inches'):
|
||||
val = float(assessment_data.get(field, 0) or 0)
|
||||
unit = assessment_data.get(unit_field, default_unit)
|
||||
if unit == 'cm' and val:
|
||||
val = val / 2.54
|
||||
return val
|
||||
|
||||
seat_width = get_value('seat_width', 'seat_width_unit')
|
||||
seat_depth = get_value('seat_depth', 'seat_depth_unit')
|
||||
back_height = get_value('finished_back_height', 'back_height_unit')
|
||||
client_weight = float(assessment_data.get('client_weight', 0) or 0)
|
||||
weight_unit = assessment_data.get('client_weight_unit', 'lbs')
|
||||
if weight_unit == 'kg' and client_weight:
|
||||
client_weight = client_weight * 2.20462
|
||||
|
||||
# Get backrest width from lines or default to seat_width
|
||||
back_width = float(assessment_data.get('back_width', 0) or 0)
|
||||
if not back_width:
|
||||
back_width = seat_width
|
||||
|
||||
measurement_map = {
|
||||
'seat_width': seat_width,
|
||||
'seat_depth': seat_depth,
|
||||
'back_width': back_width,
|
||||
'back_height': back_height,
|
||||
}
|
||||
|
||||
for rule in rules.sorted('sequence'):
|
||||
if rule.equipment_type not in (equipment_type, 'both'):
|
||||
continue
|
||||
|
||||
if rule.mutually_exclusive_group:
|
||||
if rule.mutually_exclusive_group in exclusive_triggered:
|
||||
continue
|
||||
|
||||
matched = False
|
||||
|
||||
if rule.trigger_type == 'measurement':
|
||||
val = measurement_map.get(rule.measurement_field, 0)
|
||||
if val:
|
||||
matched = self._compare(val, rule.comparison, rule.threshold_value)
|
||||
|
||||
elif rule.trigger_type == 'weight':
|
||||
if client_weight > rule.weight_min:
|
||||
if not rule.weight_max or client_weight <= rule.weight_max:
|
||||
matched = True
|
||||
|
||||
elif rule.trigger_type == 'dimension_mismatch':
|
||||
val1 = measurement_map.get(rule.compare_field_1, 0)
|
||||
val2 = measurement_map.get(rule.compare_field_2, 0)
|
||||
if val1 and val2 and abs(val1 - val2) > 0.01:
|
||||
matched = True
|
||||
|
||||
if matched:
|
||||
# Look up ADP price
|
||||
adp_device = request.env['fusion.adp.device.code'].sudo().search(
|
||||
[('device_code', '=', rule.adp_device_code), ('active', '=', True)],
|
||||
limit=1
|
||||
)
|
||||
triggered.append({
|
||||
'rule_id': rule.id,
|
||||
'name': rule.name,
|
||||
'adp_device_code': rule.adp_device_code,
|
||||
'adp_price': adp_device.adp_price if adp_device else 0,
|
||||
'description': rule.description or '',
|
||||
'measurement_field': rule.measurement_field or '',
|
||||
})
|
||||
if rule.mutually_exclusive_group:
|
||||
exclusive_triggered[rule.mutually_exclusive_group] = True
|
||||
|
||||
return triggered
|
||||
|
||||
@http.route('/my/quotation/api/save_step', type='json',
|
||||
auth='user', methods=['POST'])
|
||||
def save_step(self, assessment_id=None, step=None, data=None, **kw):
|
||||
"""Auto-save current step data as JSON for resume."""
|
||||
if not assessment_id:
|
||||
return {'success': False, 'error': 'No assessment ID'}
|
||||
|
||||
Assessment = request.env['fusion.wc.assessment'].sudo()
|
||||
assessment = Assessment.browse(int(assessment_id))
|
||||
|
||||
if not assessment.exists() or assessment.sales_rep_id.id != request.env.user.id:
|
||||
return {'success': False, 'error': 'Access denied'}
|
||||
|
||||
vals = {'current_step': int(step) if step else 1}
|
||||
if data:
|
||||
vals['form_data_json'] = json.dumps(data)
|
||||
|
||||
assessment.write(vals)
|
||||
return {'success': True}
|
||||
|
||||
@staticmethod
|
||||
def _compare(value, comparison, threshold):
|
||||
if comparison == 'gt':
|
||||
return value > threshold
|
||||
elif comparison == 'gte':
|
||||
return value >= threshold
|
||||
elif comparison == 'lt':
|
||||
return value < threshold
|
||||
elif comparison == 'eq':
|
||||
return abs(value - threshold) < 0.01
|
||||
elif comparison == 'neq':
|
||||
return abs(value - threshold) >= 0.01
|
||||
return False
|
||||
|
||||
|
||||
class QuotationPublicAPI(http.Controller):
|
||||
"""Public (token-based) JSON API — mirrors QuotationAPI for unauthenticated access."""
|
||||
|
||||
def _validate_token(self, token):
|
||||
assessment = request.env['fusion.wc.assessment'].sudo().search([
|
||||
('access_token', '=', token),
|
||||
], limit=1)
|
||||
return assessment if assessment else None
|
||||
|
||||
# --- delegates ---------------------------------------------------------
|
||||
# Each route validates the token then forwards to the existing API class.
|
||||
_api = QuotationAPI()
|
||||
|
||||
@http.route('/quotation/api/<string:token>/search_clients',
|
||||
type='json', auth='public', methods=['POST'])
|
||||
def public_search_clients(self, token, **kw):
|
||||
if not self._validate_token(token):
|
||||
return {'error': 'invalid_token'}
|
||||
return self._api.search_clients(**kw)
|
||||
|
||||
@http.route('/quotation/api/<string:token>/search_products',
|
||||
type='json', auth='public', methods=['POST'])
|
||||
def public_search_products(self, token, **kw):
|
||||
if not self._validate_token(token):
|
||||
return {'error': 'invalid_token'}
|
||||
return self._api.search_products(**kw)
|
||||
|
||||
@http.route('/quotation/api/<string:token>/get_product_attributes',
|
||||
type='json', auth='public', methods=['POST'])
|
||||
def public_get_product_attributes(self, token, **kw):
|
||||
if not self._validate_token(token):
|
||||
return {'error': 'invalid_token'}
|
||||
return self._api.get_product_attributes(**kw)
|
||||
|
||||
@http.route('/quotation/api/<string:token>/resolve_variant',
|
||||
type='json', auth='public', methods=['POST'])
|
||||
def public_resolve_variant(self, token, **kw):
|
||||
if not self._validate_token(token):
|
||||
return {'error': 'invalid_token'}
|
||||
return self._api.resolve_variant(**kw)
|
||||
|
||||
@http.route('/quotation/api/<string:token>/get_section_options',
|
||||
type='json', auth='public', methods=['POST'])
|
||||
def public_get_section_options(self, token, **kw):
|
||||
if not self._validate_token(token):
|
||||
return {'error': 'invalid_token'}
|
||||
return self._api.get_section_options(**kw)
|
||||
|
||||
@http.route('/quotation/api/<string:token>/check_upcharges',
|
||||
type='json', auth='public', methods=['POST'])
|
||||
def public_check_upcharges(self, token, **kw):
|
||||
if not self._validate_token(token):
|
||||
return {'error': 'invalid_token'}
|
||||
return self._api.check_upcharges(**kw)
|
||||
|
||||
@http.route('/quotation/api/<string:token>/save_step',
|
||||
type='json', auth='public', methods=['POST'])
|
||||
def public_save_step(self, token, **kw):
|
||||
assessment = self._validate_token(token)
|
||||
if not assessment:
|
||||
return {'error': 'invalid_token'}
|
||||
# Override assessment_id from the token-linked assessment
|
||||
kw['assessment_id'] = assessment.id
|
||||
# Bypass user ownership check by handling directly
|
||||
vals = {'current_step': int(kw.get('step', 1))}
|
||||
if kw.get('data'):
|
||||
vals['form_data_json'] = json.dumps(kw['data'])
|
||||
assessment.sudo().write(vals)
|
||||
return {'success': True}
|
||||
45
fusion_quotations/data/equipment_type_data.xml
Normal file
45
fusion_quotations/data/equipment_type_data.xml
Normal file
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<!-- ============================================================
|
||||
Equipment Type Registry — Seed Data
|
||||
============================================================ -->
|
||||
|
||||
<record id="equipment_type_manual_wheelchair" model="fusion.equipment.type">
|
||||
<field name="code">manual_wheelchair</field>
|
||||
<field name="name">Manual Wheelchair</field>
|
||||
<field name="icon">fa-wheelchair</field>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
|
||||
<record id="equipment_type_power_wheelchair" model="fusion.equipment.type">
|
||||
<field name="code">power_wheelchair</field>
|
||||
<field name="name">Power Wheelchair / Scooter</field>
|
||||
<field name="icon">fa-bolt</field>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
|
||||
<record id="equipment_type_walker" model="fusion.equipment.type">
|
||||
<field name="code">walker</field>
|
||||
<field name="name">Walker / Rollator / Ambulation Aid</field>
|
||||
<field name="icon">fa-male</field>
|
||||
<field name="sequence">30</field>
|
||||
</record>
|
||||
|
||||
<record id="equipment_type_stair_lift" model="fusion.equipment.type">
|
||||
<field name="code">stair_lift</field>
|
||||
<field name="name">Stair Lift</field>
|
||||
<field name="icon">fa-arrow-up</field>
|
||||
<field name="sequence">40</field>
|
||||
</record>
|
||||
|
||||
<record id="equipment_type_porch_lift" model="fusion.equipment.type">
|
||||
<field name="code">porch_lift</field>
|
||||
<field name="name">Porch Lift</field>
|
||||
<field name="icon">fa-building</field>
|
||||
<field name="sequence">50</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
414
fusion_quotations/data/section_seed_data.xml
Normal file
414
fusion_quotations/data/section_seed_data.xml
Normal file
@@ -0,0 +1,414 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SECTION 2b: MANUAL WHEELCHAIR -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="section_mw_frame" model="fusion.wc.section">
|
||||
<field name="name">Manual Wheelchair Frame</field>
|
||||
<field name="code">mw_frame</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="equipment_type">manual_wheelchair</field>
|
||||
<field name="icon">fa-wheelchair</field>
|
||||
<field name="description">Select the manual wheelchair frame. Includes standard, lightweight, tilt, and rigid frames.</field>
|
||||
<field name="allow_multiple" eval="False"/>
|
||||
<field name="required" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_mw_adp_options" model="fusion.wc.section">
|
||||
<field name="name">MW Additional ADP Funded Options</field>
|
||||
<field name="code">mw_adp_options</field>
|
||||
<field name="sequence">15</field>
|
||||
<field name="equipment_type">manual_wheelchair</field>
|
||||
<field name="icon">fa-check-square-o</field>
|
||||
<field name="description">ADP upcharge/modification codes for manual wheelchairs (WAMA, WAMB, heavy duty, custom mods).</field>
|
||||
<field name="is_adp_options_section" eval="True"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_mw_accessories" model="fusion.wc.section">
|
||||
<field name="name">MW Accessories</field>
|
||||
<field name="code">mw_accessories</field>
|
||||
<field name="sequence">18</field>
|
||||
<field name="equipment_type">manual_wheelchair</field>
|
||||
<field name="icon">fa-cogs</field>
|
||||
<field name="description">Manual wheelchair accessories: handrims, spoke protectors, grade aids, etc.</field>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SECTION 2a: WALKER / ROLLATOR / AMBULATION AIDS -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="section_walker_frame" model="fusion.wc.section">
|
||||
<field name="name">Walker / Rollator Frame</field>
|
||||
<field name="code">walker_frame</field>
|
||||
<field name="sequence">200</field>
|
||||
<field name="equipment_type">walker</field>
|
||||
<field name="icon">fa-male</field>
|
||||
<field name="description">Select the walker, rollator, walking frame, standing frame, or forearm crutches.</field>
|
||||
<field name="allow_multiple" eval="False"/>
|
||||
<field name="required" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_walker_adp_options" model="fusion.wc.section">
|
||||
<field name="name">Walker ADP Funded Options</field>
|
||||
<field name="code">walker_adp_options</field>
|
||||
<field name="sequence">210</field>
|
||||
<field name="equipment_type">walker</field>
|
||||
<field name="icon">fa-check-square-o</field>
|
||||
<field name="description">Adolescent size upgrades and custom modifications for walkers.</field>
|
||||
<field name="is_adp_options_section" eval="True"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_walker_accessories" model="fusion.wc.section">
|
||||
<field name="name">Walker Accessories / Addons</field>
|
||||
<field name="code">walker_accessories</field>
|
||||
<field name="sequence">220</field>
|
||||
<field name="equipment_type">walker</field>
|
||||
<field name="icon">fa-cogs</field>
|
||||
<field name="description">Walker addons and accessories.</field>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SECTION 2c: POWER BASE / POWER SCOOTER -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="section_pw_frame" model="fusion.wc.section">
|
||||
<field name="name">Power Base / Scooter Frame</field>
|
||||
<field name="code">pw_frame</field>
|
||||
<field name="sequence">300</field>
|
||||
<field name="equipment_type">power_wheelchair</field>
|
||||
<field name="icon">fa-bolt</field>
|
||||
<field name="description">Select the power wheelchair base or power scooter.</field>
|
||||
<field name="allow_multiple" eval="False"/>
|
||||
<field name="required" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_pw_adp_options" model="fusion.wc.section">
|
||||
<field name="name">Power Base ADP Funded Options</field>
|
||||
<field name="code">pw_adp_options</field>
|
||||
<field name="sequence">310</field>
|
||||
<field name="equipment_type">power_wheelchair</field>
|
||||
<field name="icon">fa-check-square-o</field>
|
||||
<field name="description">ADP funded options for power bases: adjustable tension back, recline, footplates, seat packages, etc.</field>
|
||||
<field name="is_adp_options_section" eval="True"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_pw_specialty_controls" model="fusion.wc.section">
|
||||
<field name="name">Specialty Controls</field>
|
||||
<field name="code">pw_specialty_controls</field>
|
||||
<field name="sequence">320</field>
|
||||
<field name="equipment_type">power_wheelchair</field>
|
||||
<field name="icon">fa-gamepad</field>
|
||||
<field name="description">Specialty controls requiring clinical rationale: non-standard joystick, chin/rim, simple touch, proximity, breath control, scanners, auto correction.</field>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_pw_positioning" model="fusion.wc.section">
|
||||
<field name="name">Power Positioning Devices</field>
|
||||
<field name="code">pw_positioning</field>
|
||||
<field name="sequence">330</field>
|
||||
<field name="equipment_type">power_wheelchair</field>
|
||||
<field name="icon">fa-sliders</field>
|
||||
<field name="description">Power positioning devices requiring Justification for Funding Chart: power tilt, recline, elevating footrests, multi-function control, power add-on.</field>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SEATING SECTIONS (Section 2d - shared across MW/PW) -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="section_seat_cushion" model="fusion.wc.section">
|
||||
<field name="name">Seat Cushion</field>
|
||||
<field name="code">seat_cushion</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="icon">fa-th-large</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="has_width" eval="True"/>
|
||||
<field name="has_depth" eval="True"/>
|
||||
<field name="width_label">Cushion Width (inches)</field>
|
||||
<field name="depth_label">Cushion Depth (inches)</field>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_seat_cushion_cover" model="fusion.wc.section">
|
||||
<field name="name">Seat Cushion Cover(s)</field>
|
||||
<field name="code">seat_cushion_cover</field>
|
||||
<field name="sequence">31</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="parent_id" ref="section_seat_cushion"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_seat_options" model="fusion.wc.section">
|
||||
<field name="name">Seat Option(s)</field>
|
||||
<field name="code">seat_options</field>
|
||||
<field name="sequence">32</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="parent_id" ref="section_seat_cushion"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_seat_hardware" model="fusion.wc.section">
|
||||
<field name="name">Seat Hardware</field>
|
||||
<field name="code">seat_hardware</field>
|
||||
<field name="sequence">33</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="parent_id" ref="section_seat_cushion"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_pommel" model="fusion.wc.section">
|
||||
<field name="name">Pommel/Adductors</field>
|
||||
<field name="code">pommel</field>
|
||||
<field name="sequence">34</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="parent_id" ref="section_seat_cushion"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_pommel_hardware" model="fusion.wc.section">
|
||||
<field name="name">Pommel Hardware</field>
|
||||
<field name="code">pommel_hardware</field>
|
||||
<field name="sequence">35</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="parent_id" ref="section_seat_cushion"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Back Support -->
|
||||
<record id="section_back_support" model="fusion.wc.section">
|
||||
<field name="name">Back Support</field>
|
||||
<field name="code">back_support</field>
|
||||
<field name="sequence">40</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="icon">fa-columns</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="has_width" eval="True"/>
|
||||
<field name="has_height" eval="True"/>
|
||||
<field name="width_label">Backrest Width (inches)</field>
|
||||
<field name="height_label">Backrest Height (inches)</field>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_back_support_options" model="fusion.wc.section">
|
||||
<field name="name">Back Support Options</field>
|
||||
<field name="code">back_support_options</field>
|
||||
<field name="sequence">41</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="parent_id" ref="section_back_support"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_back_cover" model="fusion.wc.section">
|
||||
<field name="name">Back Cover</field>
|
||||
<field name="code">back_cover</field>
|
||||
<field name="sequence">42</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="parent_id" ref="section_back_support"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_back_hardware" model="fusion.wc.section">
|
||||
<field name="name">Back Hardware</field>
|
||||
<field name="code">back_hardware</field>
|
||||
<field name="sequence">43</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="parent_id" ref="section_back_support"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Complete Assembly -->
|
||||
<record id="section_complete_assembly" model="fusion.wc.section">
|
||||
<field name="name">Complete Assembly</field>
|
||||
<field name="code">complete_assembly</field>
|
||||
<field name="sequence">50</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="icon">fa-cubes</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Headrest/Neckrest -->
|
||||
<record id="section_headrest" model="fusion.wc.section">
|
||||
<field name="name">Headrest/Neckrest</field>
|
||||
<field name="code">headrest</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="icon">fa-user</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_headrest_options" model="fusion.wc.section">
|
||||
<field name="name">Headrest/Neckrest Options</field>
|
||||
<field name="code">headrest_options</field>
|
||||
<field name="sequence">61</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="parent_id" ref="section_headrest"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_headrest_hardware" model="fusion.wc.section">
|
||||
<field name="name">Headrest/Neckrest Hardware</field>
|
||||
<field name="code">headrest_hardware</field>
|
||||
<field name="sequence">62</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="parent_id" ref="section_headrest"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Positioning Belts -->
|
||||
<record id="section_positioning_belts" model="fusion.wc.section">
|
||||
<field name="name">Positioning Belts</field>
|
||||
<field name="code">positioning_belts</field>
|
||||
<field name="sequence">70</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="icon">fa-link</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_positioning_belt_options" model="fusion.wc.section">
|
||||
<field name="name">Positioning Belt Options</field>
|
||||
<field name="code">positioning_belt_options</field>
|
||||
<field name="sequence">71</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="parent_id" ref="section_positioning_belts"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Arm Support -->
|
||||
<record id="section_arm_support" model="fusion.wc.section">
|
||||
<field name="name">Arm Support(s)</field>
|
||||
<field name="code">arm_support</field>
|
||||
<field name="sequence">80</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="icon">fa-hand-o-right</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_arm_support_options" model="fusion.wc.section">
|
||||
<field name="name">Arm Support Options</field>
|
||||
<field name="code">arm_support_options</field>
|
||||
<field name="sequence">81</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="parent_id" ref="section_arm_support"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_arm_support_hardware" model="fusion.wc.section">
|
||||
<field name="name">Arm Support Hardware</field>
|
||||
<field name="code">arm_support_hardware</field>
|
||||
<field name="sequence">82</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="parent_id" ref="section_arm_support"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Tray -->
|
||||
<record id="section_tray" model="fusion.wc.section">
|
||||
<field name="name">Tray</field>
|
||||
<field name="code">tray</field>
|
||||
<field name="sequence">90</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="icon">fa-inbox</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_tray_options" model="fusion.wc.section">
|
||||
<field name="name">Tray Options</field>
|
||||
<field name="code">tray_options</field>
|
||||
<field name="sequence">91</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="parent_id" ref="section_tray"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Lateral Support -->
|
||||
<record id="section_lateral_support" model="fusion.wc.section">
|
||||
<field name="name">Lateral Support(s)</field>
|
||||
<field name="code">lateral_support</field>
|
||||
<field name="sequence">100</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="icon">fa-arrows-h</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_lateral_support_options" model="fusion.wc.section">
|
||||
<field name="name">Lateral Support Options</field>
|
||||
<field name="code">lateral_support_options</field>
|
||||
<field name="sequence">101</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="parent_id" ref="section_lateral_support"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_lateral_support_hardware" model="fusion.wc.section">
|
||||
<field name="name">Lateral Support Hardware</field>
|
||||
<field name="code">lateral_support_hardware</field>
|
||||
<field name="sequence">102</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="parent_id" ref="section_lateral_support"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Foot/Leg Support -->
|
||||
<record id="section_foot_leg_support" model="fusion.wc.section">
|
||||
<field name="name">Foot/Leg Support(s)</field>
|
||||
<field name="code">foot_leg_support</field>
|
||||
<field name="sequence">110</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="icon">fa-arrows-v</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_foot_leg_support_options" model="fusion.wc.section">
|
||||
<field name="name">Foot/Leg Support Options</field>
|
||||
<field name="code">foot_leg_support_options</field>
|
||||
<field name="sequence">111</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="parent_id" ref="section_foot_leg_support"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="section_foot_leg_support_hardware" model="fusion.wc.section">
|
||||
<field name="name">Foot/Leg Support Hardware</field>
|
||||
<field name="code">foot_leg_support_hardware</field>
|
||||
<field name="sequence">112</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="has_build_type" eval="True"/>
|
||||
<field name="parent_id" ref="section_foot_leg_support"/>
|
||||
<field name="allow_multiple" eval="True"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
83
fusion_quotations/data/upcharge_rules_data.xml
Normal file
83
fusion_quotations/data/upcharge_rules_data.xml
Normal file
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<!-- WAMA: Seat width > 18 inches -->
|
||||
<record id="upcharge_wama" model="fusion.wc.upcharge.rule">
|
||||
<field name="name">Width Modification (WAMA)</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="trigger_type">measurement</field>
|
||||
<field name="measurement_field">seat_width</field>
|
||||
<field name="comparison">gt</field>
|
||||
<field name="threshold_value">18.0</field>
|
||||
<field name="adp_device_code">WAMA</field>
|
||||
<field name="equipment_type">manual_wheelchair</field>
|
||||
<field name="description">Seat width exceeds 18 inches — width modification upcharge applies.</field>
|
||||
</record>
|
||||
|
||||
<!-- WAMB: Seat depth > 18 inches -->
|
||||
<record id="upcharge_wamb" model="fusion.wc.upcharge.rule">
|
||||
<field name="name">Depth Modification (WAMB)</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="trigger_type">measurement</field>
|
||||
<field name="measurement_field">seat_depth</field>
|
||||
<field name="comparison">gt</field>
|
||||
<field name="threshold_value">18.0</field>
|
||||
<field name="adp_device_code">WAMB</field>
|
||||
<field name="equipment_type">manual_wheelchair</field>
|
||||
<field name="description">Seat depth exceeds 18 inches — depth modification upcharge applies.</field>
|
||||
</record>
|
||||
|
||||
<!-- WAMF: Client weight > 250 lbs (up to 350) -->
|
||||
<record id="upcharge_wamf" model="fusion.wc.upcharge.rule">
|
||||
<field name="name">Heavy Duty 250+ lbs (WAMF)</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="trigger_type">weight</field>
|
||||
<field name="weight_min">250.0</field>
|
||||
<field name="weight_max">350.0</field>
|
||||
<field name="adp_device_code">WAMF</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="mutually_exclusive_group">weight</field>
|
||||
<field name="description">Client weight exceeds 250 lbs — heavy duty frame upcharge.</field>
|
||||
</record>
|
||||
|
||||
<!-- WAMG: Client weight > 350 lbs (up to 400) -->
|
||||
<record id="upcharge_wamg" model="fusion.wc.upcharge.rule">
|
||||
<field name="name">Extra Heavy Duty 350+ lbs (WAMG)</field>
|
||||
<field name="sequence">31</field>
|
||||
<field name="trigger_type">weight</field>
|
||||
<field name="weight_min">350.0</field>
|
||||
<field name="weight_max">400.0</field>
|
||||
<field name="adp_device_code">WAMG</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="mutually_exclusive_group">weight</field>
|
||||
<field name="description">Client weight exceeds 350 lbs — extra heavy duty frame upcharge.</field>
|
||||
</record>
|
||||
|
||||
<!-- WAMH: Client weight > 400 lbs -->
|
||||
<record id="upcharge_wamh" model="fusion.wc.upcharge.rule">
|
||||
<field name="name">Bariatric 400+ lbs (WAMH)</field>
|
||||
<field name="sequence">32</field>
|
||||
<field name="trigger_type">weight</field>
|
||||
<field name="weight_min">400.0</field>
|
||||
<field name="weight_max">0</field>
|
||||
<field name="adp_device_code">WAMH</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="mutually_exclusive_group">weight</field>
|
||||
<field name="description">Client weight exceeds 400 lbs — bariatric frame upcharge.</field>
|
||||
</record>
|
||||
|
||||
<!-- SEICF160L: Backrest width differs from seat width -->
|
||||
<record id="upcharge_seicf160l" model="fusion.wc.upcharge.rule">
|
||||
<field name="name">Different Backrest Width (SEICF160L)</field>
|
||||
<field name="sequence">40</field>
|
||||
<field name="trigger_type">dimension_mismatch</field>
|
||||
<field name="compare_field_1">seat_width</field>
|
||||
<field name="compare_field_2">back_width</field>
|
||||
<field name="adp_device_code">SEICF160L</field>
|
||||
<field name="equipment_type">both</field>
|
||||
<field name="description">Backrest width differs from seat width — adapter hardware required.</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
14
fusion_quotations/models/__init__.py
Normal file
14
fusion_quotations/models/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import equipment_type
|
||||
from . import wc_section
|
||||
from . import wc_section_option
|
||||
from . import wc_upcharge_rule
|
||||
from . import wc_assessment
|
||||
from . import wc_assessment_line
|
||||
from . import sale_order
|
||||
from . import wc_config_flow
|
||||
from . import wc_config_flow_node
|
||||
from . import wc_config_flow_connection
|
||||
from . import wc_config_flow_node_option
|
||||
from . import wc_config_flow_step
|
||||
BIN
fusion_quotations/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_quotations/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_quotations/models/__pycache__/sale_order.cpython-312.pyc
Normal file
BIN
fusion_quotations/models/__pycache__/sale_order.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
fusion_quotations/models/__pycache__/wc_section.cpython-312.pyc
Normal file
BIN
fusion_quotations/models/__pycache__/wc_section.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
23
fusion_quotations/models/equipment_type.py
Normal file
23
fusion_quotations/models/equipment_type.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class EquipmentType(models.Model):
|
||||
_name = 'fusion.equipment.type'
|
||||
_description = 'Equipment Type'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(string='Name', required=True,
|
||||
help='e.g. "Manual Wheelchair", "Stair Lift", "Porch Lift"')
|
||||
code = fields.Char(string='Code', required=True, index=True,
|
||||
help='Technical code used in Selection fields, e.g. "manual_wheelchair", "stair_lift"')
|
||||
icon = fields.Char(string='Icon', default='fa-cog',
|
||||
help='FontAwesome icon class, e.g. "fa-wheelchair", "fa-arrow-up"')
|
||||
description = fields.Text(string='Description')
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('code_unique', 'unique(code)', 'Equipment type code must be unique.'),
|
||||
]
|
||||
11
fusion_quotations/models/sale_order.py
Normal file
11
fusion_quotations/models/sale_order.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
wc_assessment_id = fields.Many2one('fusion.wc.assessment',
|
||||
string='Wheelchair Assessment', copy=False,
|
||||
help='The wheelchair assessment that generated this quotation')
|
||||
849
fusion_quotations/models/wc_assessment.py
Normal file
849
fusion_quotations/models/wc_assessment.py
Normal file
@@ -0,0 +1,849 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from markupsafe import Markup
|
||||
import json
|
||||
import uuid
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WheelchairAssessment(models.Model):
|
||||
_name = 'fusion.wc.assessment'
|
||||
_description = 'Wheelchair Assessment & Quotation Builder'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'create_date desc, id desc'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
# =========================================================================
|
||||
# STATE & IDENTITY
|
||||
# =========================================================================
|
||||
state = fields.Selection([
|
||||
('draft', 'In Progress'),
|
||||
('review', 'Ready for Review'),
|
||||
('quoted', 'Quotation Generated'),
|
||||
('cancelled', 'Cancelled'),
|
||||
], string='Status', default='draft', tracking=True, index=True)
|
||||
|
||||
display_name = fields.Char(
|
||||
string='Display Name',
|
||||
compute='_compute_display_name',
|
||||
store=True,
|
||||
)
|
||||
reference = fields.Char(
|
||||
string='Reference',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
default=lambda self: _('New'),
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# STEP 1: CLIENT & EQUIPMENT
|
||||
# =========================================================================
|
||||
partner_id = fields.Many2one('res.partner', string='Client',
|
||||
tracking=True, index=True)
|
||||
create_new_partner = fields.Boolean(string='New Client', default=False)
|
||||
|
||||
# Client info (for new clients or override)
|
||||
client_first_name = fields.Char(string='First Name')
|
||||
client_last_name = fields.Char(string='Last Name')
|
||||
client_name = fields.Char(string='Client Name',
|
||||
compute='_compute_client_name', store=True)
|
||||
client_phone = fields.Char(string='Phone')
|
||||
client_email = fields.Char(string='Email')
|
||||
client_street = fields.Char(string='Street')
|
||||
client_street2 = fields.Char(string='Unit/Apt')
|
||||
client_city = fields.Char(string='City')
|
||||
client_state_id = fields.Many2one('res.country.state', string='Province')
|
||||
client_zip = fields.Char(string='Postal Code')
|
||||
client_country_id = fields.Many2one('res.country', string='Country')
|
||||
client_dob = fields.Date(string='Date of Birth')
|
||||
client_health_card = fields.Char(string='Health Card Number')
|
||||
|
||||
equipment_type = fields.Selection(
|
||||
selection='_get_equipment_type_selection',
|
||||
string='Equipment Type', required=True, tracking=True, index=True)
|
||||
|
||||
@api.model
|
||||
def _get_equipment_type_selection(self):
|
||||
types = self.env['fusion.equipment.type'].sudo().search([], order='sequence')
|
||||
if types:
|
||||
return [(t.code, t.name) for t in types]
|
||||
return [
|
||||
('manual_wheelchair', 'Manual Wheelchair'),
|
||||
('power_wheelchair', 'Power Wheelchair / Scooter'),
|
||||
('walker', 'Walker / Rollator / Ambulation Aid'),
|
||||
]
|
||||
|
||||
wheelchair_type = fields.Selection([
|
||||
('type_1', 'Type 1 - Standard'),
|
||||
('type_2', 'Type 2 - Lightweight'),
|
||||
('type_3', 'Type 3 - Ultra Lightweight'),
|
||||
('type_4', 'Type 4 - Rigid Frame'),
|
||||
('type_5', 'Type 5 - Dynamic Tilt'),
|
||||
], string='Wheelchair Category')
|
||||
|
||||
powerchair_type = fields.Selection([
|
||||
('type_1', 'Power Base Type 1'),
|
||||
('type_2', 'Power Base Type 2'),
|
||||
('type_3', 'Power Base Type 3'),
|
||||
('scooter', 'Power Scooter'),
|
||||
], string='Power Device Category')
|
||||
|
||||
walker_type = fields.Selection([
|
||||
('adult_walker_1', 'Adult Wheeled Walker Type 1'),
|
||||
('adult_walker_2', 'Adult Wheeled Walker Type 2'),
|
||||
('adult_walker_3', 'Adult Wheeled Walker Type 3'),
|
||||
('paed_walker_1', 'Paediatric Specific Wheeled Walker Type 1'),
|
||||
('paed_walker_2', 'Paediatric Specific Wheeled Walker Type 2'),
|
||||
('paed_walking_frame', 'Paediatric Specific Walking Frame'),
|
||||
('paed_standing_1', 'Paediatric Standing Frame Type 1'),
|
||||
('paed_standing_2', 'Paediatric Standing Frame Type 2'),
|
||||
('forearm_crutches', 'Forearm Crutches'),
|
||||
('stroller', 'Paediatric Specific Specialty Stroller'),
|
||||
], string='Walker / Aid Category')
|
||||
|
||||
client_type = fields.Selection([
|
||||
('reg', 'REG - Regular ADP (75/25)'),
|
||||
('ods', 'ODS - ODSP (100% ADP)'),
|
||||
('acs', 'ACS - ACSD (100% ADP)'),
|
||||
('owp', 'OWP - Ontario Works (100% ADP)'),
|
||||
('ltc', 'LTC - Long Term Care'),
|
||||
('sen', 'SEN - Senior'),
|
||||
('cca', 'CCA - Community Care'),
|
||||
], string='Client Type', default='reg')
|
||||
|
||||
build_type = fields.Selection([
|
||||
('modular', 'Modular'),
|
||||
('custom_fabricated', 'Custom Fabricated'),
|
||||
], string='Build Type', default='modular')
|
||||
|
||||
reason_for_application = fields.Selection([
|
||||
('first_access', 'First Access'),
|
||||
('additions', 'Additions'),
|
||||
('modifications', 'Modifications'),
|
||||
('replacements', 'Replacements'),
|
||||
], string='Reason for Application')
|
||||
|
||||
# Assessment context
|
||||
sales_rep_id = fields.Many2one('res.users', string='Sales Rep',
|
||||
default=lambda self: self.env.user, tracking=True, index=True)
|
||||
authorizer_id = fields.Many2one('res.partner', string='Authorizer/OT',
|
||||
tracking=True)
|
||||
assessment_date = fields.Datetime(string='Assessment Date',
|
||||
default=fields.Datetime.now)
|
||||
|
||||
# =========================================================================
|
||||
# STEP 2: ADP PRESCRIPTION MEASUREMENTS
|
||||
# =========================================================================
|
||||
seat_width = fields.Float(string='Seat Width', digits=(10, 2))
|
||||
seat_width_unit = fields.Selection([
|
||||
('inches', 'Inches'), ('cm', 'cm')
|
||||
], string='Seat Width Unit', default='inches')
|
||||
|
||||
seat_depth = fields.Float(string='Seat Depth', digits=(10, 2))
|
||||
seat_depth_unit = fields.Selection([
|
||||
('inches', 'Inches'), ('cm', 'cm')
|
||||
], string='Seat Depth Unit', default='inches')
|
||||
|
||||
finished_seat_to_floor_height = fields.Float(
|
||||
string='Finished Seat to Floor Height', digits=(10, 2))
|
||||
seat_to_floor_unit = fields.Selection([
|
||||
('inches', 'Inches'), ('cm', 'cm')
|
||||
], string='Seat to Floor Unit', default='inches')
|
||||
|
||||
back_cane_height = fields.Float(string='Back Cane Height', digits=(10, 2))
|
||||
cane_height_unit = fields.Selection([
|
||||
('inches', 'Inches'), ('cm', 'cm')
|
||||
], string='Cane Height Unit', default='inches')
|
||||
|
||||
finished_back_height = fields.Float(
|
||||
string='Finished Back Height', digits=(10, 2))
|
||||
back_height_unit = fields.Selection([
|
||||
('inches', 'Inches'), ('cm', 'cm')
|
||||
], string='Back Height Unit', default='inches')
|
||||
|
||||
finished_leg_rest_length = fields.Float(
|
||||
string='Finished Leg Rest Length', digits=(10, 2))
|
||||
leg_rest_unit = fields.Selection([
|
||||
('inches', 'Inches'), ('cm', 'cm')
|
||||
], string='Leg Rest Unit', default='inches')
|
||||
|
||||
client_weight = fields.Float(string='Client Weight', digits=(10, 1))
|
||||
client_weight_unit = fields.Selection([
|
||||
('lbs', 'lbs'), ('kg', 'kg')
|
||||
], string='Weight Unit', default='lbs')
|
||||
|
||||
# =========================================================================
|
||||
# WALKER / AMBULATION AID PRESCRIPTION (Section 2a)
|
||||
# =========================================================================
|
||||
walker_seat_height = fields.Float(string='Seat Height', digits=(10, 2))
|
||||
walker_seat_height_unit = fields.Selection([
|
||||
('inches', 'Inches'), ('cm', 'cm'), ('na', 'N/A')
|
||||
], string='Seat Height Unit', default='inches')
|
||||
|
||||
push_handle_height = fields.Float(string='Push Handle Height', digits=(10, 2))
|
||||
push_handle_height_unit = fields.Selection([
|
||||
('inches', 'Inches'), ('cm', 'cm')
|
||||
], string='Push Handle Height Unit', default='inches')
|
||||
|
||||
hand_grips = fields.Selection([
|
||||
('none', 'None'), ('standard', 'Standard'), ('anatomical', 'Anatomical'),
|
||||
], string='Hand Grips')
|
||||
forearm_attachments = fields.Selection([
|
||||
('one', 'One'), ('two', 'Two'),
|
||||
], string='Forearm Attachments')
|
||||
|
||||
width_between_push_handles = fields.Float(
|
||||
string='Width Between Push Handles', digits=(10, 2))
|
||||
push_handle_width_unit = fields.Selection([
|
||||
('inches', 'Inches'), ('cm', 'cm')
|
||||
], string='Push Handle Width Unit', default='inches')
|
||||
|
||||
walker_brakes = fields.Selection([
|
||||
('none', 'None'), ('push_to_lock', 'Push-To-Lock'),
|
||||
('auto_stop', 'Auto Stop'),
|
||||
], string='Brakes')
|
||||
walker_brake_type = fields.Selection([
|
||||
('none', 'None'), ('bilateral', 'Bilateral'),
|
||||
('one_hand', 'One Hand'),
|
||||
], string='Brake Type')
|
||||
walker_num_wheels = fields.Selection([
|
||||
('two', 'Two'), ('three', 'Three'), ('four', 'Four'),
|
||||
], string='Number of Wheels')
|
||||
walker_wheel_size = fields.Selection([
|
||||
('4_6', '4-6 inches'), ('6_8', '6-8 inches'), ('8_10', '8-10 inches'),
|
||||
], string='Wheel Size')
|
||||
walker_back_support = fields.Selection([
|
||||
('yes', 'Yes'), ('no', 'No'),
|
||||
], string='Back Support')
|
||||
|
||||
# Walker ADP options (checkboxes)
|
||||
walker_adolescent_wheeled_walker = fields.Boolean(
|
||||
string='Adolescent Size Paediatric Specific Wheeled Walker')
|
||||
walker_adolescent_walking_frame = fields.Boolean(
|
||||
string='Adolescent Size Paediatric Wheeled Walker – Walking Frame')
|
||||
walker_adolescent_standing_frame = fields.Boolean(
|
||||
string='Adolescent Size Paediatric Standing Frame')
|
||||
|
||||
# =========================================================================
|
||||
# POWER BASE / SCOOTER PRESCRIPTION (Section 2c)
|
||||
# =========================================================================
|
||||
# Note: power bases share seat_width, finished_back_height,
|
||||
# finished_seat_to_floor_height, finished_leg_rest_length, seat_depth,
|
||||
# and client_weight from the main measurement fields above.
|
||||
# Scooters only require client_weight (field 6).
|
||||
|
||||
# Power ADP options (checkboxes from screenshot)
|
||||
pw_adjustable_tension_back = fields.Boolean(
|
||||
string='Adjustable Tension Back Upholstery')
|
||||
pw_midline_control = fields.Boolean(string='Midline Control')
|
||||
pw_manual_recline = fields.Boolean(string='Manual Recline Option')
|
||||
pw_angle_adjustable_footplates = fields.Boolean(
|
||||
string='Angle Adjustable Footplates (pair)')
|
||||
pw_manual_elevating_legrests = fields.Boolean(
|
||||
string='Manual Elevating Legrests (pair)')
|
||||
pw_swingaway_bracket = fields.Boolean(string='Swingaway Mounting Bracket')
|
||||
pw_front_riggings = fields.Boolean(string='One Piece 90/90 Front Riggings')
|
||||
pw_seat_package_1 = fields.Boolean(
|
||||
string='Seat Package 1 for Power Bases',
|
||||
help='Includes frame, sling upholstery, armrests, footrests')
|
||||
pw_seat_package_2 = fields.Boolean(
|
||||
string='Seat Package 2 for Power Bases',
|
||||
help='Includes deluxe seat and back, armrests, footrests')
|
||||
pw_oxygen_tank = fields.Boolean(string='Oxygen Tank Holder')
|
||||
pw_ventilator_tray = fields.Boolean(string='Ventilator Tray')
|
||||
|
||||
# Specialty Components (* require clinical rationale)
|
||||
pw_specialty_1_joystick = fields.Boolean(
|
||||
string='Specialty Controls 1 Non Standard Joystick*')
|
||||
pw_specialty_2_chin = fields.Boolean(
|
||||
string='Specialty Controls 2 Chin/Rim Control*')
|
||||
pw_specialty_3_touch = fields.Boolean(
|
||||
string='Specialty Controls 3 Simple Touch*')
|
||||
pw_specialty_4_proximity = fields.Boolean(
|
||||
string='Specialty Controls 4 Proximity Control*')
|
||||
pw_specialty_5_breath = fields.Boolean(
|
||||
string='Specialty Controls 5 Breath Control*')
|
||||
pw_specialty_6_scanners = fields.Boolean(
|
||||
string='Specialty Controls 6 Scanners*')
|
||||
pw_auto_correction = fields.Boolean(string='Auto Correction System*')
|
||||
pw_specialty_rationale = fields.Text(
|
||||
string='Clinical Rationale for Specialty Components')
|
||||
|
||||
# Power Positioning Devices (require Justification for Funding Chart)
|
||||
pw_power_tilt_only = fields.Boolean(string='Power Tilt Only')
|
||||
pw_power_recline_only = fields.Boolean(string='Power Recline Only')
|
||||
pw_power_tilt_recline = fields.Boolean(string='Power Tilt and Recline')
|
||||
pw_power_elevating_footrests = fields.Boolean(
|
||||
string='Power Elevating Footrests')
|
||||
pw_multi_function_control = fields.Boolean(
|
||||
string='Multi-Function Control Box')
|
||||
pw_power_add_on = fields.Boolean(string='Power Add-On Device')
|
||||
|
||||
# Computed: convert to inches/lbs for rule evaluation
|
||||
seat_width_inches = fields.Float(
|
||||
compute='_compute_normalized_measurements', store=True)
|
||||
seat_depth_inches = fields.Float(
|
||||
compute='_compute_normalized_measurements', store=True)
|
||||
back_height_inches = fields.Float(
|
||||
compute='_compute_normalized_measurements', store=True)
|
||||
client_weight_lbs = fields.Float(
|
||||
compute='_compute_normalized_measurements', store=True)
|
||||
|
||||
# =========================================================================
|
||||
# STEP 3: FRAME
|
||||
# =========================================================================
|
||||
frame_product_tmpl_id = fields.Many2one('product.template',
|
||||
string='Frame Template')
|
||||
frame_product_id = fields.Many2one('product.product',
|
||||
string='Wheelchair Frame')
|
||||
frame_notes = fields.Text(string='Frame Notes')
|
||||
|
||||
# =========================================================================
|
||||
# STEP 4 & 5: SEATING, OPTIONS, ACCESSORIES (via lines)
|
||||
# =========================================================================
|
||||
line_ids = fields.One2many('fusion.wc.assessment.line', 'assessment_id',
|
||||
string='Selected Items')
|
||||
upcharge_line_ids = fields.One2many('fusion.wc.assessment.line', 'assessment_id',
|
||||
domain=[('is_upcharge', '=', True)], string='Auto-Applied Upcharges')
|
||||
|
||||
# =========================================================================
|
||||
# STEP 6: REVIEW & RESULT
|
||||
# =========================================================================
|
||||
sale_order_id = fields.Many2one('sale.order',
|
||||
string='Generated Quotation', readonly=True, copy=False)
|
||||
total_estimate = fields.Float(string='Total Estimate',
|
||||
compute='_compute_totals', store=True)
|
||||
total_adp_estimate = fields.Float(string='Estimated ADP Portion',
|
||||
compute='_compute_totals', store=True)
|
||||
total_client_estimate = fields.Float(string='Estimated Client Portion',
|
||||
compute='_compute_totals', store=True)
|
||||
|
||||
notes = fields.Text(string='Additional Notes')
|
||||
|
||||
# Form state for save/resume
|
||||
current_step = fields.Integer(string='Current Step', default=1)
|
||||
form_data_json = fields.Text(string='Form State (JSON)',
|
||||
help='Stores intermediate form state for save/resume')
|
||||
|
||||
# Generic equipment data (for non-wheelchair equipment types)
|
||||
equipment_data_json = fields.Text(string='Equipment Data (JSON)',
|
||||
help='JSON storage for equipment-specific field values. '
|
||||
'Used by dynamic measurement/custom steps for non-wheelchair types.')
|
||||
|
||||
# =========================================================================
|
||||
# SHARING & PUBLIC ACCESS
|
||||
# =========================================================================
|
||||
access_token = fields.Char(
|
||||
string='Access Token', copy=False, index=True,
|
||||
help='Token for public access without login')
|
||||
portal_url = fields.Char(
|
||||
string='Portal URL', compute='_compute_portal_url')
|
||||
public_url = fields.Char(
|
||||
string='Public URL', compute='_compute_public_url')
|
||||
|
||||
# =========================================================================
|
||||
# COMPUTED FIELDS
|
||||
# =========================================================================
|
||||
@api.depends('client_first_name', 'client_last_name', 'partner_id')
|
||||
def _compute_client_name(self):
|
||||
for rec in self:
|
||||
if rec.partner_id:
|
||||
rec.client_name = rec.partner_id.name
|
||||
elif rec.client_first_name or rec.client_last_name:
|
||||
parts = [rec.client_first_name or '', rec.client_last_name or '']
|
||||
rec.client_name = ' '.join(p for p in parts if p)
|
||||
else:
|
||||
rec.client_name = ''
|
||||
|
||||
@api.depends('reference', 'client_name')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
parts = [rec.reference or _('New')]
|
||||
if rec.client_name:
|
||||
parts.append(rec.client_name)
|
||||
rec.display_name = ' - '.join(parts)
|
||||
|
||||
@api.depends(
|
||||
'seat_width', 'seat_width_unit',
|
||||
'seat_depth', 'seat_depth_unit',
|
||||
'finished_back_height', 'back_height_unit',
|
||||
'client_weight', 'client_weight_unit',
|
||||
)
|
||||
def _compute_normalized_measurements(self):
|
||||
"""Convert all measurements to inches/lbs for consistent rule evaluation."""
|
||||
for rec in self:
|
||||
rec.seat_width_inches = (
|
||||
rec.seat_width if rec.seat_width_unit == 'inches'
|
||||
else rec.seat_width / 2.54
|
||||
)
|
||||
rec.seat_depth_inches = (
|
||||
rec.seat_depth if rec.seat_depth_unit == 'inches'
|
||||
else rec.seat_depth / 2.54
|
||||
)
|
||||
rec.back_height_inches = (
|
||||
rec.finished_back_height if rec.back_height_unit == 'inches'
|
||||
else rec.finished_back_height / 2.54
|
||||
)
|
||||
rec.client_weight_lbs = (
|
||||
rec.client_weight if rec.client_weight_unit == 'lbs'
|
||||
else rec.client_weight * 2.20462
|
||||
)
|
||||
|
||||
@api.depends('line_ids.subtotal', 'line_ids.adp_portion', 'line_ids.client_portion')
|
||||
def _compute_totals(self):
|
||||
for rec in self:
|
||||
rec.total_estimate = sum(rec.line_ids.mapped('subtotal'))
|
||||
rec.total_adp_estimate = sum(rec.line_ids.mapped('adp_portion'))
|
||||
rec.total_client_estimate = sum(rec.line_ids.mapped('client_portion'))
|
||||
|
||||
def _compute_portal_url(self):
|
||||
base = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
|
||||
for rec in self:
|
||||
rec.portal_url = f'{base}/my/quotation/builder/{rec.id}/edit' if rec.id else False
|
||||
|
||||
def _compute_public_url(self):
|
||||
base = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
|
||||
for rec in self:
|
||||
if rec.access_token:
|
||||
rec.public_url = f'{base}/quotation/form/{rec.access_token}'
|
||||
else:
|
||||
rec.public_url = False
|
||||
|
||||
# =========================================================================
|
||||
# SHARING ACTIONS
|
||||
# =========================================================================
|
||||
def _generate_access_token(self):
|
||||
"""Generate a unique access token for public sharing."""
|
||||
for rec in self:
|
||||
if not rec.access_token:
|
||||
rec.access_token = uuid.uuid4().hex
|
||||
|
||||
def action_open_portal_form(self):
|
||||
"""Open the portal assessment form in a new browser tab."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': self.portal_url,
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_generate_share_link(self):
|
||||
"""Generate a public access token and show the shareable URL."""
|
||||
self.ensure_one()
|
||||
self._generate_access_token()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Share Link Generated'),
|
||||
'message': _('Public URL copied to clipboard — '
|
||||
'also visible in the Sharing section of this form.'),
|
||||
'type': 'info',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# CRUD OVERRIDES
|
||||
# =========================================================================
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('reference', _('New')) == _('New'):
|
||||
vals['reference'] = self.env['ir.sequence'].next_by_code(
|
||||
'fusion.wc.assessment') or _('New')
|
||||
return super().create(vals_list)
|
||||
|
||||
# =========================================================================
|
||||
# UPCHARGE RULE ENGINE
|
||||
# =========================================================================
|
||||
def _evaluate_upcharge_rules(self):
|
||||
"""Evaluate all active upcharge rules against current assessment data.
|
||||
Creates assessment lines for triggered rules.
|
||||
Returns recordset of triggered rules.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Remove previously auto-applied upcharges (allows re-evaluation)
|
||||
self.line_ids.filtered(lambda l: l.is_upcharge).unlink()
|
||||
|
||||
rules = self.env['fusion.wc.upcharge.rule'].search([
|
||||
('active', '=', True),
|
||||
('equipment_type', 'in', [self.equipment_type, 'both']),
|
||||
])
|
||||
|
||||
triggered = self.env['fusion.wc.upcharge.rule']
|
||||
# Track exclusive groups — only the first matching rule per group applies
|
||||
exclusive_triggered = {}
|
||||
|
||||
for rule in rules.sorted('sequence'):
|
||||
# Check mutual exclusion
|
||||
if rule.mutually_exclusive_group:
|
||||
if rule.mutually_exclusive_group in exclusive_triggered:
|
||||
continue
|
||||
|
||||
matched = False
|
||||
|
||||
if rule.trigger_type == 'measurement':
|
||||
value = self._get_measurement_value(rule.measurement_field)
|
||||
if value and self._compare_values(value, rule.comparison, rule.threshold_value):
|
||||
matched = True
|
||||
|
||||
elif rule.trigger_type == 'weight':
|
||||
weight = self.client_weight_lbs
|
||||
if weight > rule.weight_min:
|
||||
if not rule.weight_max or weight <= rule.weight_max:
|
||||
matched = True
|
||||
|
||||
elif rule.trigger_type == 'dimension_mismatch':
|
||||
val1 = self._get_measurement_value(rule.compare_field_1)
|
||||
val2 = self._get_measurement_value(rule.compare_field_2)
|
||||
if val1 and val2 and abs(val1 - val2) > 0.01:
|
||||
matched = True
|
||||
|
||||
if matched:
|
||||
self._create_upcharge_line(rule)
|
||||
triggered |= rule
|
||||
if rule.mutually_exclusive_group:
|
||||
exclusive_triggered[rule.mutually_exclusive_group] = rule
|
||||
|
||||
return triggered
|
||||
|
||||
def _get_measurement_value(self, field_name):
|
||||
"""Map measurement field selection to actual normalized (inches) value."""
|
||||
field_map = {
|
||||
'seat_width': self.seat_width_inches,
|
||||
'seat_depth': self.seat_depth_inches,
|
||||
'back_width': self.seat_width_inches, # backrest width defaults to seat width
|
||||
'back_height': self.back_height_inches,
|
||||
'seat_to_floor': (
|
||||
self.finished_seat_to_floor_height
|
||||
if self.seat_to_floor_unit == 'inches'
|
||||
else self.finished_seat_to_floor_height / 2.54
|
||||
),
|
||||
'leg_rest_length': (
|
||||
self.finished_leg_rest_length
|
||||
if self.leg_rest_unit == 'inches'
|
||||
else self.finished_leg_rest_length / 2.54
|
||||
),
|
||||
}
|
||||
return field_map.get(field_name, 0.0)
|
||||
|
||||
@staticmethod
|
||||
def _compare_values(value, comparison, threshold):
|
||||
"""Evaluate a comparison between value and threshold."""
|
||||
if comparison == 'gt':
|
||||
return value > threshold
|
||||
elif comparison == 'gte':
|
||||
return value >= threshold
|
||||
elif comparison == 'lt':
|
||||
return value < threshold
|
||||
elif comparison == 'eq':
|
||||
return abs(value - threshold) < 0.01
|
||||
elif comparison == 'neq':
|
||||
return abs(value - threshold) >= 0.01
|
||||
return False
|
||||
|
||||
def _create_upcharge_line(self, rule):
|
||||
"""Create an assessment line from an upcharge rule."""
|
||||
ADPDevice = self.env['fusion.adp.device.code'].sudo()
|
||||
adp_device = ADPDevice.search(
|
||||
[('device_code', '=', rule.adp_device_code), ('active', '=', True)],
|
||||
limit=1
|
||||
)
|
||||
|
||||
vals = {
|
||||
'assessment_id': self.id,
|
||||
'product_id': rule.product_id.id if rule.product_id else False,
|
||||
'product_name': (
|
||||
adp_device.device_description
|
||||
if adp_device else rule.name
|
||||
),
|
||||
'quantity': 1,
|
||||
'adp_device_code': rule.adp_device_code,
|
||||
'adp_price': adp_device.adp_price if adp_device else 0,
|
||||
'unit_price': adp_device.adp_price if adp_device else 0,
|
||||
'is_upcharge': True,
|
||||
'upcharge_rule_id': rule.id,
|
||||
'upcharge_reason': rule.description or rule.name,
|
||||
}
|
||||
return self.env['fusion.wc.assessment.line'].create(vals)
|
||||
|
||||
# =========================================================================
|
||||
# QUOTATION GENERATION
|
||||
# =========================================================================
|
||||
def action_generate_quotation(self):
|
||||
"""Generate a draft sale.order from this assessment."""
|
||||
self.ensure_one()
|
||||
if self.sale_order_id:
|
||||
raise UserError(_('A quotation has already been generated for this assessment.'))
|
||||
|
||||
# Run upcharge engine first
|
||||
triggered_rules = self._evaluate_upcharge_rules()
|
||||
|
||||
# Resolve or create partner
|
||||
partner = self._resolve_partner()
|
||||
|
||||
# Add frame as an assessment line if not already present
|
||||
self._ensure_frame_line()
|
||||
|
||||
# Create sale order
|
||||
so_vals = self._prepare_sale_order_vals(partner)
|
||||
sale_order = self.env['sale.order'].sudo().create(so_vals)
|
||||
|
||||
# Create sale order lines from assessment lines
|
||||
for line in self.line_ids:
|
||||
sol_vals = self._prepare_sale_order_line_vals(sale_order, line)
|
||||
self.env['sale.order.line'].sudo().create(sol_vals)
|
||||
|
||||
# Update assessment
|
||||
self.write({
|
||||
'sale_order_id': sale_order.id,
|
||||
'state': 'quoted',
|
||||
})
|
||||
|
||||
# Post chatter message
|
||||
self._post_quotation_message(sale_order, triggered_rules)
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'sale.order',
|
||||
'res_id': sale_order.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def _resolve_partner(self):
|
||||
"""Get existing partner or create a new one."""
|
||||
self.ensure_one()
|
||||
if self.partner_id:
|
||||
return self.partner_id
|
||||
|
||||
if not self.client_first_name and not self.client_last_name:
|
||||
raise UserError(_('Please select an existing client or provide client name.'))
|
||||
|
||||
partner_vals = {
|
||||
'name': self.client_name,
|
||||
'phone': self.client_phone,
|
||||
'email': self.client_email,
|
||||
'street': self.client_street,
|
||||
'street2': self.client_street2,
|
||||
'city': self.client_city,
|
||||
'state_id': self.client_state_id.id if self.client_state_id else False,
|
||||
'zip': self.client_zip,
|
||||
'country_id': self.client_country_id.id if self.client_country_id else False,
|
||||
'customer_rank': 1,
|
||||
}
|
||||
partner = self.env['res.partner'].sudo().create(partner_vals)
|
||||
self.partner_id = partner
|
||||
return partner
|
||||
|
||||
def _ensure_frame_line(self):
|
||||
"""Add the frame product as an assessment line if not already present."""
|
||||
self.ensure_one()
|
||||
|
||||
# If we have a template but no resolved variant, resolve to first variant
|
||||
if not self.frame_product_id and self.frame_product_tmpl_id:
|
||||
variant = self.frame_product_tmpl_id.product_variant_ids[:1]
|
||||
if variant:
|
||||
self.frame_product_id = variant.id
|
||||
|
||||
if not self.frame_product_id:
|
||||
return
|
||||
|
||||
# Pick the right frame section based on equipment type
|
||||
frame_code_map = {
|
||||
'manual_wheelchair': 'mw_frame',
|
||||
'power_wheelchair': 'pw_frame',
|
||||
'walker': 'walker_frame',
|
||||
}
|
||||
code = frame_code_map.get(self.equipment_type, 'mw_frame')
|
||||
frame_section = self.env['fusion.wc.section'].search(
|
||||
[('code', '=', code)], limit=1)
|
||||
if not frame_section:
|
||||
# Fall back to legacy code
|
||||
frame_section = self.env['fusion.wc.section'].search(
|
||||
[('code', '=', 'frame')], limit=1)
|
||||
|
||||
existing = self.line_ids.filtered(
|
||||
lambda l: l.product_id == self.frame_product_id and not l.is_upcharge)
|
||||
if existing:
|
||||
return
|
||||
|
||||
# Build description from measurements
|
||||
desc_parts = [self.frame_product_id.display_name]
|
||||
if self.seat_width:
|
||||
desc_parts.append(f"Seat Width: {self.seat_width}{self.seat_width_unit}")
|
||||
if self.seat_depth:
|
||||
desc_parts.append(f"Seat Depth: {self.seat_depth}{self.seat_depth_unit}")
|
||||
if self.finished_seat_to_floor_height:
|
||||
desc_parts.append(
|
||||
f"Seat to Floor: {self.finished_seat_to_floor_height}"
|
||||
f"{self.seat_to_floor_unit}")
|
||||
if self.finished_leg_rest_length:
|
||||
desc_parts.append(
|
||||
f"Leg Rest: {self.finished_leg_rest_length}{self.leg_rest_unit}")
|
||||
if self.back_cane_height:
|
||||
desc_parts.append(
|
||||
f"Cane Height: {self.back_cane_height}{self.cane_height_unit}")
|
||||
|
||||
product = self.frame_product_id
|
||||
adp_code = product.product_tmpl_id.x_fc_adp_device_code or ''
|
||||
adp_price = product.product_tmpl_id.x_fc_adp_price or product.lst_price
|
||||
|
||||
self.env['fusion.wc.assessment.line'].create({
|
||||
'assessment_id': self.id,
|
||||
'section_id': frame_section.id if frame_section else False,
|
||||
'product_id': product.id,
|
||||
'product_name': '\n'.join(desc_parts),
|
||||
'quantity': 1,
|
||||
'adp_device_code': adp_code,
|
||||
'adp_price': adp_price,
|
||||
'unit_price': product.lst_price,
|
||||
})
|
||||
|
||||
def _prepare_sale_order_vals(self, partner):
|
||||
"""Prepare values for sale.order creation."""
|
||||
self.ensure_one()
|
||||
|
||||
# Map client_type to fusion_claims x_fc_client_type
|
||||
client_type_map = {
|
||||
'reg': 'REG',
|
||||
'ods': 'ODS',
|
||||
'acs': 'ACS',
|
||||
'owp': 'OWP',
|
||||
'ltc': 'LTC',
|
||||
'sen': 'SEN',
|
||||
'cca': 'CCA',
|
||||
}
|
||||
|
||||
vals = {
|
||||
'partner_id': partner.id,
|
||||
'user_id': self.sales_rep_id.id,
|
||||
'state': 'draft',
|
||||
'origin': f'WC Assessment: {self.reference}',
|
||||
'wc_assessment_id': self.id,
|
||||
}
|
||||
|
||||
# Set fusion_claims fields if available
|
||||
if hasattr(self.env['sale.order'], 'x_fc_sale_type'):
|
||||
vals['x_fc_sale_type'] = 'adp'
|
||||
if hasattr(self.env['sale.order'], 'x_fc_client_type'):
|
||||
vals['x_fc_client_type'] = client_type_map.get(self.client_type, 'REG')
|
||||
if hasattr(self.env['sale.order'], 'x_fc_adp_application_status'):
|
||||
vals['x_fc_adp_application_status'] = 'quotation'
|
||||
if self.authorizer_id and hasattr(self.env['sale.order'], 'x_fc_authorizer_id'):
|
||||
vals['x_fc_authorizer_id'] = self.authorizer_id.id
|
||||
|
||||
return vals
|
||||
|
||||
def _prepare_sale_order_line_vals(self, sale_order, line):
|
||||
"""Prepare values for sale.order.line creation from assessment line."""
|
||||
vals = {
|
||||
'order_id': sale_order.id,
|
||||
'product_id': line.product_id.id if line.product_id else False,
|
||||
'name': line.product_name or (
|
||||
line.product_id.display_name if line.product_id else line.adp_device_code
|
||||
),
|
||||
'product_uom_qty': line.quantity,
|
||||
'price_unit': line.unit_price or (
|
||||
line.product_id.lst_price if line.product_id else 0
|
||||
),
|
||||
}
|
||||
return vals
|
||||
|
||||
def _post_quotation_message(self, sale_order, triggered_rules):
|
||||
"""Post a chatter message summarizing the assessment."""
|
||||
self.ensure_one()
|
||||
|
||||
lines_html = ''
|
||||
for line in self.line_ids:
|
||||
icon = '⚡' if line.is_upcharge else '✅'
|
||||
lines_html += (
|
||||
f'<li>{icon} {line.product_name or line.adp_device_code} '
|
||||
f'x{line.quantity} — ${line.unit_price:.2f}</li>'
|
||||
)
|
||||
|
||||
measurements_html = ''
|
||||
if self.seat_width:
|
||||
measurements_html += (
|
||||
f'<li>Seat Width: {self.seat_width} {self.seat_width_unit}</li>')
|
||||
if self.seat_depth:
|
||||
measurements_html += (
|
||||
f'<li>Seat Depth: {self.seat_depth} {self.seat_depth_unit}</li>')
|
||||
if self.finished_seat_to_floor_height:
|
||||
measurements_html += (
|
||||
f'<li>Seat to Floor: {self.finished_seat_to_floor_height} '
|
||||
f'{self.seat_to_floor_unit}</li>')
|
||||
if self.back_cane_height:
|
||||
measurements_html += (
|
||||
f'<li>Back Cane Height: {self.back_cane_height} '
|
||||
f'{self.cane_height_unit}</li>')
|
||||
if self.finished_back_height:
|
||||
measurements_html += (
|
||||
f'<li>Finished Back Height: {self.finished_back_height} '
|
||||
f'{self.back_height_unit}</li>')
|
||||
if self.finished_leg_rest_length:
|
||||
measurements_html += (
|
||||
f'<li>Leg Rest Length: {self.finished_leg_rest_length} '
|
||||
f'{self.leg_rest_unit}</li>')
|
||||
if self.client_weight:
|
||||
measurements_html += (
|
||||
f'<li>Client Weight: {self.client_weight} {self.client_weight_unit}</li>')
|
||||
|
||||
upcharges_html = ''
|
||||
if triggered_rules:
|
||||
upcharges_html = '<h4>Auto-Applied Upcharges</h4><ul>'
|
||||
for rule in triggered_rules:
|
||||
upcharges_html += (
|
||||
f'<li>⚡ {rule.name} ({rule.adp_device_code})</li>')
|
||||
upcharges_html += '</ul>'
|
||||
|
||||
body = Markup(
|
||||
f'<h3>Wheelchair Assessment Completed</h3>'
|
||||
f'<p>Assessment: {self.reference}<br/>'
|
||||
f'Equipment: {dict(self._fields["equipment_type"].selection).get(self.equipment_type, "")}<br/>'
|
||||
f'Build Type: {dict(self._fields["build_type"].selection).get(self.build_type, "")}</p>'
|
||||
f'<h4>Measurements</h4><ul>{measurements_html}</ul>'
|
||||
f'<h4>Selected Items</h4><ul>{lines_html}</ul>'
|
||||
f'{upcharges_html}'
|
||||
)
|
||||
|
||||
sale_order.message_post(body=body, message_type='comment',
|
||||
subtype_xmlid='mail.mt_note')
|
||||
|
||||
# =========================================================================
|
||||
# ACTIONS
|
||||
# =========================================================================
|
||||
def action_mark_review(self):
|
||||
"""Mark assessment as ready for review."""
|
||||
for rec in self:
|
||||
rec.state = 'review'
|
||||
|
||||
def action_cancel(self):
|
||||
"""Cancel the assessment."""
|
||||
for rec in self:
|
||||
rec.state = 'cancelled'
|
||||
|
||||
def action_reset_draft(self):
|
||||
"""Reset to draft."""
|
||||
for rec in self:
|
||||
if rec.state == 'cancelled':
|
||||
rec.state = 'draft'
|
||||
|
||||
def action_view_quotation(self):
|
||||
"""Open the generated quotation."""
|
||||
self.ensure_one()
|
||||
if not self.sale_order_id:
|
||||
raise UserError(_('No quotation has been generated yet.'))
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'sale.order',
|
||||
'res_id': self.sale_order_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
97
fusion_quotations/models/wc_assessment_line.py
Normal file
97
fusion_quotations/models/wc_assessment_line.py
Normal file
@@ -0,0 +1,97 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class WheelchairAssessmentLine(models.Model):
|
||||
_name = 'fusion.wc.assessment.line'
|
||||
_description = 'Wheelchair Assessment Line Item'
|
||||
_order = 'section_sequence, sequence, id'
|
||||
|
||||
assessment_id = fields.Many2one('fusion.wc.assessment', string='Assessment',
|
||||
required=True, ondelete='cascade', index=True)
|
||||
section_id = fields.Many2one('fusion.wc.section', string='Section', index=True)
|
||||
section_sequence = fields.Integer(
|
||||
related='section_id.sequence', store=True, string='Section Order')
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
|
||||
# Product
|
||||
product_id = fields.Many2one('product.product', string='Product', index=True)
|
||||
product_name = fields.Char(string='Description',
|
||||
help='Can override product name with custom description')
|
||||
quantity = fields.Float(string='Quantity', default=1.0)
|
||||
|
||||
# ADP info
|
||||
adp_device_code = fields.Char(string='ADP Code')
|
||||
adp_price = fields.Float(string='ADP Price', digits='Product Price')
|
||||
unit_price = fields.Float(string='Selling Price', digits='Product Price')
|
||||
|
||||
# Build type (for seating items)
|
||||
build_type = fields.Selection([
|
||||
('modular', 'Modular'),
|
||||
('custom_fabricated', 'Custom Fabricated'),
|
||||
], string='Build Type')
|
||||
|
||||
# Clinical rationale (required for certain items)
|
||||
clinical_rationale = fields.Text(string='Clinical Rationale')
|
||||
|
||||
# Computed pricing
|
||||
subtotal = fields.Float(string='Subtotal',
|
||||
compute='_compute_subtotal', store=True, digits='Product Price')
|
||||
adp_portion = fields.Float(string='ADP Portion',
|
||||
compute='_compute_portions', store=True, digits='Product Price')
|
||||
client_portion = fields.Float(string='Client Portion',
|
||||
compute='_compute_portions', store=True, digits='Product Price')
|
||||
|
||||
# Upcharge tracking
|
||||
is_upcharge = fields.Boolean(string='Auto-Applied Upcharge', default=False)
|
||||
upcharge_rule_id = fields.Many2one('fusion.wc.upcharge.rule',
|
||||
string='Triggered By Rule')
|
||||
upcharge_reason = fields.Char(string='Upcharge Reason')
|
||||
|
||||
# Section-specific measurements
|
||||
width = fields.Float(string='Width', digits=(10, 2))
|
||||
depth = fields.Float(string='Depth', digits=(10, 2))
|
||||
height = fields.Float(string='Height', digits=(10, 2))
|
||||
|
||||
notes = fields.Text(string='Notes')
|
||||
|
||||
@api.depends('unit_price', 'quantity')
|
||||
def _compute_subtotal(self):
|
||||
for line in self:
|
||||
line.subtotal = line.unit_price * line.quantity
|
||||
|
||||
@api.depends('subtotal', 'adp_price', 'quantity', 'assessment_id.client_type')
|
||||
def _compute_portions(self):
|
||||
"""Estimate ADP and client portions based on client type and ADP price."""
|
||||
for line in self:
|
||||
if not line.adp_price or not line.subtotal:
|
||||
line.adp_portion = 0.0
|
||||
line.client_portion = line.subtotal
|
||||
continue
|
||||
|
||||
client_type = line.assessment_id.client_type or 'reg'
|
||||
adp_base = line.adp_price * line.quantity
|
||||
|
||||
# Determine ADP coverage percentage
|
||||
if client_type == 'reg':
|
||||
# REG: 75% ADP, 25% client
|
||||
adp_amount = adp_base * 0.75
|
||||
else:
|
||||
# ODS, ACS, OWP, etc.: 100% ADP
|
||||
adp_amount = adp_base
|
||||
|
||||
# Client pays the rest (including anything above ADP price)
|
||||
line.adp_portion = min(adp_amount, line.subtotal)
|
||||
line.client_portion = line.subtotal - line.adp_portion
|
||||
|
||||
@api.onchange('product_id')
|
||||
def _onchange_product_id(self):
|
||||
"""Auto-fill ADP code and pricing from product."""
|
||||
if self.product_id:
|
||||
tmpl = self.product_id.product_tmpl_id
|
||||
self.adp_device_code = tmpl.x_fc_adp_device_code or ''
|
||||
self.adp_price = tmpl.x_fc_adp_price or 0.0
|
||||
self.unit_price = self.product_id.lst_price
|
||||
if not self.product_name:
|
||||
self.product_name = self.product_id.display_name
|
||||
539
fusion_quotations/models/wc_config_flow.py
Normal file
539
fusion_quotations/models/wc_config_flow.py
Normal file
@@ -0,0 +1,539 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import json
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class WheelchairConfigFlow(models.Model):
|
||||
_name = 'fusion.wc.config.flow'
|
||||
_description = 'Wheelchair Configuration Flow'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(string='Name', required=True,
|
||||
help='e.g. "Standard Manual Wheelchair Config"')
|
||||
active = fields.Boolean(default=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
|
||||
equipment_type = fields.Selection(
|
||||
selection='_get_equipment_type_selection',
|
||||
string='Equipment Type', required=True)
|
||||
|
||||
@api.model
|
||||
def _get_equipment_type_selection(self):
|
||||
types = self.env['fusion.equipment.type'].sudo().search([], order='sequence')
|
||||
if types:
|
||||
return [(t.code, t.name) for t in types]
|
||||
return [
|
||||
('manual_wheelchair', 'Manual Wheelchair'),
|
||||
('power_wheelchair', 'Power Wheelchair'),
|
||||
('walker', 'Walker / Ambulation Aid'),
|
||||
]
|
||||
|
||||
description = fields.Text(string='Description')
|
||||
|
||||
# Canvas state — JSON blob for viewport (zoom, pan) preserved across sessions
|
||||
canvas_data = fields.Text(string='Canvas Data', default='{}',
|
||||
help='JSON: viewport state for the visual designer')
|
||||
|
||||
state = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('active', 'Active'),
|
||||
('archived', 'Archived'),
|
||||
], string='Status', default='draft', tracking=True)
|
||||
|
||||
# Relationships
|
||||
node_ids = fields.One2many('fusion.wc.config.flow.node', 'flow_id',
|
||||
string='Nodes')
|
||||
connection_ids = fields.One2many('fusion.wc.config.flow.connection', 'flow_id',
|
||||
string='Connections')
|
||||
step_ids = fields.One2many('fusion.wc.config.flow.step', 'flow_id',
|
||||
string='Form Steps')
|
||||
|
||||
# Computed counts
|
||||
node_count = fields.Integer(string='Nodes', compute='_compute_counts')
|
||||
connection_count = fields.Integer(string='Connections', compute='_compute_counts')
|
||||
step_count = fields.Integer(string='Steps', compute='_compute_counts')
|
||||
|
||||
@api.depends('node_ids', 'connection_ids', 'step_ids')
|
||||
def _compute_counts(self):
|
||||
for rec in self:
|
||||
rec.node_count = len(rec.node_ids)
|
||||
rec.connection_count = len(rec.connection_ids)
|
||||
rec.step_count = len(rec.step_ids)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Actions
|
||||
# ------------------------------------------------------------------
|
||||
def action_open_designer(self):
|
||||
"""Open the visual flow designer (OWL client action)."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fusion_flow_designer',
|
||||
'name': _('Flow Designer: %s') % self.name,
|
||||
'context': {'active_id': self.id},
|
||||
}
|
||||
|
||||
def action_activate(self):
|
||||
"""Set flow to active. Deactivate other flows for same equipment type.
|
||||
Auto-creates default steps if flow has no steps defined.
|
||||
"""
|
||||
self.ensure_one()
|
||||
# Auto-create default steps if none exist
|
||||
if not self.step_ids:
|
||||
self.action_create_default_steps()
|
||||
# Deactivate other active flows for the same equipment type
|
||||
siblings = self.search([
|
||||
('equipment_type', '=', self.equipment_type),
|
||||
('state', '=', 'active'),
|
||||
('id', '!=', self.id),
|
||||
])
|
||||
siblings.write({'state': 'draft'})
|
||||
self.write({'state': 'active'})
|
||||
|
||||
def action_archive(self):
|
||||
self.ensure_one()
|
||||
self.write({'state': 'archived'})
|
||||
|
||||
def action_reset_draft(self):
|
||||
self.ensure_one()
|
||||
self.write({'state': 'draft'})
|
||||
|
||||
def action_create_default_steps(self):
|
||||
"""Generate default form steps based on equipment type.
|
||||
For wheelchair types, creates the standard 6 steps.
|
||||
For other types, creates a generic 4-step flow.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Step = self.env['fusion.wc.config.flow.step']
|
||||
|
||||
# Remove existing steps
|
||||
self.step_ids.unlink()
|
||||
|
||||
wheelchair_types = ('manual_wheelchair', 'power_wheelchair')
|
||||
if self.equipment_type in wheelchair_types:
|
||||
# ── Client step config: ADP program fields ──
|
||||
is_manual = self.equipment_type == 'manual_wheelchair'
|
||||
client_config = json.dumps({
|
||||
"show_health_card": True,
|
||||
"show_dob": True,
|
||||
"show_adp_fields": True, # client_type + reason_for_application
|
||||
"show_wheelchair_category": is_manual,
|
||||
"show_powerchair_category": not is_manual,
|
||||
})
|
||||
# ── Measurement fields ──
|
||||
mw_measurements_json = json.dumps([
|
||||
{"name": "seat_width", "label": "Seat Width", "type": "float",
|
||||
"unit_field": "seat_width_unit", "units": ["inches", "cm"], "required": True},
|
||||
{"name": "seat_depth", "label": "Seat Depth", "type": "float",
|
||||
"unit_field": "seat_depth_unit", "units": ["inches", "cm"], "required": True},
|
||||
{"name": "finished_seat_to_floor_height", "label": "Finished Seat to Floor Height",
|
||||
"type": "float", "unit_field": "seat_to_floor_unit", "units": ["inches", "cm"]},
|
||||
{"name": "back_cane_height", "label": "Back Cane Height", "type": "float",
|
||||
"unit_field": "cane_height_unit", "units": ["inches", "cm"]},
|
||||
{"name": "finished_back_height", "label": "Finished Back Height", "type": "float",
|
||||
"unit_field": "back_height_unit", "units": ["inches", "cm"]},
|
||||
{"name": "finished_leg_rest_length", "label": "Finished Leg Rest Length",
|
||||
"type": "float", "unit_field": "leg_rest_unit", "units": ["inches", "cm"]},
|
||||
{"name": "client_weight", "label": "Client Weight", "type": "float",
|
||||
"unit_field": "client_weight_unit", "units": ["lbs", "kg"], "required": True},
|
||||
])
|
||||
prefix = 'mw' if is_manual else 'pw'
|
||||
steps = [
|
||||
{'sequence': 10, 'name': 'Client', 'step_type': 'client_info',
|
||||
'icon': 'fa-user', 'fields_json': client_config},
|
||||
{'sequence': 20, 'name': 'Measurements', 'step_type': 'measurements',
|
||||
'icon': 'fa-ruler', 'fields_json': mw_measurements_json},
|
||||
{'sequence': 30, 'name': 'Frame', 'step_type': 'product_select',
|
||||
'icon': 'fa-wheelchair', 'section_code': f'{prefix}_frame'},
|
||||
{'sequence': 40, 'name': 'Seating', 'step_type': 'options',
|
||||
'icon': 'fa-chair', 'section_code': 'seating'},
|
||||
{'sequence': 50, 'name': 'Options', 'step_type': 'options',
|
||||
'icon': 'fa-list', 'section_code': f'{prefix}_adp_options,{prefix}_accessories'},
|
||||
{'sequence': 60, 'name': 'Review', 'step_type': 'review',
|
||||
'icon': 'fa-check'},
|
||||
]
|
||||
elif self.equipment_type == 'walker':
|
||||
# ── Client step config: ADP program fields (no wheelchair categories) ──
|
||||
client_config = json.dumps({
|
||||
"show_health_card": True,
|
||||
"show_dob": True,
|
||||
"show_adp_fields": True,
|
||||
})
|
||||
walker_measurements_json = json.dumps([
|
||||
{"name": "walker_seat_height", "label": "Seat Height", "type": "float",
|
||||
"unit_field": "walker_seat_height_unit", "units": ["inches", "cm", "na"]},
|
||||
{"name": "push_handle_height", "label": "Push Handle Height", "type": "float",
|
||||
"unit_field": "push_handle_height_unit", "units": ["inches", "cm"]},
|
||||
{"name": "width_between_push_handles", "label": "Width Between Push Handles",
|
||||
"type": "float", "unit_field": "push_handle_width_unit", "units": ["inches", "cm"]},
|
||||
{"name": "client_weight", "label": "Client Weight", "type": "float",
|
||||
"unit_field": "client_weight_unit", "units": ["lbs", "kg"], "required": True},
|
||||
])
|
||||
steps = [
|
||||
{'sequence': 10, 'name': 'Client', 'step_type': 'client_info',
|
||||
'icon': 'fa-user', 'fields_json': client_config},
|
||||
{'sequence': 20, 'name': 'Measurements', 'step_type': 'measurements',
|
||||
'icon': 'fa-ruler', 'fields_json': walker_measurements_json},
|
||||
{'sequence': 30, 'name': 'Equipment', 'step_type': 'product_select',
|
||||
'icon': 'fa-male', 'section_code': 'walker_frame'},
|
||||
{'sequence': 40, 'name': 'Options', 'step_type': 'options',
|
||||
'icon': 'fa-list', 'section_code': 'walker_adp_options,walker_accessories'},
|
||||
{'sequence': 50, 'name': 'Review', 'step_type': 'review',
|
||||
'icon': 'fa-check'},
|
||||
]
|
||||
else:
|
||||
# ── Generic flow for new equipment types (stair lift, porch lift, etc.) ──
|
||||
# No ADP fields, no health card, no DOB — pure quotation tool
|
||||
steps = [
|
||||
{'sequence': 10, 'name': 'Client', 'step_type': 'client_info',
|
||||
'icon': 'fa-user'}, # no fields_json → no optional groups
|
||||
{'sequence': 20, 'name': 'Equipment', 'step_type': 'product_select',
|
||||
'icon': 'fa-cog'},
|
||||
{'sequence': 30, 'name': 'Options', 'step_type': 'options',
|
||||
'icon': 'fa-list'},
|
||||
{'sequence': 40, 'name': 'Review', 'step_type': 'review',
|
||||
'icon': 'fa-check'},
|
||||
]
|
||||
|
||||
for step_vals in steps:
|
||||
step_vals['flow_id'] = self.id
|
||||
Step.create(step_vals)
|
||||
|
||||
def action_new_assessment_form(self):
|
||||
"""Open a new portal assessment form pre-selected to this flow's equipment type."""
|
||||
self.ensure_one()
|
||||
base = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': f'{base}/my/quotation/builder/new?equipment_type={self.equipment_type}',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Load / Save graph for designer
|
||||
# ------------------------------------------------------------------
|
||||
def load_flow_graph(self):
|
||||
"""Return the complete flow graph for the visual designer."""
|
||||
self.ensure_one()
|
||||
nodes = []
|
||||
for n in self.node_ids:
|
||||
node_data = {
|
||||
'id': n.id,
|
||||
'name': n.name,
|
||||
'node_type': n.node_type,
|
||||
'pos_x': n.pos_x,
|
||||
'pos_y': n.pos_y,
|
||||
'color': n.color,
|
||||
'icon': n.icon,
|
||||
'section_id': n.section_id.id if n.section_id else False,
|
||||
'section_name': n.section_id.name if n.section_id else '',
|
||||
'decision_field': n.decision_field or '',
|
||||
'decision_operator': n.decision_operator or '',
|
||||
'decision_value': n.decision_value or '',
|
||||
'measurement_field': n.measurement_field or '',
|
||||
'comparison': n.comparison or '',
|
||||
'threshold_value': n.threshold_value,
|
||||
'action_type': n.action_type or '',
|
||||
'target_option_ids': n.target_option_ids.ids,
|
||||
'target_step': n.target_step,
|
||||
'config_json': n.config_json or '{}',
|
||||
'node_options': [{
|
||||
'id': opt.id,
|
||||
'name': opt.name,
|
||||
'sequence': opt.sequence,
|
||||
'section_option_id': opt.section_option_id.id if opt.section_option_id else False,
|
||||
'enables_option_ids': opt.enables_option_ids.ids,
|
||||
'disables_option_ids': opt.disables_option_ids.ids,
|
||||
'requires_option_ids': opt.requires_option_ids.ids,
|
||||
'port_key': opt.port_key,
|
||||
} for opt in n.node_option_ids],
|
||||
}
|
||||
nodes.append(node_data)
|
||||
|
||||
connections = []
|
||||
for c in self.connection_ids:
|
||||
connections.append({
|
||||
'id': c.id,
|
||||
'source_node_id': c.source_node_id.id,
|
||||
'target_node_id': c.target_node_id.id,
|
||||
'source_port': c.source_port or 'out',
|
||||
'label': c.label or '',
|
||||
'condition_json': c.condition_json or '{}',
|
||||
'sequence': c.sequence,
|
||||
})
|
||||
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'equipment_type': self.equipment_type,
|
||||
'canvas': json.loads(self.canvas_data or '{}'),
|
||||
'nodes': nodes,
|
||||
'connections': connections,
|
||||
}
|
||||
|
||||
def save_flow_graph(self, graph_data):
|
||||
"""Sync the full graph from the visual designer to ORM records."""
|
||||
self.ensure_one()
|
||||
Node = self.env['fusion.wc.config.flow.node']
|
||||
Connection = self.env['fusion.wc.config.flow.connection']
|
||||
|
||||
# Save viewport state
|
||||
canvas = graph_data.get('canvas', {})
|
||||
self.write({'canvas_data': json.dumps(canvas)})
|
||||
|
||||
# --- Sync Nodes ---
|
||||
incoming_nodes = graph_data.get('nodes', [])
|
||||
incoming_node_ids = set()
|
||||
node_id_map = {} # temp_id -> real_id
|
||||
|
||||
for ndata in incoming_nodes:
|
||||
vals = {
|
||||
'flow_id': self.id,
|
||||
'name': ndata.get('name', 'Untitled'),
|
||||
'node_type': ndata.get('node_type', 'action'),
|
||||
'pos_x': ndata.get('pos_x', 0),
|
||||
'pos_y': ndata.get('pos_y', 0),
|
||||
'color': ndata.get('color', '#3b82f6'),
|
||||
'icon': ndata.get('icon', 'fa-circle'),
|
||||
'section_id': ndata.get('section_id') or False,
|
||||
'decision_field': ndata.get('decision_field') or False,
|
||||
'decision_operator': ndata.get('decision_operator') or False,
|
||||
'decision_value': ndata.get('decision_value') or '',
|
||||
'measurement_field': ndata.get('measurement_field') or False,
|
||||
'comparison': ndata.get('comparison') or False,
|
||||
'threshold_value': ndata.get('threshold_value', 0),
|
||||
'action_type': ndata.get('action_type') or False,
|
||||
'target_step': ndata.get('target_step', 0),
|
||||
'config_json': ndata.get('config_json', '{}'),
|
||||
}
|
||||
target_ids = ndata.get('target_option_ids', [])
|
||||
|
||||
node_id = ndata.get('id')
|
||||
if isinstance(node_id, int) and node_id > 0:
|
||||
# Update existing
|
||||
node = Node.browse(node_id)
|
||||
if node.exists():
|
||||
node.write(vals)
|
||||
if target_ids is not None:
|
||||
node.write({'target_option_ids': [(6, 0, target_ids)]})
|
||||
node_id_map[node_id] = node_id
|
||||
incoming_node_ids.add(node_id)
|
||||
continue
|
||||
# Create new
|
||||
new_node = Node.create(vals)
|
||||
if target_ids:
|
||||
new_node.write({'target_option_ids': [(6, 0, target_ids)]})
|
||||
node_id_map[ndata.get('id', 'new')] = new_node.id
|
||||
incoming_node_ids.add(new_node.id)
|
||||
|
||||
# Delete nodes no longer in the graph
|
||||
existing_nodes = Node.search([('flow_id', '=', self.id)])
|
||||
to_delete = existing_nodes.filtered(lambda n: n.id not in incoming_node_ids)
|
||||
to_delete.unlink()
|
||||
|
||||
# --- Sync Connections ---
|
||||
incoming_conns = graph_data.get('connections', [])
|
||||
incoming_conn_ids = set()
|
||||
|
||||
for cdata in incoming_conns:
|
||||
src_id = cdata.get('source_node_id')
|
||||
tgt_id = cdata.get('target_node_id')
|
||||
# Resolve temp IDs
|
||||
src_id = node_id_map.get(src_id, src_id)
|
||||
tgt_id = node_id_map.get(tgt_id, tgt_id)
|
||||
|
||||
vals = {
|
||||
'flow_id': self.id,
|
||||
'source_node_id': src_id,
|
||||
'target_node_id': tgt_id,
|
||||
'source_port': cdata.get('source_port', 'out'),
|
||||
'label': cdata.get('label', ''),
|
||||
'condition_json': cdata.get('condition_json', '{}'),
|
||||
'sequence': cdata.get('sequence', 10),
|
||||
}
|
||||
|
||||
conn_id = cdata.get('id')
|
||||
if isinstance(conn_id, int) and conn_id > 0:
|
||||
conn = Connection.browse(conn_id)
|
||||
if conn.exists():
|
||||
conn.write(vals)
|
||||
incoming_conn_ids.add(conn_id)
|
||||
continue
|
||||
new_conn = Connection.create(vals)
|
||||
incoming_conn_ids.add(new_conn.id)
|
||||
|
||||
# Delete connections no longer in the graph
|
||||
existing_conns = Connection.search([('flow_id', '=', self.id)])
|
||||
to_delete = existing_conns.filtered(lambda c: c.id not in incoming_conn_ids)
|
||||
to_delete.unlink()
|
||||
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Flow Evaluation Engine
|
||||
# ------------------------------------------------------------------
|
||||
def evaluate(self, assessment_data):
|
||||
"""Walk the flow graph and return option directives.
|
||||
|
||||
Args:
|
||||
assessment_data: dict with keys like equipment_type, seat_width,
|
||||
selected_option_ids, build_type, etc.
|
||||
|
||||
Returns:
|
||||
dict with enabled/disabled/required option IDs, skip steps, messages.
|
||||
"""
|
||||
self.ensure_one()
|
||||
result = {
|
||||
'enabled_option_ids': set(),
|
||||
'disabled_option_ids': set(),
|
||||
'required_option_ids': set(),
|
||||
'skip_steps': set(),
|
||||
'active_nodes': [],
|
||||
'messages': [],
|
||||
}
|
||||
|
||||
# Find start node
|
||||
start_nodes = self.node_ids.filtered(lambda n: n.node_type == 'start')
|
||||
if not start_nodes:
|
||||
return self._finalize_result(result)
|
||||
|
||||
# BFS walk with cycle protection
|
||||
visited = set()
|
||||
queue = list(start_nodes)
|
||||
max_iterations = 200
|
||||
|
||||
iteration = 0
|
||||
while queue and iteration < max_iterations:
|
||||
iteration += 1
|
||||
node = queue.pop(0)
|
||||
if node.id in visited:
|
||||
continue
|
||||
visited.add(node.id)
|
||||
result['active_nodes'].append(node.id)
|
||||
|
||||
next_nodes = self._evaluate_node(node, assessment_data, result)
|
||||
queue.extend(next_nodes)
|
||||
|
||||
return self._finalize_result(result)
|
||||
|
||||
def _finalize_result(self, result):
|
||||
"""Convert sets to sorted lists for JSON serialization."""
|
||||
for key in ('enabled_option_ids', 'disabled_option_ids',
|
||||
'required_option_ids', 'skip_steps'):
|
||||
result[key] = sorted(result[key])
|
||||
return result
|
||||
|
||||
def _evaluate_node(self, node, data, result):
|
||||
"""Evaluate a single node. Returns list of next nodes to visit."""
|
||||
if node.node_type == 'start':
|
||||
return self._get_next_nodes(node, 'out')
|
||||
|
||||
elif node.node_type == 'end':
|
||||
return []
|
||||
|
||||
elif node.node_type == 'decision':
|
||||
passed = self._evaluate_decision(node, data)
|
||||
return self._get_next_nodes(node, 'true' if passed else 'false')
|
||||
|
||||
elif node.node_type == 'measurement_check':
|
||||
passed = self._evaluate_measurement(node, data)
|
||||
return self._get_next_nodes(node, 'pass' if passed else 'fail')
|
||||
|
||||
elif node.node_type == 'option_group':
|
||||
selected = set(data.get('selected_option_ids', []))
|
||||
for opt in node.node_option_ids:
|
||||
if opt.section_option_id and opt.section_option_id.id in selected:
|
||||
result['enabled_option_ids'].update(opt.enables_option_ids.ids)
|
||||
result['disabled_option_ids'].update(opt.disables_option_ids.ids)
|
||||
result['required_option_ids'].update(opt.requires_option_ids.ids)
|
||||
return self._get_next_nodes(node, 'out')
|
||||
|
||||
elif node.node_type == 'action':
|
||||
if node.action_type == 'enable':
|
||||
result['enabled_option_ids'].update(node.target_option_ids.ids)
|
||||
elif node.action_type == 'disable':
|
||||
result['disabled_option_ids'].update(node.target_option_ids.ids)
|
||||
elif node.action_type == 'require':
|
||||
result['required_option_ids'].update(node.target_option_ids.ids)
|
||||
elif node.action_type == 'skip_step' and node.target_step:
|
||||
result['skip_steps'].add(node.target_step)
|
||||
elif node.action_type == 'set_value':
|
||||
msg = json.loads(node.config_json or '{}').get('message', '')
|
||||
if msg:
|
||||
result['messages'].append(msg)
|
||||
return self._get_next_nodes(node, 'out')
|
||||
|
||||
elif node.node_type == 'product_select':
|
||||
return self._get_next_nodes(node, 'out')
|
||||
|
||||
return []
|
||||
|
||||
def _evaluate_decision(self, node, data):
|
||||
"""Evaluate a decision node condition against assessment data."""
|
||||
field = node.decision_field
|
||||
op = node.decision_operator
|
||||
expected = node.decision_value or ''
|
||||
|
||||
actual = data.get(field, '')
|
||||
if isinstance(actual, (int, float)) and expected:
|
||||
try:
|
||||
expected_num = float(expected)
|
||||
actual_num = float(actual)
|
||||
if op == 'eq':
|
||||
return actual_num == expected_num
|
||||
elif op == 'neq':
|
||||
return actual_num != expected_num
|
||||
elif op == 'gt':
|
||||
return actual_num > expected_num
|
||||
elif op == 'gte':
|
||||
return actual_num >= expected_num
|
||||
elif op == 'lt':
|
||||
return actual_num < expected_num
|
||||
elif op == 'lte':
|
||||
return actual_num <= expected_num
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# String comparison
|
||||
actual_str = str(actual)
|
||||
if op == 'eq':
|
||||
return actual_str == expected
|
||||
elif op == 'neq':
|
||||
return actual_str != expected
|
||||
elif op == 'in':
|
||||
return actual_str in [v.strip() for v in expected.split(',')]
|
||||
return False
|
||||
|
||||
def _evaluate_measurement(self, node, data):
|
||||
"""Evaluate a measurement check node."""
|
||||
field = node.measurement_field
|
||||
if not field:
|
||||
return False
|
||||
actual = data.get(field, 0)
|
||||
try:
|
||||
actual = float(actual)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
threshold = node.threshold_value
|
||||
comp = node.comparison
|
||||
if comp == 'gt':
|
||||
return actual > threshold
|
||||
elif comp == 'gte':
|
||||
return actual >= threshold
|
||||
elif comp == 'lt':
|
||||
return actual < threshold
|
||||
elif comp == 'eq':
|
||||
return abs(actual - threshold) < 0.001
|
||||
elif comp == 'neq':
|
||||
return abs(actual - threshold) >= 0.001
|
||||
return False
|
||||
|
||||
def _get_next_nodes(self, node, port):
|
||||
"""Get target nodes for outgoing connections from a specific port."""
|
||||
connections = node.outgoing_connection_ids.filtered(
|
||||
lambda c: c.source_port == port
|
||||
)
|
||||
return connections.mapped('target_node_id')
|
||||
28
fusion_quotations/models/wc_config_flow_connection.py
Normal file
28
fusion_quotations/models/wc_config_flow_connection.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class WheelchairConfigFlowConnection(models.Model):
|
||||
_name = 'fusion.wc.config.flow.connection'
|
||||
_description = 'Configuration Flow Connection'
|
||||
_order = 'sequence, id'
|
||||
|
||||
flow_id = fields.Many2one('fusion.wc.config.flow', string='Flow',
|
||||
required=True, ondelete='cascade', index=True)
|
||||
|
||||
source_node_id = fields.Many2one('fusion.wc.config.flow.node',
|
||||
string='Source Node', required=True, ondelete='cascade', index=True)
|
||||
target_node_id = fields.Many2one('fusion.wc.config.flow.node',
|
||||
string='Target Node', required=True, ondelete='cascade', index=True)
|
||||
|
||||
source_port = fields.Char(string='Source Port', default='out',
|
||||
help='Port key: out, true, false, or option port_key')
|
||||
|
||||
label = fields.Char(string='Label',
|
||||
help='Text shown on the connection line')
|
||||
|
||||
condition_json = fields.Text(string='Condition', default='{}',
|
||||
help='Optional condition as JSON for advanced routing')
|
||||
|
||||
sequence = fields.Integer(default=10)
|
||||
127
fusion_quotations/models/wc_config_flow_node.py
Normal file
127
fusion_quotations/models/wc_config_flow_node.py
Normal file
@@ -0,0 +1,127 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class WheelchairConfigFlowNode(models.Model):
|
||||
_name = 'fusion.wc.config.flow.node'
|
||||
_description = 'Configuration Flow Node'
|
||||
_order = 'sequence, id'
|
||||
|
||||
flow_id = fields.Many2one('fusion.wc.config.flow', string='Flow',
|
||||
required=True, ondelete='cascade', index=True)
|
||||
|
||||
name = fields.Char(string='Name', required=True, default='New Node')
|
||||
sequence = fields.Integer(default=10)
|
||||
|
||||
node_type = fields.Selection([
|
||||
('start', 'Start'),
|
||||
('end', 'End'),
|
||||
('decision', 'Decision (If/Then)'),
|
||||
('option_group', 'Option Group'),
|
||||
('product_select', 'Product Selection'),
|
||||
('measurement_check', 'Measurement Check'),
|
||||
('action', 'Action (Enable/Disable/Require)'),
|
||||
], string='Type', required=True, default='action')
|
||||
|
||||
# Canvas position
|
||||
pos_x = fields.Float(string='X Position', default=100.0)
|
||||
pos_y = fields.Float(string='Y Position', default=100.0)
|
||||
|
||||
# Visual
|
||||
color = fields.Char(string='Color', default='#3b82f6')
|
||||
icon = fields.Char(string='Icon', default='fa-circle')
|
||||
|
||||
# Extensible config for type-specific settings
|
||||
config_json = fields.Text(string='Configuration', default='{}',
|
||||
help='Type-specific configuration as JSON')
|
||||
|
||||
# Link to existing section
|
||||
section_id = fields.Many2one('fusion.wc.section', string='Section',
|
||||
help='Link this node to a wheelchair section')
|
||||
|
||||
# Node options (for option_group / product_select)
|
||||
node_option_ids = fields.One2many('fusion.wc.config.flow.node.option',
|
||||
'node_id', string='Options')
|
||||
|
||||
# ── Decision node fields ──
|
||||
decision_field = fields.Selection([
|
||||
('equipment_type', 'Equipment Type'),
|
||||
('wheelchair_type', 'Wheelchair Category'),
|
||||
('powerchair_type', 'Power Chair Category'),
|
||||
('build_type', 'Build Type'),
|
||||
('client_type', 'Client Type'),
|
||||
('reason_for_application', 'Reason for Application'),
|
||||
('seat_width', 'Seat Width (inches)'),
|
||||
('seat_depth', 'Seat Depth (inches)'),
|
||||
('client_weight', 'Client Weight (lbs)'),
|
||||
('back_height', 'Back Height (inches)'),
|
||||
('seat_to_floor', 'Seat to Floor Height (inches)'),
|
||||
('leg_rest_length', 'Leg Rest Length (inches)'),
|
||||
('custom', 'Custom Expression'),
|
||||
('custom_field', 'Custom Field (from equipment data)'),
|
||||
], string='Decision Field')
|
||||
|
||||
custom_field_name = fields.Char(string='Custom Field Name',
|
||||
help='JSON key to check in equipment_data_json (for custom_field decisions)')
|
||||
|
||||
decision_operator = fields.Selection([
|
||||
('eq', '='),
|
||||
('neq', '!='),
|
||||
('gt', '>'),
|
||||
('gte', '>='),
|
||||
('lt', '<'),
|
||||
('lte', '<='),
|
||||
('in', 'In List'),
|
||||
], string='Operator')
|
||||
|
||||
decision_value = fields.Char(string='Expected Value',
|
||||
help='For "In List" use comma-separated values')
|
||||
|
||||
# ── Measurement check fields (reuses upcharge rule pattern) ──
|
||||
measurement_field = fields.Selection([
|
||||
('seat_width', 'Seat Width'),
|
||||
('seat_depth', 'Seat Depth'),
|
||||
('back_width', 'Backrest Width'),
|
||||
('back_height', 'Back Height'),
|
||||
('seat_to_floor', 'Seat to Floor Height'),
|
||||
('leg_rest_length', 'Leg Rest Length'),
|
||||
('client_weight', 'Client Weight'),
|
||||
], string='Measurement')
|
||||
|
||||
comparison = fields.Selection([
|
||||
('gt', 'Greater Than'),
|
||||
('gte', 'Greater Than or Equal'),
|
||||
('lt', 'Less Than'),
|
||||
('eq', 'Equal To'),
|
||||
('neq', 'Not Equal To'),
|
||||
], string='Comparison')
|
||||
|
||||
threshold_value = fields.Float(string='Threshold')
|
||||
|
||||
# ── Action node fields ──
|
||||
action_type = fields.Selection([
|
||||
('enable', 'Enable Options'),
|
||||
('disable', 'Disable Options'),
|
||||
('require', 'Require Options'),
|
||||
('skip_step', 'Skip Portal Step'),
|
||||
('set_value', 'Set Field Value'),
|
||||
], string='Action Type')
|
||||
|
||||
target_option_ids = fields.Many2many(
|
||||
'fusion.wc.section.option',
|
||||
'wc_flow_node_target_option_rel',
|
||||
'node_id', 'option_id',
|
||||
string='Target Options',
|
||||
help='Section options affected by this action')
|
||||
|
||||
target_step = fields.Integer(string='Target Step',
|
||||
help='Portal form step number to skip (for skip_step action)')
|
||||
|
||||
# ── Connections (computed for convenience) ──
|
||||
outgoing_connection_ids = fields.One2many(
|
||||
'fusion.wc.config.flow.connection', 'source_node_id',
|
||||
string='Outgoing Connections')
|
||||
incoming_connection_ids = fields.One2many(
|
||||
'fusion.wc.config.flow.connection', 'target_node_id',
|
||||
string='Incoming Connections')
|
||||
51
fusion_quotations/models/wc_config_flow_node_option.py
Normal file
51
fusion_quotations/models/wc_config_flow_node_option.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class WheelchairConfigFlowNodeOption(models.Model):
|
||||
_name = 'fusion.wc.config.flow.node.option'
|
||||
_description = 'Configuration Flow Node Option'
|
||||
_order = 'sequence, id'
|
||||
|
||||
node_id = fields.Many2one('fusion.wc.config.flow.node', string='Node',
|
||||
required=True, ondelete='cascade', index=True)
|
||||
|
||||
name = fields.Char(string='Name', required=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
|
||||
# Link to actual section product option
|
||||
section_option_id = fields.Many2one('fusion.wc.section.option',
|
||||
string='Section Option',
|
||||
help='Link this choice to a wheelchair section product option')
|
||||
|
||||
# Effects when this option is selected
|
||||
enables_option_ids = fields.Many2many(
|
||||
'fusion.wc.section.option',
|
||||
'wc_flow_node_opt_enables_rel',
|
||||
'node_option_id', 'option_id',
|
||||
string='Enables Options',
|
||||
help='Section options to enable when this choice is selected')
|
||||
|
||||
disables_option_ids = fields.Many2many(
|
||||
'fusion.wc.section.option',
|
||||
'wc_flow_node_opt_disables_rel',
|
||||
'node_option_id', 'option_id',
|
||||
string='Disables Options',
|
||||
help='Section options to disable when this choice is selected')
|
||||
|
||||
requires_option_ids = fields.Many2many(
|
||||
'fusion.wc.section.option',
|
||||
'wc_flow_node_opt_requires_rel',
|
||||
'node_option_id', 'option_id',
|
||||
string='Requires Options',
|
||||
help='Section options that become required when this choice is selected')
|
||||
|
||||
# Port key for connections — auto-generated from sequence
|
||||
port_key = fields.Char(string='Port Key', compute='_compute_port_key',
|
||||
store=True)
|
||||
|
||||
@api.depends('sequence', 'node_id')
|
||||
def _compute_port_key(self):
|
||||
for record in self:
|
||||
record.port_key = 'opt_%d' % (record.sequence or 0)
|
||||
47
fusion_quotations/models/wc_config_flow_step.py
Normal file
47
fusion_quotations/models/wc_config_flow_step.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class ConfigFlowStep(models.Model):
|
||||
_name = 'fusion.wc.config.flow.step'
|
||||
_description = 'Configuration Flow Form Step'
|
||||
_order = 'sequence, id'
|
||||
|
||||
flow_id = fields.Many2one('fusion.wc.config.flow', string='Flow',
|
||||
required=True, ondelete='cascade', index=True)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
name = fields.Char(string='Step Name', required=True,
|
||||
help='Display name shown in the step indicator, e.g. "Client Info", "Measurements"')
|
||||
|
||||
step_type = fields.Selection([
|
||||
('client_info', 'Client Information'),
|
||||
('measurements', 'Measurements'),
|
||||
('product_select', 'Product Selection'),
|
||||
('options', 'Options & Accessories'),
|
||||
('review', 'Review & Submit'),
|
||||
('custom', 'Custom Fields'),
|
||||
], string='Step Type', required=True, default='custom')
|
||||
|
||||
icon = fields.Char(string='Icon', default='fa-circle',
|
||||
help='FontAwesome icon class for the step indicator')
|
||||
|
||||
# For product_select / options steps — which section to filter by
|
||||
section_id = fields.Many2one('fusion.wc.section', string='Section',
|
||||
help='Link to a wheelchair/equipment section for product filtering')
|
||||
section_code = fields.Char(string='Section Code',
|
||||
help='Alternative to section_id — match section by code. '
|
||||
'Supports comma-separated codes for options steps.')
|
||||
|
||||
# For measurements / custom steps — JSON field definitions
|
||||
fields_json = fields.Text(string='Field Definitions (JSON)',
|
||||
help='JSON array of field definitions for dynamic rendering. '
|
||||
'Each entry: {"name": "field_name", "label": "Display Label", '
|
||||
'"type": "float|integer|selection|char|text|boolean", '
|
||||
'"unit": "inches", "required": true, '
|
||||
'"options": [["value", "Label"], ...]}')
|
||||
|
||||
help_text = fields.Text(string='Help Text',
|
||||
help='Instructions displayed at the top of this step')
|
||||
is_required = fields.Boolean(string='Required', default=True,
|
||||
help='If true, this step must be completed before proceeding')
|
||||
454
fusion_quotations/models/wc_section.py
Normal file
454
fusion_quotations/models/wc_section.py
Normal file
@@ -0,0 +1,454 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# =========================================================================
|
||||
# ADP device_type -> section code mapping
|
||||
# Maps each device_type string from fusion.adp.device.code to the
|
||||
# fusion.wc.section code where matched products should be placed.
|
||||
# =========================================================================
|
||||
DEVICE_TYPE_SECTION_MAP = {
|
||||
# =================================================================
|
||||
# MANUAL WHEELCHAIR FRAMES (Section 2b base devices)
|
||||
# =================================================================
|
||||
'Adult Standard Manual Type 1 Wheelchair': 'mw_frame',
|
||||
'Adult Lightweight Standard Type 1 Wheelchair': 'mw_frame',
|
||||
'Adult Lightweight Performance Type 3 Wheelchair': 'mw_frame',
|
||||
'Adult Lightweight Performance Manual Wheelchair': 'mw_frame',
|
||||
'Adult High Performance Rigid Type 4 Wheelchair': 'mw_frame',
|
||||
'Adult High Performance Rigid Manual Wheelchair': 'mw_frame',
|
||||
'Adult Manual Dynamic Tilt Type 5 Wheelchair': 'mw_frame',
|
||||
'Adult Manual Dynamic Tilt Wheelchair': 'mw_frame',
|
||||
'Standard Manual Wheelchair Frame with Manual Dynamic Tilt': 'mw_frame',
|
||||
'Paediatric Lightweight Standard Type 1 Wheelchair': 'mw_frame',
|
||||
'Paediatric Lightweight Performance Type 4 Wheelchair': 'mw_frame',
|
||||
'Paediatric High Performance Rigid Type 4 Wheelchair': 'mw_frame',
|
||||
'Paediatric High Performance Rigid Manual Wheelchair': 'mw_frame',
|
||||
'Paediatric Manual Dynamic Tilt Type 5 Wheelchair': 'mw_frame',
|
||||
'Paediatric Specific Specialty Stroller': 'mw_frame',
|
||||
|
||||
# =================================================================
|
||||
# WALKER / ROLLATOR / AMBULATION AIDS FRAMES (Section 2a)
|
||||
# =================================================================
|
||||
'Adult Wheeled Walker Type 1': 'walker_frame',
|
||||
'Adult Wheeled Walker Type 2': 'walker_frame',
|
||||
'Adult Wheeled Walker Type 3': 'walker_frame',
|
||||
'Paediatric Wheeled Walker Type 1': 'walker_frame',
|
||||
'Paediatric Wheeled Walker Type 2': 'walker_frame',
|
||||
'Paediatric Walking Frame': 'walker_frame',
|
||||
'Paediatric Specific Walking Frame': 'walker_frame',
|
||||
'Paediatric Standing Frame Type 1': 'walker_frame',
|
||||
'Paediatric Standing Frame Type 2': 'walker_frame',
|
||||
'Forearm Crutches': 'walker_frame',
|
||||
'Walker Addons': 'walker_accessories',
|
||||
|
||||
# Walker ADP options (adolescent size upgrades)
|
||||
'AA - Custom Modifications': 'walker_adp_options',
|
||||
|
||||
# =================================================================
|
||||
# POWER BASE / SCOOTER FRAMES (Section 2c)
|
||||
# =================================================================
|
||||
'Adult Power Base Type 1': 'pw_frame',
|
||||
'Adult Power Base Type 2': 'pw_frame',
|
||||
'Adult Power Base Type 3': 'pw_frame',
|
||||
'Paediatric Power Base Type 2': 'pw_frame',
|
||||
'Paediatric Power Base Type 3': 'pw_frame',
|
||||
'Power Scooter': 'pw_frame',
|
||||
|
||||
# Power base ADP funded options
|
||||
'MW - Adjustable Tension Back Upholstery up to 18" Frame Width': 'pw_adp_options',
|
||||
'MW - Adjustable Tension Back Upholstery over 18" Frame Width': 'pw_adp_options',
|
||||
'PW - Adjustable Tension Back Upholstery up to 18" Frame Width': 'pw_adp_options',
|
||||
'PW - Adjustable Tension Back Upholstery over 18" Frame Width': 'pw_adp_options',
|
||||
'Midline Control': 'pw_adp_options',
|
||||
'Manual Recline Option': 'pw_adp_options',
|
||||
'Recliner Option': 'pw_adp_options',
|
||||
'MW - Angle Adjustable Footplates (pair)': 'pw_adp_options',
|
||||
'PW - Angle Adjustable Footplates (pair)': 'pw_adp_options',
|
||||
'Manual Elevating Legrests (pair)': 'pw_adp_options',
|
||||
'Elevating Legrests (pair)': 'pw_adp_options',
|
||||
'Swingaway Mounting Bracket': 'pw_adp_options',
|
||||
'One Piece 90/90 Front Riggings': 'pw_adp_options',
|
||||
'Seat Package 1 for Power Bases': 'pw_adp_options',
|
||||
'Seat Package 2 for Power Bases': 'pw_adp_options',
|
||||
'PW - Oxygen Tank': 'pw_adp_options',
|
||||
'MW - Oxygen Tank Holder': 'pw_adp_options',
|
||||
'PW - Ventilator Tray': 'pw_adp_options',
|
||||
'MW - Ventilator Tray': 'pw_adp_options',
|
||||
|
||||
# Power specialty controls (* require clinical rationale)
|
||||
'Specialty Controls 1 Non Standard Joystick*': 'pw_specialty_controls',
|
||||
'Specialty Controls 2 Chin/Rim Control*': 'pw_specialty_controls',
|
||||
'Specialty Controls 3 Simple Touch*': 'pw_specialty_controls',
|
||||
'Specialty Controls 4 Proximity Control*': 'pw_specialty_controls',
|
||||
'Specialty Controls 5 Breath Control*': 'pw_specialty_controls',
|
||||
'Specialty Controls 6 Scanners*': 'pw_specialty_controls',
|
||||
'Auto Correction System*': 'pw_specialty_controls',
|
||||
|
||||
# Power positioning devices (require Justification for Funding Chart)
|
||||
'Power Tilt Only': 'pw_positioning',
|
||||
'Power Recline Only': 'pw_positioning',
|
||||
'Power Tilt and Recline': 'pw_positioning',
|
||||
'Power Elevating Footrests': 'pw_positioning',
|
||||
'Multi-function Control Box': 'pw_positioning',
|
||||
'Power Add-On Device': 'pw_positioning',
|
||||
|
||||
# =================================================================
|
||||
# SEATING – SEAT CUSHION (shared across wheelchair types)
|
||||
# =================================================================
|
||||
'Seat Cushion': 'seat_cushion',
|
||||
'Seat Cushion Cover(s)': 'seat_cushion_cover',
|
||||
'Seat Options': 'seat_options',
|
||||
'Seat Hardware': 'seat_hardware',
|
||||
'Pommel/Adductors': 'pommel',
|
||||
'Pommel Hardware': 'pommel_hardware',
|
||||
|
||||
# =================================================================
|
||||
# SEATING – BACK SUPPORT
|
||||
# =================================================================
|
||||
'Back Support': 'back_support',
|
||||
'Back Support Options': 'back_support_options',
|
||||
'Back Cover': 'back_cover',
|
||||
'Back Hardware': 'back_hardware',
|
||||
|
||||
# =================================================================
|
||||
# SEATING – COMPLETE ASSEMBLY
|
||||
# =================================================================
|
||||
'Complete Assembly': 'complete_assembly',
|
||||
|
||||
# =================================================================
|
||||
# SEATING – HEADREST / NECKREST
|
||||
# =================================================================
|
||||
'Headrest/Neckrest': 'headrest',
|
||||
'Headrest/Neckrest Options': 'headrest_options',
|
||||
'Headrest/Neckrest Hardware': 'headrest_hardware',
|
||||
|
||||
# =================================================================
|
||||
# SEATING – POSITIONING BELTS
|
||||
# =================================================================
|
||||
'Positioning Belts': 'positioning_belts',
|
||||
'Positioning Belts Options': 'positioning_belt_options',
|
||||
|
||||
# =================================================================
|
||||
# SEATING – ARM SUPPORT
|
||||
# =================================================================
|
||||
'Arm Support(s)': 'arm_support',
|
||||
'Arm Support Options': 'arm_support_options',
|
||||
'Arm Support Hardware': 'arm_support_hardware',
|
||||
|
||||
# =================================================================
|
||||
# SEATING – TRAY
|
||||
# =================================================================
|
||||
'Tray': 'tray',
|
||||
'Tray Options': 'tray_options',
|
||||
|
||||
# =================================================================
|
||||
# SEATING – LATERAL SUPPORT
|
||||
# =================================================================
|
||||
'Lateral Support(s)': 'lateral_support',
|
||||
'Lateral Support Options': 'lateral_support_options',
|
||||
'Lateral Support Hardware': 'lateral_support_hardware',
|
||||
|
||||
# =================================================================
|
||||
# SEATING – FOOT / LEG SUPPORT
|
||||
# =================================================================
|
||||
'Foot/Leg Support(s)': 'foot_leg_support',
|
||||
'Foot/Leg Support Options': 'foot_leg_support_options',
|
||||
'Foot/Leg Support Hardware': 'foot_leg_support_hardware',
|
||||
|
||||
# =================================================================
|
||||
# MANUAL WHEELCHAIR ACCESSORIES (Section 2b options/extras)
|
||||
# =================================================================
|
||||
'Amputee Axle Plates (pair)': 'mw_accessories',
|
||||
'Caster Pin Locks (pair)': 'mw_accessories',
|
||||
'Clothing Guards': 'mw_accessories',
|
||||
'Grade Aids (pair)': 'mw_accessories',
|
||||
'Heavy Duty Cross Braces & Upholstery': 'mw_accessories',
|
||||
'One Arm/Lever Drive': 'mw_accessories',
|
||||
'Plastic Coated Handrims': 'mw_accessories',
|
||||
'Projected Handrims (pair)': 'mw_accessories',
|
||||
'Quick Release Axles (pair)': 'mw_accessories',
|
||||
'Spoke Protectors (pair)': 'mw_accessories',
|
||||
'Stroller Handles/Paediatric': 'mw_accessories',
|
||||
'Titanium Frame *': 'mw_accessories',
|
||||
'Uni-Lateral Wheel Lock': 'mw_accessories',
|
||||
'Unilateral Hand Brake': 'mw_accessories',
|
||||
|
||||
# =================================================================
|
||||
# MW ADP UPCHARGE / MODIFICATION CODES
|
||||
# =================================================================
|
||||
'MW - Seat Width Required is Greater Than 18"': 'mw_adp_options',
|
||||
'MW - Seat Depth Required is Greater Than 18"': 'mw_adp_options',
|
||||
'MW - Heavy Duty Model, Client Weight Exceeds 250 Lbs': 'mw_adp_options',
|
||||
'MW - Heavy Duty Model, Client Weight Exceeds 350 Lbs': 'mw_adp_options',
|
||||
'MW - Heavy Duty Model, Client Weight Exceeds 400 Lbs': 'mw_adp_options',
|
||||
'MW - Custom Modifications': 'mw_adp_options',
|
||||
|
||||
# =================================================================
|
||||
# PW ADP UPCHARGE / MODIFICATION CODES
|
||||
# =================================================================
|
||||
'PW - Seat Width Required is Greater Than 18"': 'pw_adp_options',
|
||||
'PW - Seat Depth Required is Greater Than 18"': 'pw_adp_options',
|
||||
'PW - Heavy Duty Model, Client Weight Exceeds 250 Lbs': 'pw_adp_options',
|
||||
'PW - Heavy Duty Model, Client Weight Exceeds 350 Lbs': 'pw_adp_options',
|
||||
'PW - Heavy Duty Model, Client Weight Exceeds 400 Lbs': 'pw_adp_options',
|
||||
'PW - Custom Modifications': 'pw_adp_options',
|
||||
'SE - Custom Modifications': 'pw_adp_options',
|
||||
}
|
||||
|
||||
|
||||
class WheelchairSection(models.Model):
|
||||
_name = 'fusion.wc.section'
|
||||
_description = 'Wheelchair Configuration Section'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(string='Name', required=True)
|
||||
code = fields.Char(string='Code', required=True, index=True,
|
||||
help='Unique identifier e.g. frame, cushion, backrest')
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
|
||||
equipment_type = fields.Selection(
|
||||
selection='_get_equipment_type_selection',
|
||||
string='Equipment Type', default='both', required=True)
|
||||
|
||||
@api.model
|
||||
def _get_equipment_type_selection(self):
|
||||
types = self.env['fusion.equipment.type'].sudo().search([], order='sequence')
|
||||
if types:
|
||||
result = [(t.code, t.name) for t in types]
|
||||
# Group options for shared sections
|
||||
result.append(('wheelchair', 'All Wheelchairs (Manual + Power)'))
|
||||
result.append(('both', 'All Equipment Types'))
|
||||
return result
|
||||
return [
|
||||
('manual_wheelchair', 'Manual Wheelchair'),
|
||||
('power_wheelchair', 'Power Wheelchair'),
|
||||
('walker', 'Walker / Ambulation Aid'),
|
||||
('wheelchair', 'All Wheelchairs (Manual + Power)'),
|
||||
('both', 'All Equipment Types'),
|
||||
]
|
||||
|
||||
icon = fields.Char(string='Icon', help='FontAwesome icon class e.g. fa-wheelchair')
|
||||
description = fields.Text(string='Description',
|
||||
help='Help text shown to sales reps during assessment')
|
||||
|
||||
# Section behavior flags
|
||||
is_adp_options_section = fields.Boolean(string='ADP Options Section',
|
||||
help='If true, this section shows as a checkbox grid of ADP funded options')
|
||||
has_build_type = fields.Boolean(string='Has Build Type',
|
||||
help='If true, shows Modular / Custom Fabricated toggle for items in this section')
|
||||
allow_multiple = fields.Boolean(string='Allow Multiple', default=True,
|
||||
help='Can select multiple products in this section')
|
||||
required = fields.Boolean(string='Required', default=False,
|
||||
help='Must have at least one selection')
|
||||
|
||||
# Measurement fields configuration
|
||||
has_width = fields.Boolean(string='Collect Width')
|
||||
has_depth = fields.Boolean(string='Collect Depth')
|
||||
has_height = fields.Boolean(string='Collect Height')
|
||||
has_length = fields.Boolean(string='Collect Length')
|
||||
|
||||
width_label = fields.Char(string='Width Label', default='Width (inches)')
|
||||
depth_label = fields.Char(string='Depth Label', default='Depth (inches)')
|
||||
height_label = fields.Char(string='Height Label', default='Height (inches)')
|
||||
length_label = fields.Char(string='Length Label', default='Length (inches)')
|
||||
|
||||
# Product filter for custom search
|
||||
product_category_id = fields.Many2one('product.category',
|
||||
string='Product Category Filter',
|
||||
help='Default category to filter products when searching in this section')
|
||||
|
||||
# Hierarchy
|
||||
parent_id = fields.Many2one('fusion.wc.section', string='Parent Section',
|
||||
ondelete='cascade', index=True)
|
||||
child_ids = fields.One2many('fusion.wc.section', 'parent_id',
|
||||
string='Sub-Sections')
|
||||
|
||||
# Options
|
||||
option_ids = fields.One2many('fusion.wc.section.option', 'section_id',
|
||||
string='Product Options')
|
||||
option_count = fields.Integer(string='Options', compute='_compute_option_count')
|
||||
|
||||
@api.depends('option_ids')
|
||||
def _compute_option_count(self):
|
||||
for record in self:
|
||||
record.option_count = len(record.option_ids)
|
||||
|
||||
@api.depends('name', 'code')
|
||||
def _compute_display_name(self):
|
||||
for record in self:
|
||||
if record.parent_id:
|
||||
record.display_name = f"{record.parent_id.name} / {record.name}"
|
||||
else:
|
||||
record.display_name = record.name or ''
|
||||
|
||||
# =====================================================================
|
||||
# AUTO-POPULATE OPTIONS FROM INVENTORY
|
||||
# =====================================================================
|
||||
def action_auto_populate_options(self):
|
||||
"""Auto-populate product options for THIS section by matching
|
||||
products in inventory whose ADP device code maps to this section
|
||||
via the ADP device type lookup.
|
||||
|
||||
Called from a button on the section form view.
|
||||
"""
|
||||
self.ensure_one()
|
||||
stats = self._populate_section_from_products()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Auto-Populate Complete'),
|
||||
'message': _(
|
||||
'Section "%(section)s": %(added)d products added, '
|
||||
'%(skipped)d already existed, %(unmapped)d unmapped.',
|
||||
section=self.name,
|
||||
added=stats['added'],
|
||||
skipped=stats['skipped'],
|
||||
unmapped=stats['unmapped'],
|
||||
),
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
|
||||
@api.model
|
||||
def action_auto_populate_all_sections(self):
|
||||
"""Auto-populate ALL sections at once. Called from a menu action."""
|
||||
sections = self.search([])
|
||||
totals = {'added': 0, 'skipped': 0, 'unmapped': 0}
|
||||
section_results = []
|
||||
|
||||
for section in sections:
|
||||
stats = section._populate_section_from_products()
|
||||
totals['added'] += stats['added']
|
||||
totals['skipped'] += stats['skipped']
|
||||
totals['unmapped'] += stats['unmapped']
|
||||
if stats['added'] > 0:
|
||||
section_results.append(f"{section.name}: +{stats['added']}")
|
||||
|
||||
details = ', '.join(section_results) if section_results else 'No new products found'
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Auto-Populate All Sections Complete'),
|
||||
'message': _(
|
||||
'Total: %(added)d products added across all sections. '
|
||||
'%(skipped)d already existed. '
|
||||
'%(unmapped)d products had unmapped device types.\n'
|
||||
'%(details)s',
|
||||
added=totals['added'],
|
||||
skipped=totals['skipped'],
|
||||
unmapped=totals['unmapped'],
|
||||
details=details,
|
||||
),
|
||||
'type': 'success',
|
||||
'sticky': True,
|
||||
},
|
||||
}
|
||||
|
||||
def _populate_section_from_products(self):
|
||||
"""Find products whose ADP device type maps to this section,
|
||||
and create one fusion.wc.section.option per product *template*.
|
||||
|
||||
Variants (size, colour, etc.) live under the template and are
|
||||
selected when the sales rep adds the item to an assessment.
|
||||
This keeps the option list compact and manageable.
|
||||
|
||||
Returns dict with counts: {added, skipped, unmapped}
|
||||
"""
|
||||
self.ensure_one()
|
||||
SectionOption = self.env['fusion.wc.section.option'].sudo()
|
||||
ADPDevice = self.env['fusion.adp.device.code'].sudo()
|
||||
ProductTemplate = self.env['product.template'].sudo()
|
||||
|
||||
# Build reverse map: which device_types map to THIS section's code
|
||||
my_device_types = [
|
||||
dtype for dtype, section_code in DEVICE_TYPE_SECTION_MAP.items()
|
||||
if section_code == self.code
|
||||
]
|
||||
|
||||
if not my_device_types:
|
||||
return {'added': 0, 'skipped': 0, 'unmapped': 0}
|
||||
|
||||
# Find ADP device codes with matching device_type
|
||||
matching_adp_codes = ADPDevice.search([
|
||||
('device_type', 'in', my_device_types),
|
||||
('active', '=', True),
|
||||
])
|
||||
adp_code_strings = matching_adp_codes.mapped('device_code')
|
||||
|
||||
if not adp_code_strings:
|
||||
return {'added': 0, 'skipped': 0, 'unmapped': 0}
|
||||
|
||||
# Find product templates with those ADP codes
|
||||
products = ProductTemplate.search([
|
||||
('x_fc_adp_device_code', 'in', adp_code_strings),
|
||||
])
|
||||
|
||||
if not products:
|
||||
return {'added': 0, 'skipped': 0, 'unmapped': 0}
|
||||
|
||||
# Get existing option template IDs for this section (avoid duplicates)
|
||||
existing_tmpl_ids = set(
|
||||
SectionOption.search([
|
||||
('section_id', '=', self.id),
|
||||
]).mapped('product_tmpl_id.id')
|
||||
)
|
||||
|
||||
# Pre-fetch build types: ADP code -> build_type
|
||||
build_type_cache = {}
|
||||
for adp_code_str in set(products.mapped('x_fc_adp_device_code')):
|
||||
adp_device = ADPDevice.search([
|
||||
('device_code', '=', adp_code_str),
|
||||
('active', '=', True),
|
||||
], limit=1)
|
||||
build_type = 'both'
|
||||
if adp_device and adp_device.build_type:
|
||||
if adp_device.build_type == 'modular':
|
||||
build_type = 'modular'
|
||||
elif adp_device.build_type == 'custom_fabricated':
|
||||
build_type = 'custom_fabricated'
|
||||
build_type_cache[adp_code_str] = build_type
|
||||
|
||||
added = 0
|
||||
skipped = 0
|
||||
unmapped = 0
|
||||
|
||||
# Batch-create one option record per product template
|
||||
vals_list = []
|
||||
for tmpl in products:
|
||||
if tmpl.id in existing_tmpl_ids:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
adp_code = tmpl.x_fc_adp_device_code
|
||||
build_type = build_type_cache.get(adp_code, 'both')
|
||||
|
||||
vals_list.append({
|
||||
'section_id': self.id,
|
||||
'product_tmpl_id': tmpl.id,
|
||||
'is_standard': True,
|
||||
'available_build_types': build_type,
|
||||
'sequence': 10 + added,
|
||||
})
|
||||
added += 1
|
||||
existing_tmpl_ids.add(tmpl.id)
|
||||
|
||||
# Bulk create for performance
|
||||
if vals_list:
|
||||
SectionOption.create(vals_list)
|
||||
|
||||
_logger.info(
|
||||
'Section "%s" auto-populate: %d product templates added, '
|
||||
'%d already existed, %d unmapped',
|
||||
self.name, added, skipped, unmapped,
|
||||
)
|
||||
return {'added': added, 'skipped': skipped, 'unmapped': unmapped}
|
||||
78
fusion_quotations/models/wc_section_option.py
Normal file
78
fusion_quotations/models/wc_section_option.py
Normal file
@@ -0,0 +1,78 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class WheelchairSectionOption(models.Model):
|
||||
_name = 'fusion.wc.section.option'
|
||||
_description = 'Wheelchair Section Product Option'
|
||||
_order = 'is_standard desc, sequence, id'
|
||||
|
||||
section_id = fields.Many2one('fusion.wc.section', string='Section',
|
||||
required=True, ondelete='cascade', index=True)
|
||||
|
||||
# ── Parent product (template level) ──
|
||||
product_tmpl_id = fields.Many2one('product.template',
|
||||
string='Product', required=True, index=True,
|
||||
help='The parent product. Variants (size, colour) are chosen '
|
||||
'when the sales rep adds this item to an assessment.')
|
||||
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
|
||||
is_standard = fields.Boolean(string='Standard Option', default=False,
|
||||
help='Standard options are shown prominently; non-standard found via search')
|
||||
|
||||
# ── Variant count (computed) ──
|
||||
variant_count = fields.Integer(string='Variants',
|
||||
compute='_compute_variant_count')
|
||||
|
||||
# ── ADP info (from template) ──
|
||||
adp_device_code = fields.Char(string='ADP Code',
|
||||
related='product_tmpl_id.x_fc_adp_device_code', readonly=True)
|
||||
adp_price = fields.Float(string='ADP Price',
|
||||
related='product_tmpl_id.x_fc_adp_price', readonly=True)
|
||||
list_price = fields.Float(string='Sale Price',
|
||||
related='product_tmpl_id.list_price', readonly=True)
|
||||
|
||||
# Build type applicability (for seating sections)
|
||||
available_build_types = fields.Selection([
|
||||
('modular', 'Modular Only'),
|
||||
('custom_fabricated', 'Custom Fabricated Only'),
|
||||
('both', 'Both'),
|
||||
], string='Build Types', default='both',
|
||||
help='Which build types this option is available for')
|
||||
|
||||
requires_clinical_rationale = fields.Boolean(string='Requires Clinical Rationale',
|
||||
help='Sales rep must provide clinical rationale when selecting this option')
|
||||
|
||||
# Compatibility rules
|
||||
incompatible_option_ids = fields.Many2many(
|
||||
'fusion.wc.section.option',
|
||||
'wc_option_incompatible_rel',
|
||||
'option_id', 'incompatible_option_id',
|
||||
string='Incompatible With',
|
||||
help='Cannot be selected together with these options')
|
||||
requires_option_ids = fields.Many2many(
|
||||
'fusion.wc.section.option',
|
||||
'wc_option_requires_rel',
|
||||
'option_id', 'required_option_id',
|
||||
string='Requires Options',
|
||||
help='These options must be selected first')
|
||||
|
||||
@api.depends('product_tmpl_id')
|
||||
def _compute_variant_count(self):
|
||||
for record in self:
|
||||
if record.product_tmpl_id:
|
||||
record.variant_count = record.product_tmpl_id.product_variant_count
|
||||
else:
|
||||
record.variant_count = 0
|
||||
|
||||
@api.depends('product_tmpl_id', 'section_id')
|
||||
def _compute_display_name(self):
|
||||
for record in self:
|
||||
parts = []
|
||||
if record.section_id:
|
||||
parts.append(record.section_id.name)
|
||||
if record.product_tmpl_id:
|
||||
parts.append(record.product_tmpl_id.display_name)
|
||||
record.display_name = ' / '.join(parts) if parts else ''
|
||||
109
fusion_quotations/models/wc_upcharge_rule.py
Normal file
109
fusion_quotations/models/wc_upcharge_rule.py
Normal file
@@ -0,0 +1,109 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class WheelchairUpchargeRule(models.Model):
|
||||
_name = 'fusion.wc.upcharge.rule'
|
||||
_description = 'Wheelchair Upcharge Rule'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(string='Name', required=True,
|
||||
help='e.g. "Width > 18 inches (WAMA)"')
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
|
||||
# Trigger configuration
|
||||
trigger_type = fields.Selection([
|
||||
('measurement', 'Measurement Threshold'),
|
||||
('weight', 'Client Weight'),
|
||||
('dimension_mismatch', 'Dimension Mismatch'),
|
||||
], string='Trigger Type', required=True)
|
||||
|
||||
# For measurement triggers
|
||||
measurement_field = fields.Selection([
|
||||
('seat_width', 'Seat Width'),
|
||||
('seat_depth', 'Seat Depth'),
|
||||
('back_width', 'Backrest Width'),
|
||||
('back_height', 'Back Height'),
|
||||
('seat_to_floor', 'Seat to Floor Height'),
|
||||
('leg_rest_length', 'Leg Rest Length'),
|
||||
], string='Measurement Field')
|
||||
comparison = fields.Selection([
|
||||
('gt', 'Greater Than'),
|
||||
('gte', 'Greater Than or Equal'),
|
||||
('lt', 'Less Than'),
|
||||
('eq', 'Equal To'),
|
||||
('neq', 'Not Equal To'),
|
||||
], string='Comparison', default='gt')
|
||||
threshold_value = fields.Float(string='Threshold Value',
|
||||
help='e.g. 18.0 for WAMA width threshold')
|
||||
|
||||
# For weight triggers
|
||||
weight_min = fields.Float(string='Min Weight (lbs)',
|
||||
help='Trigger when client weight exceeds this value')
|
||||
weight_max = fields.Float(string='Max Weight (lbs)',
|
||||
help='Upper bound (0 or empty = no upper limit)')
|
||||
|
||||
# For dimension mismatch
|
||||
compare_field_1 = fields.Selection([
|
||||
('seat_width', 'Seat Width'),
|
||||
('back_width', 'Backrest Width'),
|
||||
('seat_depth', 'Seat Depth'),
|
||||
], string='Compare Field 1')
|
||||
compare_field_2 = fields.Selection([
|
||||
('seat_width', 'Seat Width'),
|
||||
('back_width', 'Backrest Width'),
|
||||
('seat_depth', 'Seat Depth'),
|
||||
], string='Compare Field 2')
|
||||
|
||||
# What to add when triggered
|
||||
adp_device_code = fields.Char(string='ADP Device Code', required=True,
|
||||
help='ADP code to add (e.g. WAMA, WAMB, WAMF)')
|
||||
product_id = fields.Many2one('product.product', string='Product to Add',
|
||||
help='If set, this product will be added to the quotation. '
|
||||
'If empty, a generic line is created from the ADP code.')
|
||||
|
||||
# Equipment type applicability
|
||||
equipment_type = fields.Selection(
|
||||
selection='_get_equipment_type_selection',
|
||||
string='Equipment Type', default='both', required=True)
|
||||
|
||||
@api.model
|
||||
def _get_equipment_type_selection(self):
|
||||
types = self.env['fusion.equipment.type'].sudo().search([], order='sequence')
|
||||
if types:
|
||||
result = [(t.code, t.name) for t in types]
|
||||
result.append(('both', 'Both (All Types)'))
|
||||
return result
|
||||
return [
|
||||
('manual_wheelchair', 'Manual Wheelchair'),
|
||||
('power_wheelchair', 'Power Wheelchair'),
|
||||
('both', 'Both'),
|
||||
]
|
||||
|
||||
# Mutual exclusion
|
||||
mutually_exclusive_group = fields.Char(string='Exclusive Group',
|
||||
help='Rules in the same group are mutually exclusive '
|
||||
'(only the highest-priority matching rule applies). '
|
||||
'e.g. "weight" for WAMF/WAMG/WAMH')
|
||||
|
||||
description = fields.Text(string='Description',
|
||||
help='Explanation shown to the sales rep when this rule triggers')
|
||||
|
||||
# Linked ADP device code record (for price lookup)
|
||||
adp_device_code_id = fields.Many2one('fusion.adp.device.code',
|
||||
string='ADP Device Code Record',
|
||||
compute='_compute_adp_device_code_id', store=True)
|
||||
|
||||
@api.depends('adp_device_code')
|
||||
def _compute_adp_device_code_id(self):
|
||||
ADPDevice = self.env['fusion.adp.device.code'].sudo()
|
||||
for record in self:
|
||||
if record.adp_device_code:
|
||||
record.adp_device_code_id = ADPDevice.search(
|
||||
[('device_code', '=', record.adp_device_code), ('active', '=', True)],
|
||||
limit=1
|
||||
)
|
||||
else:
|
||||
record.adp_device_code_id = False
|
||||
30
fusion_quotations/security/ir.model.access.csv
Normal file
30
fusion_quotations/security/ir.model.access.csv
Normal file
@@ -0,0 +1,30 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_wc_section_user,fusion.wc.section.user,model_fusion_wc_section,base.group_user,1,0,0,0
|
||||
access_wc_section_manager,fusion.wc.section.manager,model_fusion_wc_section,sales_team.group_sale_manager,1,1,1,1
|
||||
access_wc_section_portal,fusion.wc.section.portal,model_fusion_wc_section,base.group_portal,1,0,0,0
|
||||
access_wc_section_option_user,fusion.wc.section.option.user,model_fusion_wc_section_option,base.group_user,1,0,0,0
|
||||
access_wc_section_option_manager,fusion.wc.section.option.manager,model_fusion_wc_section_option,sales_team.group_sale_manager,1,1,1,1
|
||||
access_wc_section_option_portal,fusion.wc.section.option.portal,model_fusion_wc_section_option,base.group_portal,1,0,0,0
|
||||
access_wc_upcharge_rule_user,fusion.wc.upcharge.rule.user,model_fusion_wc_upcharge_rule,base.group_user,1,0,0,0
|
||||
access_wc_upcharge_rule_manager,fusion.wc.upcharge.rule.manager,model_fusion_wc_upcharge_rule,sales_team.group_sale_manager,1,1,1,1
|
||||
access_wc_upcharge_rule_portal,fusion.wc.upcharge.rule.portal,model_fusion_wc_upcharge_rule,base.group_portal,1,0,0,0
|
||||
access_wc_assessment_salesman,fusion.wc.assessment.salesman,model_fusion_wc_assessment,sales_team.group_sale_salesman,1,1,1,0
|
||||
access_wc_assessment_manager,fusion.wc.assessment.manager,model_fusion_wc_assessment,sales_team.group_sale_manager,1,1,1,1
|
||||
access_wc_assessment_portal,fusion.wc.assessment.portal,model_fusion_wc_assessment,base.group_portal,1,1,1,0
|
||||
access_wc_assessment_line_salesman,fusion.wc.assessment.line.salesman,model_fusion_wc_assessment_line,sales_team.group_sale_salesman,1,1,1,1
|
||||
access_wc_assessment_line_manager,fusion.wc.assessment.line.manager,model_fusion_wc_assessment_line,sales_team.group_sale_manager,1,1,1,1
|
||||
access_wc_assessment_line_portal,fusion.wc.assessment.line.portal,model_fusion_wc_assessment_line,base.group_portal,1,1,1,0
|
||||
access_wc_config_flow_user,fusion.wc.config.flow.user,model_fusion_wc_config_flow,base.group_user,1,0,0,0
|
||||
access_wc_config_flow_manager,fusion.wc.config.flow.manager,model_fusion_wc_config_flow,sales_team.group_sale_manager,1,1,1,1
|
||||
access_wc_config_flow_node_user,fusion.wc.config.flow.node.user,model_fusion_wc_config_flow_node,base.group_user,1,0,0,0
|
||||
access_wc_config_flow_node_manager,fusion.wc.config.flow.node.manager,model_fusion_wc_config_flow_node,sales_team.group_sale_manager,1,1,1,1
|
||||
access_wc_config_flow_connection_user,fusion.wc.config.flow.connection.user,model_fusion_wc_config_flow_connection,base.group_user,1,0,0,0
|
||||
access_wc_config_flow_connection_manager,fusion.wc.config.flow.connection.manager,model_fusion_wc_config_flow_connection,sales_team.group_sale_manager,1,1,1,1
|
||||
access_wc_config_flow_node_option_user,fusion.wc.config.flow.node.option.user,model_fusion_wc_config_flow_node_option,base.group_user,1,0,0,0
|
||||
access_wc_config_flow_node_option_manager,fusion.wc.config.flow.node.option.manager,model_fusion_wc_config_flow_node_option,sales_team.group_sale_manager,1,1,1,1
|
||||
access_equipment_type_user,fusion.equipment.type.user,model_fusion_equipment_type,base.group_user,1,0,0,0
|
||||
access_equipment_type_manager,fusion.equipment.type.manager,model_fusion_equipment_type,sales_team.group_sale_manager,1,1,1,1
|
||||
access_equipment_type_portal,fusion.equipment.type.portal,model_fusion_equipment_type,base.group_portal,1,0,0,0
|
||||
access_wc_config_flow_step_user,fusion.wc.config.flow.step.user,model_fusion_wc_config_flow_step,base.group_user,1,0,0,0
|
||||
access_wc_config_flow_step_manager,fusion.wc.config.flow.step.manager,model_fusion_wc_config_flow_step,sales_team.group_sale_manager,1,1,1,1
|
||||
access_wc_config_flow_step_portal,fusion.wc.config.flow.step.portal,model_fusion_wc_config_flow_step,base.group_portal,1,0,0,0
|
||||
|
37
fusion_quotations/security/security.xml
Normal file
37
fusion_quotations/security/security.xml
Normal file
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Portal sales rep: own assessments only -->
|
||||
<record id="rule_wc_assessment_portal" model="ir.rule">
|
||||
<field name="name">Portal: Own WC Assessments</field>
|
||||
<field name="model_id" ref="model_fusion_wc_assessment"/>
|
||||
<field name="domain_force">[('sales_rep_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="True"/>
|
||||
<field name="perm_create" eval="True"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Portal: assessment lines follow assessment access -->
|
||||
<record id="rule_wc_assessment_line_portal" model="ir.rule">
|
||||
<field name="name">Portal: Own WC Assessment Lines</field>
|
||||
<field name="model_id" ref="model_fusion_wc_assessment_line"/>
|
||||
<field name="domain_force">[('assessment_id.sales_rep_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="True"/>
|
||||
<field name="perm_create" eval="True"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- IR Sequence for assessment references -->
|
||||
<record id="seq_wc_assessment" model="ir.sequence">
|
||||
<field name="name">Wheelchair Assessment</field>
|
||||
<field name="code">fusion.wc.assessment</field>
|
||||
<field name="prefix">WCA-</field>
|
||||
<field name="padding">4</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
BIN
fusion_quotations/static/description/icon.png
Normal file
BIN
fusion_quotations/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
410
fusion_quotations/static/src/css/quotation_form.css
Normal file
410
fusion_quotations/static/src/css/quotation_form.css
Normal file
@@ -0,0 +1,410 @@
|
||||
/* =================================================================
|
||||
Wheelchair Assessment Form — Dark / Light Compatible
|
||||
Uses Bootstrap 5.3 CSS custom properties for full theme support.
|
||||
No color-mix(), no absolute connectors, no transform overlap.
|
||||
================================================================= */
|
||||
|
||||
.wc-assessment-form {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------
|
||||
Step Indicators — always visible, solid colours, no opacity tricks
|
||||
----------------------------------------------------------------- */
|
||||
.wc-steps {
|
||||
border-bottom: 2px solid var(--bs-border-color, #dee2e6);
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.wc-step-indicator {
|
||||
cursor: pointer;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.wc-step-number {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #e9ecef;
|
||||
color: #6c757d;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wc-step-label {
|
||||
font-size: 0.72rem;
|
||||
color: #6c757d;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Active step — blue circle */
|
||||
.wc-step-indicator.active .wc-step-number {
|
||||
background: #0d6efd;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 4px rgba(13, 110, 253, 0.2);
|
||||
}
|
||||
|
||||
.wc-step-indicator.active .wc-step-label {
|
||||
color: #0d6efd;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Completed step — green circle with checkmark */
|
||||
.wc-step-indicator.completed .wc-step-number {
|
||||
background: #198754;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wc-step-indicator.completed .wc-step-label {
|
||||
color: #198754;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------
|
||||
Step panels — NO animation (animation creates stacking context
|
||||
that traps z-index, breaking search dropdowns)
|
||||
----------------------------------------------------------------- */
|
||||
.wc-step {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------
|
||||
Step 1 Section Cards (Client, Equipment)
|
||||
----------------------------------------------------------------- */
|
||||
.wc-section-card {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.wc-section-card > .card-header {
|
||||
background: var(--bs-tertiary-bg);
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 0.75rem 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.wc-section-card > .card-header h5 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.wc-section-card > .card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------
|
||||
Radio Buttons as Toggles — hardcoded colours, no CSS variables.
|
||||
JS sets inline styles as belt-and-suspenders.
|
||||
----------------------------------------------------------------- */
|
||||
.wc-radio-btn {
|
||||
position: relative;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 2px solid #0d6efd;
|
||||
color: #0d6efd;
|
||||
background: transparent;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.wc-radio-btn input[type="radio"] {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Primary variant — selected */
|
||||
.wc-radio-btn.active {
|
||||
background-color: #0d6efd !important;
|
||||
color: #fff !important;
|
||||
border-color: #0d6efd !important;
|
||||
}
|
||||
|
||||
/* Secondary variant (.btn-outline-secondary) — unselected */
|
||||
.wc-radio-btn.btn-outline-secondary {
|
||||
border-color: #6c757d;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Secondary variant — selected */
|
||||
.wc-radio-btn.btn-outline-secondary.active {
|
||||
background-color: #6c757d !important;
|
||||
color: #fff !important;
|
||||
border-color: #6c757d !important;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------
|
||||
Measurement Fields
|
||||
----------------------------------------------------------------- */
|
||||
.wc-measurement-field {
|
||||
background: var(--bs-tertiary-bg);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
border-radius: 0.5rem !important;
|
||||
}
|
||||
|
||||
.wc-measurement-field:focus-within {
|
||||
border-color: var(--bs-primary) !important;
|
||||
box-shadow: 0 0 0 3px rgba(var(--bs-primary-rgb), 0.12);
|
||||
}
|
||||
|
||||
.wc-upcharge-badge {
|
||||
white-space: nowrap;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------
|
||||
Option Cards (seating sections, ADP options, accessories)
|
||||
----------------------------------------------------------------- */
|
||||
.wc-option-card {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.5rem !important;
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
.wc-option-card:hover {
|
||||
border-color: rgba(var(--bs-primary-rgb), 0.5);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.wc-option-card.border-primary {
|
||||
border-width: 2px !important;
|
||||
border-color: var(--bs-primary) !important;
|
||||
background-color: var(--bs-primary-bg-subtle) !important;
|
||||
}
|
||||
|
||||
.wc-option-card .card-body {
|
||||
padding: 0.625rem 0.75rem !important;
|
||||
}
|
||||
|
||||
/* Option card label */
|
||||
.wc-option-label {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------
|
||||
Search Containers — z-index must beat ALL sibling content below.
|
||||
position:relative creates a stacking context so the absolute
|
||||
dropdown inside floats above everything that follows.
|
||||
----------------------------------------------------------------- */
|
||||
.wc-search-container {
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------
|
||||
Search Dropdowns (client, frame, section product search)
|
||||
position:absolute + high z-index within the search container
|
||||
----------------------------------------------------------------- */
|
||||
.wc-search-results,
|
||||
#clientSearchResults,
|
||||
#frameSearchResults {
|
||||
position: absolute;
|
||||
z-index: 1060;
|
||||
width: 100%;
|
||||
background: var(--bs-body-bg);
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
|
||||
border-radius: 0 0 0.5rem 0.5rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-top: none;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wc-search-results .list-group-item,
|
||||
#clientSearchResults .list-group-item,
|
||||
#frameSearchResults .list-group-item {
|
||||
padding: 0.625rem 0.875rem;
|
||||
font-size: 0.875rem;
|
||||
background: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
border-color: var(--bs-border-color);
|
||||
transition: background 0.1s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wc-search-results .list-group-item:hover,
|
||||
#clientSearchResults .list-group-item:hover,
|
||||
#frameSearchResults .list-group-item:hover {
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------
|
||||
Selected Frame Card
|
||||
----------------------------------------------------------------- */
|
||||
#selectedFrame > .card {
|
||||
border-left: 4px solid var(--bs-primary);
|
||||
background: var(--bs-primary-bg-subtle);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------
|
||||
Frame Configurator Panel
|
||||
----------------------------------------------------------------- */
|
||||
.wc-configurator-panel {
|
||||
background: var(--bs-tertiary-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.wc-configurator-panel .wc-config-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.wc-configurator-panel .wc-config-title i {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.wc-config-attr-group {
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.wc-config-attr-group .wc-config-attr-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.wc-config-attr-group .form-select {
|
||||
border-color: var(--bs-border-color);
|
||||
background-color: var(--bs-body-bg);
|
||||
font-size: 0.875rem;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.wc-config-attr-group .form-select:focus {
|
||||
border-color: var(--bs-primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--bs-primary-rgb), 0.12);
|
||||
}
|
||||
|
||||
.wc-variant-resolved {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.35em 0.65em;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
background: rgba(var(--bs-success-rgb), 0.1);
|
||||
color: var(--bs-success);
|
||||
border: 1px solid rgba(var(--bs-success-rgb), 0.25);
|
||||
border-radius: 2rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------
|
||||
Accordion Sections (Step 4 seating)
|
||||
----------------------------------------------------------------- */
|
||||
.wc-assessment-form .accordion-button:not(.collapsed) {
|
||||
background-color: #e8f0fe;
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
.wc-assessment-form .accordion-item {
|
||||
border-radius: 0.5rem !important;
|
||||
margin-bottom: 0.75rem;
|
||||
border: 1px solid #dee2e6 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wc-assessment-form .accordion-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.wc-assessment-form .accordion-button {
|
||||
border-radius: 0.5rem !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wc-assessment-form .accordion-button:not(.collapsed) {
|
||||
border-radius: 0.5rem 0.5rem 0 0 !important;
|
||||
}
|
||||
|
||||
.wc-assessment-form .accordion-body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------
|
||||
Review Table (Step 6)
|
||||
----------------------------------------------------------------- */
|
||||
#reviewSummary .table th {
|
||||
background: var(--bs-tertiary-bg);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
/* Review measurements card */
|
||||
.wc-step[data-step="6"] .card {
|
||||
border-color: var(--bs-border-color);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.wc-step[data-step="6"] .card-header {
|
||||
background: var(--bs-tertiary-bg);
|
||||
border-bottom-color: var(--bs-border-color);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------
|
||||
Navigation Buttons
|
||||
----------------------------------------------------------------- */
|
||||
.wc-assessment-form .border-top {
|
||||
border-color: var(--bs-border-color) !important;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------
|
||||
Responsive
|
||||
----------------------------------------------------------------- */
|
||||
@media (max-width: 768px) {
|
||||
.wc-step-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wc-step-number {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wc-radio-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.wc-option-label {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.wc-configurator-panel {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { Component, useState, onWillStart, onMounted, onWillUnmount, useRef } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────
|
||||
Constants
|
||||
────────────────────────────────────────────────────────────────── */
|
||||
const NODE_TYPES = {
|
||||
start: { label: 'Start', icon: 'fa-play', color: '#10b981', width: 140, height: 50 },
|
||||
end: { label: 'End', icon: 'fa-stop', color: '#ef4444', width: 140, height: 50 },
|
||||
decision: { label: 'Decision', icon: 'fa-code-fork', color: '#f59e0b', width: 180, height: 80 },
|
||||
option_group: { label: 'Option Group', icon: 'fa-list-ul', color: '#3b82f6', width: 200, height: 80 },
|
||||
product_select: { label: 'Product Selection', icon: 'fa-shopping-cart', color: '#8b5cf6', width: 200, height: 80 },
|
||||
measurement_check: { label: 'Measurement Check', icon: 'fa-ruler', color: '#f97316', width: 200, height: 80 },
|
||||
action: { label: 'Action', icon: 'fa-bolt', color: '#14b8a6', width: 180, height: 70 },
|
||||
};
|
||||
|
||||
const GRID_SIZE = 20;
|
||||
const MIN_ZOOM = 0.25;
|
||||
const MAX_ZOOM = 3;
|
||||
const PORT_RADIUS = 7;
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────
|
||||
Utility helpers
|
||||
────────────────────────────────────────────────────────────────── */
|
||||
function bezierPath(x1, y1, x2, y2) {
|
||||
const dx = Math.abs(x2 - x1) * 0.5;
|
||||
return `M${x1},${y1} C${x1 + dx},${y1} ${x2 - dx},${y2} ${x2},${y2}`;
|
||||
}
|
||||
|
||||
function getPortsForNode(node) {
|
||||
const meta = NODE_TYPES[node.node_type] || NODE_TYPES.action;
|
||||
const w = meta.width;
|
||||
const h = meta.height;
|
||||
const ports = [];
|
||||
|
||||
// Input port (all except start)
|
||||
if (node.node_type !== 'start') {
|
||||
ports.push({ key: 'in', type: 'input', x: 0, y: h / 2 });
|
||||
}
|
||||
|
||||
// Output ports
|
||||
if (node.node_type === 'decision') {
|
||||
ports.push({ key: 'true', type: 'output', x: w, y: h * 0.33, label: 'Yes' });
|
||||
ports.push({ key: 'false', type: 'output', x: w, y: h * 0.67, label: 'No' });
|
||||
} else if (node.node_type === 'measurement_check') {
|
||||
ports.push({ key: 'pass', type: 'output', x: w, y: h * 0.33, label: 'Pass' });
|
||||
ports.push({ key: 'fail', type: 'output', x: w, y: h * 0.67, label: 'Fail' });
|
||||
} else if (node.node_type === 'option_group' && node.node_options && node.node_options.length) {
|
||||
const count = node.node_options.length;
|
||||
node.node_options.forEach((opt, i) => {
|
||||
ports.push({
|
||||
key: opt.port_key || `opt_${opt.sequence || (i * 10 + 10)}`,
|
||||
type: 'output',
|
||||
x: w,
|
||||
y: h * ((i + 1) / (count + 1)),
|
||||
label: opt.name,
|
||||
});
|
||||
});
|
||||
} else if (node.node_type !== 'end') {
|
||||
ports.push({ key: 'out', type: 'output', x: w, y: h / 2 });
|
||||
}
|
||||
|
||||
return ports;
|
||||
}
|
||||
|
||||
function snapToGrid(val) {
|
||||
return Math.round(val / GRID_SIZE) * GRID_SIZE;
|
||||
}
|
||||
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
FlowDesignerAction — main OWL client action
|
||||
══════════════════════════════════════════════════════════════════ */
|
||||
export class FlowDesignerAction extends Component {
|
||||
static template = "fusion_quotations.FlowDesignerAction";
|
||||
static props = { "*": true };
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.actionService = useService("action");
|
||||
this.notification = useService("notification");
|
||||
|
||||
this.svgRef = useRef("svgCanvas");
|
||||
this.canvasGroupRef = useRef("canvasGroup");
|
||||
|
||||
this.flowId = this.props.action?.context?.active_id || false;
|
||||
|
||||
this.state = useState({
|
||||
flowName: '',
|
||||
equipmentType: '',
|
||||
nodes: [],
|
||||
connections: [],
|
||||
selectedNodeId: null,
|
||||
selectedConnectionId: null,
|
||||
dirty: false,
|
||||
saving: false,
|
||||
loading: true,
|
||||
panelOpen: false,
|
||||
|
||||
// Viewport
|
||||
viewX: 0,
|
||||
viewY: 0,
|
||||
zoom: 1,
|
||||
});
|
||||
|
||||
// Interaction state (non-reactive, doesn't need re-render)
|
||||
this._dragging = null; // { nodeId, startX, startY, origX, origY }
|
||||
this._panning = null; // { startX, startY, origVX, origVY }
|
||||
this._connecting = null; // { sourceNodeId, sourcePort, tempX, tempY }
|
||||
this._tempLine = null; // SVG path element for connection preview
|
||||
|
||||
onWillStart(async () => {
|
||||
if (this.flowId) {
|
||||
await this._loadGraph();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
this._bindCanvasEvents();
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
this._unbindCanvasEvents();
|
||||
});
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
Data loading / saving
|
||||
────────────────────────────────────────────────────────────── */
|
||||
async _loadGraph() {
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const graph = await this.orm.call(
|
||||
'fusion.wc.config.flow',
|
||||
'load_flow_graph',
|
||||
[this.flowId]
|
||||
);
|
||||
this.state.flowName = graph.name || '';
|
||||
this.state.equipmentType = graph.equipment_type || '';
|
||||
this.state.nodes = graph.nodes || [];
|
||||
this.state.connections = graph.connections || [];
|
||||
|
||||
if (graph.canvas) {
|
||||
this.state.viewX = graph.canvas.x || 0;
|
||||
this.state.viewY = graph.canvas.y || 0;
|
||||
this.state.zoom = graph.canvas.zoom || 1;
|
||||
}
|
||||
} catch (e) {
|
||||
this.notification.add(_t("Error loading flow: ") + e.message, { type: "danger" });
|
||||
console.error("Load flow error:", e);
|
||||
}
|
||||
this.state.loading = false;
|
||||
}
|
||||
|
||||
async _saveGraph() {
|
||||
if (!this.flowId) return;
|
||||
this.state.saving = true;
|
||||
try {
|
||||
await this.orm.call(
|
||||
'fusion.wc.config.flow',
|
||||
'save_flow_graph',
|
||||
[this.flowId, {
|
||||
canvas: { x: this.state.viewX, y: this.state.viewY, zoom: this.state.zoom },
|
||||
nodes: this.state.nodes,
|
||||
connections: this.state.connections,
|
||||
}]
|
||||
);
|
||||
this.state.dirty = false;
|
||||
this.notification.add(_t("Flow saved successfully"), { type: "success" });
|
||||
// Reload to get real IDs from server
|
||||
await this._loadGraph();
|
||||
} catch (e) {
|
||||
this.notification.add(_t("Error saving flow: ") + e.message, { type: "danger" });
|
||||
console.error("Save flow error:", e);
|
||||
}
|
||||
this.state.saving = false;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
Coordinate transforms
|
||||
────────────────────────────────────────────────────────────── */
|
||||
screenToCanvas(sx, sy) {
|
||||
const svg = this.svgRef.el;
|
||||
if (!svg) return { x: sx, y: sy };
|
||||
const rect = svg.getBoundingClientRect();
|
||||
return {
|
||||
x: (sx - rect.left - this.state.viewX) / this.state.zoom,
|
||||
y: (sy - rect.top - this.state.viewY) / this.state.zoom,
|
||||
};
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
Computed data for template
|
||||
────────────────────────────────────────────────────────────── */
|
||||
get transformStr() {
|
||||
return `translate(${this.state.viewX}, ${this.state.viewY}) scale(${this.state.zoom})`;
|
||||
}
|
||||
|
||||
get nodesWithPorts() {
|
||||
return this.state.nodes.map(n => {
|
||||
const meta = NODE_TYPES[n.node_type] || NODE_TYPES.action;
|
||||
return {
|
||||
...n,
|
||||
width: meta.width,
|
||||
height: meta.height,
|
||||
meta,
|
||||
ports: getPortsForNode(n),
|
||||
isSelected: n.id === this.state.selectedNodeId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
get connectionPaths() {
|
||||
const nodeMap = {};
|
||||
for (const n of this.state.nodes) {
|
||||
const meta = NODE_TYPES[n.node_type] || NODE_TYPES.action;
|
||||
nodeMap[n.id] = { ...n, width: meta.width, height: meta.height, ports: getPortsForNode(n) };
|
||||
}
|
||||
return this.state.connections.map(c => {
|
||||
const src = nodeMap[c.source_node_id];
|
||||
const tgt = nodeMap[c.target_node_id];
|
||||
if (!src || !tgt) return null;
|
||||
|
||||
const srcPort = src.ports.find(p => p.key === c.source_port) || src.ports.find(p => p.type === 'output');
|
||||
const tgtPort = tgt.ports.find(p => p.type === 'input') || { x: 0, y: tgt.height / 2 };
|
||||
|
||||
if (!srcPort) return null;
|
||||
|
||||
const x1 = src.pos_x + srcPort.x;
|
||||
const y1 = src.pos_y + srcPort.y;
|
||||
const x2 = tgt.pos_x + tgtPort.x;
|
||||
const y2 = tgt.pos_y + tgtPort.y;
|
||||
|
||||
const label = c.label || '';
|
||||
const labelW = Math.max(label.length * 7 + 16, 32); // approx width from char count
|
||||
|
||||
return {
|
||||
...c,
|
||||
path: bezierPath(x1, y1, x2, y2),
|
||||
isSelected: c.id === this.state.selectedConnectionId,
|
||||
midX: (x1 + x2) / 2,
|
||||
midY: (y1 + y2) / 2,
|
||||
labelW,
|
||||
};
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
getNodeTypeLabel(nodeType) {
|
||||
return (NODE_TYPES[nodeType] || {}).label || nodeType;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
Toolbar / Palette actions
|
||||
────────────────────────────────────────────────────────────── */
|
||||
onAddNode(ev) {
|
||||
const nodeType = ev.currentTarget.dataset.nodeType;
|
||||
if (!nodeType) return;
|
||||
const meta = NODE_TYPES[nodeType];
|
||||
if (!meta) return;
|
||||
|
||||
// Place in center of current viewport
|
||||
const svg = this.svgRef.el;
|
||||
const rect = svg ? svg.getBoundingClientRect() : { width: 800, height: 600 };
|
||||
const pos = this.screenToCanvas(rect.width / 2, rect.height / 2);
|
||||
|
||||
const tempId = 'new_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5);
|
||||
const newNode = {
|
||||
id: tempId,
|
||||
name: meta.label,
|
||||
node_type: nodeType,
|
||||
pos_x: snapToGrid(pos.x - meta.width / 2),
|
||||
pos_y: snapToGrid(pos.y - meta.height / 2),
|
||||
color: meta.color,
|
||||
icon: meta.icon,
|
||||
section_id: false,
|
||||
section_name: '',
|
||||
decision_field: '',
|
||||
decision_operator: '',
|
||||
decision_value: '',
|
||||
measurement_field: '',
|
||||
comparison: '',
|
||||
threshold_value: 0,
|
||||
action_type: '',
|
||||
target_option_ids: [],
|
||||
target_step: 0,
|
||||
config_json: '{}',
|
||||
node_options: [],
|
||||
};
|
||||
|
||||
this.state.nodes.push(newNode);
|
||||
this.state.selectedNodeId = tempId;
|
||||
this.state.selectedConnectionId = null;
|
||||
this.state.panelOpen = true;
|
||||
this.state.dirty = true;
|
||||
}
|
||||
|
||||
onDeleteSelected() {
|
||||
if (this.state.selectedConnectionId) {
|
||||
this.state.connections = this.state.connections.filter(
|
||||
c => c.id !== this.state.selectedConnectionId
|
||||
);
|
||||
this.state.selectedConnectionId = null;
|
||||
this.state.dirty = true;
|
||||
}
|
||||
if (this.state.selectedNodeId) {
|
||||
const nodeId = this.state.selectedNodeId;
|
||||
this.state.nodes = this.state.nodes.filter(n => n.id !== nodeId);
|
||||
this.state.connections = this.state.connections.filter(
|
||||
c => c.source_node_id !== nodeId && c.target_node_id !== nodeId
|
||||
);
|
||||
this.state.selectedNodeId = null;
|
||||
this.state.panelOpen = false;
|
||||
this.state.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
onSave() {
|
||||
this._saveGraph();
|
||||
}
|
||||
|
||||
onZoomIn() {
|
||||
this.state.zoom = Math.min(MAX_ZOOM, this.state.zoom * 1.2);
|
||||
}
|
||||
|
||||
onZoomOut() {
|
||||
this.state.zoom = Math.max(MIN_ZOOM, this.state.zoom / 1.2);
|
||||
}
|
||||
|
||||
onZoomReset() {
|
||||
this.state.zoom = 1;
|
||||
this.state.viewX = 0;
|
||||
this.state.viewY = 0;
|
||||
}
|
||||
|
||||
onBack() {
|
||||
// Navigate back to the flow form / list.
|
||||
// history.back() is the most reliable way to return to the
|
||||
// previous Odoo view that opened this client action.
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
Canvas events
|
||||
────────────────────────────────────────────────────────────── */
|
||||
_bindCanvasEvents() {
|
||||
const svg = this.svgRef.el;
|
||||
if (!svg) return;
|
||||
|
||||
this._onMouseDown = this._handleMouseDown.bind(this);
|
||||
this._onMouseMove = this._handleMouseMove.bind(this);
|
||||
this._onMouseUp = this._handleMouseUp.bind(this);
|
||||
this._onWheel = this._handleWheel.bind(this);
|
||||
this._onKeyDown = this._handleKeyDown.bind(this);
|
||||
|
||||
svg.addEventListener('mousedown', this._onMouseDown);
|
||||
window.addEventListener('mousemove', this._onMouseMove);
|
||||
window.addEventListener('mouseup', this._onMouseUp);
|
||||
svg.addEventListener('wheel', this._onWheel, { passive: false });
|
||||
window.addEventListener('keydown', this._onKeyDown);
|
||||
}
|
||||
|
||||
_unbindCanvasEvents() {
|
||||
const svg = this.svgRef.el;
|
||||
if (svg) {
|
||||
svg.removeEventListener('mousedown', this._onMouseDown);
|
||||
svg.removeEventListener('wheel', this._onWheel);
|
||||
}
|
||||
window.removeEventListener('mousemove', this._onMouseMove);
|
||||
window.removeEventListener('mouseup', this._onMouseUp);
|
||||
window.removeEventListener('keydown', this._onKeyDown);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
Port interaction — pure coordinate math.
|
||||
No DOM queries (elementsFromPoint / ev.target) — works
|
||||
entirely from component state + getPortsForNode().
|
||||
────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Find the nearest port within hit radius using coordinate math.
|
||||
* @param {number} cx — x in canvas coordinate space
|
||||
* @param {number} cy — y in canvas coordinate space
|
||||
* @param {'input'|'output'} portType
|
||||
* @returns {{ node: Object, port: Object, x: number, y: number }|null}
|
||||
*/
|
||||
_findPortNear(cx, cy, portType) {
|
||||
const HIT_R = 20; // canvas-unit hit radius (~20px at zoom 1)
|
||||
const rSq = HIT_R * HIT_R;
|
||||
let best = null;
|
||||
let bestDist = Infinity;
|
||||
|
||||
for (const node of this.state.nodes) {
|
||||
const ports = getPortsForNode(node);
|
||||
for (const port of ports) {
|
||||
if (port.type !== portType) continue;
|
||||
const px = node.pos_x + port.x;
|
||||
const py = node.pos_y + port.y;
|
||||
const dx = cx - px;
|
||||
const dy = cy - py;
|
||||
const distSq = dx * dx + dy * dy;
|
||||
if (distSq <= rSq && distSq < bestDist) {
|
||||
bestDist = distSq;
|
||||
best = { node, port, x: px, y: py };
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
_handleMouseDown(ev) {
|
||||
if (ev.button !== 0 && ev.button !== 1) return;
|
||||
|
||||
const canvasPos = this.screenToCanvas(ev.clientX, ev.clientY);
|
||||
|
||||
// ── 1. Port detection via coordinate math ──
|
||||
// No DOM queries — purely checks distance to port centres.
|
||||
if (ev.button === 0) {
|
||||
const outHit = this._findPortNear(canvasPos.x, canvasPos.y, 'output');
|
||||
if (outHit) {
|
||||
this._connecting = {
|
||||
sourceNodeId: outHit.node.id,
|
||||
sourcePort: outHit.port.key,
|
||||
startX: outHit.x,
|
||||
startY: outHit.y,
|
||||
};
|
||||
// Create temp bezier for visual feedback
|
||||
const svgNs = 'http://www.w3.org/2000/svg';
|
||||
this._tempLine = document.createElementNS(svgNs, 'path');
|
||||
this._tempLine.classList.add('fd-connection-temp');
|
||||
this._tempLine.setAttribute('stroke-width', '2');
|
||||
this._tempLine.setAttribute('pointer-events', 'none');
|
||||
const group = this.canvasGroupRef.el;
|
||||
if (group) group.appendChild(this._tempLine);
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Input port click — block drag/pan, do nothing
|
||||
const inHit = this._findPortNear(canvasPos.x, canvasPos.y, 'input');
|
||||
if (inHit) {
|
||||
ev.stopPropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If already connecting, ignore other interactions
|
||||
if (this._connecting) return;
|
||||
|
||||
// ── 2. Node drag ──
|
||||
const target = ev.target;
|
||||
const nodeGroup = target.closest('.fd-node-group');
|
||||
if (nodeGroup && ev.button === 0) {
|
||||
const nodeId = nodeGroup.dataset.nodeId;
|
||||
const nid = isNaN(nodeId) ? nodeId : parseInt(nodeId);
|
||||
const node = this.state.nodes.find(n => n.id === nid || String(n.id) === nodeId);
|
||||
if (node) {
|
||||
this.state.selectedNodeId = node.id;
|
||||
this.state.selectedConnectionId = null;
|
||||
this.state.panelOpen = true;
|
||||
this._dragging = {
|
||||
nodeId: node.id,
|
||||
startX: ev.clientX,
|
||||
startY: ev.clientY,
|
||||
origX: node.pos_x,
|
||||
origY: node.pos_y,
|
||||
};
|
||||
ev.stopPropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Connection click ──
|
||||
if (target.classList.contains('fd-connection-path') || target.classList.contains('fd-connection-hit')) {
|
||||
const connId = target.dataset.connId;
|
||||
if (connId) {
|
||||
const cid = isNaN(connId) ? connId : parseInt(connId);
|
||||
this.state.selectedConnectionId = cid;
|
||||
this.state.selectedNodeId = null;
|
||||
this.state.panelOpen = false;
|
||||
ev.stopPropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Canvas pan ──
|
||||
if (ev.button === 1 || (ev.button === 0 && !nodeGroup)) {
|
||||
this.state.selectedNodeId = null;
|
||||
this.state.selectedConnectionId = null;
|
||||
this.state.panelOpen = false;
|
||||
this._panning = {
|
||||
startX: ev.clientX,
|
||||
startY: ev.clientY,
|
||||
origVX: this.state.viewX,
|
||||
origVY: this.state.viewY,
|
||||
};
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
_handleMouseMove(ev) {
|
||||
if (this._dragging) {
|
||||
const dx = (ev.clientX - this._dragging.startX) / this.state.zoom;
|
||||
const dy = (ev.clientY - this._dragging.startY) / this.state.zoom;
|
||||
const node = this.state.nodes.find(n => n.id === this._dragging.nodeId);
|
||||
if (node) {
|
||||
node.pos_x = snapToGrid(this._dragging.origX + dx);
|
||||
node.pos_y = snapToGrid(this._dragging.origY + dy);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._panning) {
|
||||
this.state.viewX = this._panning.origVX + (ev.clientX - this._panning.startX);
|
||||
this.state.viewY = this._panning.origVY + (ev.clientY - this._panning.startY);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._connecting && this._tempLine) {
|
||||
const pos = this.screenToCanvas(ev.clientX, ev.clientY);
|
||||
this._tempLine.setAttribute('d',
|
||||
bezierPath(this._connecting.startX, this._connecting.startY, pos.x, pos.y));
|
||||
return;
|
||||
}
|
||||
|
||||
// Port hover cursor — crosshair when near any port
|
||||
const svg = this.svgRef.el;
|
||||
if (svg) {
|
||||
const canvasPos = this.screenToCanvas(ev.clientX, ev.clientY);
|
||||
const hit = this._findPortNear(canvasPos.x, canvasPos.y, 'output')
|
||||
|| this._findPortNear(canvasPos.x, canvasPos.y, 'input');
|
||||
svg.style.cursor = hit ? 'crosshair' : '';
|
||||
}
|
||||
}
|
||||
|
||||
_handleMouseUp(ev) {
|
||||
if (this._dragging) {
|
||||
this.state.dirty = true;
|
||||
this._dragging = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._panning) {
|
||||
this._panning = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._connecting) {
|
||||
const canvasPos = this.screenToCanvas(ev.clientX, ev.clientY);
|
||||
const inHit = this._findPortNear(canvasPos.x, canvasPos.y, 'input');
|
||||
|
||||
if (inHit) {
|
||||
const srcId = this._connecting.sourceNodeId;
|
||||
const tgtId = inHit.node.id;
|
||||
|
||||
// Prevent self-connection
|
||||
if (String(tgtId) !== String(srcId)) {
|
||||
// Check for duplicate
|
||||
const exists = this.state.connections.some(
|
||||
c => c.source_node_id === srcId &&
|
||||
c.target_node_id === tgtId &&
|
||||
c.source_port === this._connecting.sourcePort
|
||||
);
|
||||
if (!exists) {
|
||||
this.state.connections.push({
|
||||
id: 'new_conn_' + Date.now(),
|
||||
source_node_id: srcId,
|
||||
target_node_id: tgtId,
|
||||
source_port: this._connecting.sourcePort,
|
||||
label: '',
|
||||
condition_json: '{}',
|
||||
sequence: 10,
|
||||
});
|
||||
this.state.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up temp line
|
||||
if (this._tempLine && this._tempLine.parentNode) {
|
||||
this._tempLine.parentNode.removeChild(this._tempLine);
|
||||
}
|
||||
this._tempLine = null;
|
||||
this._connecting = null;
|
||||
}
|
||||
}
|
||||
|
||||
_handleWheel(ev) {
|
||||
ev.preventDefault();
|
||||
const delta = ev.deltaY > 0 ? 0.9 : 1.1;
|
||||
const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.state.zoom * delta));
|
||||
|
||||
// Zoom toward cursor position
|
||||
const svg = this.svgRef.el;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const mx = ev.clientX - rect.left;
|
||||
const my = ev.clientY - rect.top;
|
||||
|
||||
const scale = newZoom / this.state.zoom;
|
||||
this.state.viewX = mx - (mx - this.state.viewX) * scale;
|
||||
this.state.viewY = my - (my - this.state.viewY) * scale;
|
||||
this.state.zoom = newZoom;
|
||||
}
|
||||
|
||||
_handleKeyDown(ev) {
|
||||
if (ev.key === 'Delete' || ev.key === 'Backspace') {
|
||||
// Only if not typing in an input
|
||||
if (ev.target.tagName === 'INPUT' || ev.target.tagName === 'TEXTAREA' || ev.target.tagName === 'SELECT') return;
|
||||
this.onDeleteSelected();
|
||||
ev.preventDefault();
|
||||
}
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') {
|
||||
ev.preventDefault();
|
||||
this.onSave();
|
||||
}
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
Properties panel
|
||||
────────────────────────────────────────────────────────────── */
|
||||
get selectedNode() {
|
||||
if (!this.state.selectedNodeId) return null;
|
||||
return this.state.nodes.find(n => n.id === this.state.selectedNodeId) || null;
|
||||
}
|
||||
|
||||
onPanelClose() {
|
||||
this.state.panelOpen = false;
|
||||
this.state.selectedNodeId = null;
|
||||
}
|
||||
|
||||
onNodeFieldChange(ev) {
|
||||
const node = this.selectedNode;
|
||||
if (!node) return;
|
||||
const field = ev.currentTarget.dataset.field;
|
||||
const value = ev.currentTarget.value;
|
||||
if (field && node.hasOwnProperty(field)) {
|
||||
node[field] = value;
|
||||
this.state.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
onNodeNumberChange(ev) {
|
||||
const node = this.selectedNode;
|
||||
if (!node) return;
|
||||
const field = ev.currentTarget.dataset.field;
|
||||
const value = parseFloat(ev.currentTarget.value) || 0;
|
||||
if (field) {
|
||||
node[field] = value;
|
||||
this.state.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
Node option management (option_group)
|
||||
────────────────────────────────────────────────────────────── */
|
||||
onAddNodeOption() {
|
||||
const node = this.selectedNode;
|
||||
if (!node) return;
|
||||
if (!node.node_options) node.node_options = [];
|
||||
const seq = (node.node_options.length + 1) * 10;
|
||||
node.node_options.push({
|
||||
id: 'new_opt_' + Date.now(),
|
||||
name: 'Option ' + (node.node_options.length + 1),
|
||||
sequence: seq,
|
||||
section_option_id: false,
|
||||
enables_option_ids: [],
|
||||
disables_option_ids: [],
|
||||
requires_option_ids: [],
|
||||
port_key: 'opt_' + seq,
|
||||
});
|
||||
this.state.dirty = true;
|
||||
}
|
||||
|
||||
onRemoveNodeOption(ev) {
|
||||
const node = this.selectedNode;
|
||||
if (!node || !node.node_options) return;
|
||||
const idx = parseInt(ev.currentTarget.dataset.index);
|
||||
if (!isNaN(idx) && idx >= 0 && idx < node.node_options.length) {
|
||||
const removed = node.node_options.splice(idx, 1)[0];
|
||||
// Also remove connections using this port
|
||||
if (removed) {
|
||||
this.state.connections = this.state.connections.filter(
|
||||
c => !(c.source_node_id === node.id && c.source_port === removed.port_key)
|
||||
);
|
||||
}
|
||||
this.state.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
onNodeOptionNameChange(ev) {
|
||||
const node = this.selectedNode;
|
||||
if (!node || !node.node_options) return;
|
||||
const idx = parseInt(ev.currentTarget.dataset.index);
|
||||
if (!isNaN(idx) && idx >= 0 && idx < node.node_options.length) {
|
||||
node.node_options[idx].name = ev.currentTarget.value;
|
||||
this.state.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fusion_flow_designer", FlowDesignerAction);
|
||||
1072
fusion_quotations/static/src/js/quotation_form.js
Normal file
1072
fusion_quotations/static/src/js/quotation_form.js
Normal file
File diff suppressed because it is too large
Load Diff
227
fusion_quotations/static/src/scss/flow_designer.scss
Normal file
227
fusion_quotations/static/src/scss/flow_designer.scss
Normal file
@@ -0,0 +1,227 @@
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
Flow Designer — Visual Configurator Styles
|
||||
Uses Bootstrap 5.3 / Odoo CSS custom properties so light + dark
|
||||
mode are handled automatically — zero manual overrides needed.
|
||||
══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.fd-designer {
|
||||
background: var(--bs-body-bg);
|
||||
height: 100vh !important;
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Toolbar ── */
|
||||
.fd-toolbar {
|
||||
background: var(--bs-body-bg);
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
min-height: 48px;
|
||||
z-index: 10;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fd-toolbar-title {
|
||||
font-size: 1rem;
|
||||
color: var(--bs-body-color);
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
/* Measurement button — no inline styles, uses a custom class */
|
||||
.fd-btn-measure {
|
||||
border-color: var(--bs-border-color);
|
||||
color: var(--bs-body-color);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
border-color: var(--bs-secondary-color);
|
||||
color: var(--bs-emphasis-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── SVG Canvas ── */
|
||||
.fd-canvas-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.fd-svg-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bs-secondary-bg);
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
/* SVG grid dots — themed */
|
||||
.fd-grid-dot {
|
||||
fill: var(--bs-border-color);
|
||||
}
|
||||
|
||||
/* ── Nodes ── */
|
||||
.fd-node-group {
|
||||
&:hover .fd-node-rect {
|
||||
filter: brightness(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
.fd-node-content {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* Node text adapts to theme */
|
||||
.fd-node-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--bs-emphasis-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fd-node-detail {
|
||||
font-size: 10px;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Ports ── */
|
||||
|
||||
/* Visible decorative port circle — no pointer events */
|
||||
.fd-port-visual {
|
||||
stroke: var(--bs-body-bg);
|
||||
stroke-width: 2;
|
||||
pointer-events: none;
|
||||
transition: r 0.15s ease, stroke-width 0.15s ease;
|
||||
}
|
||||
|
||||
/* Invisible hit area — this is the actual click target */
|
||||
.fd-port-hit {
|
||||
fill: transparent;
|
||||
stroke: none;
|
||||
stroke-width: 0;
|
||||
pointer-events: all;
|
||||
cursor: crosshair;
|
||||
|
||||
/* Grow the visible sibling on hover via CSS ~ */
|
||||
&:hover + .fd-port-visual {
|
||||
r: 9;
|
||||
stroke-width: 3;
|
||||
}
|
||||
}
|
||||
|
||||
/* Input port fill is now set directly as SVG attribute (#8b95a1) */
|
||||
|
||||
/* Port labels — concrete color; var() doesn't resolve reliably in SVG */
|
||||
.fd-port-label {
|
||||
fill: #8b95a1;
|
||||
font-size: 9px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Connections ── */
|
||||
|
||||
/* Custom property so both light & dark themes get a visible stroke.
|
||||
Defined on SVG so markers and all children inherit reliably. */
|
||||
.fd-svg-canvas {
|
||||
--fd-conn-stroke: #8b95a1;
|
||||
--fd-conn-label-bg: rgba(55, 65, 81, 0.85); /* slate-700 @ 85 — visible on dark bg */
|
||||
--fd-conn-label-color: #e5e7eb; /* gray-200 — bright text */
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.fd-svg-canvas {
|
||||
--fd-conn-stroke: #9ca3af;
|
||||
--fd-conn-label-bg: rgba(255, 255, 255, 0.9);
|
||||
--fd-conn-label-color: #4b5563;
|
||||
}
|
||||
}
|
||||
|
||||
.fd-connection-path {
|
||||
transition: stroke 0.15s ease, stroke-width 0.15s ease;
|
||||
}
|
||||
|
||||
.fd-connection-temp {
|
||||
stroke: var(--fd-conn-stroke);
|
||||
stroke-width: 2.5;
|
||||
stroke-dasharray: 8 4;
|
||||
stroke-linecap: round;
|
||||
fill: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Label background pill */
|
||||
.fd-conn-label-bg {
|
||||
fill: var(--fd-conn-label-bg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fd-conn-label {
|
||||
fill: var(--fd-conn-label-color);
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Arrow marker fills are set directly as SVG attributes
|
||||
because CSS custom properties don't cascade into marker contexts */
|
||||
|
||||
/* ── Properties Panel ── */
|
||||
.fd-properties-panel {
|
||||
width: 320px;
|
||||
min-width: 320px;
|
||||
background: var(--bs-body-bg);
|
||||
border-left: 1px solid var(--bs-border-color);
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fd-panel-header {
|
||||
background: var(--bs-tertiary-bg);
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
min-height: 42px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fd-panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.fd-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Option list items */
|
||||
.fd-option-row {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.fd-option-bullet {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Loading Overlay ── */
|
||||
.fd-loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: color-mix(in srgb, var(--bs-body-bg) 85%, transparent);
|
||||
z-index: 50;
|
||||
}
|
||||
415
fusion_quotations/static/src/xml/flow_designer_templates.xml
Normal file
415
fusion_quotations/static/src/xml/flow_designer_templates.xml
Normal file
@@ -0,0 +1,415 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════
|
||||
FlowDesignerAction — Main layout
|
||||
All colours use Bootstrap/Odoo CSS custom properties so
|
||||
light + dark mode work automatically.
|
||||
Node-type accent colours (green, red, amber…) are intentional
|
||||
design tokens — they stay the same across themes.
|
||||
══════════════════════════════════════════════════════════════════ -->
|
||||
<t t-name="fusion_quotations.FlowDesignerAction">
|
||||
<div class="fd-designer d-flex flex-column h-100">
|
||||
<!-- ── Top Toolbar ── -->
|
||||
<div class="fd-toolbar d-flex align-items-center gap-2 px-3 py-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" t-on-click="onBack">
|
||||
<i class="fa fa-arrow-left me-1"/>Back
|
||||
</button>
|
||||
<div class="fd-toolbar-title fw-bold text-truncate ms-2" t-esc="state.flowName"/>
|
||||
<span class="badge bg-secondary-subtle text-body-secondary ms-1" t-esc="state.equipmentType"/>
|
||||
|
||||
<div class="flex-grow-1"/>
|
||||
|
||||
<!-- Add Node Buttons -->
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-outline-success" data-node-type="start" t-on-click="onAddNode"
|
||||
title="Add Start Node">
|
||||
<i class="fa fa-play me-1"/>Start
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-warning" data-node-type="decision" t-on-click="onAddNode"
|
||||
title="Add Decision Node">
|
||||
<i class="fa fa-code-fork me-1"/>Decision
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary" data-node-type="option_group" t-on-click="onAddNode"
|
||||
title="Add Option Group">
|
||||
<i class="fa fa-list-ul me-1"/>Options
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-info" data-node-type="action" t-on-click="onAddNode"
|
||||
title="Add Action Node">
|
||||
<i class="fa fa-bolt me-1"/>Action
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary fd-btn-measure"
|
||||
data-node-type="measurement_check" t-on-click="onAddNode"
|
||||
title="Add Measurement Check">
|
||||
<i class="fa fa-tachometer me-1"/>Measure
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" data-node-type="end" t-on-click="onAddNode"
|
||||
title="Add End Node">
|
||||
<i class="fa fa-stop me-1"/>End
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="vr mx-1"/>
|
||||
|
||||
<!-- Zoom Controls -->
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-secondary" t-on-click="onZoomOut" title="Zoom Out">
|
||||
<i class="fa fa-search-minus"/>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" t-on-click="onZoomReset" title="Reset Zoom"
|
||||
style="min-width:55px;">
|
||||
<t t-esc="Math.round(state.zoom * 100)"/>%
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" t-on-click="onZoomIn" title="Zoom In">
|
||||
<i class="fa fa-search-plus"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="vr mx-1"/>
|
||||
|
||||
<button class="btn btn-sm btn-outline-danger" t-on-click="onDeleteSelected"
|
||||
t-att-disabled="!state.selectedNodeId and !state.selectedConnectionId"
|
||||
title="Delete Selected (Del)">
|
||||
<i class="fa fa-trash"/>
|
||||
</button>
|
||||
|
||||
<button class="btn btn-sm btn-primary" t-on-click="onSave"
|
||||
t-att-disabled="state.saving or !state.dirty">
|
||||
<i class="fa fa-save me-1"/>
|
||||
<t t-if="state.saving">Saving...</t>
|
||||
<t t-else="">Save</t>
|
||||
</button>
|
||||
|
||||
<t t-if="state.dirty">
|
||||
<span class="badge bg-warning text-dark ms-1">Unsaved</span>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- ── Canvas + Panel ── -->
|
||||
<div class="fd-canvas-wrapper d-flex flex-grow-1 overflow-hidden position-relative">
|
||||
|
||||
<!-- SVG Canvas -->
|
||||
<svg class="fd-svg-canvas flex-grow-1" t-ref="svgCanvas" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Grid Pattern -->
|
||||
<defs>
|
||||
<pattern id="fd-grid" width="20" height="20" patternUnits="userSpaceOnUse"
|
||||
t-att-x="state.viewX" t-att-y="state.viewY"
|
||||
t-att-patternTransform="'scale(' + state.zoom + ')'">
|
||||
<circle cx="10" cy="10" r="1" class="fd-grid-dot"/>
|
||||
</pattern>
|
||||
<!-- Arrow markers — fill set as attribute because CSS custom
|
||||
properties don't cascade into SVG marker rendering contexts -->
|
||||
<marker id="fd-arrow" viewBox="0 0 10 6" refX="10" refY="3"
|
||||
markerWidth="8" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,3 L0,6 z" fill="#8b95a1"/>
|
||||
</marker>
|
||||
<marker id="fd-arrow-selected" viewBox="0 0 10 6" refX="10" refY="3"
|
||||
markerWidth="8" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,3 L0,6 z" fill="var(--bs-primary, #3b82f6)"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<rect width="100%" height="100%" fill="url(#fd-grid)"/>
|
||||
|
||||
<!-- Transform group for zoom/pan -->
|
||||
<g t-ref="canvasGroup" class="fd-canvas-group" t-att-transform="transformStr">
|
||||
|
||||
<!-- Connections -->
|
||||
<t t-foreach="connectionPaths" t-as="conn" t-key="conn.id">
|
||||
<!-- Invisible fat hit area for click detection -->
|
||||
<path t-att-d="conn.path" fill="none" stroke="transparent" stroke-width="14"
|
||||
class="fd-connection-hit" t-att-data-conn-id="conn.id"
|
||||
style="cursor:pointer;"/>
|
||||
<!-- Visible bezier line — stroke set as SVG attr for guaranteed rendering -->
|
||||
<path t-att-d="conn.path"
|
||||
class="fd-connection-path"
|
||||
t-att-stroke="conn.isSelected ? 'var(--bs-primary)' : 'var(--fd-conn-stroke, #8b95a1)'"
|
||||
t-att-stroke-width="conn.isSelected ? '3.5' : '2.5'"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
t-att-marker-end="conn.isSelected ? 'url(#fd-arrow-selected)' : 'url(#fd-arrow)'"
|
||||
t-att-data-conn-id="conn.id"
|
||||
style="pointer-events:none;"/>
|
||||
<!-- Connection label with background pill -->
|
||||
<t t-if="conn.label">
|
||||
<rect t-att-x="conn.midX - conn.labelW / 2"
|
||||
t-att-y="conn.midY - 18"
|
||||
t-att-width="conn.labelW" height="20" rx="10"
|
||||
class="fd-conn-label-bg"/>
|
||||
<text t-att-x="conn.midX" t-att-y="conn.midY - 5"
|
||||
text-anchor="middle" font-size="11" font-weight="600"
|
||||
class="fd-conn-label">
|
||||
<t t-esc="conn.label"/>
|
||||
</text>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- Nodes -->
|
||||
<t t-foreach="nodesWithPorts" t-as="node" t-key="node.id">
|
||||
<g class="fd-node-group" t-att-data-node-id="node.id"
|
||||
t-att-transform="'translate(' + node.pos_x + ',' + node.pos_y + ')'"
|
||||
style="cursor:grab;">
|
||||
|
||||
<!-- Node body — accent color fill is a design token, stays fixed -->
|
||||
<rect x="0" y="0" t-att-width="node.width" t-att-height="node.height"
|
||||
t-att-rx="node.node_type === 'start' or node.node_type === 'end' ? node.height / 2 : 8"
|
||||
t-att-fill="node.meta.color + '18'"
|
||||
t-att-stroke="node.isSelected ? 'var(--bs-primary)' : node.meta.color"
|
||||
t-att-stroke-width="node.isSelected ? '3' : '2'"
|
||||
class="fd-node-rect"/>
|
||||
|
||||
<!-- Icon + Name — pointer-events:none so clicks pass through to ports/rect -->
|
||||
<foreignObject x="0" y="0" t-att-width="node.width" t-att-height="node.height"
|
||||
style="pointer-events:none;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" class="fd-node-content"
|
||||
t-att-style="'display:flex;flex-direction:column;justify-content:center;height:' + node.height + 'px;padding:0 14px;overflow:hidden;pointer-events:none;'">
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<i t-att-class="'fa ' + (node.icon || 'fa-circle')"
|
||||
t-att-style="'color:' + node.meta.color + ';font-size:14px;flex-shrink:0;'"/>
|
||||
<span class="fd-node-name" t-esc="node.name"/>
|
||||
</div>
|
||||
<t t-if="node.node_type === 'decision' and node.decision_field">
|
||||
<div class="fd-node-detail">
|
||||
<t t-esc="node.decision_field"/>
|
||||
<t t-if="node.decision_operator"> <t t-esc="node.decision_operator"/> </t>
|
||||
<t t-if="node.decision_value"> <t t-esc="node.decision_value"/></t>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="node.node_type === 'action' and node.action_type">
|
||||
<div class="fd-node-detail">
|
||||
<t t-esc="node.action_type"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="node.node_type === 'measurement_check' and node.measurement_field">
|
||||
<div class="fd-node-detail">
|
||||
<t t-esc="node.measurement_field"/>
|
||||
<t t-if="node.comparison"> <t t-esc="node.comparison"/> </t>
|
||||
<t t-if="node.threshold_value"> <t t-esc="node.threshold_value"/></t>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="node.section_name">
|
||||
<div class="fd-node-detail">
|
||||
<i class="fa fa-folder-o me-1"/><t t-esc="node.section_name"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</foreignObject>
|
||||
|
||||
<!-- Ports -->
|
||||
<t t-foreach="node.ports" t-as="port" t-key="port.key">
|
||||
<!-- Invisible hit area — detected by elementsFromPoint in JS -->
|
||||
<circle t-att-cx="port.x" t-att-cy="port.y" r="15"
|
||||
fill="transparent" stroke="none"
|
||||
pointer-events="all"
|
||||
class="fd-port-hit fd-port"
|
||||
t-att-data-port-key="port.key"
|
||||
t-att-data-port-type="port.type"
|
||||
t-att-data-node-id="'' + node.id"
|
||||
style="cursor:crosshair;"/>
|
||||
<!-- Visible port circle — concrete fill for both types -->
|
||||
<circle t-att-cx="port.x" t-att-cy="port.y" r="7"
|
||||
t-att-fill="port.type !== 'input' ? node.meta.color : '#8b95a1'"
|
||||
class="fd-port-visual"
|
||||
style="pointer-events:none;"/>
|
||||
<t t-if="port.label">
|
||||
<text t-att-x="port.x + (port.type === 'output' ? 12 : -12)"
|
||||
t-att-y="port.y + 4"
|
||||
t-att-text-anchor="port.type === 'output' ? 'start' : 'end'"
|
||||
class="fd-port-label"
|
||||
fill="#8b95a1">
|
||||
<t t-esc="port.label"/>
|
||||
</text>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</g>
|
||||
</t>
|
||||
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- ── Properties Panel (right side) ── -->
|
||||
<t t-if="state.panelOpen and selectedNode">
|
||||
<div class="fd-properties-panel">
|
||||
<div class="fd-panel-header d-flex align-items-center justify-content-between px-3 py-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i t-att-class="'fa ' + (selectedNode.icon || 'fa-circle')"
|
||||
t-att-style="'color:' + (selectedNode.color || '#3b82f6')"/>
|
||||
<span class="fw-bold" t-esc="getNodeTypeLabel(selectedNode.node_type)"/>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-link text-body-secondary p-0" t-on-click="onPanelClose">
|
||||
<i class="fa fa-times"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="fd-panel-body p-3">
|
||||
<!-- Common: Name -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fd-label">Name</label>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
data-field="name" t-att-value="selectedNode.name"
|
||||
t-on-change="onNodeFieldChange"/>
|
||||
</div>
|
||||
|
||||
<!-- Decision Fields -->
|
||||
<t t-if="selectedNode.node_type === 'decision'">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fd-label">Decision Field</label>
|
||||
<select class="form-select form-select-sm" data-field="decision_field"
|
||||
t-on-change="onNodeFieldChange">
|
||||
<option value="">-- Select --</option>
|
||||
<option value="equipment_type" t-att-selected="selectedNode.decision_field === 'equipment_type'">Equipment Type</option>
|
||||
<option value="wheelchair_type" t-att-selected="selectedNode.decision_field === 'wheelchair_type'">Wheelchair Category</option>
|
||||
<option value="powerchair_type" t-att-selected="selectedNode.decision_field === 'powerchair_type'">Power Chair Category</option>
|
||||
<option value="build_type" t-att-selected="selectedNode.decision_field === 'build_type'">Build Type</option>
|
||||
<option value="client_type" t-att-selected="selectedNode.decision_field === 'client_type'">Client Type</option>
|
||||
<option value="reason_for_application" t-att-selected="selectedNode.decision_field === 'reason_for_application'">Reason for Application</option>
|
||||
<option value="seat_width" t-att-selected="selectedNode.decision_field === 'seat_width'">Seat Width</option>
|
||||
<option value="seat_depth" t-att-selected="selectedNode.decision_field === 'seat_depth'">Seat Depth</option>
|
||||
<option value="client_weight" t-att-selected="selectedNode.decision_field === 'client_weight'">Client Weight</option>
|
||||
<option value="back_height" t-att-selected="selectedNode.decision_field === 'back_height'">Back Height</option>
|
||||
<option value="seat_to_floor" t-att-selected="selectedNode.decision_field === 'seat_to_floor'">Seat to Floor</option>
|
||||
<option value="leg_rest_length" t-att-selected="selectedNode.decision_field === 'leg_rest_length'">Leg Rest Length</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fd-label">Operator</label>
|
||||
<select class="form-select form-select-sm" data-field="decision_operator"
|
||||
t-on-change="onNodeFieldChange">
|
||||
<option value="">-- Select --</option>
|
||||
<option value="eq" t-att-selected="selectedNode.decision_operator === 'eq'">=</option>
|
||||
<option value="neq" t-att-selected="selectedNode.decision_operator === 'neq'">≠</option>
|
||||
<option value="gt" t-att-selected="selectedNode.decision_operator === 'gt'">></option>
|
||||
<option value="gte" t-att-selected="selectedNode.decision_operator === 'gte'">≥</option>
|
||||
<option value="lt" t-att-selected="selectedNode.decision_operator === 'lt'"><</option>
|
||||
<option value="lte" t-att-selected="selectedNode.decision_operator === 'lte'">≤</option>
|
||||
<option value="in" t-att-selected="selectedNode.decision_operator === 'in'">In List</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fd-label">Expected Value</label>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
data-field="decision_value" t-att-value="selectedNode.decision_value"
|
||||
t-on-change="onNodeFieldChange"
|
||||
placeholder="For 'In List' use comma-separated"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Measurement Check Fields -->
|
||||
<t t-if="selectedNode.node_type === 'measurement_check'">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fd-label">Measurement</label>
|
||||
<select class="form-select form-select-sm" data-field="measurement_field"
|
||||
t-on-change="onNodeFieldChange">
|
||||
<option value="">-- Select --</option>
|
||||
<option value="seat_width" t-att-selected="selectedNode.measurement_field === 'seat_width'">Seat Width</option>
|
||||
<option value="seat_depth" t-att-selected="selectedNode.measurement_field === 'seat_depth'">Seat Depth</option>
|
||||
<option value="back_width" t-att-selected="selectedNode.measurement_field === 'back_width'">Backrest Width</option>
|
||||
<option value="back_height" t-att-selected="selectedNode.measurement_field === 'back_height'">Back Height</option>
|
||||
<option value="seat_to_floor" t-att-selected="selectedNode.measurement_field === 'seat_to_floor'">Seat to Floor</option>
|
||||
<option value="leg_rest_length" t-att-selected="selectedNode.measurement_field === 'leg_rest_length'">Leg Rest Length</option>
|
||||
<option value="client_weight" t-att-selected="selectedNode.measurement_field === 'client_weight'">Client Weight</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fd-label">Comparison</label>
|
||||
<select class="form-select form-select-sm" data-field="comparison"
|
||||
t-on-change="onNodeFieldChange">
|
||||
<option value="">-- Select --</option>
|
||||
<option value="gt" t-att-selected="selectedNode.comparison === 'gt'">Greater Than</option>
|
||||
<option value="gte" t-att-selected="selectedNode.comparison === 'gte'">Greater Than or Equal</option>
|
||||
<option value="lt" t-att-selected="selectedNode.comparison === 'lt'">Less Than</option>
|
||||
<option value="eq" t-att-selected="selectedNode.comparison === 'eq'">Equal To</option>
|
||||
<option value="neq" t-att-selected="selectedNode.comparison === 'neq'">Not Equal To</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fd-label">Threshold</label>
|
||||
<input type="number" class="form-control form-control-sm"
|
||||
data-field="threshold_value"
|
||||
t-att-value="selectedNode.threshold_value"
|
||||
t-on-change="onNodeNumberChange" step="0.1"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Action Fields -->
|
||||
<t t-if="selectedNode.node_type === 'action'">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fd-label">Action Type</label>
|
||||
<select class="form-select form-select-sm" data-field="action_type"
|
||||
t-on-change="onNodeFieldChange">
|
||||
<option value="">-- Select --</option>
|
||||
<option value="enable" t-att-selected="selectedNode.action_type === 'enable'">Enable Options</option>
|
||||
<option value="disable" t-att-selected="selectedNode.action_type === 'disable'">Disable Options</option>
|
||||
<option value="require" t-att-selected="selectedNode.action_type === 'require'">Require Options</option>
|
||||
<option value="skip_step" t-att-selected="selectedNode.action_type === 'skip_step'">Skip Portal Step</option>
|
||||
<option value="set_value" t-att-selected="selectedNode.action_type === 'set_value'">Set Field Value</option>
|
||||
</select>
|
||||
</div>
|
||||
<t t-if="selectedNode.action_type === 'skip_step'">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fd-label">Target Step</label>
|
||||
<input type="number" class="form-control form-control-sm"
|
||||
data-field="target_step"
|
||||
t-att-value="selectedNode.target_step"
|
||||
t-on-change="onNodeNumberChange" min="1" max="10"/>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- Option Group — options list -->
|
||||
<t t-if="selectedNode.node_type === 'option_group'">
|
||||
<div class="mb-3">
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<label class="form-label fd-label mb-0">Options</label>
|
||||
<button class="btn btn-sm btn-outline-primary" t-on-click="onAddNodeOption">
|
||||
<i class="fa fa-plus me-1"/>Add
|
||||
</button>
|
||||
</div>
|
||||
<t t-if="selectedNode.node_options and selectedNode.node_options.length">
|
||||
<t t-foreach="selectedNode.node_options" t-as="opt" t-key="opt.id">
|
||||
<div class="fd-option-row d-flex align-items-center gap-2 mb-2">
|
||||
<span class="fd-option-bullet" t-att-style="'background:' + (selectedNode.color || '#3b82f6')"/>
|
||||
<input type="text" class="form-control form-control-sm flex-grow-1"
|
||||
t-att-value="opt.name"
|
||||
t-att-data-index="opt_index"
|
||||
t-on-change="onNodeOptionNameChange"/>
|
||||
<button class="btn btn-sm btn-link text-danger p-0"
|
||||
t-att-data-index="opt_index"
|
||||
t-on-click="onRemoveNodeOption">
|
||||
<i class="fa fa-trash-o"/>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="text-body-secondary small fst-italic">No options yet. Click "Add" to create one.</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Node info footer -->
|
||||
<div class="mt-4 pt-3 border-top">
|
||||
<div class="text-body-secondary small">
|
||||
<div><strong>ID:</strong> <t t-esc="selectedNode.id"/></div>
|
||||
<div><strong>Position:</strong> (<t t-esc="Math.round(selectedNode.pos_x)"/>, <t t-esc="Math.round(selectedNode.pos_y)"/>)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<t t-if="state.loading">
|
||||
<div class="fd-loading-overlay d-flex align-items-center justify-content-center">
|
||||
<div class="text-center">
|
||||
<i class="fa fa-spinner fa-spin fa-2x text-primary mb-2"/>
|
||||
<div class="text-body-secondary">Loading flow...</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
73
fusion_quotations/views/equipment_type_views.xml
Normal file
73
fusion_quotations/views/equipment_type_views.xml
Normal file
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============================================================
|
||||
Equipment Type — Tree View
|
||||
============================================================ -->
|
||||
<record id="view_equipment_type_tree" model="ir.ui.view">
|
||||
<field name="name">fusion.equipment.type.tree</field>
|
||||
<field name="model">fusion.equipment.type</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Equipment Types" editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="icon"/>
|
||||
<field name="active" column_invisible="1"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================
|
||||
Equipment Type — Form View
|
||||
============================================================ -->
|
||||
<record id="view_equipment_type_form" model="ir.ui.view">
|
||||
<field name="name">fusion.equipment.type.form</field>
|
||||
<field name="model">fusion.equipment.type</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Equipment Type">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" placeholder="e.g. Stair Lift"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="code" placeholder="e.g. stair_lift"/>
|
||||
<field name="icon" placeholder="e.g. fa-arrow-up"/>
|
||||
<field name="sequence"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Description">
|
||||
<field name="description" nolabel="1"
|
||||
placeholder="Optional description of this equipment type..."/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================
|
||||
Equipment Type — Action
|
||||
============================================================ -->
|
||||
<record id="action_equipment_type" model="ir.actions.act_window">
|
||||
<field name="name">Equipment Types</field>
|
||||
<field name="res_model">fusion.equipment.type</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create an Equipment Type
|
||||
</p>
|
||||
<p>
|
||||
Equipment types define the categories of equipment that can be
|
||||
assessed and quoted. Each type can have its own Configuration Flow
|
||||
with custom form steps.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
62
fusion_quotations/views/menus.xml
Normal file
62
fusion_quotations/views/menus.xml
Normal file
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Top menu under Fusion Claims -->
|
||||
<menuitem id="menu_wc_quotation_root"
|
||||
name="Quotation Builder"
|
||||
parent="fusion_claims.menu_adp_claims_root"
|
||||
sequence="25"/>
|
||||
|
||||
<!-- Assessments -->
|
||||
<menuitem id="menu_wc_assessment"
|
||||
name="Wheelchair Assessments"
|
||||
parent="menu_wc_quotation_root"
|
||||
action="action_wc_assessment"
|
||||
sequence="10"/>
|
||||
|
||||
<!-- Configuration submenu -->
|
||||
<menuitem id="menu_wc_config"
|
||||
name="Configuration"
|
||||
parent="menu_wc_quotation_root"
|
||||
sequence="90"
|
||||
groups="sales_team.group_sale_manager"/>
|
||||
|
||||
<menuitem id="menu_wc_sections"
|
||||
name="Wheelchair Sections"
|
||||
parent="menu_wc_config"
|
||||
action="action_wc_section"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="menu_wc_upcharge_rules"
|
||||
name="Upcharge Rules"
|
||||
parent="menu_wc_config"
|
||||
action="action_wc_upcharge_rule"
|
||||
sequence="20"/>
|
||||
|
||||
<menuitem id="menu_wc_config_flows"
|
||||
name="Configuration Flows"
|
||||
parent="menu_wc_config"
|
||||
action="action_wc_config_flow"
|
||||
sequence="30"/>
|
||||
|
||||
<menuitem id="menu_equipment_types"
|
||||
name="Equipment Types"
|
||||
parent="menu_wc_config"
|
||||
action="action_equipment_type"
|
||||
sequence="40"/>
|
||||
|
||||
<!-- Server action: Auto-Populate All Sections -->
|
||||
<record id="action_auto_populate_all" model="ir.actions.server">
|
||||
<field name="name">Auto-Populate All Sections from Inventory</field>
|
||||
<field name="model_id" ref="model_fusion_wc_section"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = model.action_auto_populate_all_sections()</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_wc_auto_populate"
|
||||
name="Auto-Populate Products"
|
||||
parent="menu_wc_config"
|
||||
action="action_auto_populate_all"
|
||||
sequence="5"/>
|
||||
|
||||
</odoo>
|
||||
990
fusion_quotations/views/portal_quotation_templates.xml
Normal file
990
fusion_quotations/views/portal_quotation_templates.xml
Normal file
@@ -0,0 +1,990 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- ASSESSMENT LIST PAGE -->
|
||||
<!-- ============================================================ -->
|
||||
<template id="portal_quotation_list" name="Equipment Assessments List">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
|
||||
<div class="o_portal_my_home">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h3>Equipment Assessments</h3>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-primary dropdown-toggle" type="button"
|
||||
data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="fa fa-plus me-1"/> New Assessment
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<t t-foreach="equipment_types" t-as="etype">
|
||||
<li>
|
||||
<a class="dropdown-item"
|
||||
t-attf-href="/my/quotation/builder/new?equipment_type=#{etype.code}">
|
||||
<i t-attf-class="fa #{etype.icon or 'fa-cog'} me-2"/>
|
||||
<t t-out="etype.name"/>
|
||||
</a>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<t t-if="not assessments">
|
||||
<div class="alert alert-info text-center">
|
||||
No assessments yet. Click "New Assessment" to start.
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="assessments">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Reference</th>
|
||||
<th>Client</th>
|
||||
<th>Equipment</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
<th>Total</th>
|
||||
<th/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="assessments" t-as="a">
|
||||
<tr>
|
||||
<td><t t-out="a.reference"/></td>
|
||||
<td><t t-out="a.client_name or 'N/A'"/></td>
|
||||
<td><t t-out="equip_type_map.get(a.equipment_type, a.equipment_type or '')"/></td>
|
||||
<td><t t-out="a.assessment_date" t-options="{'widget': 'date'}"/></td>
|
||||
<td>
|
||||
<span t-attf-class="badge #{
|
||||
'text-bg-info' if a.state == 'draft' else
|
||||
'text-bg-warning' if a.state == 'review' else
|
||||
'text-bg-success' if a.state == 'quoted' else
|
||||
'text-bg-secondary'
|
||||
}">
|
||||
<t t-out="dict(a._fields['state'].selection).get(a.state, '')"/>
|
||||
</span>
|
||||
</td>
|
||||
<td>$<t t-out="'%.2f' % a.total_estimate"/></td>
|
||||
<td>
|
||||
<a t-attf-href="/my/quotation/builder/#{a.id}/edit"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<t t-if="a.state == 'draft'">Continue</t>
|
||||
<t t-else="">View</t>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- MULTI-STEP ASSESSMENT FORM (Dynamic) -->
|
||||
<!-- ============================================================ -->
|
||||
<template id="portal_quotation_form" name="Equipment Assessment Form">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="additional_title">Equipment Assessment</t>
|
||||
|
||||
<div class="wc-assessment-form" id="wcAssessmentForm">
|
||||
|
||||
<!-- Success/Error Messages -->
|
||||
<t t-if="request.params.get('success') == 'saved'">
|
||||
<div class="alert alert-success alert-dismissible fade show">
|
||||
Assessment saved successfully.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="request.params.get('success') == 'quotation_generated'">
|
||||
<div class="alert alert-success alert-dismissible fade show">
|
||||
<i class="fa fa-check-circle me-1"/> Quotation generated successfully!
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="request.params.get('error')">
|
||||
<div class="alert alert-danger alert-dismissible fade show">
|
||||
<t t-out="request.params.get('error')"/>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- ===== DYNAMIC Step Indicator ===== -->
|
||||
<div class="wc-steps mb-4">
|
||||
<div class="d-flex justify-content-between">
|
||||
<t t-foreach="flow_steps" t-as="fstep">
|
||||
<div t-attf-class="wc-step-indicator text-center flex-fill #{'active' if fstep_index == 0 else ''}"
|
||||
t-att-data-step="fstep_index + 1">
|
||||
<div class="wc-step-number rounded-circle d-inline-flex align-items-center justify-content-center">
|
||||
<t t-out="fstep_index + 1"/>
|
||||
</div>
|
||||
<div class="wc-step-label small mt-1">
|
||||
<t t-out="fstep.name"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Open in Backend link — only for internal users -->
|
||||
<t t-if="assessment and request.env.user.has_group('base.group_user')">
|
||||
<div class="text-end mb-2">
|
||||
<a t-attf-href="/odoo/fusion-quotation-builder/#{assessment.id}"
|
||||
class="btn btn-sm btn-outline-secondary" target="_blank">
|
||||
<i class="fa fa-pencil me-1"/> Open in Backend
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<form method="post"
|
||||
t-att-action="'/quotation/form/' + access_token + '/save'
|
||||
if is_public else '/my/quotation/builder/save'"
|
||||
id="wcForm" class="wc-form">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<input type="hidden" name="assessment_id"
|
||||
t-att-value="assessment.id if assessment else 0"/>
|
||||
<input type="hidden" name="current_step" id="currentStep" value="1"/>
|
||||
<input type="hidden" name="action" id="formAction" value="save"/>
|
||||
<!-- Store access token for JS to detect public mode -->
|
||||
<t t-if="is_public">
|
||||
<input type="hidden" name="access_token" id="accessToken"
|
||||
t-att-value="access_token"/>
|
||||
</t>
|
||||
|
||||
<!-- Hidden fields for line data (populated by JS) -->
|
||||
<input type="hidden" name="line_product_ids" id="lineProductIds" value=""/>
|
||||
<input type="hidden" name="line_section_ids" id="lineSectionIds" value=""/>
|
||||
<input type="hidden" name="line_build_types" id="lineBuildTypes" value=""/>
|
||||
<input type="hidden" name="line_quantities" id="lineQuantities" value=""/>
|
||||
<input type="hidden" name="line_rationales" id="lineRationales" value=""/>
|
||||
|
||||
<!-- ===== DYNAMIC Step Content ===== -->
|
||||
<t t-foreach="flow_steps" t-as="fstep">
|
||||
<div t-attf-class="wc-step #{'d-none' if fstep_index > 0 else ''}"
|
||||
t-att-data-step="fstep_index + 1"
|
||||
t-att-data-step-type="fstep.step_type">
|
||||
<t t-if="fstep.step_type == 'client_info'"
|
||||
t-call="fusion_quotations.fq_step_client"/>
|
||||
<t t-if="fstep.step_type == 'measurements'"
|
||||
t-call="fusion_quotations.fq_step_measurements"/>
|
||||
<t t-if="fstep.step_type == 'product_select'"
|
||||
t-call="fusion_quotations.fq_step_product_select"/>
|
||||
<t t-if="fstep.step_type == 'options'"
|
||||
t-call="fusion_quotations.fq_step_options"/>
|
||||
<t t-if="fstep.step_type == 'review'"
|
||||
t-call="fusion_quotations.fq_step_review"/>
|
||||
<t t-if="fstep.step_type == 'custom'"
|
||||
t-call="fusion_quotations.fq_step_custom"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- NAVIGATION BUTTONS -->
|
||||
<!-- ============================================ -->
|
||||
<div class="d-flex justify-content-between mt-4 pt-3 border-top">
|
||||
<button type="button" class="btn btn-outline-secondary" id="btnPrev"
|
||||
style="display:none;">
|
||||
<i class="fa fa-arrow-left me-1"/> Previous
|
||||
</button>
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<button type="submit" class="btn btn-outline-primary" id="btnSave"
|
||||
name="action" value="save">
|
||||
<i class="fa fa-save me-1"/> Save Draft
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" id="btnNext">
|
||||
Next <i class="fa fa-arrow-right ms-1"/>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success d-none" id="btnGenerate"
|
||||
name="action" value="generate">
|
||||
<i class="fa fa-magic me-1"/> Generate Quotation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SUB-TEMPLATE: Client & Equipment (step_type=client_info) -->
|
||||
<!-- ============================================================ -->
|
||||
<template id="fq_step_client" name="Step: Client & Equipment">
|
||||
<!--
|
||||
Config-driven client step. The step's fields_json controls which
|
||||
optional field groups are shown. Example fields_json:
|
||||
{
|
||||
"show_health_card": true,
|
||||
"show_dob": true,
|
||||
"show_adp_fields": true,
|
||||
"show_wheelchair_category": true,
|
||||
"show_powerchair_category": false
|
||||
}
|
||||
Steps with no fields_json (or empty {}) show only the base fields:
|
||||
name, contact, address, and equipment type selector.
|
||||
-->
|
||||
<t t-set="client_config" t-value="step_fields.get(fstep.id, {})"/>
|
||||
<h4 class="mb-3">Client & Equipment</h4>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- ── LEFT COLUMN: Client Info ── -->
|
||||
<div class="col-md-6">
|
||||
<div class="card wc-section-card h-100">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="fa fa-user me-2"/>Client</h5></div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3 wc-search-container" style="position:relative;z-index:100;">
|
||||
<label class="form-label fw-bold">Search Existing Client</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fa fa-search"/></span>
|
||||
<input type="text" class="form-control" id="clientSearch"
|
||||
placeholder="Type name, phone, or health card..."
|
||||
autocomplete="off"/>
|
||||
</div>
|
||||
<div id="clientSearchResults" class="list-group d-none"
|
||||
style="position:absolute;z-index:1060;width:100%;max-height:300px;overflow-y:auto;background:var(--bs-body-bg);box-shadow:0 8px 30px rgba(0,0,0,.18);border:1px solid var(--bs-border-color);border-top:none;border-radius:0 0 .5rem .5rem;"/>
|
||||
<input type="hidden" name="partner_id" id="partnerId"
|
||||
t-att-value="assessment.partner_id.id if assessment and assessment.partner_id else 0"/>
|
||||
</div>
|
||||
<div id="selectedClient" class="alert alert-info d-none mb-3">
|
||||
<strong id="selectedClientName"/>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary ms-2"
|
||||
id="clearClient">Change</button>
|
||||
</div>
|
||||
<div id="newClientFields">
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col">
|
||||
<input type="text" name="client_first_name" class="form-control"
|
||||
placeholder="First Name"
|
||||
t-att-value="assessment.client_first_name if assessment else ''"/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<input type="text" name="client_last_name" class="form-control"
|
||||
placeholder="Last Name"
|
||||
t-att-value="assessment.client_last_name if assessment else ''"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col">
|
||||
<input type="tel" name="client_phone" class="form-control"
|
||||
placeholder="Phone"
|
||||
t-att-value="assessment.client_phone if assessment else ''"/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<input type="email" name="client_email" class="form-control"
|
||||
placeholder="Email"
|
||||
t-att-value="assessment.client_email if assessment else ''"/>
|
||||
</div>
|
||||
</div>
|
||||
<input type="text" name="client_street" class="form-control mb-2"
|
||||
placeholder="Street Address"
|
||||
t-att-value="assessment.client_street if assessment else ''"/>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col">
|
||||
<input type="text" name="client_city" class="form-control"
|
||||
placeholder="City"
|
||||
t-att-value="assessment.client_city if assessment else ''"/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<select name="client_state_id" class="form-select">
|
||||
<option value="">Province</option>
|
||||
<t t-foreach="provinces" t-as="prov">
|
||||
<option t-att-value="prov.id"
|
||||
t-att-selected="assessment and assessment.client_state_id.id == prov.id">
|
||||
<t t-out="prov.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<input type="text" name="client_zip" class="form-control"
|
||||
placeholder="Postal Code"
|
||||
t-att-value="assessment.client_zip if assessment else ''"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Health Card — controlled by step config -->
|
||||
<t t-if="client_config.get('show_health_card') or client_config.get('show_dob')">
|
||||
<div class="row g-2 mb-2">
|
||||
<t t-if="client_config.get('show_health_card')">
|
||||
<div class="col">
|
||||
<label class="form-label">Health Card</label>
|
||||
<input type="text" name="client_health_card" class="form-control"
|
||||
t-att-value="assessment.client_health_card if assessment else ''"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="client_config.get('show_dob')">
|
||||
<div class="col">
|
||||
<label class="form-label">Date of Birth</label>
|
||||
<input type="date" name="client_dob" class="form-control"
|
||||
t-att-value="assessment.client_dob if assessment else ''"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div><!-- /card-body -->
|
||||
</div><!-- /card -->
|
||||
</div>
|
||||
|
||||
<!-- ── RIGHT COLUMN: Equipment Config ── -->
|
||||
<div class="col-md-6">
|
||||
<div class="card wc-section-card h-100">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="fa fa-cogs me-2"/>Equipment</h5></div>
|
||||
<div class="card-body">
|
||||
<!-- Equipment type — show current type as label + hidden field -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Equipment Type</label>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<t t-foreach="equipment_types" t-as="etype">
|
||||
<t t-if="etype.code == equipment_type">
|
||||
<span class="badge bg-primary fs-6 py-2 px-3">
|
||||
<i t-attf-class="fa #{etype.icon or 'fa-cog'} me-1"/> <t t-out="etype.name"/>
|
||||
</span>
|
||||
</t>
|
||||
</t>
|
||||
<input type="hidden" name="equipment_type" t-att-value="equipment_type"/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Wheelchair category — controlled by step config -->
|
||||
<t t-if="client_config.get('show_wheelchair_category')">
|
||||
<div class="mb-3" id="wheelchairTypeGroup">
|
||||
<label class="form-label">Wheelchair Category</label>
|
||||
<select name="wheelchair_type" class="form-select">
|
||||
<option value="">Select...</option>
|
||||
<option value="type_1" t-att-selected="assessment and assessment.wheelchair_type == 'type_1'">Type 1 - Standard</option>
|
||||
<option value="type_2" t-att-selected="assessment and assessment.wheelchair_type == 'type_2'">Type 2 - Lightweight</option>
|
||||
<option value="type_3" t-att-selected="assessment and assessment.wheelchair_type == 'type_3'">Type 3 - Ultra Lightweight</option>
|
||||
<option value="type_4" t-att-selected="assessment and assessment.wheelchair_type == 'type_4'">Type 4 - Rigid Frame</option>
|
||||
<option value="type_5" t-att-selected="assessment and assessment.wheelchair_type == 'type_5'">Type 5 - Dynamic Tilt</option>
|
||||
</select>
|
||||
</div>
|
||||
</t>
|
||||
<!-- Powerchair category — controlled by step config -->
|
||||
<t t-if="client_config.get('show_powerchair_category')">
|
||||
<div class="mb-3" id="powerchairTypeGroup">
|
||||
<label class="form-label">Powerchair Category</label>
|
||||
<select name="powerchair_type" class="form-select">
|
||||
<option value="">Select...</option>
|
||||
<option value="type_1" t-att-selected="assessment and assessment.powerchair_type == 'type_1'">Power Base Type 1</option>
|
||||
<option value="type_2" t-att-selected="assessment and assessment.powerchair_type == 'type_2'">Power Base Type 2</option>
|
||||
<option value="type_3" t-att-selected="assessment and assessment.powerchair_type == 'type_3'">Power Base Type 3</option>
|
||||
</select>
|
||||
</div>
|
||||
</t>
|
||||
<!-- ADP fields (client type + reason) — controlled by step config -->
|
||||
<t t-if="client_config.get('show_adp_fields')">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Client Type</label>
|
||||
<select name="client_type" class="form-select">
|
||||
<option value="reg" t-att-selected="not assessment or assessment.client_type == 'reg'">REG - Regular ADP (75/25)</option>
|
||||
<option value="ods" t-att-selected="assessment and assessment.client_type == 'ods'">ODS - ODSP (100% ADP)</option>
|
||||
<option value="acs" t-att-selected="assessment and assessment.client_type == 'acs'">ACS - ACSD (100% ADP)</option>
|
||||
<option value="owp" t-att-selected="assessment and assessment.client_type == 'owp'">OWP - Ontario Works (100% ADP)</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="hidden" name="build_type" value="modular"/>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Reason for Application</label>
|
||||
<select name="reason_for_application" class="form-select">
|
||||
<option value="">Select...</option>
|
||||
<option value="first_access" t-att-selected="assessment and assessment.reason_for_application == 'first_access'">First Access</option>
|
||||
<option value="additions" t-att-selected="assessment and assessment.reason_for_application == 'additions'">Additions</option>
|
||||
<option value="modifications" t-att-selected="assessment and assessment.reason_for_application == 'modifications'">Modifications</option>
|
||||
<option value="replacements" t-att-selected="assessment and assessment.reason_for_application == 'replacements'">Replacements</option>
|
||||
</select>
|
||||
</div>
|
||||
</t>
|
||||
</div><!-- /card-body -->
|
||||
</div><!-- /card -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SUB-TEMPLATE: Measurements (step_type=measurements) -->
|
||||
<!-- ============================================================ -->
|
||||
<template id="fq_step_measurements" name="Step: Measurements">
|
||||
<h4 class="mb-3"><t t-out="fstep.name"/></h4>
|
||||
<t t-if="fstep.help_text">
|
||||
<p class="text-muted"><t t-out="fstep.help_text"/></p>
|
||||
</t>
|
||||
<t t-if="not fstep.help_text">
|
||||
<p class="text-muted">All measurements are required for the ADP application.</p>
|
||||
</t>
|
||||
|
||||
<!-- Dynamic fields from fields_json -->
|
||||
<t t-set="mfields" t-value="step_fields.get(fstep.id, [])"/>
|
||||
<t t-if="mfields">
|
||||
<div class="row g-3">
|
||||
<t t-foreach="mfields" t-as="mf">
|
||||
<div class="col-md-6">
|
||||
<div class="wc-measurement-field p-3 border rounded">
|
||||
<label class="form-label fw-bold" t-out="mf.get('label', '')"/>
|
||||
<t t-if="mf.get('type') == 'selection'">
|
||||
<select t-att-name="mf.get('name')" class="form-select"
|
||||
t-att-required="mf.get('required')">
|
||||
<option value="">Select...</option>
|
||||
<t t-foreach="mf.get('options', [])" t-as="opt">
|
||||
<option t-att-value="opt[0]"
|
||||
t-att-selected="field_values.get(mf.get('name')) == str(opt[0])"
|
||||
t-out="opt[1]"/>
|
||||
</t>
|
||||
</select>
|
||||
</t>
|
||||
<t t-if="mf.get('type') != 'selection'">
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<input t-att-type="'number' if mf.get('type') in ('float', 'integer') else 'text'"
|
||||
t-att-step="mf.get('step', '0.25') if mf.get('type') == 'float' else ('1' if mf.get('type') == 'integer' else '')"
|
||||
t-att-name="mf.get('name')"
|
||||
class="form-control wc-measurement-input"
|
||||
t-att-data-upcharge-field="mf.get('name')"
|
||||
t-att-value="field_values.get(mf.get('name'), '')"
|
||||
t-att-required="mf.get('required')"/>
|
||||
<t t-if="mf.get('units')">
|
||||
<select t-att-name="mf.get('unit_field', mf.get('name') + '_unit')"
|
||||
class="form-select wc-unit-select" style="width:100px;">
|
||||
<t t-foreach="mf.get('units', [])" t-as="unit">
|
||||
<option t-att-value="unit"
|
||||
t-att-selected="field_values.get(mf.get('unit_field', mf.get('name') + '_unit')) == unit"
|
||||
t-out="unit"/>
|
||||
</t>
|
||||
</select>
|
||||
</t>
|
||||
<t t-if="mf.get('unit') and not mf.get('units')">
|
||||
<span class="input-group-text" t-out="mf.get('unit')"/>
|
||||
</t>
|
||||
<span class="wc-upcharge-badge d-none"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Fallback: hardcoded wheelchair measurement fields -->
|
||||
<t t-if="not mfields">
|
||||
<div class="row g-3">
|
||||
<t t-foreach="[
|
||||
('seat_width', 'Seat Width', 'seat_width_unit', 'inches'),
|
||||
('seat_depth', 'Seat Depth', 'seat_depth_unit', 'inches'),
|
||||
('finished_seat_to_floor_height', 'Finished Seat to Floor Height', 'seat_to_floor_unit', 'inches'),
|
||||
('back_cane_height', 'Back Cane Height', 'cane_height_unit', 'inches'),
|
||||
('finished_back_height', 'Finished Back Height', 'back_height_unit', 'inches'),
|
||||
('finished_leg_rest_length', 'Finished Leg Rest Length', 'leg_rest_unit', 'inches'),
|
||||
]" t-as="mfield">
|
||||
<div class="col-md-6">
|
||||
<div class="wc-measurement-field p-3 border rounded">
|
||||
<label class="form-label fw-bold" t-out="mfield[1]"/>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<input type="number" step="0.25" t-att-name="mfield[0]"
|
||||
class="form-control wc-measurement-input"
|
||||
t-att-data-upcharge-field="mfield[0]"
|
||||
t-att-value="field_values.get(mfield[0], '')"/>
|
||||
<select t-att-name="mfield[2]" class="form-select wc-unit-select" style="width:100px;">
|
||||
<option value="cm"
|
||||
t-att-selected="field_values.get(mfield[2]) == 'cm'">cm</option>
|
||||
<option value="inches"
|
||||
t-att-selected="not field_values.get(mfield[2]) or field_values.get(mfield[2]) == 'inches'">inches</option>
|
||||
</select>
|
||||
<span class="wc-upcharge-badge d-none"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<!-- Client Weight -->
|
||||
<div class="col-md-6">
|
||||
<div class="wc-measurement-field p-3 border rounded">
|
||||
<label class="form-label fw-bold">Client Weight</label>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<input type="number" step="1" name="client_weight"
|
||||
class="form-control wc-measurement-input"
|
||||
data-upcharge-field="client_weight"
|
||||
t-att-value="assessment and assessment.client_weight or ''"/>
|
||||
<select name="client_weight_unit" class="form-select wc-unit-select" style="width:100px;">
|
||||
<option value="kg"
|
||||
t-att-selected="assessment and assessment.client_weight_unit == 'kg'">kg</option>
|
||||
<option value="lbs"
|
||||
t-att-selected="not assessment or assessment.client_weight_unit == 'lbs'">lbs</option>
|
||||
</select>
|
||||
<span class="wc-upcharge-badge d-none"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Upcharges Preview -->
|
||||
<div id="upchargePreview" class="mt-3 d-none">
|
||||
<h5>Auto-Applied Upcharges</h5>
|
||||
<div id="upchargeList"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SUB-TEMPLATE: Product Selection (step_type=product_select) -->
|
||||
<!-- ============================================================ -->
|
||||
<template id="fq_step_product_select" name="Step: Product Selection">
|
||||
<h4 class="mb-3"><t t-out="fstep.name"/></h4>
|
||||
<t t-if="fstep.help_text">
|
||||
<p class="text-muted"><t t-out="fstep.help_text"/></p>
|
||||
</t>
|
||||
|
||||
<div class="mb-3 wc-search-container" style="position:relative;z-index:100;">
|
||||
<label class="form-label fw-bold">Search Product</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fa fa-search"/></span>
|
||||
<input type="text" class="form-control wc-product-search"
|
||||
id="frameSearch" t-att-data-section="fstep.section_code or 'frame'"
|
||||
placeholder="Type product name, model, or ADP code..."
|
||||
autocomplete="off"/>
|
||||
</div>
|
||||
<div id="frameSearchResults" class="list-group d-none"
|
||||
style="position:absolute;z-index:1060;width:100%;max-height:300px;overflow-y:auto;background:var(--bs-body-bg);box-shadow:0 8px 30px rgba(0,0,0,.18);border:1px solid var(--bs-border-color);border-top:none;border-radius:0 0 .5rem .5rem;"/>
|
||||
<input type="hidden" name="frame_product_tmpl_id" id="frameProductTmplId"
|
||||
t-att-value="assessment.frame_product_tmpl_id.id if assessment and assessment.frame_product_tmpl_id else ''"/>
|
||||
<input type="hidden" name="frame_product_id" id="frameProductId"
|
||||
t-att-value="assessment.frame_product_id.id if assessment and assessment.frame_product_id else ''"/>
|
||||
</div>
|
||||
<div id="selectedFrame" class="d-none" style="position:relative;z-index:1;">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h5 class="card-title mb-1" id="selectedFrameName"/>
|
||||
<div class="d-flex gap-4 text-muted small">
|
||||
<span>ADP Code: <strong id="selectedFrameCode"/></span>
|
||||
<span>ADP Price: $<strong id="selectedFramePrice"/></span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
id="clearFrame"><i class="fa fa-times me-1"/>Change</button>
|
||||
</div>
|
||||
<!-- Frame Configurator (shown when template has multiple variants) -->
|
||||
<div id="frameConfigurator" class="wc-configurator-panel d-none">
|
||||
<div class="wc-config-title">
|
||||
<i class="fa fa-sliders me-2"/>Configure Options
|
||||
</div>
|
||||
<div id="frameAttributeSelectors" class="row g-2"/>
|
||||
<div id="frameVariantInfo" class="d-none">
|
||||
<span class="wc-variant-resolved">
|
||||
<i class="fa fa-check-circle"/>
|
||||
<span>Resolved: <strong id="frameVariantName"/></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Standard options for this section -->
|
||||
<div id="standardFrameOptions" class="mt-3" style="position:relative;z-index:1;">
|
||||
<h5>Standard Options</h5>
|
||||
<div id="frameOptionsGrid" class="row g-2"/>
|
||||
</div>
|
||||
|
||||
<div class="mt-3" style="position:relative;z-index:1;">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea name="frame_notes" class="form-control" rows="2"
|
||||
t-out="assessment.frame_notes if assessment else ''"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SUB-TEMPLATE: Options & Accessories (step_type=options) -->
|
||||
<!-- ============================================================ -->
|
||||
<template id="fq_step_options" name="Step: Options">
|
||||
<h4 class="mb-3"><t t-out="fstep.name"/></h4>
|
||||
<t t-if="fstep.help_text">
|
||||
<p class="text-muted"><t t-out="fstep.help_text"/></p>
|
||||
</t>
|
||||
|
||||
<!-- Seating/Positioning sections with accordion -->
|
||||
<t t-if="fstep.section_code == 'seating'">
|
||||
<p class="text-muted">Select seating and positioning devices. Choose Modular or Custom Fabricated for each.</p>
|
||||
<div class="accordion" id="seatingAccordion">
|
||||
<t t-foreach="sections" t-as="section">
|
||||
<t t-if="section.has_build_type and section.code != 'accessories'">
|
||||
<div class="accordion-item wc-equipment-section"
|
||||
t-att-data-equipment-type="section.equipment_type"
|
||||
t-att-style="'' if (section.equipment_type in ('both', equipment_type) or (section.equipment_type == 'wheelchair' and equipment_type in ('manual_wheelchair', 'power_wheelchair'))) else 'display:none;'">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button"
|
||||
data-bs-toggle="collapse"
|
||||
t-attf-data-bs-target="#collapse_#{section.code}">
|
||||
<i t-attf-class="fa #{section.icon or 'fa-cube'} me-2"/>
|
||||
<t t-out="section.name"/>
|
||||
</button>
|
||||
</h2>
|
||||
<div t-attf-id="collapse_#{section.code}" class="accordion-collapse collapse"
|
||||
data-bs-parent="#seatingAccordion">
|
||||
<div class="accordion-body">
|
||||
<!-- Build Type Toggle -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Build Type</label>
|
||||
<div class="d-flex gap-2">
|
||||
<label class="btn btn-sm btn-outline-secondary wc-radio-btn">
|
||||
<input type="radio" t-attf-name="section_build_#{section.code}"
|
||||
value="modular" checked="checked"
|
||||
class="wc-section-build-type"
|
||||
t-att-data-section="section.code"/>
|
||||
Modular
|
||||
</label>
|
||||
<label class="btn btn-sm btn-outline-secondary wc-radio-btn">
|
||||
<input type="radio" t-attf-name="section_build_#{section.code}"
|
||||
value="custom_fabricated"
|
||||
class="wc-section-build-type"
|
||||
t-att-data-section="section.code"/>
|
||||
Custom Fabricated
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Measurement fields if section has them -->
|
||||
<t t-if="section.has_width or section.has_depth or section.has_height">
|
||||
<div class="row g-2 mb-3">
|
||||
<t t-if="section.has_width">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" t-out="section.width_label"/>
|
||||
<input type="number" step="0.25" class="form-control"
|
||||
t-attf-name="section_width_#{section.code}"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="section.has_depth">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" t-out="section.depth_label"/>
|
||||
<input type="number" step="0.25" class="form-control"
|
||||
t-attf-name="section_depth_#{section.code}"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="section.has_height">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" t-out="section.height_label"/>
|
||||
<input type="number" step="0.25" class="form-control"
|
||||
t-attf-name="section_height_#{section.code}"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Product options for this section -->
|
||||
<div class="wc-section-options" t-att-data-section="section.code"
|
||||
t-att-data-section-id="section.id">
|
||||
<div class="wc-options-grid row g-2"/>
|
||||
<!-- Custom search -->
|
||||
<div class="mt-2 wc-search-container">
|
||||
<input type="text" class="form-control form-control-sm wc-product-search"
|
||||
t-att-data-section="section.code"
|
||||
placeholder="Search for product..."
|
||||
autocomplete="off"/>
|
||||
<div class="wc-search-results list-group d-none"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sub-sections -->
|
||||
<t t-foreach="section.child_ids" t-as="child">
|
||||
<div class="mt-3 ps-3 border-start">
|
||||
<h6><t t-out="child.name"/></h6>
|
||||
<div class="wc-section-options" t-att-data-section="child.code"
|
||||
t-att-data-section-id="child.id">
|
||||
<div class="wc-options-grid row g-2"/>
|
||||
<div class="mt-1 wc-search-container">
|
||||
<input type="text" class="form-control form-control-sm wc-product-search"
|
||||
t-att-data-section="child.code"
|
||||
placeholder="Search..."
|
||||
autocomplete="off"/>
|
||||
<div class="wc-search-results list-group d-none"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- ADP Options & Accessories (flat checkbox grids) -->
|
||||
<t t-if="fstep.section_code != 'seating'">
|
||||
<p class="text-muted">
|
||||
Select applicable options. Items marked with * require clinical rationale.
|
||||
</p>
|
||||
<div id="adpOptionsGrid" class="row g-2">
|
||||
<!-- Populated by JS from section options -->
|
||||
</div>
|
||||
|
||||
<h4 class="mt-4 mb-3">Accessories</h4>
|
||||
<div id="accessoriesGrid" class="row g-2">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<div class="mt-2 wc-search-container">
|
||||
<input type="text" class="form-control wc-product-search"
|
||||
data-section="accessories"
|
||||
placeholder="Search for accessories..."
|
||||
autocomplete="off"/>
|
||||
<div class="wc-search-results list-group d-none"/>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SUB-TEMPLATE: Review & Generate (step_type=review) -->
|
||||
<!-- ============================================================ -->
|
||||
<template id="fq_step_review" name="Step: Review & Generate">
|
||||
<h4 class="mb-3">Review & Generate Quotation</h4>
|
||||
|
||||
<!-- Selected Items Summary -->
|
||||
<div id="reviewSummary">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Section</th>
|
||||
<th>Product</th>
|
||||
<th>ADP Code</th>
|
||||
<th>Build</th>
|
||||
<th class="text-end">Qty</th>
|
||||
<th class="text-end">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="reviewTableBody"/>
|
||||
<tfoot>
|
||||
<tr class="fw-bold">
|
||||
<td colspan="5" class="text-end">Estimated Total:</td>
|
||||
<td class="text-end" id="reviewTotal">$0.00</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Upcharges Summary -->
|
||||
<div id="reviewUpcharges" class="d-none">
|
||||
<h5>Auto-Applied Upcharges</h5>
|
||||
<div id="reviewUpchargeList"/>
|
||||
</div>
|
||||
|
||||
<!-- Measurements Summary -->
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Measurements Summary</h5>
|
||||
</div>
|
||||
<div class="card-body" id="reviewMeasurements"/>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label class="form-label">Additional Notes</label>
|
||||
<textarea name="notes" class="form-control" rows="3"
|
||||
t-out="assessment.notes if assessment else ''"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SUB-TEMPLATE: Custom Fields (step_type=custom) -->
|
||||
<!-- ============================================================ -->
|
||||
<template id="fq_step_custom" name="Step: Custom Fields">
|
||||
<h4 class="mb-3"><t t-out="fstep.name"/></h4>
|
||||
<t t-if="fstep.help_text">
|
||||
<p class="text-muted"><t t-out="fstep.help_text"/></p>
|
||||
</t>
|
||||
|
||||
<t t-set="cfields" t-value="step_fields.get(fstep.id, [])"/>
|
||||
<t t-if="cfields">
|
||||
<div class="row g-3">
|
||||
<t t-foreach="cfields" t-as="cf">
|
||||
<div class="col-md-6">
|
||||
<div class="p-3 border rounded">
|
||||
<label class="form-label fw-bold" t-out="cf.get('label', '')"/>
|
||||
<t t-if="cf.get('type') == 'selection'">
|
||||
<select t-att-name="cf.get('name')" class="form-select"
|
||||
t-att-required="cf.get('required')">
|
||||
<option value="">Select...</option>
|
||||
<t t-foreach="cf.get('options', [])" t-as="opt">
|
||||
<option t-att-value="opt[0]"
|
||||
t-att-selected="field_values.get(cf.get('name')) == str(opt[0])"
|
||||
t-out="opt[1]"/>
|
||||
</t>
|
||||
</select>
|
||||
</t>
|
||||
<t t-if="cf.get('type') == 'text'">
|
||||
<textarea t-att-name="cf.get('name')" class="form-control" rows="3"
|
||||
t-att-required="cf.get('required')"
|
||||
t-out="field_values.get(cf.get('name'), '')"/>
|
||||
</t>
|
||||
<t t-if="cf.get('type') == 'boolean'">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input"
|
||||
t-att-name="cf.get('name')"
|
||||
t-att-checked="field_values.get(cf.get('name'))"/>
|
||||
<label class="form-check-label" t-out="cf.get('label', '')"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="cf.get('type') in ('float', 'integer')">
|
||||
<div class="input-group">
|
||||
<input t-att-type="'number'"
|
||||
t-att-step="'0.01' if cf.get('type') == 'float' else '1'"
|
||||
t-att-name="cf.get('name')"
|
||||
class="form-control"
|
||||
t-att-value="field_values.get(cf.get('name'), '')"
|
||||
t-att-required="cf.get('required')"/>
|
||||
<t t-if="cf.get('unit')">
|
||||
<span class="input-group-text" t-out="cf.get('unit')"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="cf.get('type') not in ('selection', 'text', 'boolean', 'float', 'integer')">
|
||||
<input type="text" t-att-name="cf.get('name')" class="form-control"
|
||||
t-att-value="field_values.get(cf.get('name'), '')"
|
||||
t-att-required="cf.get('required')"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="not cfields">
|
||||
<div class="alert alert-info">
|
||||
No custom fields configured for this step.
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- EMBEDDABLE FORM (no portal chrome — for iframe embedding) -->
|
||||
<!-- ============================================================ -->
|
||||
<template id="portal_quotation_form_embed" name="Equipment Assessment Form (Embed)">
|
||||
<t t-call="web.frontend_layout">
|
||||
<t t-set="no_header" t-value="True"/>
|
||||
<t t-set="no_footer" t-value="True"/>
|
||||
|
||||
<div class="container-fluid py-3 wc-assessment-form" id="wcAssessmentForm">
|
||||
|
||||
<!-- Success/Error Messages -->
|
||||
<t t-if="request.params.get('success') == 'saved'">
|
||||
<div class="alert alert-success alert-dismissible fade show">
|
||||
Assessment saved successfully.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="request.params.get('success') == 'quotation_generated'">
|
||||
<div class="alert alert-success alert-dismissible fade show">
|
||||
<i class="fa fa-check-circle me-1"/> Quotation generated!
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="request.params.get('error')">
|
||||
<div class="alert alert-danger alert-dismissible fade show">
|
||||
<t t-out="request.params.get('error')"/>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Dynamic Step Indicator -->
|
||||
<div class="wc-steps mb-4">
|
||||
<div class="d-flex justify-content-between">
|
||||
<t t-foreach="flow_steps" t-as="fstep">
|
||||
<div t-attf-class="wc-step-indicator text-center flex-fill #{'active' if fstep_index == 0 else ''}"
|
||||
t-att-data-step="fstep_index + 1">
|
||||
<div class="wc-step-number rounded-circle d-inline-flex align-items-center justify-content-center">
|
||||
<t t-out="fstep_index + 1"/>
|
||||
</div>
|
||||
<div class="wc-step-label small mt-1">
|
||||
<t t-out="fstep.name"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post"
|
||||
t-att-action="'/quotation/form/' + access_token + '/save'"
|
||||
id="wcForm" class="wc-form">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<input type="hidden" name="assessment_id"
|
||||
t-att-value="assessment.id if assessment else 0"/>
|
||||
<input type="hidden" name="current_step" id="currentStep" value="1"/>
|
||||
<input type="hidden" name="action" id="formAction" value="save"/>
|
||||
<input type="hidden" name="access_token" id="accessToken"
|
||||
t-att-value="access_token"/>
|
||||
|
||||
<!-- Hidden fields for line data -->
|
||||
<input type="hidden" name="line_product_ids" id="lineProductIds" value=""/>
|
||||
<input type="hidden" name="line_section_ids" id="lineSectionIds" value=""/>
|
||||
<input type="hidden" name="line_build_types" id="lineBuildTypes" value=""/>
|
||||
<input type="hidden" name="line_quantities" id="lineQuantities" value=""/>
|
||||
<input type="hidden" name="line_rationales" id="lineRationales" value=""/>
|
||||
|
||||
<p class="text-muted text-center mb-3">
|
||||
<i class="fa fa-cogs me-1"/>
|
||||
Equipment Assessment Form
|
||||
</p>
|
||||
|
||||
<!-- NOTE: The embed template reuses the same step structure.
|
||||
For a full implementation, you would include the same step
|
||||
content from the main template. For now we render a minimal
|
||||
placeholder that the JS widget will populate. -->
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fa fa-spinner fa-spin fa-2x mb-3 d-block"/>
|
||||
Loading assessment form...
|
||||
</div>
|
||||
|
||||
<!-- Navigation Buttons -->
|
||||
<div class="wc-form-navigation mt-4 border-top pt-3">
|
||||
<div class="d-flex justify-content-between">
|
||||
<button type="submit" class="btn btn-outline-secondary" id="btnSaveDraft">
|
||||
<i class="fa fa-save me-1"/> Save Draft
|
||||
</button>
|
||||
<div>
|
||||
<button type="button" class="btn btn-secondary d-none" id="btnPrev">
|
||||
<i class="fa fa-arrow-left me-1"/> Previous
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" id="btnNext">
|
||||
Next <i class="fa fa-arrow-right ms-1"/>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success d-none" id="btnGenerate"
|
||||
name="action" value="generate">
|
||||
<i class="fa fa-magic me-1"/> Generate Quotation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- PUBLIC FORM — INVALID TOKEN PAGE -->
|
||||
<!-- ============================================================ -->
|
||||
<template id="public_form_invalid" name="Invalid Assessment Link">
|
||||
<t t-call="web.frontend_layout">
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 text-center">
|
||||
<i class="fa fa-chain-broken fa-4x text-muted mb-3 d-block"/>
|
||||
<h3 class="mb-3">This link is no longer valid</h3>
|
||||
<t t-if="status == 'not_found'">
|
||||
<p class="text-muted">
|
||||
The assessment form you're trying to access doesn't exist
|
||||
or the link has expired.
|
||||
</p>
|
||||
</t>
|
||||
<t t-if="status == 'cancelled'">
|
||||
<p class="text-muted">
|
||||
This assessment has been cancelled and is no longer
|
||||
available for editing.
|
||||
</p>
|
||||
</t>
|
||||
<a href="/" class="btn btn-primary mt-3">
|
||||
<i class="fa fa-home me-1"/> Go Home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
496
fusion_quotations/views/wc_assessment_views.xml
Normal file
496
fusion_quotations/views/wc_assessment_views.xml
Normal file
@@ -0,0 +1,496 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- ASSESSMENT TREE VIEW -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="view_wc_assessment_tree" model="ir.ui.view">
|
||||
<field name="name">fusion.wc.assessment.tree</field>
|
||||
<field name="model">fusion.wc.assessment</field>
|
||||
<field name="arch" type="xml">
|
||||
<list decoration-info="state == 'draft'"
|
||||
decoration-warning="state == 'review'"
|
||||
decoration-success="state == 'quoted'"
|
||||
decoration-muted="state == 'cancelled'">
|
||||
<field name="reference"/>
|
||||
<field name="client_name"/>
|
||||
<field name="equipment_type"/>
|
||||
<field name="wheelchair_type"/>
|
||||
<field name="client_type"/>
|
||||
<field name="build_type"/>
|
||||
<field name="sales_rep_id"/>
|
||||
<field name="assessment_date"/>
|
||||
<field name="total_estimate" sum="Total"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-info="state == 'draft'"
|
||||
decoration-warning="state == 'review'"
|
||||
decoration-success="state == 'quoted'"
|
||||
decoration-danger="state == 'cancelled'"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- ASSESSMENT KANBAN VIEW -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="view_wc_assessment_kanban" model="ir.ui.view">
|
||||
<field name="name">fusion.wc.assessment.kanban</field>
|
||||
<field name="model">fusion.wc.assessment</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="state" class="o_kanban_small_column">
|
||||
<field name="state"/>
|
||||
<field name="reference"/>
|
||||
<field name="client_name"/>
|
||||
<field name="equipment_type"/>
|
||||
<field name="sales_rep_id"/>
|
||||
<field name="total_estimate"/>
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<div class="oe_kanban_content">
|
||||
<strong><field name="reference"/></strong>
|
||||
<div><field name="client_name"/></div>
|
||||
<div class="text-muted">
|
||||
<field name="equipment_type"/>
|
||||
</div>
|
||||
<div>
|
||||
<field name="sales_rep_id" widget="many2one_avatar_user"/>
|
||||
<span class="float-end fw-bold">
|
||||
$<field name="total_estimate"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- ASSESSMENT SEARCH VIEW -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="view_wc_assessment_search" model="ir.ui.view">
|
||||
<field name="name">fusion.wc.assessment.search</field>
|
||||
<field name="model">fusion.wc.assessment</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Wheelchair Assessments">
|
||||
<field name="reference"/>
|
||||
<field name="client_name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="sales_rep_id"/>
|
||||
<field name="authorizer_id"/>
|
||||
<separator/>
|
||||
<filter name="draft" string="In Progress"
|
||||
domain="[('state', '=', 'draft')]"/>
|
||||
<filter name="review" string="Ready for Review"
|
||||
domain="[('state', '=', 'review')]"/>
|
||||
<filter name="quoted" string="Quoted"
|
||||
domain="[('state', '=', 'quoted')]"/>
|
||||
<separator/>
|
||||
<filter name="manual" string="Manual Wheelchair"
|
||||
domain="[('equipment_type', '=', 'manual_wheelchair')]"/>
|
||||
<filter name="power" string="Power Wheelchair / Scooter"
|
||||
domain="[('equipment_type', '=', 'power_wheelchair')]"/>
|
||||
<filter name="walker" string="Walker / Ambulation Aid"
|
||||
domain="[('equipment_type', '=', 'walker')]"/>
|
||||
<separator/>
|
||||
<filter name="my_assessments" string="My Assessments"
|
||||
domain="[('sales_rep_id', '=', uid)]"/>
|
||||
<separator/>
|
||||
<filter name="group_state" string="Status"
|
||||
context="{'group_by': 'state'}"/>
|
||||
<filter name="group_equipment" string="Equipment Type"
|
||||
context="{'group_by': 'equipment_type'}"/>
|
||||
<filter name="group_rep" string="Sales Rep"
|
||||
context="{'group_by': 'sales_rep_id'}"/>
|
||||
<filter name="group_date" string="Date"
|
||||
context="{'group_by': 'assessment_date:month'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- ASSESSMENT FORM VIEW -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="view_wc_assessment_form" model="ir.ui.view">
|
||||
<field name="name">fusion.wc.assessment.form</field>
|
||||
<field name="model">fusion.wc.assessment</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Wheelchair Assessment">
|
||||
<header>
|
||||
<button name="action_generate_quotation" string="Generate Quotation"
|
||||
type="object" class="btn-primary"
|
||||
invisible="state not in ('draft', 'review')"
|
||||
confirm="Generate a quotation from this assessment?"/>
|
||||
<button name="action_mark_review" string="Mark Ready"
|
||||
type="object" class="btn-secondary"
|
||||
invisible="state != 'draft'"/>
|
||||
<button name="action_cancel" string="Cancel"
|
||||
type="object" class="btn-secondary"
|
||||
invisible="state in ('quoted', 'cancelled')"/>
|
||||
<button name="action_reset_draft" string="Reset to Draft"
|
||||
type="object" class="btn-secondary"
|
||||
invisible="state != 'cancelled'"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,review,quoted"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_view_quotation" type="object"
|
||||
class="oe_stat_button" icon="fa-file-text-o"
|
||||
invisible="not sale_order_id">
|
||||
<span class="o_stat_text">Quotation</span>
|
||||
</button>
|
||||
<button name="action_open_portal_form" type="object"
|
||||
class="oe_stat_button" icon="fa-external-link">
|
||||
<span class="o_stat_text">Open Form</span>
|
||||
</button>
|
||||
<button name="action_generate_share_link" type="object"
|
||||
class="oe_stat_button" icon="fa-share-alt">
|
||||
<span class="o_stat_text">Share Link</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="reference" readonly="1"/>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<notebook>
|
||||
<!-- TAB 1: CLIENT & EQUIPMENT -->
|
||||
<page string="Client & Equipment" name="client">
|
||||
<group>
|
||||
<group string="Client">
|
||||
<field name="partner_id"
|
||||
invisible="create_new_partner"/>
|
||||
<field name="create_new_partner"/>
|
||||
<field name="client_first_name"
|
||||
invisible="not create_new_partner"/>
|
||||
<field name="client_last_name"
|
||||
invisible="not create_new_partner"/>
|
||||
<field name="client_phone"
|
||||
invisible="not create_new_partner"/>
|
||||
<field name="client_email"
|
||||
invisible="not create_new_partner"/>
|
||||
<field name="client_health_card"/>
|
||||
<field name="client_dob"/>
|
||||
</group>
|
||||
<group string="Equipment">
|
||||
<field name="equipment_type"/>
|
||||
<field name="wheelchair_type"
|
||||
invisible="equipment_type != 'manual_wheelchair'"/>
|
||||
<field name="powerchair_type"
|
||||
invisible="equipment_type != 'power_wheelchair'"/>
|
||||
<field name="walker_type"
|
||||
invisible="equipment_type != 'walker'"/>
|
||||
<field name="client_type"/>
|
||||
<field name="build_type"
|
||||
invisible="equipment_type == 'walker'"/>
|
||||
<field name="reason_for_application"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Assessment">
|
||||
<field name="sales_rep_id"/>
|
||||
<field name="authorizer_id"/>
|
||||
<field name="assessment_date"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
|
||||
<!-- TAB 2: ADP MEASUREMENTS -->
|
||||
<page string="Measurements" name="measurements">
|
||||
|
||||
<!-- MANUAL WHEELCHAIR MEASUREMENTS (Section 2b) -->
|
||||
<group string="Manual Wheelchair Prescription Details"
|
||||
invisible="equipment_type != 'manual_wheelchair'">
|
||||
<group>
|
||||
<label for="seat_width"/>
|
||||
<div class="d-flex gap-2">
|
||||
<field name="seat_width" class="oe_inline"/>
|
||||
<field name="seat_width_unit" class="oe_inline"/>
|
||||
</div>
|
||||
<label for="seat_depth"/>
|
||||
<div class="d-flex gap-2">
|
||||
<field name="seat_depth" class="oe_inline"/>
|
||||
<field name="seat_depth_unit" class="oe_inline"/>
|
||||
</div>
|
||||
<label for="finished_seat_to_floor_height"/>
|
||||
<div class="d-flex gap-2">
|
||||
<field name="finished_seat_to_floor_height" class="oe_inline"/>
|
||||
<field name="seat_to_floor_unit" class="oe_inline"/>
|
||||
</div>
|
||||
<label for="back_cane_height"/>
|
||||
<div class="d-flex gap-2">
|
||||
<field name="back_cane_height" class="oe_inline"/>
|
||||
<field name="cane_height_unit" class="oe_inline"/>
|
||||
</div>
|
||||
</group>
|
||||
<group>
|
||||
<label for="finished_back_height"/>
|
||||
<div class="d-flex gap-2">
|
||||
<field name="finished_back_height" class="oe_inline"/>
|
||||
<field name="back_height_unit" class="oe_inline"/>
|
||||
</div>
|
||||
<label for="finished_leg_rest_length"/>
|
||||
<div class="d-flex gap-2">
|
||||
<field name="finished_leg_rest_length" class="oe_inline"/>
|
||||
<field name="leg_rest_unit" class="oe_inline"/>
|
||||
</div>
|
||||
<label for="client_weight"/>
|
||||
<div class="d-flex gap-2">
|
||||
<field name="client_weight" class="oe_inline"/>
|
||||
<field name="client_weight_unit" class="oe_inline"/>
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- WALKER / ROLLATOR MEASUREMENTS (Section 2a) -->
|
||||
<group string="Wheeled Walker Prescription Details"
|
||||
invisible="equipment_type != 'walker'">
|
||||
<group>
|
||||
<label for="walker_seat_height"/>
|
||||
<div class="d-flex gap-2">
|
||||
<field name="walker_seat_height" class="oe_inline"/>
|
||||
<field name="walker_seat_height_unit" class="oe_inline"/>
|
||||
</div>
|
||||
<label for="push_handle_height"/>
|
||||
<div class="d-flex gap-2">
|
||||
<field name="push_handle_height" class="oe_inline"/>
|
||||
<field name="push_handle_height_unit" class="oe_inline"/>
|
||||
</div>
|
||||
<field name="hand_grips"/>
|
||||
<field name="forearm_attachments"/>
|
||||
<label for="width_between_push_handles"/>
|
||||
<div class="d-flex gap-2">
|
||||
<field name="width_between_push_handles" class="oe_inline"/>
|
||||
<field name="push_handle_width_unit" class="oe_inline"/>
|
||||
</div>
|
||||
</group>
|
||||
<group>
|
||||
<label for="client_weight"/>
|
||||
<div class="d-flex gap-2">
|
||||
<field name="client_weight" class="oe_inline"/>
|
||||
<field name="client_weight_unit" class="oe_inline"/>
|
||||
</div>
|
||||
<field name="walker_brakes"/>
|
||||
<field name="walker_brake_type"/>
|
||||
<field name="walker_num_wheels"/>
|
||||
<field name="walker_wheel_size"/>
|
||||
<field name="walker_back_support"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Walker ADP Funded Options"
|
||||
invisible="equipment_type != 'walker'">
|
||||
<group>
|
||||
<field name="walker_adolescent_wheeled_walker"/>
|
||||
<field name="walker_adolescent_walking_frame"/>
|
||||
<field name="walker_adolescent_standing_frame"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- POWER BASE / SCOOTER MEASUREMENTS (Section 2c) -->
|
||||
<group string="Power Device Prescription Details"
|
||||
invisible="equipment_type != 'power_wheelchair'">
|
||||
<group>
|
||||
<label for="seat_width"/>
|
||||
<div class="d-flex gap-2">
|
||||
<field name="seat_width" class="oe_inline"/>
|
||||
<field name="seat_width_unit" class="oe_inline"/>
|
||||
</div>
|
||||
<label for="finished_back_height"/>
|
||||
<div class="d-flex gap-2">
|
||||
<field name="finished_back_height" class="oe_inline"/>
|
||||
<field name="back_height_unit" class="oe_inline"/>
|
||||
</div>
|
||||
<label for="finished_seat_to_floor_height"/>
|
||||
<div class="d-flex gap-2">
|
||||
<field name="finished_seat_to_floor_height" class="oe_inline"/>
|
||||
<field name="seat_to_floor_unit" class="oe_inline"/>
|
||||
</div>
|
||||
</group>
|
||||
<group>
|
||||
<label for="finished_leg_rest_length"/>
|
||||
<div class="d-flex gap-2">
|
||||
<field name="finished_leg_rest_length" class="oe_inline"/>
|
||||
<field name="leg_rest_unit" class="oe_inline"/>
|
||||
</div>
|
||||
<label for="seat_depth"/>
|
||||
<div class="d-flex gap-2">
|
||||
<field name="seat_depth" class="oe_inline"/>
|
||||
<field name="seat_depth_unit" class="oe_inline"/>
|
||||
</div>
|
||||
<label for="client_weight"/>
|
||||
<div class="d-flex gap-2">
|
||||
<field name="client_weight" class="oe_inline"/>
|
||||
<field name="client_weight_unit" class="oe_inline"/>
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Power Base ADP Funded Options"
|
||||
invisible="equipment_type != 'power_wheelchair'">
|
||||
<group>
|
||||
<field name="pw_adjustable_tension_back"/>
|
||||
<field name="pw_midline_control"/>
|
||||
<field name="pw_manual_recline"/>
|
||||
<field name="pw_angle_adjustable_footplates"/>
|
||||
<field name="pw_manual_elevating_legrests"/>
|
||||
<field name="pw_swingaway_bracket"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="pw_front_riggings"/>
|
||||
<field name="pw_seat_package_1"/>
|
||||
<field name="pw_seat_package_2"/>
|
||||
<field name="pw_oxygen_tank"/>
|
||||
<field name="pw_ventilator_tray"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Specialty Components (* Provide Clinical Rationale)"
|
||||
invisible="equipment_type != 'power_wheelchair'">
|
||||
<group>
|
||||
<field name="pw_specialty_1_joystick"/>
|
||||
<field name="pw_specialty_2_chin"/>
|
||||
<field name="pw_specialty_3_touch"/>
|
||||
<field name="pw_specialty_4_proximity"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="pw_specialty_5_breath"/>
|
||||
<field name="pw_specialty_6_scanners"/>
|
||||
<field name="pw_auto_correction"/>
|
||||
</group>
|
||||
<group colspan="2">
|
||||
<field name="pw_specialty_rationale"
|
||||
placeholder="Clinical rationale for specialty components..."
|
||||
invisible="not pw_specialty_1_joystick and not pw_specialty_2_chin and not pw_specialty_3_touch and not pw_specialty_4_proximity and not pw_specialty_5_breath and not pw_specialty_6_scanners and not pw_auto_correction"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Power Positioning Devices"
|
||||
invisible="equipment_type != 'power_wheelchair'">
|
||||
<group>
|
||||
<field name="pw_power_tilt_only"/>
|
||||
<field name="pw_power_recline_only"/>
|
||||
<field name="pw_power_tilt_recline"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="pw_power_elevating_footrests"/>
|
||||
<field name="pw_multi_function_control"/>
|
||||
<field name="pw_power_add_on"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
</page>
|
||||
|
||||
<!-- TAB 3: FRAME -->
|
||||
<page string="Frame" name="frame">
|
||||
<group>
|
||||
<group>
|
||||
<field name="frame_product_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="frame_notes" placeholder="Frame notes..."/>
|
||||
</page>
|
||||
|
||||
<!-- TAB 4: SELECTED ITEMS -->
|
||||
<page string="Selected Items" name="lines">
|
||||
<field name="line_ids">
|
||||
<list editable="bottom">
|
||||
<field name="section_id"/>
|
||||
<field name="product_id"/>
|
||||
<field name="product_name"/>
|
||||
<field name="build_type"/>
|
||||
<field name="quantity"/>
|
||||
<field name="adp_device_code"/>
|
||||
<field name="adp_price"/>
|
||||
<field name="unit_price"/>
|
||||
<field name="subtotal" sum="Total"/>
|
||||
<field name="adp_portion" sum="ADP Total"/>
|
||||
<field name="client_portion" sum="Client Total"/>
|
||||
<field name="is_upcharge"/>
|
||||
<field name="upcharge_reason"
|
||||
invisible="not is_upcharge"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
|
||||
<!-- TAB 5: UPCHARGES -->
|
||||
<page string="Upcharges" name="upcharges">
|
||||
<field name="upcharge_line_ids" readonly="1">
|
||||
<list>
|
||||
<field name="adp_device_code"/>
|
||||
<field name="product_name"/>
|
||||
<field name="upcharge_reason"/>
|
||||
<field name="unit_price"/>
|
||||
<field name="adp_portion"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
|
||||
<!-- TAB 6: QUOTATION -->
|
||||
<page string="Quotation" name="quotation">
|
||||
<group>
|
||||
<group>
|
||||
<field name="sale_order_id"/>
|
||||
<field name="total_estimate"/>
|
||||
<field name="total_adp_estimate"/>
|
||||
<field name="total_client_estimate"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="notes" placeholder="Additional notes..."/>
|
||||
</page>
|
||||
|
||||
<!-- TAB 7: SHARING -->
|
||||
<page string="Sharing" name="sharing">
|
||||
<div class="alert alert-info" role="alert"
|
||||
invisible="access_token">
|
||||
<i class="fa fa-info-circle me-1"/>
|
||||
Click <b>Share Link</b> above to generate a public URL
|
||||
that can be opened without logging in, or embedded in
|
||||
an external website via an iframe.
|
||||
</div>
|
||||
<group invisible="not access_token">
|
||||
<group string="Public Access">
|
||||
<field name="public_url" widget="CopyClipboardChar"
|
||||
readonly="1" string="Public URL"/>
|
||||
<field name="portal_url" widget="CopyClipboardChar"
|
||||
readonly="1" string="Portal URL (login required)"/>
|
||||
<field name="access_token" readonly="1"/>
|
||||
</group>
|
||||
<group string="Embed Code">
|
||||
<div colspan="2">
|
||||
<p class="text-muted mb-2">
|
||||
Copy this snippet to embed the form in an external page:
|
||||
</p>
|
||||
<code style="display:block;white-space:pre-wrap;background:#f8f9fa;padding:12px;border-radius:6px;font-size:13px;"><iframe src="<field name="public_url" readonly="1"/>/embed"
|
||||
width="100%" height="900"
|
||||
frameborder="0"
|
||||
allow="clipboard-write"></iframe></code>
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- ASSESSMENT ACTION -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="action_wc_assessment" model="ir.actions.act_window">
|
||||
<field name="name">Wheelchair Assessments</field>
|
||||
<field name="res_model">fusion.wc.assessment</field>
|
||||
<field name="view_mode">list,kanban,form</field>
|
||||
<field name="context">{'search_default_my_assessments': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create a wheelchair assessment
|
||||
</p>
|
||||
<p>
|
||||
Start a new wheelchair assessment to configure a chair
|
||||
and automatically generate a quotation.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
159
fusion_quotations/views/wc_config_flow_views.xml
Normal file
159
fusion_quotations/views/wc_config_flow_views.xml
Normal file
@@ -0,0 +1,159 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============================================================
|
||||
Configuration Flow — Tree View
|
||||
============================================================ -->
|
||||
<record id="view_wc_config_flow_tree" model="ir.ui.view">
|
||||
<field name="name">fusion.wc.config.flow.tree</field>
|
||||
<field name="model">fusion.wc.config.flow</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Configuration Flows">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="equipment_type"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-success="state == 'active'"
|
||||
decoration-info="state == 'draft'"
|
||||
decoration-muted="state == 'archived'"/>
|
||||
<field name="step_count"/>
|
||||
<field name="node_count"/>
|
||||
<field name="connection_count"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================
|
||||
Configuration Flow — Form View
|
||||
============================================================ -->
|
||||
<record id="view_wc_config_flow_form" model="ir.ui.view">
|
||||
<field name="name">fusion.wc.config.flow.form</field>
|
||||
<field name="model">fusion.wc.config.flow</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Configuration Flow">
|
||||
<header>
|
||||
<button name="action_open_designer" type="object"
|
||||
string="Open Visual Designer"
|
||||
class="btn-primary"
|
||||
icon="fa-sitemap"/>
|
||||
<button name="action_new_assessment_form" type="object"
|
||||
string="New Assessment"
|
||||
class="btn-secondary"
|
||||
icon="fa-external-link"/>
|
||||
<button name="action_activate" type="object"
|
||||
string="Activate"
|
||||
class="btn-success"
|
||||
invisible="state == 'active'"/>
|
||||
<button name="action_archive" type="object"
|
||||
string="Archive"
|
||||
invisible="state == 'archived'"/>
|
||||
<button name="action_reset_draft" type="object"
|
||||
string="Reset to Draft"
|
||||
invisible="state == 'draft'"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,active,archived"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" placeholder="e.g. Standard Manual Wheelchair Config"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="equipment_type"/>
|
||||
<field name="sequence"/>
|
||||
<field name="active" invisible="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="step_count"/>
|
||||
<field name="node_count"/>
|
||||
<field name="connection_count"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Form Steps" name="form_steps">
|
||||
<field name="step_ids">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="step_type"/>
|
||||
<field name="icon"/>
|
||||
<field name="section_code"/>
|
||||
<field name="is_required"/>
|
||||
</list>
|
||||
</field>
|
||||
<div class="mt-2">
|
||||
<button name="action_create_default_steps" type="object"
|
||||
string="Generate Default Steps"
|
||||
class="btn-secondary"
|
||||
icon="fa-magic"
|
||||
confirm="This will replace existing steps. Continue?"/>
|
||||
</div>
|
||||
</page>
|
||||
<page string="Description" name="description">
|
||||
<field name="description" placeholder="Describe this configuration flow..."/>
|
||||
</page>
|
||||
<page string="Nodes" name="nodes">
|
||||
<field name="node_ids">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="node_type"/>
|
||||
<field name="section_id"/>
|
||||
<field name="decision_field"
|
||||
invisible="node_type != 'decision'"/>
|
||||
<field name="decision_operator"
|
||||
invisible="node_type != 'decision'"/>
|
||||
<field name="decision_value"
|
||||
invisible="node_type != 'decision'"/>
|
||||
<field name="action_type"
|
||||
invisible="node_type != 'action'"/>
|
||||
<field name="measurement_field"
|
||||
invisible="node_type != 'measurement_check'"/>
|
||||
<field name="comparison"
|
||||
invisible="node_type != 'measurement_check'"/>
|
||||
<field name="threshold_value"
|
||||
invisible="node_type != 'measurement_check'"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Connections" name="connections">
|
||||
<field name="connection_ids">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="source_node_id"
|
||||
domain="[('flow_id', '=', parent.id)]"/>
|
||||
<field name="target_node_id"
|
||||
domain="[('flow_id', '=', parent.id)]"/>
|
||||
<field name="source_port"/>
|
||||
<field name="label"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================
|
||||
Configuration Flow — Action
|
||||
============================================================ -->
|
||||
<record id="action_wc_config_flow" model="ir.actions.act_window">
|
||||
<field name="name">Configuration Flows</field>
|
||||
<field name="res_model">fusion.wc.config.flow</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create a Configuration Flow
|
||||
</p>
|
||||
<p>
|
||||
Configuration flows define visual decision trees that control
|
||||
which options are shown, hidden, or required during a wheelchair
|
||||
assessment. Use the Visual Designer to build flows graphically.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
146
fusion_quotations/views/wc_section_views.xml
Normal file
146
fusion_quotations/views/wc_section_views.xml
Normal file
@@ -0,0 +1,146 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SECTION TREE VIEW -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="view_wc_section_tree" model="ir.ui.view">
|
||||
<field name="name">fusion.wc.section.tree</field>
|
||||
<field name="model">fusion.wc.section</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="parent_id"/>
|
||||
<field name="equipment_type"/>
|
||||
<field name="has_build_type"/>
|
||||
<field name="is_adp_options_section"/>
|
||||
<field name="option_count" string="Options"/>
|
||||
<field name="active" column_invisible="1"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SECTION OPTION ACTION (for stat button — must be before form) -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="action_wc_section_option" model="ir.actions.act_window">
|
||||
<field name="name">Section Options</field>
|
||||
<field name="res_model">fusion.wc.section.option</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'default_section_id': active_id}</field>
|
||||
<field name="domain">[('section_id', '=', active_id)]</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SECTION FORM VIEW -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="view_wc_section_form" model="ir.ui.view">
|
||||
<field name="name">fusion.wc.section.form</field>
|
||||
<field name="model">fusion.wc.section</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Wheelchair Section">
|
||||
<header>
|
||||
<button name="action_auto_populate_options"
|
||||
string="Auto-Populate Products from Inventory"
|
||||
type="object" class="btn-secondary"
|
||||
icon="fa-magic"
|
||||
confirm="This will scan your product inventory for ADP device codes matching this section and add them as options. Continue?"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="%(fusion_quotations.action_wc_section_option)d"
|
||||
type="action" class="oe_stat_button" icon="fa-list"
|
||||
context="{'default_section_id': id, 'search_default_section_id': id}">
|
||||
<field name="option_count" widget="statinfo" string="Options"/>
|
||||
</button>
|
||||
</div>
|
||||
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger"
|
||||
invisible="active"/>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" placeholder="Section Name"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="code"/>
|
||||
<field name="sequence"/>
|
||||
<field name="parent_id"/>
|
||||
<field name="equipment_type"/>
|
||||
<field name="icon" placeholder="fa-wheelchair"/>
|
||||
<field name="product_category_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="required"/>
|
||||
<field name="allow_multiple"/>
|
||||
<field name="is_adp_options_section"/>
|
||||
<field name="has_build_type"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Measurement Fields" col="4">
|
||||
<field name="has_width"/>
|
||||
<field name="width_label" invisible="not has_width"/>
|
||||
<field name="has_depth"/>
|
||||
<field name="depth_label" invisible="not has_depth"/>
|
||||
<field name="has_height"/>
|
||||
<field name="height_label" invisible="not has_height"/>
|
||||
<field name="has_length"/>
|
||||
<field name="length_label" invisible="not has_length"/>
|
||||
</group>
|
||||
<field name="description" placeholder="Help text for sales reps..."/>
|
||||
<notebook>
|
||||
<page string="Product Options" name="options">
|
||||
<field name="option_ids">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="product_tmpl_id"/>
|
||||
<field name="variant_count" string="Variants"/>
|
||||
<field name="is_standard"/>
|
||||
<field name="adp_device_code"/>
|
||||
<field name="adp_price"/>
|
||||
<field name="list_price"/>
|
||||
<field name="available_build_types"/>
|
||||
<field name="requires_clinical_rationale"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Sub-Sections" name="children">
|
||||
<field name="child_ids">
|
||||
<list>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="has_build_type"/>
|
||||
<field name="option_count" string="Options"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SECTION ACTION -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="action_wc_section" model="ir.actions.act_window">
|
||||
<field name="name">Wheelchair Sections</field>
|
||||
<field name="res_model">fusion.wc.section</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'search_default_active': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Configure wheelchair sections
|
||||
</p>
|
||||
<p>
|
||||
Define sections like Frame, Cushion, Backrest, etc.
|
||||
and add product options to each section.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
109
fusion_quotations/views/wc_upcharge_rule_views.xml
Normal file
109
fusion_quotations/views/wc_upcharge_rule_views.xml
Normal file
@@ -0,0 +1,109 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- UPCHARGE RULE TREE VIEW -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="view_wc_upcharge_rule_tree" model="ir.ui.view">
|
||||
<field name="name">fusion.wc.upcharge.rule.tree</field>
|
||||
<field name="model">fusion.wc.upcharge.rule</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="trigger_type"/>
|
||||
<field name="adp_device_code"/>
|
||||
<field name="equipment_type"/>
|
||||
<field name="mutually_exclusive_group"/>
|
||||
<field name="active"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- UPCHARGE RULE FORM VIEW -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="view_wc_upcharge_rule_form" model="ir.ui.view">
|
||||
<field name="name">fusion.wc.upcharge.rule.form</field>
|
||||
<field name="model">fusion.wc.upcharge.rule</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Upcharge Rule">
|
||||
<sheet>
|
||||
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger"
|
||||
invisible="active"/>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" placeholder="Rule Name"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Trigger">
|
||||
<field name="trigger_type"/>
|
||||
<field name="equipment_type"/>
|
||||
<field name="sequence"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
<group string="Result">
|
||||
<field name="adp_device_code"/>
|
||||
<field name="adp_device_code_id" readonly="1"/>
|
||||
<field name="product_id"/>
|
||||
<field name="mutually_exclusive_group"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Measurement trigger fields -->
|
||||
<group string="Measurement Condition"
|
||||
invisible="trigger_type != 'measurement'">
|
||||
<group>
|
||||
<field name="measurement_field"/>
|
||||
<field name="comparison"/>
|
||||
<field name="threshold_value"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Weight trigger fields -->
|
||||
<group string="Weight Condition"
|
||||
invisible="trigger_type != 'weight'">
|
||||
<group>
|
||||
<field name="weight_min"/>
|
||||
<field name="weight_max"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Dimension mismatch fields -->
|
||||
<group string="Dimension Mismatch Condition"
|
||||
invisible="trigger_type != 'dimension_mismatch'">
|
||||
<group>
|
||||
<field name="compare_field_1"/>
|
||||
<field name="compare_field_2"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<group>
|
||||
<field name="description" placeholder="Explanation shown to sales rep when rule triggers..."/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- UPCHARGE RULE ACTION -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="action_wc_upcharge_rule" model="ir.actions.act_window">
|
||||
<field name="name">Upcharge Rules</field>
|
||||
<field name="res_model">fusion.wc.upcharge.rule</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'search_default_active': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Configure upcharge rules
|
||||
</p>
|
||||
<p>
|
||||
Define rules that auto-add ADP upcharge codes based on
|
||||
wheelchair measurements (e.g. WAMA for width > 18").
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user