Compare commits

...

3 Commits

Author SHA1 Message Date
gsinghpal
9d9453b5c8 feat: customizable portal gradient theme + LTC repair form fixes
- Add portal gradient branding settings with 4 presets (Green/Teal,
  Blue/Purple, Sunset Orange, Dark Slate) and custom color picker
- Live preview in settings, onchange updates colors reactively
- Dynamic gradient applied across portal home, CSS, and card elements
- Fix after photos visibility (conditional on resolved=yes)
- Fix technician section gating on portal repair form
- Move Create Sale Order button to form header for visibility
- Fix portal home row width inconsistency (xpath target change)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 02:53:55 -05:00
gsinghpal
f85658c03a updates 2026-02-24 01:18:44 -05:00
gsinghpal
e8e554de95 changes 2026-02-23 00:32:20 -05:00
583 changed files with 5787 additions and 124886 deletions

View File

@@ -1,5 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import models

View File

@@ -1,45 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Accounts',
'version': '19.0.1.0.0',
'category': 'Accounting',
'summary': 'Smart vendor bill creation from email with AI extraction and vendor matching',
'description': """
Fusion Accounts - Smart Vendor Bill Management
===============================================
Automatically creates vendor bills from incoming emails with:
- Multi-level vendor matching (email, domain, name)
- Vendor blocking for PO-tracked vendors
- AI-powered data extraction from email body and PDF attachments
- Full activity logging and dashboard
""",
'author': 'Nexa Systems Inc.',
'website': 'https://nexasystems.ca',
'license': 'OPL-1',
'depends': [
'base',
'account',
'mail',
'purchase',
],
'external_dependencies': {
'python': ['fitz'],
},
'data': [
'security/security.xml',
'security/ir.model.access.csv',
'data/ir_config_parameter_data.xml',
'views/fusion_accounts_log_views.xml',
'views/fusion_accounts_dashboard.xml',
'views/res_partner_views.xml',
'views/res_config_settings_views.xml',
'views/account_move_views.xml',
'views/fusion_accounts_menus.xml',
],
'installable': True,
'application': True,
'auto_install': False,
}

View File

@@ -1,36 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
Default configuration parameters for Fusion Accounts.
noupdate="1" ensures these are ONLY set on first install,
never overwritten during module upgrades.
-->
<data noupdate="1">
<record id="config_ai_enabled" model="ir.config_parameter">
<field name="key">fusion_accounts.ai_enabled</field>
<field name="value">True</field>
</record>
<record id="config_ai_model" model="ir.config_parameter">
<field name="key">fusion_accounts.ai_model</field>
<field name="value">gpt-4o-mini</field>
</record>
<record id="config_ai_max_pages" model="ir.config_parameter">
<field name="key">fusion_accounts.ai_max_pages</field>
<field name="value">2</field>
</record>
<record id="config_enable_domain_match" model="ir.config_parameter">
<field name="key">fusion_accounts.enable_domain_match</field>
<field name="value">True</field>
</record>
<record id="config_enable_name_match" model="ir.config_parameter">
<field name="key">fusion_accounts.enable_name_match</field>
<field name="value">True</field>
</record>
<record id="config_log_retention_days" model="ir.config_parameter">
<field name="key">fusion_accounts.log_retention_days</field>
<field name="value">90</field>
</record>
</data>
</odoo>

View File

@@ -1,9 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import res_partner
from . import fusion_accounts_log
from . import ai_bill_extractor
from . import account_move
from . import res_config_settings

View File

@@ -1,265 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from email.utils import parseaddr
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class AccountMove(models.Model):
_inherit = 'account.move'
x_fa_created_from_email = fields.Boolean(
string='Created from Email',
default=False,
readonly=True,
copy=False,
help='This bill was automatically created from an incoming email.',
)
x_fa_match_level = fields.Selection(
selection=[
('exact_email', 'Exact Email'),
('domain', 'Domain Match'),
('name', 'Name Match'),
('no_match', 'No Match'),
],
string='Vendor Match Level',
readonly=True,
copy=False,
help='How the vendor was matched from the sender email.',
)
x_fa_ai_extracted = fields.Boolean(
string='AI Extracted',
default=False,
readonly=True,
copy=False,
help='Bill data was extracted using AI.',
)
x_fa_original_sender = fields.Char(
string='Original Email Sender',
readonly=True,
copy=False,
help='The original sender email address that triggered bill creation.',
)
# =========================================================================
# VENDOR MATCHING
# =========================================================================
@api.model
def _fa_match_vendor_from_email(self, email_from):
"""Multi-level vendor matching from sender email.
Tries three levels:
1. Exact email match
2. Domain match (email domain or website)
3. Name match (sender display name)
Returns: (partner_record, match_level) or (False, 'no_match')
"""
if not email_from:
return False, 'no_match'
# Parse "Display Name <email@example.com>" format
display_name, email_address = parseaddr(email_from)
if not email_address:
return False, 'no_match'
email_address = email_address.strip().lower()
Partner = self.env['res.partner'].sudo()
# Check settings for which match levels are enabled
ICP = self.env['ir.config_parameter'].sudo()
enable_domain = ICP.get_param('fusion_accounts.enable_domain_match', 'True') == 'True'
enable_name = ICP.get_param('fusion_accounts.enable_name_match', 'True') == 'True'
# ----- Level 1: Exact email match -----
partner = Partner.search([
('email', '=ilike', email_address),
('supplier_rank', '>', 0),
], limit=1)
if not partner:
# Also check without supplier_rank filter (contact might not be flagged as vendor)
partner = Partner.search([
('email', '=ilike', email_address),
], limit=1)
if partner:
_logger.info("Vendor match Level 1 (exact email): %s -> %s",
email_address, partner.name)
return partner, 'exact_email'
# ----- Level 2: Domain match -----
if enable_domain and '@' in email_address:
domain = email_address.split('@')[1]
# Skip common email providers
common_domains = {
'gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com',
'live.com', 'aol.com', 'icloud.com', 'mail.com',
'protonmail.com', 'zoho.com',
}
if domain not in common_domains:
# Search by email domain
partners = Partner.search([
'|',
('email', '=ilike', f'%@{domain}'),
('website', 'ilike', domain),
])
if partners:
# Prefer is_company=True (the parent company)
company_partner = partners.filtered(lambda p: p.is_company)
partner = company_partner[0] if company_partner else partners[0]
_logger.info("Vendor match Level 2 (domain): %s -> %s (from %d candidates)",
domain, partner.name, len(partners))
return partner, 'domain'
# ----- Level 3: Name match -----
if enable_name and display_name:
clean_name = display_name.strip().strip('"').strip("'")
if len(clean_name) >= 3: # Only match names with 3+ characters
partners = Partner.search([
'|',
('name', 'ilike', clean_name),
('commercial_company_name', 'ilike', clean_name),
])
if len(partners) == 1:
_logger.info("Vendor match Level 3 (name): '%s' -> %s",
clean_name, partners.name)
return partners, 'name'
elif len(partners) > 1:
_logger.info("Vendor match Level 3 skipped: '%s' matched %d partners (ambiguous)",
clean_name, len(partners))
_logger.info("No vendor match found for: %s (%s)", display_name, email_address)
return False, 'no_match'
# =========================================================================
# MESSAGE_NEW OVERRIDE
# =========================================================================
@api.model
def message_new(self, msg_dict, custom_values=None):
"""Override to add vendor matching and blocking for incoming bills.
When an email arrives via the accounts alias:
1. Match sender to a vendor
2. If vendor is blocked -> log to Discuss, don't create bill
3. If not blocked -> create draft bill, run AI extraction
"""
email_from = msg_dict.get('email_from', '') or msg_dict.get('from', '')
subject = msg_dict.get('subject', '')
_logger.info("Fusion Accounts: Processing incoming email from '%s' subject '%s'",
email_from, subject)
# Match vendor
partner, match_level = self._fa_match_vendor_from_email(email_from)
# Check if vendor is blocked
if partner and partner.x_fa_block_email_bill:
_logger.info("Vendor '%s' is blocked for email bill creation. Skipping bill.",
partner.name)
# Log the blocked action
self.env['fusion.accounts.log'].sudo().create({
'email_from': email_from,
'email_subject': subject,
'email_date': msg_dict.get('date'),
'vendor_id': partner.id,
'match_level': match_level,
'action_taken': 'blocked',
'notes': f'Vendor "{partner.name}" has email bill creation blocked.',
})
# Post note to vendor's chatter
try:
partner.message_post(
body=f'<p><strong>Blocked bill email:</strong> {subject}</p>'
f'<p><strong>From:</strong> {email_from}</p>',
message_type='comment',
subtype_xmlid='mail.mt_note',
)
except Exception as e:
_logger.warning("Failed to post blocked email to partner chatter: %s", e)
# Don't create a bill -- just let fetchmail mark the email as handled
# by raising a controlled exception that fetchmail catches gracefully
_logger.info("Skipping bill creation for blocked vendor %s", partner.name)
raise ValueError(
f"Fusion Accounts: Bill creation blocked for vendor '{partner.name}'. "
f"Email from {email_from} logged to activity log."
)
# Not blocked -- create the bill
custom_values = custom_values or {}
custom_values['move_type'] = 'in_invoice'
if partner:
custom_values['partner_id'] = partner.id
# Create the bill via standard Odoo mechanism
try:
move = super().message_new(msg_dict, custom_values=custom_values)
# Write FA fields after creation (Odoo may strip unknown fields from custom_values)
move.sudo().write({
'x_fa_created_from_email': True,
'x_fa_match_level': match_level,
'x_fa_original_sender': email_from,
})
except Exception as e:
_logger.error("Failed to create bill from email: %s", e)
self.env['fusion.accounts.log'].sudo().create({
'email_from': email_from,
'email_subject': subject,
'email_date': msg_dict.get('date'),
'vendor_id': partner.id if partner else False,
'match_level': match_level,
'action_taken': 'failed',
'notes': str(e),
})
raise
# Run AI extraction using attachments from msg_dict
# (ir.attachment records don't exist yet at this point - they're created by message_post later)
ai_extracted = False
ai_result = ''
try:
ICP = self.env['ir.config_parameter'].sudo()
ai_enabled = ICP.get_param('fusion_accounts.ai_enabled', 'True') == 'True'
if ai_enabled:
extractor = self.env['fusion.accounts.ai.extractor']
email_body = msg_dict.get('body', '')
raw_attachments = msg_dict.get('attachments', [])
extracted_data = extractor.extract_bill_data_from_raw(
email_body, raw_attachments
)
if extracted_data:
extractor.apply_extracted_data(move, extracted_data)
ai_extracted = True
ai_result = str(extracted_data)
move.sudo().write({'x_fa_ai_extracted': True})
except Exception as e:
_logger.warning("AI extraction failed for bill %s: %s", move.id, e)
ai_result = f'Error: {e}'
# Log the successful creation
self.env['fusion.accounts.log'].sudo().create({
'email_from': email_from,
'email_subject': subject,
'email_date': msg_dict.get('date'),
'vendor_id': partner.id if partner else False,
'match_level': match_level,
'action_taken': 'bill_created',
'bill_id': move.id,
'ai_extracted': ai_extracted,
'ai_result': ai_result,
})
_logger.info("Fusion Accounts: Created bill %s from email (vendor=%s, match=%s, ai=%s)",
move.name, partner.name if partner else 'None', match_level, ai_extracted)
return move

View File

@@ -1,614 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import base64
import json
import logging
import re
from odoo import models
_logger = logging.getLogger(__name__)
EXTRACTION_PROMPT = """You are an accounts payable assistant. Extract billing information from the attached invoice/bill document and email.
IMPORTANT RULES:
- The PDF attachment is the PRIMARY source of truth. Always prefer data from the PDF over the email body.
- "vendor_name" = the company that ISSUED the invoice/bill (the seller/supplier name on the document), NOT the email sender.
- "invoice_number" = the Invoice Number, Bill Number, Reference Number, or Sales Order Number printed on the document.
- "invoice_date" = the date the invoice was issued (not the email date).
- "due_date" = the payment due date on the invoice.
- For line items, extract each product/service line with description, quantity, unit price, and line total.
Return ONLY valid JSON with this exact structure (use null for missing values):
{
"vendor_name": "string - the company name that issued the bill",
"invoice_number": "string - invoice/bill/reference number",
"invoice_date": "YYYY-MM-DD",
"due_date": "YYYY-MM-DD",
"currency": "CAD or USD",
"subtotal": 0.00,
"tax_amount": 0.00,
"total_amount": 0.00,
"po_reference": "string or null - any PO reference on the document",
"lines": [
{
"description": "string",
"quantity": 1.0,
"unit_price": 0.00,
"amount": 0.00
}
]
}
If you cannot determine a value, use null. For lines, include as many as you can find.
Do NOT include any text outside the JSON object."""
class AIBillExtractor(models.AbstractModel):
_name = 'fusion.accounts.ai.extractor'
_description = 'AI Bill Data Extractor'
def _get_api_key(self):
"""Get the OpenAI API key from settings."""
return self.env['ir.config_parameter'].sudo().get_param(
'fusion_accounts.openai_api_key', ''
)
def _get_ai_model(self):
"""Get the configured AI model."""
return self.env['ir.config_parameter'].sudo().get_param(
'fusion_accounts.ai_model', 'gpt-4o-mini'
)
def _get_max_pages(self):
"""Get the max PDF pages to process."""
try:
return int(self.env['ir.config_parameter'].sudo().get_param(
'fusion_accounts.ai_max_pages', '2'
))
except (ValueError, TypeError):
return 2
def _is_ai_enabled(self):
"""Check if AI extraction is enabled."""
return self.env['ir.config_parameter'].sudo().get_param(
'fusion_accounts.ai_enabled', 'True'
) == 'True'
def extract_bill_data_from_raw(self, email_body, raw_attachments=None):
"""Extract bill data using raw attachments from msg_dict.
Raw attachments come as a list that can contain:
- tuples: (filename, content_bytes, info_dict)
- ir.attachment records (if already created)
Args:
email_body: HTML email body
raw_attachments: list from msg_dict['attachments']
Returns:
dict with extracted data, or empty dict on failure
"""
if not self._is_ai_enabled():
_logger.info("AI extraction is disabled")
return {}
api_key = self._get_api_key()
if not api_key:
_logger.warning("No OpenAI API key configured")
return {}
try:
import requests as req_lib
except ImportError:
_logger.error("requests library not available")
return {}
clean_body = self._strip_html(email_body or '')
content_parts = []
has_pdf_content = False
# Process raw attachments from msg_dict
if raw_attachments:
for att in raw_attachments[:3]:
fname = ''
content = None
if hasattr(att, 'datas'):
# ir.attachment record
fname = att.name or ''
content = base64.b64decode(att.datas) if att.datas else None
mimetype = att.mimetype or ''
elif hasattr(att, 'fname') and hasattr(att, 'content'):
# Odoo Attachment namedtuple (fname, content, info)
fname = att.fname or ''
content = att.content if isinstance(att.content, bytes) else None
mimetype = getattr(att, 'info', {}).get('content_type', '') if hasattr(att, 'info') and att.info else ''
elif isinstance(att, (tuple, list)) and len(att) >= 2:
# (filename, content_bytes, ...) tuple
fname = att[0] or ''
content = att[1] if isinstance(att[1], bytes) else None
mimetype = ''
else:
continue
# Determine mimetype from filename if not set
if not mimetype:
if fname.lower().endswith('.pdf'):
mimetype = 'application/pdf'
elif fname.lower().endswith(('.png', '.jpg', '.jpeg')):
mimetype = 'image/' + fname.rsplit('.', 1)[-1].lower()
if not content:
continue
_logger.info("Processing attachment: %s (%d bytes)", fname, len(content))
if fname.lower().endswith('.pdf') or mimetype == 'application/pdf':
# Convert PDF to images
pdf_images = self._pdf_bytes_to_images(content)
if pdf_images:
has_pdf_content = True
for img_data in pdf_images:
content_parts.append({
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{img_data}",
"detail": "high",
}
})
else:
# Fallback: text extraction
pdf_text = self._pdf_bytes_to_text(content)
if pdf_text:
has_pdf_content = True
content_parts.append({
"type": "text",
"text": f"INVOICE/BILL DOCUMENT:\n{pdf_text[:8000]}"
})
elif mimetype.startswith('image/'):
has_pdf_content = True
img_b64 = base64.b64encode(content).decode()
content_parts.append({
"type": "image_url",
"image_url": {
"url": f"data:{mimetype};base64,{img_b64}",
"detail": "high",
}
})
# Email body as secondary context
if clean_body and not has_pdf_content:
content_parts.append({
"type": "text",
"text": f"EMAIL BODY (no invoice attachment):\n{clean_body[:5000]}"
})
elif clean_body and has_pdf_content:
content_parts.append({
"type": "text",
"text": f"ADDITIONAL CONTEXT FROM EMAIL:\n{clean_body[:2000]}"
})
if not content_parts:
_logger.info("No content to extract from")
return {}
# Call OpenAI API
model = self._get_ai_model()
messages = [
{"role": "system", "content": EXTRACTION_PROMPT},
{"role": "user", "content": content_parts},
]
try:
response = req_lib.post(
'https://api.openai.com/v1/chat/completions',
headers={
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
},
json={
'model': model,
'messages': messages,
'max_tokens': 2000,
'temperature': 0.1,
},
timeout=60,
)
response.raise_for_status()
result = response.json()
content = result['choices'][0]['message']['content']
content = content.strip()
if content.startswith('```'):
lines = content.split('\n')
content = '\n'.join(lines[1:-1] if lines[-1].strip() == '```' else lines[1:])
content = content.strip()
if not content:
_logger.warning("AI returned empty response")
return {}
extracted = json.loads(content)
_logger.info("AI extraction successful: %s", json.dumps(extracted, indent=2)[:500])
return extracted
except Exception as e:
_logger.error("AI extraction failed: %s", e)
return {}
def _pdf_bytes_to_images(self, pdf_bytes):
"""Convert raw PDF bytes to base64 PNG images."""
max_pages = self._get_max_pages()
images = []
try:
import fitz
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
for page_num in range(min(len(doc), max_pages)):
page = doc[page_num]
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2))
img_data = base64.b64encode(pix.tobytes("png")).decode()
images.append(img_data)
_logger.info("Converted PDF page %d to image (%d bytes)", page_num + 1, len(img_data))
doc.close()
except ImportError:
_logger.warning("PyMuPDF not available")
except Exception as e:
_logger.warning("PDF to image failed: %s", e)
return images
def _pdf_bytes_to_text(self, pdf_bytes):
"""Extract text from raw PDF bytes."""
max_pages = self._get_max_pages()
try:
import fitz
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
parts = []
for page_num in range(min(len(doc), max_pages)):
parts.append(doc[page_num].get_text())
doc.close()
return '\n'.join(parts)
except Exception:
return ''
def extract_bill_data(self, email_body, attachments=None):
"""Extract bill data from email body and attachments using OpenAI.
Args:
email_body: Plain text or HTML email body
attachments: List of ir.attachment records
Returns:
dict with extracted data, or empty dict on failure
"""
if not self._is_ai_enabled():
_logger.info("AI extraction is disabled")
return {}
api_key = self._get_api_key()
if not api_key:
_logger.warning("No OpenAI API key configured for Fusion Accounts")
return {}
try:
import requests
except ImportError:
_logger.error("requests library not available")
return {}
# Clean HTML from email body
clean_body = self._strip_html(email_body or '')
# Build messages for OpenAI
messages = [
{"role": "system", "content": EXTRACTION_PROMPT},
]
# Build content -- PDF attachments FIRST (primary source), email body second
content_parts = []
has_pdf_content = False
# Add PDF/image attachments first (these are the invoice documents)
if attachments:
for attachment in attachments[:3]: # Max 3 attachments
if attachment.mimetype == 'application/pdf':
# Try image conversion first (best for AI vision)
pdf_images = self._pdf_to_images(attachment)
if pdf_images:
has_pdf_content = True
for img_data in pdf_images:
content_parts.append({
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{img_data}",
"detail": "high",
}
})
else:
# Fallback: extract text from PDF
pdf_text = self._pdf_to_text(attachment)
if pdf_text:
has_pdf_content = True
content_parts.append({
"type": "text",
"text": f"INVOICE/BILL DOCUMENT:\n{pdf_text[:8000]}"
})
elif attachment.mimetype in ('image/png', 'image/jpeg', 'image/jpg'):
has_pdf_content = True
img_b64 = base64.b64encode(base64.b64decode(attachment.datas)).decode()
content_parts.append({
"type": "image_url",
"image_url": {
"url": f"data:{attachment.mimetype};base64,{img_b64}",
"detail": "high",
}
})
# Add email body as secondary context (only if no PDF content found)
if clean_body and not has_pdf_content:
content_parts.append({
"type": "text",
"text": f"EMAIL BODY (no invoice attachment found):\n{clean_body[:5000]}"
})
elif clean_body and has_pdf_content:
content_parts.append({
"type": "text",
"text": f"ADDITIONAL CONTEXT FROM EMAIL:\n{clean_body[:2000]}"
})
if not content_parts:
_logger.info("No content to extract from")
return {}
messages.append({"role": "user", "content": content_parts})
# Call OpenAI API
model = self._get_ai_model()
try:
response = requests.post(
'https://api.openai.com/v1/chat/completions',
headers={
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
},
json={
'model': model,
'messages': messages,
'max_tokens': 2000,
'temperature': 0.1,
},
timeout=60,
)
response.raise_for_status()
result = response.json()
content = result['choices'][0]['message']['content']
# Parse JSON from response -- handle markdown code fences
content = content.strip()
if content.startswith('```'):
# Remove ```json ... ``` wrapper
lines = content.split('\n')
content = '\n'.join(lines[1:-1] if lines[-1].strip() == '```' else lines[1:])
content = content.strip()
if not content:
_logger.warning("AI returned empty response")
return {}
extracted = json.loads(content)
_logger.info("AI extraction successful: %s", json.dumps(extracted, indent=2)[:500])
return extracted
except requests.exceptions.RequestException as e:
_logger.error("OpenAI API request failed: %s", e)
return {}
except (json.JSONDecodeError, KeyError, IndexError) as e:
_logger.warning("Failed to parse AI response: %s (content: %s)", e, content[:200] if content else 'empty')
return {}
def apply_extracted_data(self, move, extracted_data):
"""Apply AI-extracted data to a draft vendor bill.
The PDF/invoice is the source of truth for:
- Vendor name (matched to Odoo contact)
- Invoice/bill number (ref)
- Invoice date, due date
- Line items
Args:
move: account.move record (draft vendor bill)
extracted_data: dict from extract_bill_data()
"""
if not extracted_data:
return
vals = {}
# --- Vendor matching from AI-extracted vendor name ---
# This overrides the email sender match because the PDF
# shows the actual billing company (e.g., "Canada Computers Inc.")
ai_vendor_name = extracted_data.get('vendor_name')
if ai_vendor_name:
partner = self._match_vendor_by_name(ai_vendor_name)
if partner:
vals['partner_id'] = partner.id
_logger.info("AI vendor match: '%s' -> %s (id=%d)",
ai_vendor_name, partner.name, partner.id)
# Invoice reference (vendor's invoice/bill/SO number)
if extracted_data.get('invoice_number'):
vals['ref'] = extracted_data['invoice_number']
# Invoice date
if extracted_data.get('invoice_date'):
try:
from datetime import datetime
vals['invoice_date'] = datetime.strptime(
extracted_data['invoice_date'], '%Y-%m-%d'
).date()
except (ValueError, TypeError):
pass
# Due date
if extracted_data.get('due_date'):
try:
from datetime import datetime
vals['invoice_date_due'] = datetime.strptime(
extracted_data['due_date'], '%Y-%m-%d'
).date()
except (ValueError, TypeError):
pass
if vals:
try:
move.write(vals)
_logger.info("Applied AI data to bill %s: %s", move.id, vals)
except Exception as e:
_logger.error("Failed to apply AI data to bill %s: %s", move.id, e)
# Add invoice lines if extracted
lines = extracted_data.get('lines', [])
if lines and not move.invoice_line_ids:
line_vals_list = []
for line in lines[:20]: # Max 20 lines
line_vals = {
'move_id': move.id,
'name': line.get('description', 'Extracted line'),
'quantity': line.get('quantity', 1.0),
'price_unit': line.get('unit_price', 0.0),
}
line_vals_list.append(line_vals)
if line_vals_list:
try:
move.write({
'invoice_line_ids': [(0, 0, lv) for lv in line_vals_list]
})
_logger.info("Added %d AI-extracted lines to bill %s",
len(line_vals_list), move.id)
except Exception as e:
_logger.error("Failed to add lines to bill %s: %s", move.id, e)
def _match_vendor_by_name(self, vendor_name):
"""Match AI-extracted vendor name to an Odoo partner.
Tries multiple strategies:
1. Exact name match
2. Commercial company name match
3. Partial/contains match (only if single result)
Returns: res.partner record or False
"""
if not vendor_name or len(vendor_name) < 3:
return False
Partner = self.env['res.partner'].sudo()
vendor_name = vendor_name.strip()
# Level 1: Exact name match
partner = Partner.search([
('name', '=ilike', vendor_name),
('supplier_rank', '>', 0),
], limit=1)
if partner:
return partner
# Level 2: Exact name match without supplier_rank filter
partner = Partner.search([
('name', '=ilike', vendor_name),
], limit=1)
if partner:
return partner
# Level 3: Commercial company name match
partner = Partner.search([
('commercial_company_name', '=ilike', vendor_name),
], limit=1)
if partner:
return partner
# Level 4: Contains match (only accept single result to avoid false positives)
partners = Partner.search([
'|',
('name', 'ilike', vendor_name),
('commercial_company_name', 'ilike', vendor_name),
])
if len(partners) == 1:
return partners
# Level 5: Try without common suffixes (Inc, Ltd, Corp, etc.)
clean_name = vendor_name
for suffix in [' Inc', ' Inc.', ' Ltd', ' Ltd.', ' Corp', ' Corp.',
' Co', ' Co.', ' LLC', ' Company', ' Limited']:
if clean_name.lower().endswith(suffix.lower()):
clean_name = clean_name[:len(clean_name) - len(suffix)].strip()
break
if clean_name != vendor_name and len(clean_name) >= 3:
partners = Partner.search([
'|',
('name', 'ilike', clean_name),
('commercial_company_name', 'ilike', clean_name),
])
if len(partners) == 1:
return partners
_logger.info("No vendor match for AI-extracted name: '%s'", vendor_name)
return False
def _strip_html(self, html):
"""Strip HTML tags from text."""
clean = re.sub(r'<style[^>]*>.*?</style>', '', html, flags=re.DOTALL)
clean = re.sub(r'<script[^>]*>.*?</script>', '', clean, flags=re.DOTALL)
clean = re.sub(r'<[^>]+>', ' ', clean)
clean = re.sub(r'\s+', ' ', clean).strip()
return clean
def _pdf_to_images(self, attachment):
"""Convert PDF attachment pages to base64 PNG images using PyMuPDF."""
max_pages = self._get_max_pages()
images = []
try:
import fitz # PyMuPDF
pdf_data = base64.b64decode(attachment.datas)
doc = fitz.open(stream=pdf_data, filetype="pdf")
for page_num in range(min(len(doc), max_pages)):
page = doc[page_num]
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2)) # 2x zoom for readability
img_data = base64.b64encode(pix.tobytes("png")).decode()
images.append(img_data)
_logger.info("Converted PDF page %d to image (%d bytes)", page_num + 1, len(img_data))
doc.close()
except ImportError:
_logger.warning("PyMuPDF not available, will try text extraction fallback")
except Exception as e:
_logger.warning("PDF to image conversion failed: %s", e)
return images
def _pdf_to_text(self, attachment):
"""Extract text content from PDF as fallback when image conversion fails."""
max_pages = self._get_max_pages()
try:
import fitz # PyMuPDF
pdf_data = base64.b64decode(attachment.datas)
doc = fitz.open(stream=pdf_data, filetype="pdf")
text_parts = []
for page_num in range(min(len(doc), max_pages)):
page = doc[page_num]
text_parts.append(page.get_text())
doc.close()
full_text = '\n'.join(text_parts)
if full_text.strip():
_logger.info("Extracted %d chars of text from PDF", len(full_text))
return full_text
except ImportError:
pass
except Exception as e:
_logger.warning("PDF text extraction failed: %s", e)
return ''

View File

@@ -1,140 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api, _
class FusionAccountsLog(models.Model):
_name = 'fusion.accounts.log'
_description = 'Fusion Accounts - Email Processing Log'
_order = 'create_date desc'
_rec_name = 'email_subject'
email_from = fields.Char(
string='From',
readonly=True,
help='Sender email address',
)
vendor_blocked = fields.Boolean(
related='vendor_id.x_fa_block_email_bill',
string='Vendor Blocked',
readonly=True,
)
email_subject = fields.Char(
string='Subject',
readonly=True,
)
email_date = fields.Datetime(
string='Email Date',
readonly=True,
)
vendor_id = fields.Many2one(
'res.partner',
string='Matched Vendor',
readonly=True,
help='Vendor matched from sender email',
)
match_level = fields.Selection(
selection=[
('exact_email', 'Exact Email'),
('domain', 'Domain Match'),
('name', 'Name Match'),
('no_match', 'No Match'),
],
string='Match Level',
readonly=True,
help='How the vendor was identified',
)
action_taken = fields.Selection(
selection=[
('bill_created', 'Bill Created'),
('blocked', 'Blocked (Vendor)'),
('failed', 'Failed'),
('no_vendor', 'No Vendor Match'),
],
string='Action',
readonly=True,
)
bill_id = fields.Many2one(
'account.move',
string='Created Bill',
readonly=True,
help='The vendor bill created from this email',
)
ai_extracted = fields.Boolean(
string='AI Extracted',
readonly=True,
default=False,
help='Whether AI data extraction was performed',
)
ai_result = fields.Text(
string='AI Extraction Result',
readonly=True,
help='JSON output from AI extraction',
)
notes = fields.Text(
string='Notes',
readonly=True,
help='Error messages or additional details',
)
def action_block_vendor(self):
"""Block the vendor from this log entry from email bill creation."""
for log in self:
if log.vendor_id and not log.vendor_id.x_fa_block_email_bill:
log.vendor_id.write({'x_fa_block_email_bill': True})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Vendor Blocked'),
'message': _('Vendor blocked from email bill creation.'),
'type': 'success',
'sticky': False,
}
}
def action_enable_vendor(self):
"""Enable the vendor from this log entry for email bill creation."""
for log in self:
if log.vendor_id and log.vendor_id.x_fa_block_email_bill:
log.vendor_id.write({'x_fa_block_email_bill': False})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Vendor Enabled'),
'message': _('Vendor enabled for email bill creation.'),
'type': 'success',
'sticky': False,
}
}
# Stat fields for dashboard
@api.model
def get_dashboard_data(self):
"""Return statistics for the dashboard."""
today = fields.Date.today()
return {
'bills_pending': self.env['account.move'].search_count([
('move_type', '=', 'in_invoice'),
('state', '=', 'draft'),
('x_fa_created_from_email', '=', True),
]),
'bills_today': self.search_count([
('action_taken', '=', 'bill_created'),
('create_date', '>=', fields.Datetime.to_string(today)),
]),
'blocked_today': self.search_count([
('action_taken', '=', 'blocked'),
('create_date', '>=', fields.Datetime.to_string(today)),
]),
'failed_today': self.search_count([
('action_taken', '=', 'failed'),
('create_date', '>=', fields.Datetime.to_string(today)),
]),
'total_blocked_vendors': self.env['res.partner'].search_count([
('x_fa_block_email_bill', '=', True),
]),
}

View File

@@ -1,84 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from odoo import models, fields
_logger = logging.getLogger(__name__)
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
# =========================================================================
# AI SETTINGS
# =========================================================================
x_fa_ai_enabled = fields.Boolean(
string='Enable AI Extraction',
config_parameter='fusion_accounts.ai_enabled',
help='Enable AI-powered data extraction from email body and attachments.',
)
x_fa_openai_api_key = fields.Char(
string='OpenAI API Key',
config_parameter='fusion_accounts.openai_api_key',
help='Your OpenAI API key for bill data extraction.',
)
x_fa_ai_model = fields.Selection(
selection=[
('gpt-4o-mini', 'GPT-4o Mini (Fast, Low Cost)'),
('gpt-4o', 'GPT-4o (Best Quality)'),
],
string='AI Model',
config_parameter='fusion_accounts.ai_model',
help='OpenAI model to use for extraction.',
)
x_fa_ai_max_pages = fields.Integer(
string='Max PDF Pages',
config_parameter='fusion_accounts.ai_max_pages',
help='Maximum number of PDF pages to send to AI for extraction.',
)
# =========================================================================
# MATCHING SETTINGS
# =========================================================================
x_fa_enable_domain_match = fields.Boolean(
string='Enable Domain Matching',
config_parameter='fusion_accounts.enable_domain_match',
help='Match vendors by email domain (Level 2 matching).',
)
x_fa_enable_name_match = fields.Boolean(
string='Enable Name Matching',
config_parameter='fusion_accounts.enable_name_match',
help='Match vendors by sender display name (Level 3 matching).',
)
x_fa_auto_block_po_vendors = fields.Boolean(
string='Auto-Block PO Vendors',
config_parameter='fusion_accounts.auto_block_po_vendors',
help='Automatically block email bill creation for vendors with active Purchase Orders.',
)
# =========================================================================
# GENERAL SETTINGS
# =========================================================================
x_fa_log_retention_days = fields.Integer(
string='Log Retention (Days)',
config_parameter='fusion_accounts.log_retention_days',
help='Number of days to keep activity logs. Set 0 to keep forever.',
)
def set_values(self):
ICP = self.env['ir.config_parameter'].sudo()
# Protect API key and customized settings from accidental blanking
_protected = {
'fusion_accounts.openai_api_key': ICP.get_param('fusion_accounts.openai_api_key', ''),
'fusion_accounts.ai_model': ICP.get_param('fusion_accounts.ai_model', ''),
'fusion_accounts.ai_max_pages': ICP.get_param('fusion_accounts.ai_max_pages', ''),
}
super().set_values()
for key, old_val in _protected.items():
new_val = ICP.get_param(key, '')
if not new_val and old_val:
ICP.set_param(key, old_val)
_logger.warning("Settings protection: restored %s", key)

View File

@@ -1,47 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, _
class ResPartner(models.Model):
_inherit = 'res.partner'
x_fa_block_email_bill = fields.Boolean(
string='Block Email Bill Creation',
default=False,
help='When enabled, incoming emails from this vendor will NOT '
'automatically create vendor bills. Use this for vendors '
'whose bills should be created through Purchase Orders instead.',
)
def action_fa_block_vendors(self):
"""Block selected vendors from email bill creation."""
self.write({'x_fa_block_email_bill': True})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Vendors Blocked'),
'message': _('%d vendor(s) blocked from email bill creation.') % len(self),
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.act_window_close'},
}
}
def action_fa_enable_vendors(self):
"""Enable selected vendors for email bill creation."""
self.write({'x_fa_block_email_bill': False})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Vendors Enabled'),
'message': _('%d vendor(s) enabled for email bill creation.') % len(self),
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.act_window_close'},
}
}

View File

@@ -1,3 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_accounts_log_user,fusion.accounts.log user,model_fusion_accounts_log,group_fusion_accounts_user,1,0,0,0
access_fusion_accounts_log_manager,fusion.accounts.log manager,model_fusion_accounts_log,group_fusion_accounts_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_accounts_log_user fusion.accounts.log user model_fusion_accounts_log group_fusion_accounts_user 1 0 0 0
3 access_fusion_accounts_log_manager fusion.accounts.log manager model_fusion_accounts_log group_fusion_accounts_manager 1 1 1 1

View File

@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Privilege (replaces module_category in Odoo 19) -->
<record id="res_groups_privilege_fusion_accounts" model="res.groups.privilege">
<field name="name">Fusion Accounts</field>
<field name="sequence">50</field>
</record>
<!-- User Group -->
<record id="group_fusion_accounts_user" model="res.groups">
<field name="name">User</field>
<field name="sequence">10</field>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="privilege_id" ref="res_groups_privilege_fusion_accounts"/>
</record>
<!-- Manager Group -->
<record id="group_fusion_accounts_manager" model="res.groups">
<field name="name">Administrator</field>
<field name="sequence">20</field>
<field name="privilege_id" ref="res_groups_privilege_fusion_accounts"/>
<field name="implied_ids" eval="[(4, ref('group_fusion_accounts_user'))]"/>
<field name="user_ids" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
</record>
</odoo>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,63 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ================================================================= -->
<!-- VENDOR BILL FORM: Email creation info -->
<!-- ================================================================= -->
<record id="view_move_form_fusion_accounts" model="ir.ui.view">
<field name="name">account.move.form.fusion.accounts</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="priority">80</field>
<field name="arch" type="xml">
<!-- Add email creation badge -->
<xpath expr="//div[hasclass('oe_title')]" position="before">
<field name="x_fa_created_from_email" invisible="1"/>
<div class="float-end" invisible="not x_fa_created_from_email or move_type != 'in_invoice'">
<span class="badge text-bg-info">
<i class="fa fa-envelope me-1"/>Created from Email
</span>
<field name="x_fa_ai_extracted" invisible="1"/>
<span class="badge text-bg-primary ms-1" invisible="not x_fa_ai_extracted">
<i class="fa fa-magic me-1"/>AI Extracted
</span>
</div>
</xpath>
<!-- Add email origin info in notebook -->
<xpath expr="//notebook" position="inside">
<field name="x_fa_created_from_email" invisible="1"/>
<page string="Email Origin" name="fa_email_origin"
invisible="not x_fa_created_from_email or move_type != 'in_invoice'">
<group>
<group string="Email Details">
<field name="x_fa_original_sender" readonly="1"/>
<field name="x_fa_match_level" widget="badge" readonly="1"/>
</group>
<group string="Processing">
<field name="x_fa_ai_extracted" readonly="1"/>
</group>
</group>
</page>
</xpath>
</field>
</record>
<!-- ================================================================= -->
<!-- VENDOR BILL SEARCH: Add email filter -->
<!-- ================================================================= -->
<record id="view_move_search_fusion_accounts" model="ir.ui.view">
<field name="name">account.move.search.fusion.accounts</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_account_invoice_filter"/>
<field name="priority">80</field>
<field name="arch" type="xml">
<xpath expr="//search" position="inside">
<separator/>
<filter string="From Email" name="from_email"
domain="[('x_fa_created_from_email', '=', True)]"/>
<filter string="AI Extracted" name="ai_extracted"
domain="[('x_fa_ai_extracted', '=', True)]"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -1,92 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ================================================================= -->
<!-- DASHBOARD ACTION -->
<!-- ================================================================= -->
<record id="action_fusion_accounts_dashboard" model="ir.actions.act_window">
<field name="name">Dashboard</field>
<field name="res_model">fusion.accounts.log</field>
<field name="view_mode">kanban,list,form</field>
<field name="context">{'search_default_filter_date': 1, 'search_default_group_action': 1}</field>
<field name="search_view_id" ref="view_fusion_accounts_log_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Fusion Accounts Dashboard
</p>
<p>
Email processing activity will appear here.
Configure your email aliases and AI settings under Configuration.
</p>
</field>
</record>
<!-- ================================================================= -->
<!-- DASHBOARD KANBAN VIEW -->
<!-- ================================================================= -->
<record id="view_fusion_accounts_log_kanban" model="ir.ui.view">
<field name="name">fusion.accounts.log.kanban</field>
<field name="model">fusion.accounts.log</field>
<field name="arch" type="xml">
<kanban class="o_kanban_dashboard" create="0" edit="0"
group_create="0" group_delete="0" group_edit="0"
default_group_by="action_taken">
<field name="email_from"/>
<field name="email_subject"/>
<field name="vendor_id"/>
<field name="match_level"/>
<field name="action_taken"/>
<field name="bill_id"/>
<field name="ai_extracted"/>
<field name="create_date"/>
<field name="vendor_blocked"/>
<templates>
<t t-name="card">
<div class="d-flex flex-column">
<strong class="fs-5 mb-1">
<field name="email_subject"/>
</strong>
<div class="text-muted small mb-1">
<i class="fa fa-envelope-o me-1"/>
<field name="email_from"/>
</div>
<div class="d-flex align-items-center gap-2 mb-1">
<field name="match_level" widget="badge"
decoration-info="match_level == 'exact_email'"
decoration-success="match_level == 'domain'"
decoration-warning="match_level == 'name'"
decoration-danger="match_level == 'no_match'"/>
<span t-if="record.ai_extracted.raw_value" class="badge text-bg-primary">
<i class="fa fa-magic me-1"/>AI
</span>
</div>
<div t-if="record.vendor_id.value" class="text-muted small">
<i class="fa fa-building-o me-1"/>
<field name="vendor_id"/>
</div>
<div t-if="record.bill_id.value" class="small mt-1">
<i class="fa fa-file-text-o me-1"/>
<field name="bill_id"/>
</div>
<div class="text-muted small mt-1">
<field name="create_date" widget="datetime"/>
</div>
<!-- Block/Enable buttons -->
<div t-if="record.vendor_id.value" class="mt-2 d-flex gap-1">
<button t-if="!record.vendor_blocked.raw_value"
name="action_block_vendor" type="object"
class="btn btn-sm btn-outline-danger">
<i class="fa fa-ban me-1"/>Block Vendor
</button>
<button t-if="record.vendor_blocked.raw_value"
name="action_enable_vendor" type="object"
class="btn btn-sm btn-outline-success">
<i class="fa fa-check me-1"/>Enable Vendor
</button>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
</odoo>

View File

@@ -1,130 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ================================================================= -->
<!-- ACTIVITY LOG - LIST VIEW -->
<!-- ================================================================= -->
<record id="view_fusion_accounts_log_list" model="ir.ui.view">
<field name="name">fusion.accounts.log.list</field>
<field name="model">fusion.accounts.log</field>
<field name="arch" type="xml">
<list string="Email Processing Log" create="0" edit="0"
decoration-success="action_taken == 'bill_created'"
decoration-warning="action_taken == 'blocked'"
decoration-danger="action_taken == 'failed'"
decoration-muted="action_taken == 'no_vendor'">
<header>
<button name="action_block_vendor" type="object"
string="Block Vendor" class="btn-secondary"
icon="fa-ban"/>
<button name="action_enable_vendor" type="object"
string="Enable Vendor" class="btn-secondary"
icon="fa-check"/>
</header>
<field name="create_date" string="Date"/>
<field name="email_from"/>
<field name="email_subject"/>
<field name="vendor_id"/>
<field name="match_level" widget="badge"
decoration-info="match_level == 'exact_email'"
decoration-success="match_level == 'domain'"
decoration-warning="match_level == 'name'"
decoration-danger="match_level == 'no_match'"/>
<field name="action_taken" widget="badge"
decoration-success="action_taken == 'bill_created'"
decoration-warning="action_taken == 'blocked'"
decoration-danger="action_taken == 'failed'"/>
<field name="bill_id"/>
<field name="ai_extracted" widget="boolean"/>
<field name="vendor_blocked" string="Blocked" widget="boolean"/>
</list>
</field>
</record>
<!-- ================================================================= -->
<!-- ACTIVITY LOG - FORM VIEW -->
<!-- ================================================================= -->
<record id="view_fusion_accounts_log_form" model="ir.ui.view">
<field name="name">fusion.accounts.log.form</field>
<field name="model">fusion.accounts.log</field>
<field name="arch" type="xml">
<form string="Email Processing Log" create="0" edit="0">
<header>
<button name="action_block_vendor" type="object"
string="Block Vendor" class="btn-secondary"
icon="fa-ban"
invisible="not vendor_id or vendor_blocked"/>
<button name="action_enable_vendor" type="object"
string="Enable Vendor" class="btn-secondary"
icon="fa-check"
invisible="not vendor_id or not vendor_blocked"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="email_subject" readonly="1"/></h1>
</div>
<group>
<group string="Email Details">
<field name="email_from"/>
<field name="email_date"/>
<field name="create_date" string="Processed At"/>
</group>
<group string="Processing Result">
<field name="vendor_id"/>
<field name="vendor_blocked" string="Vendor Blocked"/>
<field name="match_level" widget="badge"/>
<field name="action_taken" widget="badge"/>
<field name="bill_id"/>
<field name="ai_extracted"/>
</group>
</group>
<group string="AI Extraction Result" invisible="not ai_extracted">
<field name="ai_result" widget="text" nolabel="1" colspan="2"/>
</group>
<group string="Notes" invisible="not notes">
<field name="notes" nolabel="1" colspan="2"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ================================================================= -->
<!-- ACTIVITY LOG - SEARCH VIEW -->
<!-- ================================================================= -->
<record id="view_fusion_accounts_log_search" model="ir.ui.view">
<field name="name">fusion.accounts.log.search</field>
<field name="model">fusion.accounts.log</field>
<field name="arch" type="xml">
<search string="Activity Log">
<field name="email_from"/>
<field name="email_subject"/>
<field name="vendor_id"/>
<separator/>
<!-- Action Filters -->
<filter string="Bills Created" name="bill_created" domain="[('action_taken', '=', 'bill_created')]"/>
<filter string="Blocked" name="blocked" domain="[('action_taken', '=', 'blocked')]"/>
<filter string="Failed" name="failed" domain="[('action_taken', '=', 'failed')]"/>
<separator/>
<filter string="AI Extracted" name="ai_extracted" domain="[('ai_extracted', '=', True)]"/>
<separator/>
<!-- Time Period Filter (Odoo date filter with period selector) -->
<filter string="Date" name="filter_date" date="create_date"/>
<separator/>
<!-- Match Level Filters -->
<filter string="Exact Email Match" name="exact" domain="[('match_level', '=', 'exact_email')]"/>
<filter string="Domain Match" name="domain_match" domain="[('match_level', '=', 'domain')]"/>
<filter string="Name Match" name="name_match" domain="[('match_level', '=', 'name')]"/>
<filter string="No Match" name="no_match" domain="[('match_level', '=', 'no_match')]"/>
<group>
<filter string="Action" name="group_action" context="{'group_by': 'action_taken'}"/>
<filter string="Match Level" name="group_match" context="{'group_by': 'match_level'}"/>
<filter string="Vendor" name="group_vendor" context="{'group_by': 'vendor_id'}"/>
<filter string="Day" name="group_day" context="{'group_by': 'create_date:day'}"/>
<filter string="Week" name="group_week" context="{'group_by': 'create_date:week'}"/>
<filter string="Month" name="group_month" context="{'group_by': 'create_date:month'}"/>
<filter string="Year" name="group_year" context="{'group_by': 'create_date:year'}"/>
</group>
</search>
</field>
</record>
</odoo>

View File

@@ -1,190 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ================================================================= -->
<!-- WINDOW ACTIONS (must be before menus) -->
<!-- ================================================================= -->
<!-- Bills from Email -->
<record id="action_bills_from_email" model="ir.actions.act_window">
<field name="name">Bills from Email</field>
<field name="res_model">account.move</field>
<field name="view_mode">list,form</field>
<field name="domain">[('move_type', '=', 'in_invoice'), ('x_fa_created_from_email', '=', True)]</field>
<field name="context">{'default_move_type': 'in_invoice'}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No bills from email yet
</p>
<p>
Bills will appear here when incoming emails create vendor bills automatically.
</p>
</field>
</record>
<!-- All Vendor Bills -->
<record id="action_all_vendor_bills" model="ir.actions.act_window">
<field name="name">All Vendor Bills</field>
<field name="res_model">account.move</field>
<field name="view_mode">list,form</field>
<field name="domain">[('move_type', '=', 'in_invoice')]</field>
<field name="context">{'default_move_type': 'in_invoice'}</field>
</record>
<!-- Blocked Vendors -->
<record id="action_blocked_vendors" model="ir.actions.act_window">
<field name="name">Blocked Vendors</field>
<field name="res_model">res.partner</field>
<field name="view_mode">list,form</field>
<field name="view_id" ref="view_partner_list_fusion_accounts"/>
<field name="domain">[('x_fa_block_email_bill', '=', True)]</field>
<field name="context">{'default_x_fa_block_email_bill': True}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No blocked vendors
</p>
<p>
Vendors blocked from automatic email bill creation will appear here.
Block vendors whose bills should be created through Purchase Orders instead.
</p>
</field>
</record>
<!-- Vendors with Active POs -->
<record id="action_vendors_with_po" model="ir.actions.act_window">
<field name="name">Vendors with Active POs</field>
<field name="res_model">res.partner</field>
<field name="view_mode">list,form</field>
<field name="view_id" ref="view_partner_list_fusion_accounts"/>
<field name="domain">[('purchase_line_ids', '!=', False), ('supplier_rank', '>', 0)]</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No vendors with purchase orders
</p>
<p>
Vendors with Purchase Orders appear here.
Consider blocking these vendors from automatic email bill creation.
</p>
</field>
</record>
<!-- All Vendors -->
<record id="action_all_vendors" model="ir.actions.act_window">
<field name="name">All Vendors</field>
<field name="res_model">res.partner</field>
<field name="view_mode">list,form</field>
<field name="view_id" ref="view_partner_list_fusion_accounts"/>
<field name="domain">[('supplier_rank', '>', 0)]</field>
</record>
<!-- Activity Log -->
<record id="action_fusion_accounts_log" model="ir.actions.act_window">
<field name="name">Activity Log</field>
<field name="res_model">fusion.accounts.log</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No activity logged yet
</p>
<p>
Email processing activity will be logged here automatically.
</p>
</field>
</record>
<!-- Settings -->
<record id="action_fusion_accounts_settings" model="ir.actions.act_window">
<field name="name">Fusion Accounts Settings</field>
<field name="res_model">res.config.settings</field>
<field name="view_mode">form</field>
<field name="target">current</field>
<field name="context">{'module': 'fusion_accounts'}</field>
</record>
<!-- ================================================================= -->
<!-- TOP-LEVEL APP MENU -->
<!-- ================================================================= -->
<menuitem id="menu_fusion_accounts_root"
name="Fusion Accounts"
web_icon="fusion_accounts,static/description/icon.png"
sequence="35"
groups="group_fusion_accounts_user"/>
<!-- ================================================================= -->
<!-- DASHBOARD -->
<!-- ================================================================= -->
<menuitem id="menu_fusion_accounts_dashboard"
name="Dashboard"
parent="menu_fusion_accounts_root"
action="action_fusion_accounts_dashboard"
sequence="10"/>
<!-- ================================================================= -->
<!-- BILLS -->
<!-- ================================================================= -->
<menuitem id="menu_fusion_accounts_bills"
name="Bills"
parent="menu_fusion_accounts_root"
sequence="20"/>
<menuitem id="menu_fusion_accounts_bills_email"
name="Bills from Email"
parent="menu_fusion_accounts_bills"
action="action_bills_from_email"
sequence="10"/>
<menuitem id="menu_fusion_accounts_bills_all"
name="All Vendor Bills"
parent="menu_fusion_accounts_bills"
action="action_all_vendor_bills"
sequence="20"/>
<!-- ================================================================= -->
<!-- VENDORS -->
<!-- ================================================================= -->
<menuitem id="menu_fusion_accounts_vendors"
name="Vendors"
parent="menu_fusion_accounts_root"
sequence="30"/>
<menuitem id="menu_fusion_accounts_vendors_blocked"
name="Blocked Vendors"
parent="menu_fusion_accounts_vendors"
action="action_blocked_vendors"
sequence="10"/>
<menuitem id="menu_fusion_accounts_vendors_with_po"
name="Vendors with Active POs"
parent="menu_fusion_accounts_vendors"
action="action_vendors_with_po"
sequence="15"/>
<menuitem id="menu_fusion_accounts_vendors_all"
name="All Vendors"
parent="menu_fusion_accounts_vendors"
action="action_all_vendors"
sequence="20"/>
<!-- ================================================================= -->
<!-- ACTIVITY LOG -->
<!-- ================================================================= -->
<menuitem id="menu_fusion_accounts_logs"
name="Activity Log"
parent="menu_fusion_accounts_root"
action="action_fusion_accounts_log"
sequence="40"/>
<!-- ================================================================= -->
<!-- CONFIGURATION -->
<!-- ================================================================= -->
<menuitem id="menu_fusion_accounts_config"
name="Configuration"
parent="menu_fusion_accounts_root"
sequence="90"
groups="group_fusion_accounts_manager"/>
<menuitem id="menu_fusion_accounts_settings"
name="Settings"
parent="menu_fusion_accounts_config"
action="action_fusion_accounts_settings"
sequence="10"/>
</odoo>

View File

@@ -1,73 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ================================================================= -->
<!-- SETTINGS PAGE -->
<!-- ================================================================= -->
<record id="view_res_config_settings_fusion_accounts" model="ir.ui.view">
<field name="name">res.config.settings.fusion.accounts</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app data-string="Fusion Accounts" string="Fusion Accounts"
name="fusion_accounts"
groups="fusion_accounts.group_fusion_accounts_manager">
<!-- AI SETTINGS -->
<block title="AI Data Extraction" name="fa_ai_settings">
<setting id="fa_ai_enabled" string="Enable AI Extraction"
help="Use OpenAI to automatically extract bill data from emails and PDF attachments.">
<field name="x_fa_ai_enabled"/>
</setting>
<setting id="fa_openai_key" string="OpenAI API Key"
help="Your OpenAI API key for bill data extraction."
invisible="not x_fa_ai_enabled">
<field name="x_fa_openai_api_key" password="True"/>
</setting>
<setting id="fa_ai_model" string="AI Model"
help="Select the OpenAI model. GPT-4o Mini is faster and cheaper, GPT-4o is more accurate."
invisible="not x_fa_ai_enabled">
<field name="x_fa_ai_model"/>
</setting>
<setting id="fa_ai_max_pages" string="Max PDF Pages"
help="Maximum number of PDF pages to send to AI for extraction. More pages = higher cost."
invisible="not x_fa_ai_enabled">
<field name="x_fa_ai_max_pages"/>
</setting>
</block>
<!-- VENDOR MATCHING SETTINGS -->
<block title="Vendor Matching" name="fa_matching_settings">
<setting id="fa_domain_match" string="Domain Matching (Level 2)"
help="Match vendors by email domain when exact email is not found.">
<field name="x_fa_enable_domain_match"/>
</setting>
<setting id="fa_name_match" string="Name Matching (Level 3)"
help="Match vendors by sender display name when email and domain don't match.">
<field name="x_fa_enable_name_match"/>
</setting>
<setting id="fa_auto_block" string="Auto-Block PO Vendors"
help="Automatically block email bill creation for vendors that have active Purchase Orders.">
<field name="x_fa_auto_block_po_vendors"/>
</setting>
</block>
<!-- GENERAL SETTINGS -->
<block title="General" name="fa_general_settings">
<setting id="fa_log_retention" string="Log Retention"
help="Number of days to keep activity logs. Set to 0 to keep forever.">
<div class="content-group">
<div class="row mt8">
<label for="x_fa_log_retention_days" string="Keep logs for" class="col-3"/>
<field name="x_fa_log_retention_days" class="col-1"/>
<span class="col-2"> days</span>
</div>
</div>
</setting>
</block>
</app>
</xpath>
</field>
</record>
</odoo>

View File

@@ -1,59 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ================================================================= -->
<!-- VENDOR FORM: Add Block Email Bill checkbox -->
<!-- ================================================================= -->
<record id="view_partner_form_fusion_accounts" model="ir.ui.view">
<field name="name">res.partner.form.fusion.accounts</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="priority">50</field>
<field name="arch" type="xml">
<xpath expr="//page[@name='internal_notes']" position="before">
<page string="Fusion Accounts" name="fusion_accounts">
<group>
<group string="Email Bill Settings">
<field name="x_fa_block_email_bill" widget="boolean_toggle"/>
<div class="alert alert-info" role="alert" colspan="2"
invisible="not x_fa_block_email_bill">
<i class="fa fa-info-circle"/>
Emails from this vendor will <strong>not</strong> create vendor bills automatically.
Bills for this vendor should be created through Purchase Orders.
</div>
</group>
</group>
</page>
</xpath>
</field>
</record>
<!-- ================================================================= -->
<!-- FUSION ACCOUNTS: Custom Vendor List View (clean actions) -->
<!-- ================================================================= -->
<record id="view_partner_list_fusion_accounts" model="ir.ui.view">
<field name="name">res.partner.list.fusion.accounts</field>
<field name="model">res.partner</field>
<field name="priority">99</field>
<field name="arch" type="xml">
<list string="Vendors" multi_edit="1">
<header>
<button name="action_fa_block_vendors" type="object"
string="Block Email Bills" class="btn-secondary"
icon="fa-ban"/>
<button name="action_fa_enable_vendors" type="object"
string="Enable Email Bills" class="btn-secondary"
icon="fa-check"/>
</header>
<field name="name"/>
<field name="email"/>
<field name="phone"/>
<field name="x_fa_block_email_bill" string="Blocked" widget="boolean_toggle"/>
<field name="supplier_rank" column_invisible="True"/>
</list>
</field>
</record>
<!-- ================================================================= -->
<!-- Add block/enable methods to res.partner -->
<!-- ================================================================= -->
</odoo>

BIN
fusion_authorizer_portal/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
{
'name': 'Fusion Authorizer & Sales Portal',
'version': '19.0.2.0.9',
'version': '19.0.2.2.0',
'category': 'Sales/Portal',
'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms',
'description': """
@@ -66,6 +66,7 @@ This module provides external portal access for:
'views/res_partner_views.xml',
'views/sale_order_views.xml',
'views/assessment_views.xml',
'views/loaner_checkout_views.xml',
'views/pdf_template_views.xml',
# Portal Templates
'views/portal_templates.xml',
@@ -75,6 +76,7 @@ This module provides external portal access for:
'views/portal_accessibility_forms.xml',
'views/portal_technician_templates.xml',
'views/portal_book_assessment.xml',
'views/portal_repair_form.xml',
],
'assets': {
'web.assets_backend': [

View File

@@ -3,3 +3,4 @@
from . import portal_main
from . import portal_assessment
from . import pdf_editor
from . import portal_repair

View File

@@ -35,6 +35,7 @@ class FusionPdfEditorController(http.Controller):
fields = template.field_ids.read([
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
'text_align',
])
return request.render('fusion_authorizer_portal.portal_pdf_field_editor', {
@@ -56,6 +57,7 @@ class FusionPdfEditorController(http.Controller):
return template.field_ids.read([
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
'text_align',
])
# ================================================================
@@ -73,6 +75,7 @@ class FusionPdfEditorController(http.Controller):
allowed = {
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
'text_align',
}
safe_values = {k: v for k, v in values.items() if k in allowed}
if safe_values:

View File

@@ -38,6 +38,14 @@ class AuthorizerPortal(CustomerPortal):
response.qcontext['sign_count'] = sign_count
response.qcontext['sign_module_available'] = sign_module_available
ICP = request.env['ir.config_parameter'].sudo()
g_start = ICP.get_param('fusion_claims.portal_gradient_start', '#5ba848')
g_mid = ICP.get_param('fusion_claims.portal_gradient_mid', '#3a8fb7')
g_end = ICP.get_param('fusion_claims.portal_gradient_end', '#2e7aad')
response.qcontext['portal_gradient'] = (
'linear-gradient(135deg, %s 0%%, %s 60%%, %s 100%%)' % (g_start, g_mid, g_end)
)
return response
def _prepare_home_portal_values(self, counters):

View File

@@ -0,0 +1,182 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import http, _, fields
from odoo.http import request
import base64
import logging
_logger = logging.getLogger(__name__)
class LTCRepairPortal(http.Controller):
def _is_password_required(self):
password = request.env['ir.config_parameter'].sudo().get_param(
'fusion_claims.ltc_form_password', ''
)
return bool(password and password.strip())
def _process_photos(self, file_list, repair):
attachment_ids = []
for photo in file_list:
if photo and photo.filename:
data = photo.read()
if data:
attachment = request.env['ir.attachment'].sudo().create({
'name': photo.filename,
'datas': base64.b64encode(data),
'res_model': 'fusion.ltc.repair',
'res_id': repair.id,
})
attachment_ids.append(attachment.id)
return attachment_ids
def _is_authenticated(self):
if not request.env.user._is_public():
return True
if not self._is_password_required():
return True
return request.session.get('ltc_form_authenticated', False)
@http.route('/repair-form', type='http', auth='public', website=True,
sitemap=False)
def repair_form(self, **kw):
if not self._is_authenticated():
return request.render(
'fusion_authorizer_portal.portal_ltc_repair_password',
{'error': kw.get('auth_error', False)}
)
facilities = request.env['fusion.ltc.facility'].sudo().search(
[('active', '=', True)], order='name'
)
is_technician = not request.env.user._is_public() and request.env.user.has_group(
'base.group_user'
)
return request.render(
'fusion_authorizer_portal.portal_ltc_repair_form',
{
'facilities': facilities,
'today': fields.Date.today(),
'is_technician': is_technician,
}
)
@http.route('/repair-form/auth', type='http', auth='public',
website=True, methods=['POST'], csrf=True)
def repair_form_auth(self, **kw):
stored_password = request.env['ir.config_parameter'].sudo().get_param(
'fusion_claims.ltc_form_password', ''
).strip()
entered_password = (kw.get('password', '') or '').strip()
if stored_password and entered_password == stored_password:
request.session['ltc_form_authenticated'] = True
return request.redirect('/repair-form')
return request.render(
'fusion_authorizer_portal.portal_ltc_repair_password',
{'error': True}
)
@http.route('/repair-form/submit', type='http', auth='public',
website=True, methods=['POST'], csrf=True)
def repair_form_submit(self, **kw):
if not self._is_authenticated():
return request.redirect('/repair-form')
try:
facility_id = int(kw.get('facility_id', 0))
if not facility_id:
return request.redirect('/repair-form?error=facility')
vals = {
'facility_id': facility_id,
'client_name': kw.get('client_name', '').strip(),
'room_number': kw.get('room_number', '').strip(),
'product_serial': kw.get('product_serial', '').strip(),
'issue_description': kw.get('issue_description', '').strip(),
'issue_reported_date': kw.get('issue_reported_date') or fields.Date.today(),
'is_emergency': kw.get('is_emergency') == 'on',
'poa_name': kw.get('poa_name', '').strip() or False,
'poa_phone': kw.get('poa_phone', '').strip() or False,
'source': 'portal_form',
}
if not vals['client_name']:
return request.redirect('/repair-form?error=name')
if not vals['issue_description']:
return request.redirect('/repair-form?error=description')
before_files = request.httprequest.files.getlist('before_photos')
has_before = any(f and f.filename for f in before_files)
if not has_before:
return request.redirect('/repair-form?error=photos')
repair = request.env['fusion.ltc.repair'].sudo().create(vals)
before_ids = self._process_photos(before_files, repair)
if before_ids:
repair.sudo().write({
'before_photo_ids': [(6, 0, before_ids)],
})
after_files = request.httprequest.files.getlist('after_photos')
after_ids = self._process_photos(after_files, repair)
if after_ids:
repair.sudo().write({
'after_photo_ids': [(6, 0, after_ids)],
})
resolved = kw.get('resolved') == 'yes'
if resolved:
resolution = kw.get('resolution_description', '').strip()
if resolution:
repair.sudo().write({
'resolution_description': resolution,
'issue_fixed_date': fields.Date.today(),
})
repair.sudo().activity_schedule(
'mail.mail_activity_data_todo',
summary=_('New repair request from portal: %s', repair.display_client_name),
note=_(
'Repair request submitted via portal form for %s at %s (Room %s).',
repair.display_client_name,
repair.facility_id.name,
repair.room_number or 'N/A',
),
)
ip_address = request.httprequest.headers.get(
'X-Forwarded-For', request.httprequest.remote_addr
)
if ip_address and ',' in ip_address:
ip_address = ip_address.split(',')[0].strip()
try:
request.env['fusion.ltc.form.submission'].sudo().create({
'form_type': 'repair',
'repair_id': repair.id,
'facility_id': facility_id,
'client_name': vals['client_name'],
'room_number': vals['room_number'],
'product_serial': vals['product_serial'],
'is_emergency': vals['is_emergency'],
'ip_address': ip_address or '',
'status': 'processed',
})
except Exception:
_logger.warning('Failed to log form submission', exc_info=True)
return request.render(
'fusion_authorizer_portal.portal_ltc_repair_thank_you',
{'repair': repair}
)
except Exception:
_logger.exception('Error submitting LTC repair form')
return request.redirect('/repair-form?error=server')

View File

@@ -1,721 +0,0 @@
# Fusion Authorizer & Sales Portal
**Version:** 19.0.1.0.0
**License:** LGPL-3
**Category:** Sales/Portal
**Author:** Fusion Claims
## Table of Contents
1. [Overview](#overview)
2. [Features](#features)
3. [Installation](#installation)
4. [Configuration](#configuration)
5. [Models](#models)
6. [Controllers & Routes](#controllers--routes)
7. [Security](#security)
8. [Frontend Assets](#frontend-assets)
9. [Email Templates](#email-templates)
10. [User Guide](#user-guide)
11. [API Reference](#api-reference)
12. [Troubleshooting](#troubleshooting)
13. [Changelog](#changelog)
---
## Overview
The **Fusion Authorizer & Sales Portal** module extends Odoo's portal functionality to provide external access for two key user types:
- **Authorizers (Occupational Therapists/OTs):** Healthcare professionals who authorize ADP (Assistive Devices Program) claims
- **Sales Representatives:** Field sales staff who conduct client assessments and manage orders
This module integrates with the `fusion_claims` module to provide a seamless workflow for ADP claims management, from initial client assessment through to order completion.
### Target Platform
- **Odoo Enterprise v19**
- Requires: `base`, `sale`, `portal`, `website`, `mail`, `fusion_claims`
---
## Features
### Authorizer Portal
- View all assigned ADP cases with full details (excluding internal costs)
- Real-time search by client name, reference numbers, or claim number
- Upload ADP application documents with revision tracking
- Add comments/notes to cases
- Download submitted ADP applications
- Receive email notifications for new assignments and status changes
### Sales Rep Portal
- View sales cases linked to the logged-in user
- Start and manage client assessments
- Record detailed wheelchair specifications and measurements
- Capture digital signatures for ADP pages 11 & 12
- Track assessment progress through workflow states
### Assessment System
- Comprehensive client information collection
- Wheelchair specifications (seat width, depth, height, cushion type, etc.)
- Accessibility and mobility needs documentation
- Touch-friendly digital signature capture
- Automatic draft Sale Order creation upon completion
- Document distribution to authorizers, sales reps, and internal records
- Automated email notifications
---
## Installation
### Prerequisites
1. Odoo Enterprise v19 installed and running
2. The `fusion_claims` module installed and configured
3. Portal module enabled
4. Website module enabled
5. Mail module configured with outgoing email server
### Installation Steps
1. **Copy the module** to your Odoo addons directory:
```bash
cp -r fusion_authorizer_portal /path/to/odoo/custom-addons/
```
2. **Update the apps list** in Odoo:
- Go to Apps menu
- Click "Update Apps List"
- Search for "Fusion Authorizer"
3. **Install the module**:
- Click Install on "Fusion Authorizer & Sales Portal"
- Wait for installation to complete
4. **Restart Odoo** (recommended):
```bash
docker restart odoo-app # For Docker installations
# OR
sudo systemctl restart odoo # For systemd installations
```
---
## Configuration
### Granting Portal Access to Users
1. Navigate to **Contacts** in Odoo backend
2. Open the contact record for the authorizer or sales rep
3. Go to the **Portal Access** tab
4. Check the appropriate role:
- `Is Authorizer` - For Occupational Therapists
- `Is Sales Rep (Portal)` - For Sales Representatives
5. Click the **Grant Portal Access** button
6. An invitation email will be sent to the contact's email address
### Setting Up Authorizers on Sales Orders
1. Open a Sales Order
2. In the order details, set the **Authorizer** field (`x_fc_authorizer_id`)
3. The authorizer will receive an email notification about the assignment
4. The case will appear in their portal dashboard
---
## Models
### New Models
#### `fusion.assessment`
**Wheelchair Assessment Record**
Captures comprehensive client assessment data including:
| Field Group | Fields |
|-------------|--------|
| **Client Info** | `client_name`, `client_first_name`, `client_last_name`, `client_street`, `client_city`, `client_state`, `client_postal_code`, `client_country_id`, `client_phone`, `client_mobile`, `client_email`, `client_dob`, `client_health_card`, `client_reference_1`, `client_reference_2` |
| **Participants** | `sales_rep_id` (res.users), `authorizer_id` (res.partner) |
| **Assessment Details** | `assessment_date`, `assessment_location`, `assessment_location_notes` |
| **Measurements** | `seat_width`, `seat_depth`, `seat_to_floor_height`, `back_height`, `armrest_height`, `footrest_length`, `overall_width`, `overall_length`, `overall_height`, `seat_angle`, `back_angle`, `client_weight`, `client_height` |
| **Product Types** | `cushion_type`, `cushion_notes`, `backrest_type`, `backrest_notes`, `frame_type`, `frame_notes`, `wheel_type`, `wheel_notes` |
| **Needs** | `mobility_notes`, `accessibility_notes`, `special_requirements`, `diagnosis` |
| **Signatures** | `signature_page_11`, `signature_page_11_name`, `signature_page_11_date`, `signature_page_12`, `signature_page_12_name`, `signature_page_12_date` |
| **Status** | `state` (draft, pending_signature, completed, cancelled) |
| **References** | `reference` (auto-generated ASM-XXXXX), `sale_order_id`, `partner_id` |
**Key Methods:**
- `action_complete()` - Completes assessment, creates draft Sale Order, sends notifications
- `_ensure_partner()` - Creates or links res.partner for the client
- `_create_draft_sale_order()` - Generates Sale Order with specifications
- `_generate_signed_documents()` - Creates document records for signatures
- `_send_completion_notifications()` - Sends emails to authorizer and client
---
#### `fusion.adp.document`
**ADP Document Management with Revision Tracking**
| Field | Type | Description |
|-------|------|-------------|
| `sale_order_id` | Many2one | Link to Sale Order |
| `assessment_id` | Many2one | Link to Assessment |
| `document_type` | Selection | full_application, page_11, page_12, pages_11_12, final_submission, other |
| `file` | Binary | Document file content |
| `filename` | Char | Original filename |
| `file_size` | Integer | File size in bytes |
| `mimetype` | Char | MIME type |
| `revision` | Integer | Revision number (auto-incremented) |
| `revision_note` | Text | Notes about this revision |
| `is_current` | Boolean | Whether this is the current version |
| `uploaded_by` | Many2one | User who uploaded |
| `upload_date` | Datetime | Upload timestamp |
| `source` | Selection | portal, internal, assessment |
**Key Methods:**
- `action_download()` - Download the document
- `get_documents_for_order()` - Get all documents for a sale order
- `get_revision_history()` - Get all revisions of a document type
---
#### `fusion.authorizer.comment`
**Portal Comments System**
| Field | Type | Description |
|-------|------|-------------|
| `sale_order_id` | Many2one | Link to Sale Order |
| `assessment_id` | Many2one | Link to Assessment |
| `author_id` | Many2one | res.partner who authored |
| `author_user_id` | Many2one | res.users who authored |
| `comment` | Text | Comment content |
| `comment_type` | Selection | general, question, update, approval |
| `is_internal` | Boolean | Internal-only comment |
---
### Extended Models
#### `res.partner` (Extended)
| New Field | Type | Description |
|-----------|------|-------------|
| `is_authorizer` | Boolean | Partner is an Authorizer/OT |
| `is_sales_rep_portal` | Boolean | Partner is a Sales Rep with portal access |
| `authorizer_portal_user_id` | Many2one | Linked portal user account |
| `assigned_case_count` | Integer | Computed count of assigned cases |
| `assessment_count` | Integer | Computed count of assessments |
**New Methods:**
- `action_grant_portal_access()` - Creates portal user and sends invitation
- `action_view_assigned_cases()` - Opens list of assigned Sale Orders
- `action_view_assessments()` - Opens list of assessments
---
#### `sale.order` (Extended)
| New Field | Type | Description |
|-----------|------|-------------|
| `portal_comment_ids` | One2many | Comments from portal users |
| `portal_comment_count` | Integer | Computed comment count |
| `portal_document_ids` | One2many | Documents uploaded via portal |
| `portal_document_count` | Integer | Computed document count |
| `assessment_id` | Many2one | Source assessment that created this order |
| `portal_authorizer_id` | Many2one | Authorizer reference (computed from x_fc_authorizer_id) |
**New Methods:**
- `_send_authorizer_assignment_notification()` - Email on authorizer assignment
- `_send_status_change_notification()` - Email on status change
- `get_portal_display_data()` - Safe data for portal display (excludes costs)
- `get_authorizer_portal_cases()` - Search cases for authorizer portal
- `get_sales_rep_portal_cases()` - Search cases for sales rep portal
---
## Controllers & Routes
### Authorizer Portal Routes
| Route | Method | Auth | Description |
|-------|--------|------|-------------|
| `/my/authorizer` | GET | user | Authorizer dashboard |
| `/my/authorizer/cases` | GET | user | List of assigned cases |
| `/my/authorizer/cases/search` | POST | user | AJAX search (jsonrpc) |
| `/my/authorizer/case/<id>` | GET | user | Case detail view |
| `/my/authorizer/case/<id>/comment` | POST | user | Add comment to case |
| `/my/authorizer/case/<id>/upload` | POST | user | Upload document |
| `/my/authorizer/document/<id>/download` | GET | user | Download document |
### Sales Rep Portal Routes
| Route | Method | Auth | Description |
|-------|--------|------|-------------|
| `/my/sales` | GET | user | Sales rep dashboard |
| `/my/sales/cases` | GET | user | List of sales cases |
| `/my/sales/cases/search` | POST | user | AJAX search (jsonrpc) |
| `/my/sales/case/<id>` | GET | user | Case detail view |
### Assessment Routes
| Route | Method | Auth | Description |
|-------|--------|------|-------------|
| `/my/assessments` | GET | user | List of assessments |
| `/my/assessment/new` | GET | user | New assessment form |
| `/my/assessment/<id>` | GET | user | View/edit assessment |
| `/my/assessment/save` | POST | user | Save assessment data |
| `/my/assessment/<id>/signatures` | GET | user | Signature capture page |
| `/my/assessment/<id>/save_signature` | POST | user | Save signature (jsonrpc) |
| `/my/assessment/<id>/complete` | POST | user | Complete assessment |
---
## Security
### Security Groups
| Group | XML ID | Description |
|-------|--------|-------------|
| Authorizer Portal | `group_authorizer_portal` | Access to authorizer portal features |
| Sales Rep Portal | `group_sales_rep_portal` | Access to sales rep portal features |
### Record Rules
| Model | Rule | Description |
|-------|------|-------------|
| `fusion.authorizer.comment` | Portal Read | Users can read non-internal comments on their cases |
| `fusion.authorizer.comment` | Portal Create | Users can create comments on their cases |
| `fusion.adp.document` | Portal Read | Users can read documents on their cases |
| `fusion.adp.document` | Portal Create | Users can upload documents to their cases |
| `fusion.assessment` | Portal Access | Users can access assessments they're linked to |
| `sale.order` | Portal Authorizer | Authorizers can view their assigned orders |
### Access Rights (ir.model.access.csv)
| Model | Group | Read | Write | Create | Unlink |
|-------|-------|------|-------|--------|--------|
| `fusion.authorizer.comment` | base.group_user | 1 | 1 | 1 | 1 |
| `fusion.authorizer.comment` | base.group_portal | 1 | 0 | 1 | 0 |
| `fusion.adp.document` | base.group_user | 1 | 1 | 1 | 1 |
| `fusion.adp.document` | base.group_portal | 1 | 0 | 1 | 0 |
| `fusion.assessment` | base.group_user | 1 | 1 | 1 | 1 |
| `fusion.assessment` | base.group_portal | 1 | 1 | 1 | 0 |
---
## Frontend Assets
### CSS (`static/src/css/portal_style.css`)
Custom portal styling with a dark blue and green color scheme:
- **Primary Color:** Dark blue (#1e3a5f)
- **Secondary Color:** Medium blue (#2c5282)
- **Accent Color:** Green (#38a169)
- **Background:** Light gray (#f7fafc)
Styled components:
- Portal cards with shadow effects
- Status badges with color coding
- Custom buttons with hover effects
- Responsive tables
- Form inputs with focus states
### JavaScript
#### `portal_search.js`
Real-time search functionality:
- Debounced input handling (300ms delay)
- AJAX calls to search endpoints
- Dynamic table updates
- Search result highlighting
#### `assessment_form.js`
Assessment form enhancements:
- Unsaved changes warning
- Auto-fill client name from first/last name
- Number input validation
- Form state tracking
#### `signature_pad.js`
Digital signature capture:
- HTML5 Canvas-based drawing
- Touch and mouse event support
- Clear signature functionality
- Export to base64 PNG
- AJAX save to server
---
## Email Templates
### Case Assignment (`mail_template_case_assigned`)
**Trigger:** Authorizer assigned to a Sale Order
**Recipient:** Authorizer email
**Content:** Case details, client information, link to portal
### Status Change (`mail_template_status_changed`)
**Trigger:** Sale Order state changes
**Recipient:** Assigned authorizer
**Content:** Previous and new status, case details
### Assessment Complete - Authorizer (`mail_template_assessment_complete_authorizer`)
**Trigger:** Assessment completed
**Recipient:** Assigned authorizer
**Content:** Assessment details, measurements, signed documents
### Assessment Complete - Client (`mail_template_assessment_complete_client`)
**Trigger:** Assessment completed
**Recipient:** Client email
**Content:** Confirmation, next steps, measurements summary
### Document Uploaded (`mail_template_document_uploaded`)
**Trigger:** Document uploaded via portal
**Recipient:** Internal team
**Content:** Document details, revision info, download link
---
## User Guide
### For Administrators
#### Granting Portal Access
1. Go to **Contacts** > Select the contact
2. Navigate to the **Portal Access** tab
3. Enable the appropriate role:
- Check `Is Authorizer` for OTs/Therapists
- Check `Is Sales Rep (Portal)` for Sales Reps
4. Click **Grant Portal Access**
5. The user receives an email with login instructions
#### Assigning Cases to Authorizers
1. Open a **Sale Order**
2. Set the **Authorizer** field to the appropriate contact
3. Save the order
4. The authorizer receives a notification email
5. The case appears in their portal dashboard
---
### For Authorizers
#### Accessing the Portal
1. Visit `https://your-domain.com/my`
2. Log in with your portal credentials
3. Click **Authorizer Portal** in the menu
#### Viewing Cases
1. From the dashboard, view recent cases and statistics
2. Click **View All Cases** or **My Cases** for the full list
3. Use the search bar to find specific cases by:
- Client name
- Client reference 1 or 2
- Claim number
#### Adding Comments
1. Open a case detail view
2. Scroll to the Comments section
3. Enter your comment
4. Select comment type (General, Question, Update, Approval)
5. Click **Add Comment**
#### Uploading Documents
1. Open a case detail view
2. Go to the Documents section
3. Click **Upload Document**
4. Select document type (Full Application, Page 11, Page 12, etc.)
5. Choose the file and add revision notes
6. Click **Upload**
---
### For Sales Representatives
#### Starting a New Assessment
1. Log in to the portal
2. Click **New Assessment**
3. Fill in client information:
- Name, address, contact details
- Client references
4. Record wheelchair specifications:
- Measurements (seat width, depth, height)
- Product types (cushion, backrest, frame, wheels)
5. Document accessibility and mobility needs
6. Click **Save & Continue**
#### Capturing Signatures
1. After saving assessment data, click **Proceed to Signatures**
2. **Page 11 (Authorizer):**
- Have the OT sign on the canvas
- Enter their printed name
- Click **Save Signature**
3. **Page 12 (Client):**
- Have the client sign on the canvas
- Enter their printed name
- Click **Save Signature**
#### Completing the Assessment
1. Once both signatures are captured, click **Complete Assessment**
2. The system will:
- Create a new customer record (if needed)
- Generate a draft Sale Order
- Attach signed documents
- Send notification emails
3. The assessment moves to "Completed" status
---
## API Reference
### Assessment Model Methods
```python
# Complete an assessment and create Sale Order
assessment.action_complete()
# Get formatted specifications for order notes
specs = assessment._format_specifications_for_order()
# Ensure partner exists or create new
partner = assessment._ensure_partner()
```
### Sale Order Portal Methods
```python
# Get safe data for portal display (no costs)
data = order.get_portal_display_data()
# Search cases for authorizer
cases = SaleOrder.get_authorizer_portal_cases(
partner_id=123,
search_query='Smith',
limit=50,
offset=0
)
# Search cases for sales rep
cases = SaleOrder.get_sales_rep_portal_cases(
user_id=456,
search_query='wheelchair',
limit=50,
offset=0
)
```
### Partner Methods
```python
# Grant portal access programmatically
partner.action_grant_portal_access()
# Check if partner is an authorizer
if partner.is_authorizer:
cases = partner.assigned_case_count
```
### Document Methods
```python
# Get all documents for an order
docs = ADPDocument.get_documents_for_order(sale_order_id)
# Get revision history
history = document.get_revision_history()
```
---
## Troubleshooting
### Common Errors
#### Error: `Invalid field 'in_portal' in 'portal.wizard.user'`
**Cause:** Odoo 19 changed the portal wizard API, removing the `in_portal` field.
**Solution:** The `action_grant_portal_access` method has been updated to:
1. First attempt using the standard portal wizard
2. If that fails, fall back to direct user creation with portal group assignment
```python
# The fallback code creates the user directly:
portal_group = self.env.ref('base.group_portal')
portal_user = self.env['res.users'].sudo().create({
'name': self.name,
'login': self.email,
'email': self.email,
'partner_id': self.id,
'groups_id': [(6, 0, [portal_group.id])],
})
```
---
#### Error: `Invalid view type: 'tree'`
**Cause:** Odoo 19 renamed `<tree>` views to `<list>`.
**Solution:** Replace all `<tree>` tags with `<list>` in XML view definitions:
```xml
<!-- Old (Odoo 18 and earlier) -->
<tree>...</tree>
<!-- New (Odoo 19) -->
<list>...</list>
```
---
#### Error: `Invalid field 'category_id' in 'res.groups'`
**Cause:** Odoo 19 no longer supports `category_id` in `res.groups` XML definitions.
**Solution:** Remove the `<field name="category_id">` element from security group definitions:
```xml
<!-- Remove this line -->
<field name="category_id" ref="base.module_category_sales"/>
```
---
#### Error: `DeprecationWarning: @route(type='json') is deprecated`
**Cause:** Odoo 19 uses `type='jsonrpc'` instead of `type='json'`.
**Solution:** Update route decorators:
```python
# Old
@http.route('/my/endpoint', type='json', auth='user')
# New
@http.route('/my/endpoint', type='jsonrpc', auth='user')
```
---
### Portal Access Issues
#### User can't see cases in portal
1. Verify the partner has `is_authorizer` or `is_sales_rep_portal` checked
2. Verify the `authorizer_portal_user_id` is set
3. For authorizers, verify the Sale Order has `x_fc_authorizer_id` set to their partner ID
4. For sales reps, verify the Sale Order has `user_id` set to their user ID
#### Email notifications not sending
1. Check that the outgoing mail server is configured in Odoo
2. Verify the email templates exist and are active
3. Check the mail queue (Settings > Technical > Email > Emails)
4. Review the Odoo logs for mail errors
---
### Debug Logging
Enable debug logging for this module:
```python
import logging
_logger = logging.getLogger('fusion_authorizer_portal')
_logger.setLevel(logging.DEBUG)
```
Or in Odoo configuration:
```ini
[options]
log_handler = fusion_authorizer_portal:DEBUG
```
---
## Changelog
### Version 19.0.1.0.0 (Initial Release)
**New Features:**
- Authorizer Portal with case management
- Sales Rep Portal with assessment forms
- Wheelchair Assessment model with 50+ fields
- Digital signature capture (Pages 11 & 12)
- Document management with revision tracking
- Real-time search functionality
- Email notifications for key events
- Portal access management from partner form
**Technical:**
- Compatible with Odoo Enterprise v19
- Integrates with fusion_claims module
- Mobile-responsive portal design
- Touch-friendly signature pad
- AJAX-powered search
**Bug Fixes:**
- Fixed `in_portal` field error in Odoo 19 portal wizard
- Fixed `tree` to `list` view type for Odoo 19
- Fixed `category_id` error in security groups
- Fixed `type='json'` deprecation warning
---
## File Structure
```
fusion_authorizer_portal/
├── __init__.py
├── __manifest__.py
├── README.md
├── controllers/
│ ├── __init__.py
│ ├── portal_main.py # Authorizer & Sales Rep portal routes
│ └── portal_assessment.py # Assessment routes
├── data/
│ ├── mail_template_data.xml # Email templates & sequences
│ └── portal_menu_data.xml # Portal menu items
├── models/
│ ├── __init__.py
│ ├── adp_document.py # Document management model
│ ├── assessment.py # Assessment model
│ ├── authorizer_comment.py # Comments model
│ ├── res_partner.py # Partner extensions
│ └── sale_order.py # Sale Order extensions
├── security/
│ ├── ir.model.access.csv # Access rights
│ └── portal_security.xml # Groups & record rules
├── static/
│ └── src/
│ ├── css/
│ │ └── portal_style.css # Portal styling
│ └── js/
│ ├── assessment_form.js # Form enhancements
│ ├── portal_search.js # Real-time search
│ └── signature_pad.js # Signature capture
└── views/
├── assessment_views.xml # Assessment backend views
├── portal_templates.xml # Portal QWeb templates
├── res_partner_views.xml # Partner form extensions
└── sale_order_views.xml # Sale Order extensions
```
---
## Support
For support or feature requests, contact:
- **Email:** support@fusionclaims.com
- **Website:** https://fusionclaims.com
---
*Last Updated: January 2026*

View File

@@ -1,4 +0,0 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers

View File

@@ -1,100 +0,0 @@
# -*- coding: utf-8 -*-
{
'name': 'Fusion Authorizer & Sales Portal',
'version': '19.0.2.0.9',
'category': 'Sales/Portal',
'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms',
'description': """
Fusion Authorizer & Sales Rep Portal
=====================================
This module provides external portal access for:
**Authorizers (Occupational Therapists)**
- View assigned ADP cases
- Upload documents (ADP applications, signed pages)
- Add comments to cases
- Complete assessments with clients
- Capture digital signatures for ADP pages 11 & 12
**Sales Representatives**
- View their sales cases
- Start new client assessments
- Record wheelchair specifications and measurements
- Capture client signatures
- Track assessment progress
**Assessment System**
- Client information collection
- Wheelchair specifications (seat width, depth, height, etc.)
- Accessibility and mobility needs documentation
- Digital signature capture for ADP pages 11 & 12
- Automatic draft Sale Order creation
- Document distribution to all parties
- Automated email notifications
**Features**
- Real-time client search
- Document version tracking
- Mobile-friendly signature capture
- Email notifications for status changes
- Secure portal access with role-based permissions
""",
'author': 'Fusion Claims',
'website': 'https://fusionclaims.com',
'license': 'LGPL-3',
'depends': [
'base',
'sale',
'portal',
'website',
'mail',
'calendar',
'knowledge',
'fusion_claims',
],
'data': [
# Security
'security/portal_security.xml',
'security/ir.model.access.csv',
# Data
'data/mail_template_data.xml',
'data/portal_menu_data.xml',
'data/ir_actions_server_data.xml',
'data/welcome_articles.xml',
# Views
'views/res_partner_views.xml',
'views/sale_order_views.xml',
'views/assessment_views.xml',
'views/pdf_template_views.xml',
# Portal Templates
'views/portal_templates.xml',
'views/portal_assessment_express.xml',
'views/portal_pdf_editor.xml',
'views/portal_accessibility_templates.xml',
'views/portal_accessibility_forms.xml',
'views/portal_technician_templates.xml',
'views/portal_book_assessment.xml',
],
'assets': {
'web.assets_backend': [
'fusion_authorizer_portal/static/src/xml/chatter_message_authorizer.xml',
'fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js',
],
'web.assets_frontend': [
'fusion_authorizer_portal/static/src/css/portal_style.css',
'fusion_authorizer_portal/static/src/css/technician_portal.css',
'fusion_authorizer_portal/static/src/js/portal_search.js',
'fusion_authorizer_portal/static/src/js/assessment_form.js',
'fusion_authorizer_portal/static/src/js/signature_pad.js',
'fusion_authorizer_portal/static/src/js/loaner_portal.js',
'fusion_authorizer_portal/static/src/js/pdf_field_editor.js',
'fusion_authorizer_portal/static/src/js/technician_push.js',
'fusion_authorizer_portal/static/src/js/technician_location.js',
],
},
'images': ['static/description/icon.png'],
'installable': True,
'application': False,
'auto_install': False,
}

View File

@@ -1,5 +0,0 @@
# -*- coding: utf-8 -*-
from . import portal_main
from . import portal_assessment
from . import pdf_editor

View File

@@ -1,218 +0,0 @@
# -*- coding: utf-8 -*-
# Fusion PDF Field Editor Controller
# Provides routes for the visual drag-and-drop field position editor
import base64
import json
import logging
from odoo import http
from odoo.http import request
_logger = logging.getLogger(__name__)
class FusionPdfEditorController(http.Controller):
"""Controller for the PDF field position visual editor."""
# ================================================================
# Editor Page
# ================================================================
@http.route('/fusion/pdf-editor/<int:template_id>', type='http', auth='user', website=True)
def pdf_field_editor(self, template_id, **kw):
"""Render the visual field editor for a PDF template."""
template = request.env['fusion.pdf.template'].browse(template_id)
if not template.exists():
return request.redirect('/web')
# Get preview image for page 1
preview_url = ''
preview = template.preview_ids.filtered(lambda p: p.page == 1)
if preview and preview[0].image:
preview_url = f'/web/image/fusion.pdf.template.preview/{preview[0].id}/image'
fields = template.field_ids.read([
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
'text_align',
])
return request.render('fusion_authorizer_portal.portal_pdf_field_editor', {
'template': template,
'fields': fields,
'preview_url': preview_url,
})
# ================================================================
# JSONRPC: Get fields for template
# ================================================================
@http.route('/fusion/pdf-editor/fields', type='json', auth='user')
def get_fields(self, template_id, **kw):
"""Return all fields for a template."""
template = request.env['fusion.pdf.template'].browse(template_id)
if not template.exists():
return []
return template.field_ids.read([
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
'text_align',
])
# ================================================================
# JSONRPC: Update field position/properties
# ================================================================
@http.route('/fusion/pdf-editor/update-field', type='json', auth='user')
def update_field(self, field_id, values, **kw):
"""Update a field's position or properties."""
field = request.env['fusion.pdf.template.field'].browse(field_id)
if not field.exists():
return {'error': 'Field not found'}
# Filter to allowed fields only
allowed = {
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
'text_align',
}
safe_values = {k: v for k, v in values.items() if k in allowed}
if safe_values:
field.write(safe_values)
return {'success': True}
# ================================================================
# JSONRPC: Create new field
# ================================================================
@http.route('/fusion/pdf-editor/create-field', type='json', auth='user')
def create_field(self, **kw):
"""Create a new field on a template."""
template_id = kw.get('template_id')
if not template_id:
return {'error': 'Missing template_id'}
vals = {
'template_id': int(template_id),
'name': kw.get('name', 'new_field'),
'label': kw.get('label', 'New Field'),
'field_type': kw.get('field_type', 'text'),
'field_key': kw.get('field_key', kw.get('name', '')),
'page': int(kw.get('page', 1)),
'pos_x': float(kw.get('pos_x', 0.3)),
'pos_y': float(kw.get('pos_y', 0.3)),
'width': float(kw.get('width', 0.150)),
'height': float(kw.get('height', 0.015)),
'font_size': float(kw.get('font_size', 10)),
}
field = request.env['fusion.pdf.template.field'].create(vals)
return {'id': field.id, 'success': True}
# ================================================================
# JSONRPC: Delete field
# ================================================================
@http.route('/fusion/pdf-editor/delete-field', type='json', auth='user')
def delete_field(self, field_id, **kw):
"""Delete a field from a template."""
field = request.env['fusion.pdf.template.field'].browse(field_id)
if field.exists():
field.unlink()
return {'success': True}
# ================================================================
# JSONRPC: Get page preview image URL
# ================================================================
@http.route('/fusion/pdf-editor/page-image', type='json', auth='user')
def get_page_image(self, template_id, page, **kw):
"""Return the preview image URL for a specific page."""
template = request.env['fusion.pdf.template'].browse(template_id)
if not template.exists():
return {'image_url': ''}
preview = template.preview_ids.filtered(lambda p: p.page == page)
if preview and preview[0].image:
return {'image_url': f'/web/image/fusion.pdf.template.preview/{preview[0].id}/image'}
return {'image_url': ''}
# ================================================================
# Upload page preview image (from editor)
# ================================================================
@http.route('/fusion/pdf-editor/upload-preview', type='http', auth='user',
methods=['POST'], csrf=True, website=True)
def upload_preview_image(self, **kw):
"""Upload a preview image for a template page directly from the editor."""
template_id = int(kw.get('template_id', 0))
page = int(kw.get('page', 1))
template = request.env['fusion.pdf.template'].browse(template_id)
if not template.exists():
return json.dumps({'error': 'Template not found'})
image_file = request.httprequest.files.get('preview_image')
if not image_file:
return json.dumps({'error': 'No image uploaded'})
image_data = base64.b64encode(image_file.read())
# Find or create preview for this page
preview = template.preview_ids.filtered(lambda p: p.page == page)
if preview:
preview[0].write({'image': image_data, 'image_filename': image_file.filename})
else:
request.env['fusion.pdf.template.preview'].create({
'template_id': template_id,
'page': page,
'image': image_data,
'image_filename': image_file.filename,
})
_logger.info("Uploaded preview image for template %s page %d", template.name, page)
return request.redirect(f'/fusion/pdf-editor/{template_id}')
# ================================================================
# Preview: Generate sample filled PDF
# ================================================================
@http.route('/fusion/pdf-editor/preview/<int:template_id>', type='http', auth='user')
def preview_pdf(self, template_id, **kw):
"""Generate a preview filled PDF with sample data."""
template = request.env['fusion.pdf.template'].browse(template_id)
if not template.exists() or not template.pdf_file:
return request.redirect('/web')
# Build sample data for preview
sample_context = {
'client_last_name': 'Smith',
'client_first_name': 'John',
'client_middle_name': 'A',
'client_health_card': '1234-567-890',
'client_health_card_version': 'AB',
'client_street': '123 Main Street',
'client_unit': 'Unit 4B',
'client_city': 'Toronto',
'client_state': 'Ontario',
'client_postal_code': 'M5V 2T6',
'client_phone': '(416) 555-0123',
'client_email': 'john.smith@example.com',
'client_weight': '185',
'consent_applicant': True,
'consent_agent': False,
'consent_date': '2026-02-08',
'agent_last_name': '',
'agent_first_name': '',
}
try:
pdf_bytes = template.generate_filled_pdf(sample_context)
headers = [
('Content-Type', 'application/pdf'),
('Content-Disposition', f'inline; filename="preview_{template.name}.pdf"'),
]
return request.make_response(pdf_bytes, headers=headers)
except Exception as e:
_logger.error("PDF preview generation failed: %s", e)
return request.redirect(f'/fusion/pdf-editor/{template_id}?error=preview_failed')

View File

@@ -1,42 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ==================== BATCH SERVER ACTIONS FOR RES.PARTNER ==================== -->
<!-- Mark as Authorizer - Batch Action (Gear Menu) -->
<record id="action_mark_as_authorizer" model="ir.actions.server">
<field name="name">Mark as Authorizer</field>
<field name="model_id" ref="base.model_res_partner"/>
<field name="binding_model_id" ref="base.model_res_partner"/>
<field name="binding_view_types">list</field>
<field name="state">code</field>
<field name="code">
action = records.action_mark_as_authorizer()
</field>
</record>
<!-- Send Portal Invitation - Batch Action (Gear Menu) -->
<record id="action_batch_send_invitation" model="ir.actions.server">
<field name="name">Send Portal Invitation</field>
<field name="model_id" ref="base.model_res_partner"/>
<field name="binding_model_id" ref="base.model_res_partner"/>
<field name="binding_view_types">list</field>
<field name="state">code</field>
<field name="code">
action = records.action_batch_send_portal_invitation()
</field>
</record>
<!-- Mark as Authorizer & Send Invitation (Combined) - Batch Action (Gear Menu) -->
<record id="action_mark_and_invite" model="ir.actions.server">
<field name="name">Mark as Authorizer &amp; Send Invitation</field>
<field name="model_id" ref="base.model_res_partner"/>
<field name="binding_model_id" ref="base.model_res_partner"/>
<field name="binding_view_types">list</field>
<field name="state">code</field>
<field name="code">
action = records.action_mark_and_send_invitation()
</field>
</record>
</odoo>

View File

@@ -1,172 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Sequence for Assessment Reference - noupdate=1 prevents reset on module upgrade -->
<data noupdate="1">
<record id="seq_fusion_assessment" model="ir.sequence">
<field name="name">Assessment Sequence</field>
<field name="code">fusion.assessment</field>
<field name="prefix">ASM-</field>
<field name="padding">5</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
</record>
<!-- Sequence for Accessibility Assessment Reference -->
<record id="seq_fusion_accessibility_assessment" model="ir.sequence">
<field name="name">Accessibility Assessment Sequence</field>
<field name="code">fusion.accessibility.assessment</field>
<field name="prefix">ACC-</field>
<field name="padding">5</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
</record>
</data>
<!-- ================================================================= -->
<!-- Email Template: Case Assigned to Authorizer -->
<!-- ================================================================= -->
<record id="mail_template_case_assigned" model="mail.template">
<field name="name">Authorizer Portal: Case Assigned</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">New Case Assigned: {{ object.name }} - {{ object.partner_id.name }}</field>
<field name="email_from">{{ (object.company_id.email or object.user_id.email or 'noreply@example.com') }}</field>
<field name="email_to">{{ object.x_fc_authorizer_id.email }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
<div style="height:4px;background-color:#2B6CB0;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
<p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;"><t t-out="object.company_id.name"/></p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;">New Case Assigned</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">A new ADP case has been assigned to you.</p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Case Details</td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Case</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.name"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Client</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.partner_id.name"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Date</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.date_order" t-options='{"widget": "date"}'/></td></tr>
</table>
<p style="text-align:center;margin:28px 0;"><a href="/my/authorizer" style="display:inline-block;background:#2B6CB0;color:#ffffff;padding:12px 28px;text-decoration:none;border-radius:6px;font-size:14px;font-weight:600;">View in Portal</a></p>
<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:24px 0 0 0;">Best regards,<br/><strong><t t-out="object.user_id.name or object.company_id.name"/></strong><br/><span style="color:#718096;"><t t-out="object.company_id.name"/></span></p>
</div>
<div style="padding:16px 28px;text-align:center;"><p style="color:#a0aec0;font-size:11px;margin:0;">This is an automated notification from <t t-out="object.company_id.name"/>.</p></div>
</div>
</field>
<field name="auto_delete" eval="False"/>
</record>
<!-- Status Changed template removed - redundant.
Each workflow transition sends its own detailed email from fusion_claims.
Record kept to avoid XML ID reference errors on upgrade. -->
<record id="mail_template_status_changed" model="mail.template">
<field name="name">Authorizer Portal: Status Changed (Disabled)</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">Case Update: {{ object.name }}</field>
<field name="body_html" type="html"><p>This template is no longer in use.</p></field>
<field name="auto_delete" eval="True"/>
</record>
<!-- ================================================================= -->
<!-- Email Template: Assessment Complete - To Authorizer -->
<!-- ================================================================= -->
<record id="mail_template_assessment_complete_authorizer" model="mail.template">
<field name="name">Assessment Complete - Authorizer Notification</field>
<field name="model_id" ref="model_fusion_assessment"/>
<field name="subject">Assessment Complete: {{ object.reference }} - {{ object.client_name }}</field>
<field name="email_from">{{ (object.sales_rep_id.company_id.email or 'noreply@example.com') }}</field>
<field name="email_to">{{ object.authorizer_id.email }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
<div style="height:4px;background-color:#38a169;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
<p style="color:#38a169;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;"><t t-out="object.sales_rep_id.company_id.name or object.env.company.name"/></p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;">Assessment Complete</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">The assessment for <strong style="color:#2d3748;"><t t-out="object.client_name"/></strong> has been completed and a sale order has been created.</p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Assessment Details</td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Reference</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.reference"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Client</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.client_name"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Date</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.assessment_date" t-options='{"widget": "date"}'/></td></tr>
<t t-if="object.sale_order_id">
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Sale Order</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.sale_order_id.name"/></td></tr>
</t>
</table>
<div style="border-left:3px solid #38a169;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;"><strong>Next steps:</strong> Please submit the ADP application (including pages 11-12 signed by the client) so we can proceed with the claim submission.</p>
</div>
<p style="text-align:center;margin:28px 0;"><a href="/my/authorizer" style="display:inline-block;background:#38a169;color:#ffffff;padding:12px 28px;text-decoration:none;border-radius:6px;font-size:14px;font-weight:600;">View in Portal</a></p>
<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:24px 0 0 0;">Best regards,<br/><strong><t t-out="object.sales_rep_id.name or 'The Team'"/></strong></p>
</div>
<div style="padding:16px 28px;text-align:center;"><p style="color:#a0aec0;font-size:11px;margin:0;">This is an automated notification from the ADP Claims Management System.</p></div>
</div>
</field>
<field name="auto_delete" eval="False"/>
</record>
<!-- ================================================================= -->
<!-- Email Template: Assessment Complete - To Client -->
<!-- ================================================================= -->
<record id="mail_template_assessment_complete_client" model="mail.template">
<field name="name">Assessment Complete - Client Notification</field>
<field name="model_id" ref="model_fusion_assessment"/>
<field name="subject">Your Assessment is Complete - {{ object.reference }}</field>
<field name="email_from">{{ (object.sales_rep_id.company_id.email or 'noreply@example.com') }}</field>
<field name="email_to">{{ object.client_email }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
<div style="height:4px;background-color:#2B6CB0;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
<p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;"><t t-out="object.sales_rep_id.company_id.name or object.env.company.name"/></p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;">Assessment Complete</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">Dear <strong style="color:#2d3748;"><t t-out="object.client_name"/></strong>, thank you for completing your assessment with us.</p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Summary</td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Reference</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.reference"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Date</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.assessment_date" t-options='{"widget": "date"}'/></td></tr>
<t t-if="object.authorizer_id">
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Therapist</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.authorizer_id.name"/></td></tr>
</t>
</table>
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
<p style="margin:0 0 8px 0;font-size:14px;color:#2d3748;font-weight:600;">What happens next:</p>
<ol style="margin:0;padding-left:20px;font-size:14px;line-height:1.6;color:#2d3748;">
<li>Your assessment will be reviewed by our team</li>
<li>We will submit the ADP application on your behalf</li>
<li>You will be notified once approval is received</li>
<li>Your equipment will be ordered and delivered</li>
</ol>
</div>
<p style="color:#2d3748;font-size:14px;line-height:1.5;">If you have any questions, please do not hesitate to contact us.</p>
<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:24px 0 0 0;">Best regards,<br/><strong><t t-out="object.sales_rep_id.name or 'The Team'"/></strong></p>
</div>
<div style="padding:16px 28px;text-align:center;"><p style="color:#a0aec0;font-size:11px;margin:0;">This is an automated notification from the ADP Claims Management System.</p></div>
</div>
</field>
<field name="auto_delete" eval="False"/>
</record>
<!-- ================================================================= -->
<!-- Email Template: Document Uploaded -->
<!-- ================================================================= -->
<record id="mail_template_document_uploaded" model="mail.template">
<field name="name">Authorizer Portal: Document Uploaded</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">New Document Uploaded: {{ object.name }}</field>
<field name="email_from">{{ (object.company_id.email or 'noreply@example.com') }}</field>
<field name="email_to">{{ object.x_fc_authorizer_id.email }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
<div style="height:4px;background-color:#2B6CB0;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
<p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;"><t t-out="object.company_id.name"/></p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;">New Document Available</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">A new document has been uploaded for case <strong style="color:#2d3748;"><t t-out="object.name"/></strong> (<t t-out="object.partner_id.name"/>).</p>
<p style="text-align:center;margin:28px 0;"><a href="/my/authorizer" style="display:inline-block;background:#2B6CB0;color:#ffffff;padding:12px 28px;text-decoration:none;border-radius:6px;font-size:14px;font-weight:600;">View in Portal</a></p>
<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:24px 0 0 0;">Best regards,<br/><strong><t t-out="object.company_id.name"/></strong></p>
</div>
<div style="padding:16px 28px;text-align:center;"><p style="color:#a0aec0;font-size:11px;margin:0;">This is an automated notification from <t t-out="object.company_id.name"/>.</p></div>
</div>
</field>
<field name="auto_delete" eval="False"/>
</record>
</odoo>

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- This file can be used for portal menu customizations if needed -->
<!-- Currently, portal menus are handled by the templates -->
</odoo>

View File

@@ -1,432 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- ================================================================== -->
<!-- AUTHORIZER WELCOME ARTICLE TEMPLATE -->
<!-- ================================================================== -->
<template id="welcome_article_authorizer">
<div style="font-family: Arial, Helvetica, sans-serif; max-width: 800px; margin: 0 auto; color: #333;">
<!-- Header -->
<div style="background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%); padding: 30px 40px; border-radius: 12px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 28px;">Welcome to the Authorizer Portal</h1>
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0; font-size: 16px;">
<t t-out="company_name"/> - Assistive Devices Program
</p>
</div>
<p style="font-size: 15px; line-height: 1.7;">
Dear <strong><t t-out="user_name"/></strong>,
</p>
<p style="font-size: 15px; line-height: 1.7;">
Welcome to the <strong><t t-out="company_name"/></strong> Authorizer Portal.
This portal is designed to streamline the ADP (Assistive Devices Program) process
and keep you connected with your assigned cases.
</p>
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">What You Can Do</h2>
<table style="width: 100%; border-collapse: collapse; margin: 15px 0;">
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f8f9fa; width: 40px; text-align: center; font-size: 20px;">1</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>View Assigned Cases</strong><br/>
Access all ADP cases assigned to you with real-time status updates.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f8f9fa; text-align: center; font-size: 20px;">2</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Complete Assessments</strong><br/>
Fill out assessments online with measurements, photos, and specifications.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f8f9fa; text-align: center; font-size: 20px;">3</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Track Application Status</strong><br/>
Monitor the progress of ADP applications from submission to approval.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f8f9fa; text-align: center; font-size: 20px;">4</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Upload Documents</strong><br/>
Upload ADP applications, signed pages 11 and 12, and supporting documentation.
</td>
</tr>
</table>
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">Getting Started</h2>
<ol style="font-size: 14px; line-height: 1.8;">
<li>Navigate to <strong>My Cases</strong> from the portal menu to see your assigned ADP cases.</li>
<li>Click on any case to view details, upload documents, or add comments.</li>
<li>To start a new assessment, go to <strong>Assessments</strong> and click <strong>New Assessment</strong>.</li>
<li>Complete the assessment form with all required measurements and photos.</li>
</ol>
<h2 style="color: #e53e3e; border-bottom: 2px solid #e53e3e; padding-bottom: 8px;">Important Reminders</h2>
<div style="background: #fff5f5; border-left: 4px solid #e53e3e; padding: 15px; margin: 15px 0; border-radius: 0 8px 8px 0;">
<ul style="margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.8;">
<li><strong>Assessment Validity:</strong> Assessments are valid for 3 months from the completion date.</li>
<li><strong>Application Submission:</strong> Please submit the ADP application promptly after the assessment is completed.</li>
<li><strong>Page 11:</strong> Must be signed by the applicant (or authorized agent: spouse, parent, legal guardian, public trustee, or power of attorney).</li>
<li><strong>Page 12:</strong> Must be signed by the authorizer and the vendor.</li>
</ul>
</div>
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">Need Help?</h2>
<p style="font-size: 14px; line-height: 1.7;">
If you have any questions or need assistance, please contact our office:
</p>
<div style="background: #f0f4ff; padding: 15px 20px; border-radius: 8px; font-size: 14px;">
<strong><t t-out="company_name"/></strong><br/>
Email: <t t-out="company_email"/><br/>
Phone: <t t-out="company_phone"/>
</div>
</div>
</template>
<!-- ================================================================== -->
<!-- SALES REP WELCOME ARTICLE TEMPLATE -->
<!-- ================================================================== -->
<template id="welcome_article_sales_rep">
<div style="font-family: Arial, Helvetica, sans-serif; max-width: 800px; margin: 0 auto; color: #333;">
<!-- Header -->
<div style="background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%); padding: 30px 40px; border-radius: 12px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 28px;">Welcome to the Sales Portal</h1>
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0; font-size: 16px;">
<t t-out="company_name"/> - Sales Dashboard
</p>
</div>
<p style="font-size: 15px; line-height: 1.7;">
Dear <strong><t t-out="user_name"/></strong>,
</p>
<p style="font-size: 15px; line-height: 1.7;">
Welcome to the <strong><t t-out="company_name"/></strong> Sales Portal.
This is your hub for managing sales orders, completing assessments, and tracking ADP cases.
</p>
<h2 style="color: #5ba848; border-bottom: 2px solid #5ba848; padding-bottom: 8px;">What You Can Do</h2>
<table style="width: 100%; border-collapse: collapse; margin: 15px 0;">
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f0fff4; width: 40px; text-align: center; font-size: 20px;">1</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Sales Dashboard</strong><br/>
View all your sales orders, filter by status, sale type, and search by client name or order number.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f0fff4; text-align: center; font-size: 20px;">2</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Complete Assessments</strong><br/>
Start ADP Express Assessments and Accessibility Assessments (stair lifts, platform lifts, ceiling lifts, ramps, bathroom modifications).
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f0fff4; text-align: center; font-size: 20px;">3</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Proof of Delivery</strong><br/>
Get proof of delivery signed by clients directly from your phone or tablet.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f0fff4; text-align: center; font-size: 20px;">4</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Loaner Equipment</strong><br/>
Track loaner equipment checkouts and returns.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f0fff4; text-align: center; font-size: 20px;">5</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Track ADP Cases</strong><br/>
Monitor ADP application status from assessment through approval to billing.
</td>
</tr>
</table>
<h2 style="color: #5ba848; border-bottom: 2px solid #5ba848; padding-bottom: 8px;">Getting Started</h2>
<ol style="font-size: 14px; line-height: 1.8;">
<li>Go to <strong>Sales Dashboard</strong> to see all your cases at a glance.</li>
<li>Use the <strong>search bar</strong> and <strong>filters</strong> to quickly find cases by client name, order number, sale type, or status.</li>
<li>Click on any case to view full details and take action.</li>
<li>To start a new assessment, go to <strong>Assessments</strong> and select the appropriate assessment type.</li>
<li>For deliveries, navigate to <strong>Signature Requests</strong> to collect proof of delivery signatures.</li>
</ol>
<h2 style="color: #ff9800; border-bottom: 2px solid #ff9800; padding-bottom: 8px;">Tips for Success</h2>
<div style="background: #fff8e1; border-left: 4px solid #ff9800; padding: 15px; margin: 15px 0; border-radius: 0 8px 8px 0;">
<ul style="margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.8;">
<li>Take clear photos during assessments - they will be attached to the case automatically.</li>
<li>Complete all required measurements before submitting an assessment.</li>
<li>Follow up on cases in <strong>Waiting for Application</strong> status to keep the process moving.</li>
<li>Always collect proof of delivery signatures at the time of delivery.</li>
</ul>
</div>
<h2 style="color: #5ba848; border-bottom: 2px solid #5ba848; padding-bottom: 8px;">Need Help?</h2>
<p style="font-size: 14px; line-height: 1.7;">
If you have any questions or need assistance, please contact the office:
</p>
<div style="background: #f0fff4; padding: 15px 20px; border-radius: 8px; font-size: 14px;">
<strong><t t-out="company_name"/></strong><br/>
Email: <t t-out="company_email"/><br/>
Phone: <t t-out="company_phone"/>
</div>
</div>
</template>
<!-- ================================================================== -->
<!-- TECHNICIAN WELCOME ARTICLE TEMPLATE -->
<!-- ================================================================== -->
<template id="welcome_article_technician">
<div style="font-family: Arial, Helvetica, sans-serif; max-width: 800px; margin: 0 auto; color: #333;">
<!-- Header -->
<div style="background: linear-gradient(135deg, #3a8fb7 0%, #2e7aad 60%, #1a6b9a 100%); padding: 30px 40px; border-radius: 12px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 28px;">Welcome to the Technician Portal</h1>
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0; font-size: 16px;">
<t t-out="company_name"/> - Delivery and Service
</p>
</div>
<p style="font-size: 15px; line-height: 1.7;">
Dear <strong><t t-out="user_name"/></strong>,
</p>
<p style="font-size: 15px; line-height: 1.7;">
Welcome to the <strong><t t-out="company_name"/></strong> Technician Portal.
This portal helps you manage your assigned deliveries and collect proof of delivery signatures.
</p>
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">What You Can Do</h2>
<table style="width: 100%; border-collapse: collapse; margin: 15px 0;">
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #fff0f3; width: 40px; text-align: center; font-size: 20px;">1</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>View Assigned Deliveries</strong><br/>
See all deliveries assigned to you with client details, addresses, and product information.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #fff0f3; text-align: center; font-size: 20px;">2</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Collect Proof of Delivery</strong><br/>
Get the client's signature on the proof of delivery document directly from your device.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #fff0f3; text-align: center; font-size: 20px;">3</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Track Delivery Status</strong><br/>
Monitor which deliveries are pending, in progress, or completed.
</td>
</tr>
</table>
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">Getting Started</h2>
<ol style="font-size: 14px; line-height: 1.8;">
<li>Navigate to <strong>My Deliveries</strong> to see all deliveries assigned to you.</li>
<li>Click on a delivery to view the client details and delivery address.</li>
<li>Go to <strong>Signature Requests</strong> to collect proof of delivery signatures.</li>
<li>After collecting the signature, the signed POD is automatically attached to the order.</li>
</ol>
<h2 style="color: #e53e3e; border-bottom: 2px solid #e53e3e; padding-bottom: 8px;">Important Reminders</h2>
<div style="background: #fff5f5; border-left: 4px solid #e53e3e; padding: 15px; margin: 15px 0; border-radius: 0 8px 8px 0;">
<ul style="margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.8;">
<li><strong>Always get POD signed before leaving.</strong> The proof of delivery is required for billing.</li>
<li>Ensure the client's <strong>name</strong> and <strong>date</strong> are filled in on the delivery form.</li>
<li>If the client is unavailable, contact the office immediately.</li>
<li>Report any product issues or damages to the office right away.</li>
</ul>
</div>
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">Need Help?</h2>
<p style="font-size: 14px; line-height: 1.7;">
If you have any questions or need assistance, please contact the office:
</p>
<div style="background: #fff0f3; padding: 15px 20px; border-radius: 8px; font-size: 14px;">
<strong><t t-out="company_name"/></strong><br/>
Email: <t t-out="company_email"/><br/>
Phone: <t t-out="company_phone"/>
</div>
</div>
</template>
<!-- ================================================================== -->
<!-- GENERAL PORTAL USER (CLIENT) WELCOME ARTICLE TEMPLATE -->
<!-- ================================================================== -->
<template id="welcome_article_client">
<div style="font-family: Arial, Helvetica, sans-serif; max-width: 800px; margin: 0 auto; color: #333;">
<!-- Header -->
<div style="background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%); padding: 30px 40px; border-radius: 12px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 28px;">Welcome to Your Portal</h1>
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0; font-size: 16px;">
<t t-out="company_name"/>
</p>
</div>
<p style="font-size: 15px; line-height: 1.7;">
Dear <strong><t t-out="user_name"/></strong>,
</p>
<p style="font-size: 15px; line-height: 1.7;">
Welcome to the <strong><t t-out="company_name"/></strong> Portal.
Here you can view your orders, track their status, and access your documents.
</p>
<h2 style="color: #3a8fb7; border-bottom: 2px solid #3a8fb7; padding-bottom: 8px;">What You Can Do</h2>
<table style="width: 100%; border-collapse: collapse; margin: 15px 0;">
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f0f8ff; width: 40px; text-align: center; font-size: 20px;">1</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>View Your Orders</strong><br/>
Access all your orders and track their current status.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f0f8ff; text-align: center; font-size: 20px;">2</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Track Status</strong><br/>
See real-time updates on your application and delivery status.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f0f8ff; text-align: center; font-size: 20px;">3</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Access Documents</strong><br/>
Download invoices, delivery receipts, and other important documents.
</td>
</tr>
</table>
<h2 style="color: #3a8fb7; border-bottom: 2px solid #3a8fb7; padding-bottom: 8px;">Getting Started</h2>
<ol style="font-size: 14px; line-height: 1.8;">
<li>Click on <strong>My Orders</strong> from the portal menu to see your orders.</li>
<li>Click on any order to view its full details and status.</li>
<li>Download documents by clicking the download button next to each file.</li>
</ol>
<h2 style="color: #3a8fb7; border-bottom: 2px solid #3a8fb7; padding-bottom: 8px;">Need Help?</h2>
<p style="font-size: 14px; line-height: 1.7;">
If you have any questions, please don't hesitate to reach out:
</p>
<div style="background: #f0f8ff; padding: 15px 20px; border-radius: 8px; font-size: 14px;">
<strong><t t-out="company_name"/></strong><br/>
Email: <t t-out="company_email"/><br/>
Phone: <t t-out="company_phone"/>
</div>
</div>
</template>
<!-- ================================================================== -->
<!-- INTERNAL STAFF WELCOME ARTICLE TEMPLATE -->
<!-- ================================================================== -->
<template id="welcome_article_internal">
<div style="font-family: Arial, Helvetica, sans-serif; max-width: 800px; margin: 0 auto; color: #333;">
<!-- Header -->
<div style="background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%); padding: 30px 40px; border-radius: 12px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 28px;">Welcome to <t t-out="company_name"/></h1>
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0; font-size: 16px;">
Fusion Claims - Internal Operations
</p>
</div>
<p style="font-size: 15px; line-height: 1.7;">
Welcome, <strong><t t-out="user_name"/></strong>!
</p>
<p style="font-size: 15px; line-height: 1.7;">
This is your quick-start guide to the <strong><t t-out="company_name"/></strong> system.
Below you'll find an overview of the key areas and how to navigate the system.
</p>
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">System Overview</h2>
<table style="width: 100%; border-collapse: collapse; margin: 15px 0;">
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #eaf4fd; width: 40px; text-align: center; font-size: 20px;">1</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>ADP Case Management</strong><br/>
Process ADP claims through the full workflow: Assessment, Application, Submission, Approval, Billing, and Case Close.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #eaf4fd; text-align: center; font-size: 20px;">2</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Sales Orders</strong><br/>
Manage quotations, sales orders, invoicing, and delivery for all sale types (ADP, ODSP, Private, Insurance, etc.).
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #eaf4fd; text-align: center; font-size: 20px;">3</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Device Codes</strong><br/>
Look up and manage ADP device codes, prices, and serial number requirements.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #eaf4fd; text-align: center; font-size: 20px;">4</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Inventory and Loaner Tracking</strong><br/>
Manage product inventory, track loaner equipment checkouts and returns.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #eaf4fd; text-align: center; font-size: 20px;">5</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Delivery Management</strong><br/>
Assign technicians, track deliveries, and manage proof of delivery documents.
</td>
</tr>
</table>
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">ADP Workflow Quick Reference</h2>
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; margin: 15px 0;">
<ol style="font-size: 14px; line-height: 2.0; margin: 0;">
<li><strong>Quotation</strong> - Create the sales order</li>
<li><strong>Assessment Scheduled</strong> - Schedule the client assessment</li>
<li><strong>Assessment Completed</strong> - Complete the on-site assessment</li>
<li><strong>Waiting for Application</strong> - Wait for the authorizer to submit the application</li>
<li><strong>Application Received</strong> - Upload the ADP application and signed pages</li>
<li><strong>Ready for Submission</strong> - Verify all documents are ready</li>
<li><strong>Application Submitted</strong> - Submit to ADP</li>
<li><strong>Accepted / Approved</strong> - ADP accepts and approves the case</li>
<li><strong>Ready for Delivery</strong> - Prepare and deliver the product</li>
<li><strong>Billed to ADP</strong> - Submit the claim for payment</li>
<li><strong>Case Closed</strong> - Finalize the case</li>
</ol>
</div>
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">Key Menu Locations</h2>
<table style="width: 100%; border-collapse: collapse; margin: 15px 0; font-size: 14px;">
<tr style="background: #eaf4fd;">
<td style="padding: 10px 12px; border: 1px solid #e0e0e0; font-weight: bold;">ADP Claims</td>
<td style="padding: 10px 12px; border: 1px solid #e0e0e0;">Sales menu - ADP Claims - filter by workflow stage</td>
</tr>
<tr>
<td style="padding: 10px 12px; border: 1px solid #e0e0e0; font-weight: bold;">Device Codes</td>
<td style="padding: 10px 12px; border: 1px solid #e0e0e0;">Sales menu - Configuration - ADP Device Codes</td>
</tr>
<tr style="background: #eaf4fd;">
<td style="padding: 10px 12px; border: 1px solid #e0e0e0; font-weight: bold;">Fusion Claims Settings</td>
<td style="padding: 10px 12px; border: 1px solid #e0e0e0;">Settings - Fusion Claims (scroll down)</td>
</tr>
<tr>
<td style="padding: 10px 12px; border: 1px solid #e0e0e0; font-weight: bold;">Contacts (Authorizers)</td>
<td style="padding: 10px 12px; border: 1px solid #e0e0e0;">Contacts - filter by "Authorizers"</td>
</tr>
</table>
<h2 style="color: #ff9800; border-bottom: 2px solid #ff9800; padding-bottom: 8px;">Tips</h2>
<div style="background: #fff8e1; border-left: 4px solid #ff9800; padding: 15px; margin: 15px 0; border-radius: 0 8px 8px 0;">
<ul style="margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.8;">
<li>Use <strong>filters and search</strong> to quickly find cases by status, client, or authorizer.</li>
<li>Check the <strong>chatter</strong> (message log) on each case for the full history of changes and communications.</li>
<li>Use the <strong>status bar</strong> at the top of each case to see where it is in the workflow.</li>
<li>All automated emails are logged in the chatter for audit purposes.</li>
<li>Use <strong>scheduled activities</strong> to set reminders and follow-ups.</li>
</ul>
</div>
</div>
</template>
</data>
</odoo>

View File

@@ -1,10 +0,0 @@
# -*- coding: utf-8 -*-
from . import res_partner
from . import res_users
from . import authorizer_comment
from . import adp_document
from . import assessment
from . import accessibility_assessment
from . import sale_order
from . import pdf_template

View File

@@ -1,874 +0,0 @@
# -*- coding: utf-8 -*-
import logging
import math
from datetime import timedelta
from markupsafe import Markup
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FusionAccessibilityAssessment(models.Model):
_name = 'fusion.accessibility.assessment'
_description = 'Accessibility Assessment'
_inherit = ['mail.thread', 'mail.activity.mixin', 'fusion.email.builder.mixin']
_order = 'assessment_date desc, id desc'
_rec_name = 'display_name'
# ==========================================================================
# COMMON FIELDS (all assessment types)
# ==========================================================================
reference = fields.Char(
string='Reference',
readonly=True,
copy=False,
default=lambda self: _('New'),
)
display_name = fields.Char(
compute='_compute_display_name',
store=True,
)
assessment_type = fields.Selection(
selection=[
('stairlift_straight', 'Straight Stair Lift'),
('stairlift_curved', 'Curved Stair Lift'),
('vpl', 'Vertical Platform Lift'),
('ceiling_lift', 'Ceiling Lift'),
('ramp', 'Custom Ramp'),
('bathroom', 'Bathroom Modification'),
('tub_cutout', 'Tub Cutout'),
],
string='Assessment Type',
required=True,
tracking=True,
)
state = fields.Selection(
selection=[
('draft', 'Draft'),
('completed', 'Completed'),
('cancelled', 'Cancelled'),
],
string='Status',
default='draft',
tracking=True,
)
# Client Information
client_name = fields.Char(string='Client Name', required=True)
client_address = fields.Char(string='Address')
client_unit = fields.Char(string='Unit/Apt/Suite')
client_address_street = fields.Char(string='Street')
client_address_city = fields.Char(string='City')
client_address_province = fields.Char(string='Province')
client_address_postal = fields.Char(string='Postal Code')
client_phone = fields.Char(string='Phone')
client_email = fields.Char(string='Email')
# Booking fields
booking_source = fields.Selection(
selection=[
('phone_authorizer', 'Phone - Authorizer'),
('phone_client', 'Phone - Client'),
('walk_in', 'Walk-In'),
('portal', 'Online Booking'),
],
string='Booking Source',
default='phone_client',
help='How the assessment was booked',
)
modification_requested = fields.Text(
string='Modification Requested',
help='What the client or authorizer is looking for',
)
sms_confirmation_sent = fields.Boolean(
string='SMS Confirmation Sent',
default=False,
)
calendar_event_id = fields.Many2one(
'calendar.event',
string='Calendar Event',
readonly=True,
copy=False,
)
# Relationships
sales_rep_id = fields.Many2one(
'res.users',
string='Sales Rep',
default=lambda self: self.env.user,
tracking=True,
)
authorizer_id = fields.Many2one(
'res.partner',
string='Authorizer/OT',
tracking=True,
help='The Occupational Therapist or Authorizer for this assessment',
)
partner_id = fields.Many2one(
'res.partner',
string='Client Partner',
help='Linked partner record (created on completion)',
)
sale_order_id = fields.Many2one(
'sale.order',
string='Created Sale Order',
readonly=True,
copy=False,
)
# Dates
assessment_date = fields.Date(
string='Assessment Date',
default=fields.Date.today,
)
# General Notes
notes = fields.Text(string='General Notes')
# ==========================================================================
# STAIR LIFT - STRAIGHT FIELDS
# ==========================================================================
stair_steps = fields.Integer(string='Number of Steps')
stair_nose_to_nose = fields.Float(string='Nose to Nose Distance (inches)')
stair_side = fields.Selection(
selection=[('left', 'Left'), ('right', 'Right')],
string='Installation Side',
)
stair_style = fields.Selection(
selection=[
('standard', 'Standard Stair Lift'),
('slide_track', 'Slide Track Stair Lift'),
('foldable_hinge', 'Foldable Hinge Stair Lift'),
],
string='Stair Lift Style',
)
stair_power_swivel_upstairs = fields.Boolean(string='Power Swivel (Upstairs)')
stair_power_folding_footrest = fields.Boolean(string='Power Folding Footrest')
stair_calculated_length = fields.Float(
string='Calculated Track Length (inches)',
compute='_compute_stair_straight_length',
store=True,
)
stair_manual_length_override = fields.Float(string='Manual Length Override (inches)')
stair_final_length = fields.Float(
string='Final Track Length (inches)',
compute='_compute_stair_final_length',
store=True,
)
# ==========================================================================
# STAIR LIFT - CURVED FIELDS
# ==========================================================================
stair_curved_steps = fields.Integer(string='Number of Steps (Curved)')
stair_curves_count = fields.Integer(string='Number of Curves')
# Top Landing Options
stair_top_landing_type = fields.Selection(
selection=[
('none', 'Standard (No special landing)'),
('90_exit', '90° Exit'),
('90_parking', '90° Parking'),
('180_parking', '180° Parking'),
('flush_landing', 'Flush Landing'),
('vertical_overrun', 'Vertical Overrun (Custom)'),
],
string='Top Landing Type',
default='none',
help='Type of landing at the top of the staircase',
)
top_overrun_custom_length = fields.Float(
string='Top Overrun Length (inches)',
help='Custom overrun length when Vertical Overrun is selected',
)
# Bottom Landing Options
stair_bottom_landing_type = fields.Selection(
selection=[
('none', 'Standard (No special landing)'),
('90_park', '90° Park'),
('180_park', '180° Park'),
('drop_nose', 'Drop Nose Landing'),
('short_vertical', 'Short Vertical Start'),
('horizontal_overrun', 'Horizontal Overrun (Custom)'),
],
string='Bottom Landing Type',
default='none',
help='Type of landing at the bottom of the staircase',
)
bottom_overrun_custom_length = fields.Float(
string='Bottom Overrun Length (inches)',
help='Custom overrun length when Horizontal Overrun is selected',
)
# Legacy fields kept for backwards compatibility
stair_has_drop_nose = fields.Boolean(string='Has Drop Nose (Legacy)')
stair_parking_type = fields.Selection(
selection=[
('none', 'No Parking'),
('90_degree', '90° Parking (+2 feet)'),
('180_degree', '180° Parking (+4 feet)'),
],
string='Parking Type (Legacy)',
default='none',
)
stair_power_swivel_downstairs = fields.Boolean(string='Power Swivel (Downstairs)')
stair_auto_folding_footrest = fields.Boolean(string='Automatic Folding Footrest')
stair_auto_folding_hinge = fields.Boolean(string='Automatic Folding Hinge')
stair_auto_folding_seat = fields.Boolean(string='Automatic Folding Seat')
stair_custom_color = fields.Boolean(string='Customizable Colored Seat')
stair_additional_charging = fields.Boolean(string='Additional Charging Station')
stair_charging_with_remote = fields.Boolean(string='Charging Station with Remote')
stair_curved_calculated_length = fields.Float(
string='Calculated Track Length (inches)',
compute='_compute_stair_curved_length',
store=True,
)
stair_curved_manual_override = fields.Float(string='Manual Length Override (inches)')
stair_curved_final_length = fields.Float(
string='Final Track Length (inches)',
compute='_compute_stair_curved_final_length',
store=True,
)
# ==========================================================================
# VERTICAL PLATFORM LIFT (VPL) FIELDS
# ==========================================================================
vpl_room_width = fields.Float(string='Room Width (inches)')
vpl_room_depth = fields.Float(string='Room Depth (inches)')
vpl_rise_height = fields.Float(string='Total Rise Height (inches)')
vpl_has_existing_platform = fields.Boolean(string='Existing Platform Available')
vpl_concrete_depth = fields.Float(string='Concrete Depth (inches)', help='Minimum 4 inches required')
vpl_model_type = fields.Selection(
selection=[
('ac', 'AC Model (Dedicated 15-amp breaker required)'),
('dc', 'DC Model (No dedicated breaker required)'),
],
string='Model Type',
)
vpl_has_nearby_plug = fields.Boolean(string='Power Plug Nearby')
vpl_plug_specs = fields.Char(string='Plug Specifications', default='110V / 15-amp')
vpl_needs_plug_install = fields.Boolean(string='Needs Plug Installation')
vpl_needs_certification = fields.Boolean(string='Needs City Certification')
vpl_certification_notes = fields.Text(string='Certification Notes')
# ==========================================================================
# CEILING LIFT FIELDS
# ==========================================================================
ceiling_track_length = fields.Float(string='Total Track Length (feet)')
ceiling_movement_type = fields.Selection(
selection=[
('manual', 'Manual Movement (left-to-right)'),
('powered', 'Powered Movement (left-to-right)'),
],
string='Horizontal Movement Type',
help='All ceiling lifts move up/down with power. This is for left-to-right movement.',
)
ceiling_charging_throughout = fields.Boolean(
string='Charging Throughout Track',
help='Charging available throughout the track instead of one location',
)
ceiling_carry_bar = fields.Boolean(string='Carry Bar')
ceiling_additional_slings = fields.Integer(string='Additional Slings Needed')
# ==========================================================================
# CUSTOM RAMP FIELDS
# ==========================================================================
ramp_height = fields.Float(string='Total Height (inches from ground)')
ramp_ground_incline = fields.Float(string='Ground Incline (degrees)', help='Optional - if ground is inclined')
ramp_at_door = fields.Boolean(string='Ramp at Door', help='Requires 5ft landing at door')
ramp_calculated_length = fields.Float(
string='Calculated Ramp Length (inches)',
compute='_compute_ramp_length',
store=True,
help='Ontario Building Code: 12 inches length per 1 inch height',
)
ramp_landings_needed = fields.Integer(
string='Landings Needed',
compute='_compute_ramp_landings',
store=True,
help='Landing required every 30 feet (minimum 5 feet each)',
)
ramp_total_length = fields.Float(
string='Total Length with Landings (inches)',
compute='_compute_ramp_total_length',
store=True,
)
ramp_handrail_height = fields.Float(
string='Handrail Height (inches)',
default=32.0,
help='Minimum 32 inches required',
)
ramp_manual_override = fields.Float(string='Manual Length Override (inches)')
# ==========================================================================
# BATHROOM MODIFICATION FIELDS
# ==========================================================================
bathroom_description = fields.Text(
string='Modification Description',
help='Describe all bathroom modifications needed',
)
# ==========================================================================
# TUB CUTOUT FIELDS
# ==========================================================================
tub_internal_height = fields.Float(string='Internal Height of Tub (inches)')
tub_external_height = fields.Float(string='External Height of Tub (inches)')
tub_additional_supplies = fields.Text(string='Additional Supplies Needed')
# ==========================================================================
# COMPUTED FIELDS
# ==========================================================================
@api.depends('reference', 'assessment_type', 'client_name')
def _compute_display_name(self):
type_labels = dict(self._fields['assessment_type'].selection)
for rec in self:
type_label = type_labels.get(rec.assessment_type, '')
rec.display_name = f"{rec.reference or 'New'} - {type_label} - {rec.client_name or ''}"
@api.depends('stair_steps', 'stair_nose_to_nose')
def _compute_stair_straight_length(self):
"""Straight stair lift: (steps × nose_to_nose) + 13" top landing"""
for rec in self:
if rec.stair_steps and rec.stair_nose_to_nose:
rec.stair_calculated_length = (rec.stair_steps * rec.stair_nose_to_nose) + 13
else:
rec.stair_calculated_length = 0
@api.depends('stair_calculated_length', 'stair_manual_length_override')
def _compute_stair_final_length(self):
"""Use manual override if provided, otherwise use calculated"""
for rec in self:
if rec.stair_manual_length_override:
rec.stair_final_length = rec.stair_manual_length_override
else:
rec.stair_final_length = rec.stair_calculated_length
@api.depends('stair_curved_steps', 'stair_curves_count',
'stair_top_landing_type', 'stair_bottom_landing_type',
'top_overrun_custom_length', 'bottom_overrun_custom_length')
def _compute_stair_curved_length(self):
"""Curved stair lift calculation:
- 12" per step
- 16" per curve
- Top landing type additions (or custom overrun)
- Bottom landing type additions (or custom overrun)
"""
# Track length additions for each landing type (in inches)
# Note: vertical_overrun and horizontal_overrun use custom lengths
TOP_LANDING_LENGTHS = {
'none': 0,
'90_exit': 24, # 2 feet
'90_parking': 24, # 2 feet
'180_parking': 48, # 4 feet
'flush_landing': 12, # 1 foot
}
BOTTOM_LANDING_LENGTHS = {
'none': 0,
'90_park': 24, # 2 feet
'180_park': 48, # 4 feet
'drop_nose': 12, # 1 foot
'short_vertical': 12, # 1 foot
}
for rec in self:
if rec.stair_curved_steps:
base_length = rec.stair_curved_steps * 12 # 12" per step
curves_length = (rec.stair_curves_count or 0) * 16 # 16" per curve
# Top landing length - use custom if overrun selected
if rec.stair_top_landing_type == 'vertical_overrun':
top_landing = rec.top_overrun_custom_length or 0
else:
top_landing = TOP_LANDING_LENGTHS.get(rec.stair_top_landing_type or 'none', 0)
# Bottom landing length - use custom if overrun selected
if rec.stair_bottom_landing_type == 'horizontal_overrun':
bottom_landing = rec.bottom_overrun_custom_length or 0
else:
bottom_landing = BOTTOM_LANDING_LENGTHS.get(rec.stair_bottom_landing_type or 'none', 0)
rec.stair_curved_calculated_length = (
base_length + curves_length + top_landing + bottom_landing
)
else:
rec.stair_curved_calculated_length = 0
@api.depends('stair_curved_calculated_length', 'stair_curved_manual_override')
def _compute_stair_curved_final_length(self):
"""Use manual override if provided, otherwise use calculated"""
for rec in self:
if rec.stair_curved_manual_override:
rec.stair_curved_final_length = rec.stair_curved_manual_override
else:
rec.stair_curved_final_length = rec.stair_curved_calculated_length
@api.depends('ramp_height')
def _compute_ramp_length(self):
"""Ontario Building Code: 12 inches length per 1 inch height (1:12 ratio)"""
for rec in self:
if rec.ramp_height:
rec.ramp_calculated_length = rec.ramp_height * 12
else:
rec.ramp_calculated_length = 0
@api.depends('ramp_calculated_length')
def _compute_ramp_landings(self):
"""Landing required every 30 feet (360 inches)"""
for rec in self:
if rec.ramp_calculated_length:
# Calculate how many landings are needed (every 30 feet = 360 inches)
rec.ramp_landings_needed = math.ceil(rec.ramp_calculated_length / 360)
else:
rec.ramp_landings_needed = 0
@api.depends('ramp_calculated_length', 'ramp_landings_needed', 'ramp_at_door')
def _compute_ramp_total_length(self):
"""Total length including landings (5 feet = 60 inches each)"""
for rec in self:
base_length = rec.ramp_calculated_length or 0
landings_length = (rec.ramp_landings_needed or 0) * 60 # 5 feet per landing
door_landing = 60 if rec.ramp_at_door else 0 # 5 feet at door
rec.ramp_total_length = base_length + landings_length + door_landing
# ==========================================================================
# CRUD METHODS
# ==========================================================================
@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.accessibility.assessment'
) or _('New')
return super().create(vals_list)
# ==========================================================================
# BUSINESS LOGIC
# ==========================================================================
def action_complete(self):
"""Complete the assessment and create a Sale Order"""
self.ensure_one()
if not self.client_name:
raise UserError(_('Please enter the client name.'))
# Create or find partner
partner = self._ensure_partner()
# Create draft sale order
sale_order = self._create_draft_sale_order(partner)
# Add tag based on assessment type
self._add_assessment_tag(sale_order)
# Copy photos from assessment to sale order chatter
self._copy_photos_to_sale_order(sale_order)
# Update state
self.write({
'state': 'completed',
'sale_order_id': sale_order.id,
'partner_id': partner.id,
})
# Send email notification to office
self._send_completion_email(sale_order)
# Schedule follow-up activity for sales rep
self._schedule_followup_activity(sale_order)
_logger.info(f"Completed accessibility assessment {self.reference}, created SO {sale_order.name}")
return sale_order
def _add_assessment_tag(self, sale_order):
"""Add a tag to the sale order based on assessment type"""
self.ensure_one()
# Map assessment types to tag names (ALL CAPS)
tag_map = {
'stairlift_straight': 'STRAIGHT STAIR LIFT',
'stairlift_curved': 'CURVED STAIR LIFT',
'vpl': 'VERTICAL PLATFORM LIFT',
'ceiling_lift': 'CEILING LIFT',
'ramp': 'CUSTOM RAMP',
'bathroom': 'BATHROOM MODIFICATION',
'tub_cutout': 'TUB CUTOUT',
}
tag_name = tag_map.get(self.assessment_type)
if not tag_name:
return
# Find or create the tag
Tag = self.env['crm.tag'].sudo()
tag = Tag.search([('name', '=', tag_name)], limit=1)
if not tag:
tag = Tag.create({'name': tag_name})
_logger.info(f"Created new tag: {tag_name}")
# Add tag to sale order
if hasattr(sale_order, 'tag_ids'):
sale_order.write({'tag_ids': [(4, tag.id)]})
_logger.info(f"Added tag '{tag_name}' to SO {sale_order.name}")
def _copy_photos_to_sale_order(self, sale_order):
"""Copy assessment photos to sale order chatter"""
self.ensure_one()
Attachment = self.env['ir.attachment'].sudo()
# Find photos attached to this assessment
photos = Attachment.search([
('res_model', '=', 'fusion.accessibility.assessment'),
('res_id', '=', self.id),
('mimetype', 'like', 'image/%'),
])
if not photos:
return
# Copy attachments to sale order and post in chatter
attachment_ids = []
for photo in photos:
new_attachment = photo.copy({
'res_model': 'sale.order',
'res_id': sale_order.id,
})
attachment_ids.append(new_attachment.id)
if attachment_ids:
type_labels = dict(self._fields['assessment_type'].selection)
type_label = type_labels.get(self.assessment_type, 'Accessibility')
sale_order.message_post(
body=Markup(f'''
<div class="alert alert-secondary">
<strong><i class="fa fa-camera"></i> Assessment Photos</strong><br/>
{len(attachment_ids)} photo(s) from {type_label} Assessment ({self.reference})
</div>
'''),
message_type='comment',
subtype_xmlid='mail.mt_note',
attachment_ids=attachment_ids,
)
_logger.info(f"Copied {len(attachment_ids)} photos to SO {sale_order.name}")
def _send_completion_email(self, sale_order):
"""Send email notification to office about assessment completion"""
self.ensure_one()
ICP = self.env['ir.config_parameter'].sudo()
# Check if email notifications are enabled
if not ICP.get_param('fusion_claims.enable_email_notifications', 'True') == 'True':
return
# Get office notification emails from company
company = self.env.company
office_partners = company.sudo().x_fc_office_notification_ids
email_list = [p.email for p in office_partners if p.email]
office_emails = ', '.join(email_list)
if not office_emails:
_logger.warning("No office notification recipients configured for accessibility assessment completion")
return
type_labels = dict(self._fields['assessment_type'].selection)
type_label = type_labels.get(self.assessment_type, 'Accessibility')
body = self._email_build(
title='Accessibility Assessment Completed',
summary=f'A new {type_label.lower()} assessment has been completed for '
f'<strong>{self.client_name}</strong>. A sale order has been created.',
email_type='info',
sections=[('Assessment Details', [
('Type', type_label),
('Reference', self.reference),
('Client', self.client_name),
('Sales Rep', self.sales_rep_id.name if self.sales_rep_id else 'N/A'),
('Sale Order', sale_order.name),
])],
button_url=f'{sale_order.get_base_url()}/web#id={sale_order.id}&model=sale.order&view_type=form',
button_text='View Sale Order',
)
# Send email
mail_values = {
'subject': f'Accessibility Assessment Completed: {type_label} - {self.client_name}',
'body_html': body,
'email_to': office_emails,
'email_from': self.env.company.email or 'noreply@example.com',
}
try:
mail = self.env['mail.mail'].sudo().create(mail_values)
mail.send()
_logger.info(f"Sent accessibility assessment completion email to {office_emails}")
except Exception as e:
_logger.error(f"Failed to send assessment completion email: {e}")
def _schedule_followup_activity(self, sale_order):
"""Schedule a follow-up activity for the sales rep"""
self.ensure_one()
if not self.sales_rep_id:
return
type_labels = dict(self._fields['assessment_type'].selection)
type_label = type_labels.get(self.assessment_type, 'Accessibility')
# Get the "To Do" activity type
activity_type = self.env.ref('mail.mail_activity_data_todo', raise_if_not_found=False)
if not activity_type:
_logger.warning("Could not find 'To Do' activity type")
return
# Schedule activity for tomorrow
due_date = fields.Date.today() + timedelta(days=1)
try:
sale_order.activity_schedule(
activity_type_id=activity_type.id,
date_deadline=due_date,
user_id=self.sales_rep_id.id,
summary=f'Follow up on {type_label} Assessment',
note=f'Assessment {self.reference} for {self.client_name} has been completed. Please follow up with the client.',
)
_logger.info(f"Scheduled follow-up activity for {self.sales_rep_id.name} on SO {sale_order.name}")
except Exception as e:
_logger.error(f"Failed to schedule follow-up activity: {e}")
def _ensure_partner(self):
"""Find or create a partner for the client"""
self.ensure_one()
Partner = self.env['res.partner'].sudo()
# First, try to find existing partner by email
if self.client_email:
existing = Partner.search([('email', '=ilike', self.client_email)], limit=1)
if existing:
return existing
# Create new partner
partner_vals = {
'name': self.client_name,
'email': self.client_email,
'phone': self.client_phone,
'street': self.client_address_street or self.client_address,
'street2': self.client_unit or False,
'city': self.client_address_city,
'zip': self.client_address_postal,
'customer_rank': 1,
}
# Set province/state if provided
if self.client_address_province:
state = self.env['res.country.state'].sudo().search([
('code', '=ilike', self.client_address_province),
('country_id.code', '=', 'CA'),
], limit=1)
if state:
partner_vals['state_id'] = state.id
partner_vals['country_id'] = state.country_id.id
else:
# Default to Canada
canada = self.env.ref('base.ca', raise_if_not_found=False)
if canada:
partner_vals['country_id'] = canada.id
partner = Partner.create(partner_vals)
_logger.info(f"Created partner {partner.name} from accessibility assessment {self.reference}")
return partner
def _create_draft_sale_order(self, partner):
"""Create a draft sale order from the assessment"""
self.ensure_one()
SaleOrder = self.env['sale.order'].sudo()
type_labels = dict(self._fields['assessment_type'].selection)
type_label = type_labels.get(self.assessment_type, 'Accessibility')
so_vals = {
'partner_id': partner.id,
'user_id': self.sales_rep_id.id if self.sales_rep_id else self.env.user.id,
'state': 'draft',
'origin': f'Accessibility: {self.reference} ({type_label})',
'x_fc_sale_type': 'direct_private', # Accessibility items typically private pay
}
sale_order = SaleOrder.create(so_vals)
_logger.info(f"Created draft sale order {sale_order.name} from accessibility assessment {self.reference}")
# Post assessment details to chatter
assessment_html = self._format_assessment_html_table()
sale_order.message_post(
body=Markup(assessment_html),
message_type='comment',
subtype_xmlid='mail.mt_note',
)
return sale_order
def _format_assessment_html_table(self):
"""Format assessment details as HTML for chatter"""
type_labels = dict(self._fields['assessment_type'].selection)
type_label = type_labels.get(self.assessment_type, 'Unknown')
html = f'''
<div class="alert alert-info" role="alert">
<h5 class="alert-heading"><i class="fa fa-wheelchair"></i> Accessibility Assessment: {type_label}</h5>
<p><strong>Reference:</strong> {self.reference}<br/>
<strong>Client:</strong> {self.client_name}<br/>
<strong>Address:</strong> {self.client_address or 'N/A'}<br/>
<strong>Date:</strong> {self.assessment_date}</p>
'''
# Add type-specific details
if self.assessment_type == 'stairlift_straight':
html += f'''
<hr>
<p><strong>Straight Stair Lift Details:</strong></p>
<ul>
<li>Steps: {self.stair_steps or 'N/A'}</li>
<li>Nose to Nose: {self.stair_nose_to_nose or 0}" per step</li>
<li>Installation Side: {self.stair_side or 'N/A'}</li>
<li>Style: {dict(self._fields['stair_style'].selection or {}).get(self.stair_style, 'N/A')}</li>
<li>Calculated Track Length: {self.stair_calculated_length:.1f}"</li>
<li>Final Track Length: {self.stair_final_length:.1f}"</li>
</ul>
<p><strong>Features:</strong></p>
<ul>
{'<li>Power Swivel (Upstairs)</li>' if self.stair_power_swivel_upstairs else ''}
{'<li>Power Folding Footrest</li>' if self.stair_power_folding_footrest else ''}
</ul>
'''
elif self.assessment_type == 'stairlift_curved':
# Format landing types for display
top_landing_display = dict(self._fields['stair_top_landing_type'].selection or {}).get(self.stair_top_landing_type, 'Standard')
bottom_landing_display = dict(self._fields['stair_bottom_landing_type'].selection or {}).get(self.stair_bottom_landing_type, 'Standard')
# Add custom overrun values if applicable
if self.stair_top_landing_type == 'vertical_overrun' and self.top_overrun_custom_length:
top_landing_display += f' ({self.top_overrun_custom_length:.1f}")'
if self.stair_bottom_landing_type == 'horizontal_overrun' and self.bottom_overrun_custom_length:
bottom_landing_display += f' ({self.bottom_overrun_custom_length:.1f}")'
html += f'''
<hr>
<p><strong>Curved Stair Lift Details:</strong></p>
<ul>
<li>Steps: {self.stair_curved_steps or 'N/A'}</li>
<li>Number of Curves: {self.stair_curves_count or 0}</li>
<li>Top Landing: {top_landing_display}</li>
<li>Bottom Landing: {bottom_landing_display}</li>
<li>Calculated Track Length: {self.stair_curved_calculated_length:.1f}"</li>
<li>Final Track Length: {self.stair_curved_final_length:.1f}"</li>
</ul>
<p><strong>Features:</strong></p>
<ul>
{'<li>Power Swivel (Upstairs)</li>' if self.stair_power_swivel_upstairs else ''}
{'<li>Power Swivel (Downstairs)</li>' if self.stair_power_swivel_downstairs else ''}
{'<li>Auto Folding Footrest</li>' if self.stair_auto_folding_footrest else ''}
{'<li>Auto Folding Hinge</li>' if self.stair_auto_folding_hinge else ''}
{'<li>Auto Folding Seat</li>' if self.stair_auto_folding_seat else ''}
{'<li>Customizable Color</li>' if self.stair_custom_color else ''}
{'<li>Additional Charging Station</li>' if self.stair_additional_charging else ''}
{'<li>Charging with Remote</li>' if self.stair_charging_with_remote else ''}
</ul>
'''
elif self.assessment_type == 'vpl':
html += f'''
<hr>
<p><strong>Vertical Platform Lift Details:</strong></p>
<ul>
<li>Room Dimensions: {self.vpl_room_width or 0}" W x {self.vpl_room_depth or 0}" D</li>
<li>Rise Height: {self.vpl_rise_height or 0}"</li>
<li>Existing Platform: {'Yes' if self.vpl_has_existing_platform else 'No'}</li>
<li>Concrete Depth: {self.vpl_concrete_depth or 0}" (min 4" required)</li>
<li>Model Type: {dict(self._fields['vpl_model_type'].selection or {}).get(self.vpl_model_type, 'N/A')}</li>
<li>Power Plug Nearby: {'Yes' if self.vpl_has_nearby_plug else 'No'}</li>
<li>Needs Plug Installation: {'Yes' if self.vpl_needs_plug_install else 'No'}</li>
<li>Needs Certification: {'Yes' if self.vpl_needs_certification else 'No'}</li>
</ul>
'''
elif self.assessment_type == 'ceiling_lift':
html += f'''
<hr>
<p><strong>Ceiling Lift Details:</strong></p>
<ul>
<li>Track Length: {self.ceiling_track_length or 0} feet</li>
<li>Movement Type: {dict(self._fields['ceiling_movement_type'].selection or {}).get(self.ceiling_movement_type, 'N/A')}</li>
<li>Charging Throughout Track: {'Yes' if self.ceiling_charging_throughout else 'No'}</li>
<li>Carry Bar: {'Yes' if self.ceiling_carry_bar else 'No'}</li>
<li>Additional Slings: {self.ceiling_additional_slings or 0}</li>
</ul>
'''
elif self.assessment_type == 'ramp':
html += f'''
<hr>
<p><strong>Custom Ramp Details:</strong></p>
<ul>
<li>Height: {self.ramp_height or 0}" from ground</li>
<li>Ground Incline: {self.ramp_ground_incline or 0}°</li>
<li>At Door: {'Yes (5ft landing required)' if self.ramp_at_door else 'No'}</li>
<li>Calculated Ramp Length: {self.ramp_calculated_length:.1f}" ({self.ramp_calculated_length/12:.1f} ft)</li>
<li>Landings Needed: {self.ramp_landings_needed or 0} (5ft each)</li>
<li>Total Length with Landings: {self.ramp_total_length:.1f}" ({self.ramp_total_length/12:.1f} ft)</li>
<li>Handrail Height: {self.ramp_handrail_height or 32}"</li>
</ul>
'''
elif self.assessment_type == 'bathroom':
html += f'''
<hr>
<p><strong>Bathroom Modification Description:</strong></p>
<p>{self.bathroom_description or 'No description provided.'}</p>
'''
elif self.assessment_type == 'tub_cutout':
html += f'''
<hr>
<p><strong>Tub Cutout Details:</strong></p>
<ul>
<li>Internal Height: {self.tub_internal_height or 0}"</li>
<li>External Height: {self.tub_external_height or 0}"</li>
</ul>
<p><strong>Additional Supplies:</strong></p>
<p>{self.tub_additional_supplies or 'None specified.'}</p>
'''
# Add general notes
if self.notes:
html += f'''
<hr>
<p><strong>Notes:</strong></p>
<p>{self.notes}</p>
'''
html += '</div>'
return html
def action_cancel(self):
"""Cancel the assessment"""
self.ensure_one()
self.write({'state': 'cancelled'})
def action_reset_to_draft(self):
"""Reset to draft state"""
self.ensure_one()
self.write({'state': 'draft'})

View File

@@ -1,183 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
from odoo.exceptions import UserError
import base64
import logging
_logger = logging.getLogger(__name__)
class ADPDocument(models.Model):
_name = 'fusion.adp.document'
_description = 'ADP Application Document'
_order = 'upload_date desc, revision desc'
_rec_name = 'display_name'
# Relationships
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
ondelete='cascade',
index=True,
)
assessment_id = fields.Many2one(
'fusion.assessment',
string='Assessment',
ondelete='cascade',
index=True,
)
# Document Type
document_type = fields.Selection([
('full_application', 'Full ADP Application (14 pages)'),
('pages_11_12', 'Pages 11 & 12 (Signature Pages)'),
('page_11', 'Page 11 Only (Authorizer Signature)'),
('page_12', 'Page 12 Only (Client Signature)'),
('submitted_final', 'Final Submitted Application'),
('assessment_report', 'Assessment Report'),
('assessment_signed', 'Signed Pages from Assessment'),
('other', 'Other Document'),
], string='Document Type', required=True, default='full_application')
# File Data
file = fields.Binary(
string='File',
required=True,
attachment=True,
)
filename = fields.Char(
string='Filename',
required=True,
)
file_size = fields.Integer(
string='File Size (bytes)',
compute='_compute_file_size',
store=True,
)
mimetype = fields.Char(
string='MIME Type',
default='application/pdf',
)
# Revision Tracking
revision = fields.Integer(
string='Revision',
default=1,
readonly=True,
)
revision_note = fields.Text(
string='Revision Note',
help='Notes about what changed in this revision',
)
is_current = fields.Boolean(
string='Is Current Version',
default=True,
index=True,
)
# Upload Information
uploaded_by = fields.Many2one(
'res.users',
string='Uploaded By',
default=lambda self: self.env.user,
readonly=True,
)
upload_date = fields.Datetime(
string='Upload Date',
default=fields.Datetime.now,
readonly=True,
)
source = fields.Selection([
('authorizer', 'Authorizer Portal'),
('sales_rep', 'Sales Rep Portal'),
('internal', 'Internal User'),
('assessment', 'Assessment Form'),
], string='Source', default='internal')
# Display
display_name = fields.Char(
string='Display Name',
compute='_compute_display_name',
store=True,
)
@api.depends('file')
def _compute_file_size(self):
for doc in self:
if doc.file:
doc.file_size = len(base64.b64decode(doc.file))
else:
doc.file_size = 0
@api.depends('document_type', 'filename', 'revision')
def _compute_display_name(self):
type_labels = dict(self._fields['document_type'].selection)
for doc in self:
type_label = type_labels.get(doc.document_type, doc.document_type)
doc.display_name = f"{type_label} - v{doc.revision} ({doc.filename or 'No file'})"
@api.model_create_multi
def create(self, vals_list):
"""Override create to handle revision numbering"""
for vals in vals_list:
# Find existing documents of the same type for the same order/assessment
domain = [('document_type', '=', vals.get('document_type'))]
if vals.get('sale_order_id'):
domain.append(('sale_order_id', '=', vals.get('sale_order_id')))
if vals.get('assessment_id'):
domain.append(('assessment_id', '=', vals.get('assessment_id')))
existing = self.search(domain, order='revision desc', limit=1)
if existing:
# Mark existing as not current and increment revision
existing.is_current = False
vals['revision'] = existing.revision + 1
else:
vals['revision'] = 1
vals['is_current'] = True
return super().create(vals_list)
def action_download(self):
"""Download the document"""
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'url': f'/web/content/{self._name}/{self.id}/file/{self.filename}?download=true',
'target': 'self',
}
def get_document_url(self):
"""Get the download URL for portal access"""
self.ensure_one()
return f'/my/authorizer/document/{self.id}/download'
@api.model
def get_documents_for_order(self, sale_order_id, document_type=None, current_only=True):
"""Get documents for a sale order, optionally filtered by type"""
domain = [('sale_order_id', '=', sale_order_id)]
if document_type:
domain.append(('document_type', '=', document_type))
if current_only:
domain.append(('is_current', '=', True))
return self.search(domain, order='document_type, revision desc')
@api.model
def get_revision_history(self, sale_order_id, document_type):
"""Get all revisions of a specific document type"""
return self.search([
('sale_order_id', '=', sale_order_id),
('document_type', '=', document_type),
], order='revision desc')

View File

@@ -1,85 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
import logging
_logger = logging.getLogger(__name__)
class AuthorizerComment(models.Model):
_name = 'fusion.authorizer.comment'
_description = 'Authorizer/Sales Rep Comment'
_order = 'create_date desc'
_rec_name = 'display_name'
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
required=True,
ondelete='cascade',
index=True,
)
assessment_id = fields.Many2one(
'fusion.assessment',
string='Assessment',
ondelete='cascade',
index=True,
)
author_id = fields.Many2one(
'res.partner',
string='Author',
required=True,
default=lambda self: self.env.user.partner_id,
index=True,
)
author_user_id = fields.Many2one(
'res.users',
string='Author User',
default=lambda self: self.env.user,
index=True,
)
comment = fields.Text(
string='Comment',
required=True,
)
comment_type = fields.Selection([
('general', 'General Comment'),
('question', 'Question'),
('update', 'Status Update'),
('internal', 'Internal Note'),
], string='Type', default='general')
is_internal = fields.Boolean(
string='Internal Only',
default=False,
help='If checked, this comment will not be visible to portal users',
)
display_name = fields.Char(
string='Display Name',
compute='_compute_display_name',
store=True,
)
@api.depends('author_id', 'create_date')
def _compute_display_name(self):
for comment in self:
if comment.author_id and comment.create_date:
comment.display_name = f"{comment.author_id.name} - {comment.create_date.strftime('%Y-%m-%d %H:%M')}"
else:
comment.display_name = _('New Comment')
@api.model_create_multi
def create(self, vals_list):
"""Override create to set author from current user if not provided"""
for vals in vals_list:
if not vals.get('author_id'):
vals['author_id'] = self.env.user.partner_id.id
if not vals.get('author_user_id'):
vals['author_user_id'] = self.env.user.id
return super().create(vals_list)

View File

@@ -1,328 +0,0 @@
# -*- coding: utf-8 -*-
# Fusion PDF Template Engine
# Generic system for filling any funding agency's PDF forms
import base64
import logging
from io import BytesIO
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FusionPdfTemplate(models.Model):
_name = 'fusion.pdf.template'
_description = 'PDF Form Template'
_order = 'category, name'
name = fields.Char(string='Template Name', required=True)
category = fields.Selection([
('adp', 'ADP - Assistive Devices Program'),
('mod', 'March of Dimes'),
('odsp', 'ODSP'),
('hardship', 'Hardship Funding'),
('other', 'Other'),
], string='Funding Agency', required=True, default='adp')
version = fields.Char(string='Form Version', default='1.0')
state = fields.Selection([
('draft', 'Draft'),
('active', 'Active'),
('archived', 'Archived'),
], string='Status', default='draft', tracking=True)
# The actual PDF template file
pdf_file = fields.Binary(string='PDF Template', required=True, attachment=True)
pdf_filename = fields.Char(string='PDF Filename')
page_count = fields.Integer(
string='Page Count',
compute='_compute_page_count',
store=True,
)
# Page preview images for the visual editor
preview_ids = fields.One2many(
'fusion.pdf.template.preview', 'template_id',
string='Page Previews',
)
# Field positions configured via the visual editor
field_ids = fields.One2many(
'fusion.pdf.template.field', 'template_id',
string='Template Fields',
)
field_count = fields.Integer(
string='Fields',
compute='_compute_field_count',
)
notes = fields.Text(
string='Notes',
help='Usage notes, which assessments/forms use this template',
)
def write(self, vals):
res = super().write(vals)
if 'pdf_file' in vals and vals['pdf_file']:
for rec in self:
try:
rec.action_generate_previews()
except Exception as e:
_logger.warning("Auto preview generation failed for %s: %s", rec.name, e)
return res
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
for rec in records:
if rec.pdf_file:
try:
rec.action_generate_previews()
except Exception as e:
_logger.warning("Auto preview generation failed for %s: %s", rec.name, e)
return records
@api.depends('pdf_file')
def _compute_page_count(self):
for rec in self:
if rec.pdf_file:
try:
from odoo.tools.pdf import PdfFileReader
pdf_data = base64.b64decode(rec.pdf_file)
reader = PdfFileReader(BytesIO(pdf_data))
rec.page_count = reader.getNumPages()
except Exception as e:
_logger.warning("Could not read PDF page count: %s", e)
rec.page_count = 0
else:
rec.page_count = 0
def action_generate_previews(self):
"""Generate PNG preview images from the PDF using poppler (pdftoppm).
Falls back gracefully if the PDF is protected or poppler is not available.
"""
self.ensure_one()
if not self.pdf_file:
raise UserError(_('Please upload a PDF file first.'))
import subprocess
import tempfile
import os
pdf_data = base64.b64decode(self.pdf_file)
try:
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, 'template.pdf')
with open(pdf_path, 'wb') as f:
f.write(pdf_data)
# Use pdftoppm to convert each page to PNG
result = subprocess.run(
['pdftoppm', '-png', '-r', '200', pdf_path, os.path.join(tmpdir, 'page')],
capture_output=True, timeout=30,
)
if result.returncode != 0:
stderr = result.stderr.decode('utf-8', errors='replace')
_logger.warning("pdftoppm failed: %s", stderr)
raise UserError(_(
'Could not generate previews automatically. '
'The PDF may be protected. Please upload preview images manually '
'in the Page Previews tab (screenshots of each page).'
))
# Find generated PNG files
png_files = sorted([
f for f in os.listdir(tmpdir)
if f.startswith('page-') and f.endswith('.png')
])
if not png_files:
raise UserError(_('No pages were generated. Please upload preview images manually.'))
# Delete existing previews
self.preview_ids.unlink()
# Create preview records
for idx, png_file in enumerate(png_files):
png_path = os.path.join(tmpdir, png_file)
with open(png_path, 'rb') as f:
image_data = base64.b64encode(f.read())
self.env['fusion.pdf.template.preview'].create({
'template_id': self.id,
'page': idx + 1,
'image': image_data,
'image_filename': f'page_{idx + 1}.png',
})
_logger.info("Generated %d preview images for template %s", len(png_files), self.name)
except subprocess.TimeoutExpired:
raise UserError(_('PDF conversion timed out. Please upload preview images manually.'))
except FileNotFoundError:
raise UserError(_(
'poppler-utils (pdftoppm) is not installed on the server. '
'Please upload preview images manually in the Page Previews tab.'
))
@api.depends('field_ids')
def _compute_field_count(self):
for rec in self:
rec.field_count = len(rec.field_ids)
def action_activate(self):
"""Set template to active."""
self.ensure_one()
if not self.pdf_file:
raise UserError(_('Please upload a PDF file before activating.'))
self.state = 'active'
def action_archive(self):
"""Archive the template."""
self.ensure_one()
self.state = 'archived'
def action_reset_draft(self):
"""Reset to draft."""
self.ensure_one()
self.state = 'draft'
def action_open_field_editor(self):
"""Open the visual field position editor."""
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'url': f'/fusion/pdf-editor/{self.id}',
'target': 'new',
}
def generate_filled_pdf(self, context_data, signatures=None):
"""Generate a filled PDF using this template and the provided data.
Args:
context_data: flat dict of {field_key: value}
signatures: dict of {field_key: binary_png} for signature fields
Returns:
bytes of the filled PDF
"""
self.ensure_one()
if not self.pdf_file:
raise UserError(_('Template has no PDF file.'))
if self.state != 'active':
_logger.warning("Generating PDF from non-active template %s", self.name)
from ..utils.pdf_filler import PDFTemplateFiller
template_bytes = base64.b64decode(self.pdf_file)
# Build fields_by_page dict
fields_by_page = {}
for field in self.field_ids.filtered(lambda f: f.is_active):
page = field.page
if page not in fields_by_page:
fields_by_page[page] = []
fields_by_page[page].append({
'field_name': field.name,
'field_key': field.field_key or field.name,
'pos_x': field.pos_x,
'pos_y': field.pos_y,
'width': field.width,
'height': field.height,
'field_type': field.field_type,
'font_size': field.font_size,
'font_name': field.font_name or 'Helvetica',
'text_align': field.text_align or 'left',
})
return PDFTemplateFiller.fill_template(
template_bytes, fields_by_page, context_data, signatures
)
class FusionPdfTemplatePreview(models.Model):
_name = 'fusion.pdf.template.preview'
_description = 'PDF Template Page Preview'
_order = 'page'
template_id = fields.Many2one(
'fusion.pdf.template', string='Template',
required=True, ondelete='cascade', index=True,
)
page = fields.Integer(string='Page Number', required=True, default=1)
image = fields.Binary(string='Page Image (PNG)', attachment=True)
image_filename = fields.Char(string='Image Filename')
class FusionPdfTemplateField(models.Model):
_name = 'fusion.pdf.template.field'
_description = 'PDF Template Field'
_order = 'page, sequence'
template_id = fields.Many2one(
'fusion.pdf.template', string='Template',
required=True, ondelete='cascade', index=True,
)
name = fields.Char(
string='Field Name', required=True,
help='Internal identifier, e.g. client_last_name',
)
label = fields.Char(
string='Display Label',
help='Human-readable label shown in the editor, e.g. "Last Name"',
)
sequence = fields.Integer(string='Sequence', default=10)
page = fields.Integer(string='Page', default=1, required=True)
# Percentage-based positioning (0.0 to 1.0) -- same as sign.item
pos_x = fields.Float(
string='Position X', digits=(4, 3),
help='Horizontal position as ratio (0.0 = left edge, 1.0 = right edge)',
)
pos_y = fields.Float(
string='Position Y', digits=(4, 3),
help='Vertical position as ratio (0.0 = top edge, 1.0 = bottom edge)',
)
width = fields.Float(
string='Width', digits=(4, 3), default=0.150,
help='Width as ratio of page width',
)
height = fields.Float(
string='Height', digits=(4, 3), default=0.015,
help='Height as ratio of page height',
)
# Rendering settings
field_type = fields.Selection([
('text', 'Text'),
('checkbox', 'Checkbox'),
('signature', 'Signature Image'),
('date', 'Date'),
], string='Field Type', default='text', required=True)
font_size = fields.Float(string='Font Size', default=10.0)
font_name = fields.Selection([
('Helvetica', 'Helvetica'),
('Courier', 'Courier'),
('Times-Roman', 'Times Roman'),
], string='Font', default='Helvetica')
text_align = fields.Selection([
('left', 'Left'),
('center', 'Center'),
('right', 'Right'),
], string='Text Alignment', default='left')
# Data mapping
field_key = fields.Char(
string='Data Key',
help='Key to look up in the data context dict.\n'
'Examples: client_last_name, client_health_card, consent_date, signature_page_11\n'
'The generating code passes a flat dict of all available data.',
)
default_value = fields.Char(
string='Default Value',
help='Fallback value if field_key returns empty',
)
is_active = fields.Boolean(string='Active', default=True)

View File

@@ -1,764 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from markupsafe import Markup, escape
import logging
_logger = logging.getLogger(__name__)
class ResPartner(models.Model):
_inherit = 'res.partner'
# Portal Role Flags
is_authorizer = fields.Boolean(
string='Is Authorizer',
default=False,
help='Check if this partner is an Authorizer (OT/Therapist) who can access the Authorizer Portal',
)
is_sales_rep_portal = fields.Boolean(
string='Is Sales Rep (Portal)',
default=False,
help='Check if this partner is a Sales Rep who can access the Sales Rep Portal',
)
is_client_portal = fields.Boolean(
string='Is Client (Portal)',
default=False,
help='Check if this partner can access the Funding Claims Portal to view their claims',
)
is_technician_portal = fields.Boolean(
string='Is Technician (Portal)',
default=False,
help='Check if this partner is a Field Technician who can access the Technician Portal for deliveries',
)
# Computed field for assigned deliveries (for technicians)
assigned_delivery_count = fields.Integer(
string='Assigned Deliveries',
compute='_compute_assigned_delivery_count',
help='Number of sale orders assigned to this partner as delivery technician',
)
# Geocoding coordinates (for travel time calculations)
x_fc_latitude = fields.Float(
string='Latitude',
digits=(10, 7),
help='GPS latitude of the partner address (auto-geocoded)',
)
x_fc_longitude = fields.Float(
string='Longitude',
digits=(10, 7),
help='GPS longitude of the partner address (auto-geocoded)',
)
# Link to portal user account
authorizer_portal_user_id = fields.Many2one(
'res.users',
string='Portal User Account',
help='The portal user account linked to this authorizer/sales rep',
copy=False,
)
# Portal access status tracking
portal_access_status = fields.Selection(
selection=[
('no_access', 'No Access'),
('invited', 'Invited'),
('active', 'Active'),
],
string='Portal Status',
compute='_compute_portal_access_status',
store=True,
help='Tracks portal access: No Access = no portal user, Invited = user created but never logged in, Active = user has logged in',
)
# Computed counts
assigned_case_count = fields.Integer(
string='Assigned Cases',
compute='_compute_assigned_case_count',
help='Number of sale orders assigned to this partner as authorizer',
)
assessment_count = fields.Integer(
string='Assessments',
compute='_compute_assessment_count',
help='Number of assessments linked to this partner',
)
@api.depends('authorizer_portal_user_id', 'authorizer_portal_user_id.login_date')
def _compute_portal_access_status(self):
"""Compute portal access status based on user account and login history."""
for partner in self:
if not partner.authorizer_portal_user_id:
partner.portal_access_status = 'no_access'
elif partner.authorizer_portal_user_id.login_date:
partner.portal_access_status = 'active'
else:
partner.portal_access_status = 'invited'
@api.depends('is_authorizer')
def _compute_assigned_case_count(self):
"""Count sale orders where this partner is the authorizer"""
SaleOrder = self.env['sale.order'].sudo()
for partner in self:
if partner.is_authorizer:
# Use x_fc_authorizer_id field from fusion_claims
domain = [('x_fc_authorizer_id', '=', partner.id)]
partner.assigned_case_count = SaleOrder.search_count(domain)
else:
partner.assigned_case_count = 0
@api.depends('is_authorizer', 'is_sales_rep_portal')
def _compute_assessment_count(self):
"""Count assessments where this partner is involved"""
Assessment = self.env['fusion.assessment'].sudo()
for partner in self:
count = 0
if partner.is_authorizer:
count += Assessment.search_count([('authorizer_id', '=', partner.id)])
if partner.is_sales_rep_portal and partner.authorizer_portal_user_id:
count += Assessment.search_count([('sales_rep_id', '=', partner.authorizer_portal_user_id.id)])
partner.assessment_count = count
@api.depends('is_technician_portal')
def _compute_assigned_delivery_count(self):
"""Count sale orders assigned to this partner as delivery technician"""
SaleOrder = self.env['sale.order'].sudo()
for partner in self:
if partner.is_technician_portal and partner.authorizer_portal_user_id:
# Technicians are linked via user_id in x_fc_delivery_technician_ids
domain = [('x_fc_delivery_technician_ids', 'in', [partner.authorizer_portal_user_id.id])]
partner.assigned_delivery_count = SaleOrder.search_count(domain)
else:
partner.assigned_delivery_count = 0
def _assign_portal_role_groups(self, portal_user):
"""Assign role-specific portal groups to a portal user based on contact checkboxes."""
groups_to_add = []
if self.is_technician_portal:
g = self.env.ref('fusion_authorizer_portal.group_technician_portal', raise_if_not_found=False)
if g and g not in portal_user.group_ids:
groups_to_add.append((4, g.id))
if self.is_authorizer:
g = self.env.ref('fusion_authorizer_portal.group_authorizer_portal', raise_if_not_found=False)
if g and g not in portal_user.group_ids:
groups_to_add.append((4, g.id))
if self.is_sales_rep_portal:
g = self.env.ref('fusion_authorizer_portal.group_sales_rep_portal', raise_if_not_found=False)
if g and g not in portal_user.group_ids:
groups_to_add.append((4, g.id))
if groups_to_add:
portal_user.sudo().write({'group_ids': groups_to_add})
def _assign_internal_role_groups(self, internal_user):
"""Assign backend groups to an internal user based on contact checkboxes.
Also sets x_fc_is_field_staff so the user appears in technician/staff dropdowns.
Returns list of group names that were added."""
added = []
needs_field_staff = False
if self.is_technician_portal:
# Add Field Technician group
g = self.env.ref('fusion_claims.group_field_technician', raise_if_not_found=False)
if g and g not in internal_user.group_ids:
internal_user.sudo().write({'group_ids': [(4, g.id)]})
added.append('Field Technician')
needs_field_staff = True
if self.is_sales_rep_portal:
# Internal sales reps don't need a portal group but should show in staff dropdowns
added.append('Sales Rep (internal)')
needs_field_staff = True
if self.is_authorizer:
# Internal authorizers already have full backend access
added.append('Authorizer (internal)')
# Mark as field staff so they appear in technician/delivery dropdowns
if needs_field_staff and hasattr(internal_user, 'x_fc_is_field_staff'):
if not internal_user.x_fc_is_field_staff:
internal_user.sudo().write({'x_fc_is_field_staff': True})
added.append('Field Staff')
return added
def action_grant_portal_access(self):
"""Grant portal access to this partner, or update permissions for existing users."""
self.ensure_one()
if not self.email:
raise UserError(_('Please set an email address before granting portal access.'))
email_normalized = self.email.strip().lower()
# ── Step 1: Find existing user ──
# Search by partner_id first (direct link)
existing_user = self.env['res.users'].sudo().search([
('partner_id', '=', self.id),
], limit=1)
# If not found by partner, search by email (handles internal users
# whose auto-created partner is different from this contact)
if not existing_user:
existing_user = self.env['res.users'].sudo().search([
'|',
('login', '=ilike', email_normalized),
('email', '=ilike', email_normalized),
], limit=1)
# ── Step 2: Handle existing user ──
if existing_user:
from datetime import datetime
self.authorizer_portal_user_id = existing_user
if not existing_user.share:
# ── INTERNAL user: assign backend groups, do NOT add portal ──
groups_added = self._assign_internal_role_groups(existing_user)
groups_text = ', '.join(groups_added) if groups_added else 'No new groups needed'
chatter_msg = Markup(
'<div style="border: 1px solid #6f42c1; border-radius: 4px; overflow: hidden; margin: 8px 0;">'
'<div style="background: #6f42c1; color: white; padding: 10px 12px; font-weight: 600;">'
'<i class="fa fa-user-circle"></i> Internal User &mdash; Permissions Updated'
'</div>'
'<div style="padding: 12px; background: #f8f5ff;">'
'<table style="font-size: 13px; width: 100%;">'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">User:</td><td>{escape(existing_user.name)} (ID: {existing_user.id})</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Login:</td><td>{escape(existing_user.login)}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Type:</td><td>Internal (backend) user</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Groups added:</td><td>{escape(groups_text)}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Updated by:</td><td>{escape(self.env.user.name)}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Updated at:</td><td>{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</td></tr>'
'</table>'
'</div>'
'</div>'
)
self.message_post(body=chatter_msg, message_type='notification', subtype_xmlid='mail.mt_note')
notify_msg = _('Internal user detected. Backend permissions updated: %s') % groups_text
else:
# ── Existing PORTAL user: ensure role groups are set ──
portal_group = self.env.ref('base.group_portal', raise_if_not_found=False)
if portal_group and portal_group not in existing_user.group_ids:
existing_user.sudo().write({'group_ids': [(4, portal_group.id)]})
self._assign_portal_role_groups(existing_user)
chatter_msg = Markup(
'<div style="border: 1px solid #17a2b8; border-radius: 4px; overflow: hidden; margin: 8px 0;">'
'<div style="background: #17a2b8; color: white; padding: 10px 12px; font-weight: 600;">'
'<i class="fa fa-check-circle"></i> Portal Access &mdash; Roles Updated'
'</div>'
'<div style="padding: 12px; background: #f0f9ff;">'
'<table style="font-size: 13px; width: 100%;">'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Status:</td><td>Portal user exists &mdash; roles updated</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">User:</td><td>{escape(existing_user.name)} (ID: {existing_user.id})</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Login:</td><td>{escape(existing_user.login)}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Checked by:</td><td>{escape(self.env.user.name)}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Checked at:</td><td>{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</td></tr>'
'</table>'
'</div>'
'</div>'
)
self.message_post(body=chatter_msg, message_type='notification', subtype_xmlid='mail.mt_note')
notify_msg = _('Portal user already exists — role groups updated (User ID: %s).') % existing_user.id
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Access Updated'),
'message': notify_msg,
'type': 'info',
'sticky': False,
}
}
# No existing user found - create portal user directly
portal_group = self.env.ref('base.group_portal', raise_if_not_found=False)
if not portal_group:
raise UserError(_('Portal group not found. Please contact administrator.'))
try:
# Create user without groups first (Odoo 17+ compatibility)
portal_user = self.env['res.users'].sudo().with_context(no_reset_password=True, knowledge_skip_onboarding_article=True).create({
'name': self.name,
'login': email_normalized,
'email': self.email,
'partner_id': self.id,
'active': True,
})
# Add portal group after creation
portal_user.sudo().write({
'group_ids': [(6, 0, [portal_group.id])],
})
# Assign role-specific portal groups based on contact checkboxes
self._assign_portal_role_groups(portal_user)
self.authorizer_portal_user_id = portal_user
# Create welcome Knowledge article for the user
self._create_welcome_article(portal_user)
# Send professional portal invitation email
email_sent = False
try:
email_sent = self._send_portal_invitation_email(portal_user)
except Exception as mail_error:
_logger.warning(f"Could not send portal invitation email: {mail_error}")
# Post message in chatter
sent_by = self.env.user.name
from datetime import datetime
sent_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
if email_sent:
status_text = '<span style="color: green;">Invitation email sent successfully</span>'
border_color = '#28a745'
header_bg = '#28a745'
body_bg = '#f0fff0'
else:
status_text = '<span style="color: orange;">User created but email could not be sent</span>'
border_color = '#fd7e14'
header_bg = '#fd7e14'
body_bg = '#fff8f0'
chatter_msg = Markup(
f'<div style="border: 1px solid {border_color}; border-radius: 4px; overflow: hidden; margin: 8px 0;">'
f'<div style="background: {header_bg}; color: white; padding: 10px 12px; font-weight: 600;">'
'<i class="fa fa-envelope"></i> Portal Access Granted'
'</div>'
f'<div style="padding: 12px; background: {body_bg};">'
'<table style="font-size: 13px; width: 100%;">'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Email:</td><td>{self.email}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Sent by:</td><td>{sent_by}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Sent at:</td><td>{sent_at}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">User ID:</td><td>{portal_user.id}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Status:</td><td>{status_text}</td></tr>'
'</table>'
'</div>'
'</div>'
)
self.message_post(body=chatter_msg, message_type='notification', subtype_xmlid='mail.mt_note')
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Portal Access Granted'),
'message': _('Portal user created for %s. A password reset email has been sent.') % self.email,
'type': 'success',
'sticky': False,
}
}
except Exception as e:
_logger.error(f"Failed to create portal user: {e}")
raise UserError(_('Failed to create portal user: %s') % str(e))
def _create_welcome_article(self, portal_user):
"""Create a role-specific welcome Knowledge article for the new portal user.
Determines the role from partner flags and renders the matching template.
The article is private to the user and set as a favorite.
"""
self.ensure_one()
# Check if Knowledge module is installed
if 'knowledge.article' not in self.env:
_logger.info("Knowledge module not installed, skipping welcome article")
return
# Determine role and template
if self.is_technician_portal:
template_xmlid = 'fusion_authorizer_portal.welcome_article_technician'
icon = '🔧'
title = f"Welcome {self.name} - Technician Portal"
elif self.is_authorizer:
template_xmlid = 'fusion_authorizer_portal.welcome_article_authorizer'
icon = '📋'
title = f"Welcome {self.name} - Authorizer Portal"
elif self.is_sales_rep_portal:
template_xmlid = 'fusion_authorizer_portal.welcome_article_sales_rep'
icon = '💼'
title = f"Welcome {self.name} - Sales Portal"
elif self.is_client_portal:
template_xmlid = 'fusion_authorizer_portal.welcome_article_client'
icon = '👤'
title = f"Welcome {self.name}"
else:
template_xmlid = 'fusion_authorizer_portal.welcome_article_client'
icon = '👋'
title = f"Welcome {self.name}"
company = self.env.company
render_ctx = {
'user_name': self.name or 'Valued Partner',
'company_name': company.name or 'Our Company',
'company_email': company.email or '',
'company_phone': company.phone or '',
}
try:
body = self.env['ir.qweb']._render(
template_xmlid,
render_ctx,
minimal_qcontext=True,
raise_if_not_found=False,
)
if not body:
_logger.warning(f"Welcome article template not found: {template_xmlid}")
return
article = self.env['knowledge.article'].sudo().create({
'name': title,
'icon': icon,
'body': body,
'internal_permission': 'none',
'is_article_visible_by_everyone': False,
'article_member_ids': [(0, 0, {
'partner_id': self.id,
'permission': 'write',
})],
'favorite_ids': [(0, 0, {
'sequence': 0,
'user_id': portal_user.id,
})],
})
_logger.info(f"Created welcome article '{title}' (ID: {article.id}) for {self.name}")
except Exception as e:
_logger.warning(f"Failed to create welcome article for {self.name}: {e}")
def _send_portal_invitation_email(self, portal_user, is_resend=False):
"""Send a professional portal invitation email to the partner.
Generates a signup URL and sends a branded invitation email
instead of the generic Odoo password reset email.
Returns True if email was sent successfully, False otherwise.
"""
self.ensure_one()
# Generate signup token and build URL
partner = portal_user.sudo().partner_id
# Set signup type to 'signup' - this auto-logs in after password is set
partner.signup_prepare(signup_type='signup')
# Use Odoo's built-in URL generation with signup_email context
# so the email is pre-filled and user just sets password
signup_urls = partner.with_context(
signup_valid=True,
create_user=True,
)._get_signup_url_for_action()
signup_url = signup_urls.get(partner.id)
if not signup_url:
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
signup_url = f"{base_url}/web/reset_password"
_logger.warning(f"Could not generate signup URL for {self.email}, using generic reset page")
company = self.env.company
company_name = company.name or 'Our Company'
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
partner_name = self.name or 'Valued Partner'
subject = f"You're Invited to the {company_name} Portal" if not is_resend else f"Portal Access Reminder - {company_name}"
invite_text = 'We are pleased to invite you' if not is_resend else 'This is a reminder that you have been invited'
body_html = (
f'<div style="font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Arial,sans-serif;'
f'max-width:600px;margin:0 auto;color:#2d3748;">'
f'<div style="height:4px;background-color:#2B6CB0;"></div>'
f'<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">'
f'<p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;'
f'text-transform:uppercase;margin:0 0 24px 0;">{company_name}</p>'
f'<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;">Portal Invitation</h2>'
f'<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">'
f'Dear {partner_name}, {invite_text} to access the <strong>{company_name} Portal</strong>.</p>'
f'<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:0 0 20px 0;">'
f'With the portal you can:</p>'
f'<ul style="color:#2d3748;font-size:14px;line-height:1.8;margin:0 0 24px 0;padding-left:20px;">'
f'<li>View and manage your assigned cases</li>'
f'<li>Complete assessments online</li>'
f'<li>Track application status and progress</li>'
f'<li>Access important documents</li></ul>'
f'<p style="text-align:center;margin:28px 0;">'
f'<a href="{signup_url}" style="display:inline-block;background:#2B6CB0;color:#ffffff;'
f'padding:12px 28px;text-decoration:none;border-radius:6px;font-size:14px;font-weight:600;">'
f'Accept Invitation &amp; Set Password</a></p>'
f'<p style="font-size:12px;color:#718096;text-align:center;margin:0 0 20px 0;">'
f'If the button does not work, copy this link: '
f'<a href="{signup_url}" style="color:#2B6CB0;word-break:break-all;">{signup_url}</a></p>'
f'<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">'
f'<p style="margin:0;font-size:14px;color:#2d3748;">'
f'After setting your password, access the portal anytime at: '
f'<a href="{base_url}/my" style="color:#2B6CB0;">{base_url}/my</a></p></div>'
f'<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:24px 0 0 0;">'
f'Best regards,<br/><strong>{company_name} Team</strong></p>'
f'</div>'
f'<div style="padding:16px 28px;text-align:center;">'
f'<p style="color:#a0aec0;font-size:11px;margin:0;">'
f'This is an automated message from {company_name}.</p></div></div>'
)
mail_values = {
'subject': subject,
'body_html': body_html,
'email_to': self.email,
'email_from': company.email or self.env.user.email or 'noreply@example.com',
'auto_delete': True,
}
try:
mail = self.env['mail.mail'].sudo().create(mail_values)
mail.send()
_logger.info(f"Portal invitation email sent to {self.email}")
return True
except Exception as e:
_logger.error(f"Failed to send portal invitation email to {self.email}: {e}")
return False
def action_resend_portal_invitation(self):
"""Resend portal invitation email to an existing portal user."""
self.ensure_one()
if not self.authorizer_portal_user_id:
raise UserError(_('No portal user found for this contact. Use "Send Portal Invitation" instead.'))
portal_user = self.authorizer_portal_user_id
# Send professional portal invitation email
email_sent = False
try:
email_sent = self._send_portal_invitation_email(portal_user, is_resend=True)
except Exception as mail_error:
_logger.warning(f"Could not send portal invitation email: {mail_error}")
# Post in chatter
from datetime import datetime
sent_by = self.env.user.name
sent_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
if email_sent:
chatter_msg = Markup(
'<div style="border: 1px solid #17a2b8; border-radius: 4px; overflow: hidden; margin: 8px 0;">'
'<div style="background: #17a2b8; color: white; padding: 10px 12px; font-weight: 600;">'
'<i class="fa fa-refresh"></i> Portal Invitation Resent'
'</div>'
'<div style="padding: 12px; background: #f0f9ff;">'
'<table style="font-size: 13px; width: 100%%;">'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Email:</td><td>{self.email}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Sent by:</td><td>{sent_by}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Sent at:</td><td>{sent_at}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">User ID:</td><td>{portal_user.id}</td></tr>'
'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Status:</td>'
'<td style="color: green;">Invitation email resent successfully</td></tr>'
'</table>'
'</div>'
'</div>'
)
else:
chatter_msg = Markup(
'<div style="border: 1px solid #fd7e14; border-radius: 4px; overflow: hidden; margin: 8px 0;">'
'<div style="background: #fd7e14; color: white; padding: 10px 12px; font-weight: 600;">'
'<i class="fa fa-refresh"></i> Portal Invitation Resend Attempted'
'</div>'
'<div style="padding: 12px; background: #fff8f0;">'
'<table style="font-size: 13px; width: 100%%;">'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Email:</td><td>{self.email}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Sent by:</td><td>{sent_by}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Sent at:</td><td>{sent_at}</td></tr>'
'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Status:</td>'
'<td style="color: orange;">Email could not be sent - check mail configuration</td></tr>'
'</table>'
'</div>'
'</div>'
)
self.message_post(body=chatter_msg, message_type='notification', subtype_xmlid='mail.mt_note')
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Portal Invitation Resent') if email_sent else _('Email Failed'),
'message': _('Portal invitation resent to %s.') % self.email if email_sent else _('Could not send email. Check mail configuration.'),
'type': 'success' if email_sent else 'warning',
'sticky': False,
}
}
def action_view_assigned_cases(self):
"""Open the list of assigned sale orders"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Assigned Cases'),
'res_model': 'sale.order',
'view_mode': 'list,form',
'domain': [('x_fc_authorizer_id', '=', self.id)],
'context': {'default_x_fc_authorizer_id': self.id},
}
def action_view_assessments(self):
"""Open the list of assessments for this partner"""
self.ensure_one()
domain = []
if self.is_authorizer:
domain = [('authorizer_id', '=', self.id)]
elif self.is_sales_rep_portal and self.authorizer_portal_user_id:
domain = [('sales_rep_id', '=', self.authorizer_portal_user_id.id)]
return {
'type': 'ir.actions.act_window',
'name': _('Assessments'),
'res_model': 'fusion.assessment',
'view_mode': 'list,form',
'domain': domain,
}
# ==================== BATCH ACTIONS ====================
def action_mark_as_authorizer(self):
"""Batch action to mark selected contacts as authorizers"""
self.write({'is_authorizer': True})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Authorizers Updated'),
'message': _('%d contact(s) marked as authorizer.') % len(self),
'type': 'success',
'sticky': False,
}
}
def action_batch_send_portal_invitation(self):
"""Batch action to send portal invitations to selected authorizers"""
sent_count = 0
skipped_no_email = 0
skipped_not_authorizer = 0
skipped_has_access = 0
errors = []
for partner in self:
if not partner.is_authorizer:
skipped_not_authorizer += 1
continue
if not partner.email:
skipped_no_email += 1
continue
if partner.authorizer_portal_user_id:
skipped_has_access += 1
continue
try:
partner.action_grant_portal_access()
sent_count += 1
except Exception as e:
errors.append(f"{partner.name}: {str(e)}")
# Build result message
messages = []
if sent_count:
messages.append(_('%d invitation(s) sent successfully.') % sent_count)
if skipped_not_authorizer:
messages.append(_('%d skipped (not marked as authorizer).') % skipped_not_authorizer)
if skipped_no_email:
messages.append(_('%d skipped (no email).') % skipped_no_email)
if skipped_has_access:
messages.append(_('%d skipped (already has portal access).') % skipped_has_access)
if errors:
messages.append(_('%d error(s) occurred.') % len(errors))
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Portal Invitations'),
'message': ' '.join(messages),
'type': 'success' if sent_count and not errors else 'warning' if not errors else 'danger',
'sticky': True if errors else False,
}
}
def action_mark_and_send_invitation(self):
"""Combined action: mark as authorizer and send invitation"""
self.action_mark_as_authorizer()
return self.action_batch_send_portal_invitation()
def action_view_assigned_deliveries(self):
"""Open the list of assigned deliveries for technician"""
self.ensure_one()
if not self.authorizer_portal_user_id:
raise UserError(_('This partner does not have a portal user account.'))
return {
'type': 'ir.actions.act_window',
'name': _('Assigned Deliveries'),
'res_model': 'sale.order',
'view_mode': 'list,form',
'domain': [('x_fc_delivery_technician_ids', 'in', [self.authorizer_portal_user_id.id])],
}
def action_mark_as_technician(self):
"""Batch action to mark selected contacts as technicians"""
self.write({'is_technician_portal': True})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Technicians Updated'),
'message': _('%d contact(s) marked as technician.') % len(self),
'type': 'success',
'sticky': False,
}
}
# ------------------------------------------------------------------
# GEOCODING
# ------------------------------------------------------------------
def _geocode_address(self):
"""Geocode partner address using Google Geocoding API and cache lat/lng."""
import requests as http_requests
api_key = self.env['ir.config_parameter'].sudo().get_param(
'fusion_claims.google_maps_api_key', ''
)
if not api_key:
return
for partner in self:
parts = [partner.street, partner.city,
partner.state_id.name if partner.state_id else '',
partner.zip]
address = ', '.join([p for p in parts if p])
if not address:
continue
try:
resp = http_requests.get(
'https://maps.googleapis.com/maps/api/geocode/json',
params={'address': address, 'key': api_key, 'region': 'ca'},
timeout=10,
)
data = resp.json()
if data.get('status') == 'OK' and data.get('results'):
loc = data['results'][0]['geometry']['location']
partner.write({
'x_fc_latitude': loc['lat'],
'x_fc_longitude': loc['lng'],
})
except Exception as e:
_logger.warning(f"Geocoding failed for partner {partner.id}: {e}")
def write(self, vals):
"""Override write to auto-geocode when address changes."""
res = super().write(vals)
address_fields = {'street', 'city', 'state_id', 'zip', 'country_id'}
if address_fields & set(vals.keys()):
# Check if distance matrix is enabled before geocoding
enabled = self.env['ir.config_parameter'].sudo().get_param(
'fusion_claims.google_distance_matrix_enabled', False
)
if enabled:
self._geocode_address()
return res

View File

@@ -1,119 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import api, models, _
from odoo.exceptions import UserError
import logging
_logger = logging.getLogger(__name__)
class PortalWizardUser(models.TransientModel):
"""Override standard portal wizard to handle internal users with Fusion roles."""
_inherit = 'portal.wizard.user'
def action_grant_access(self):
"""Override: Handle Fusion portal roles when granting portal access.
- Internal users with Fusion roles: assign backend groups, skip portal.
- Portal users with Fusion roles: standard flow + assign role groups.
"""
self.ensure_one()
partner = self.partner_id
# Check if the partner has any Fusion portal flags
has_fusion_role = getattr(partner, 'is_technician_portal', False) or \
getattr(partner, 'is_authorizer', False) or \
getattr(partner, 'is_sales_rep_portal', False)
# Find the linked user
user = self.user_id
if user and user._is_internal() and has_fusion_role:
# Internal user with Fusion roles -- assign backend groups, no portal
partner._assign_internal_role_groups(user)
partner.authorizer_portal_user_id = user
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Internal User Updated'),
'message': _('%s is an internal user. Backend permissions updated (no portal access needed).') % partner.name,
'type': 'info',
'sticky': True,
}
}
# Standard Odoo portal flow (creates user, sends email, etc.)
result = super().action_grant_access()
# After standard flow, assign Fusion portal role groups
if has_fusion_role:
portal_user = self.user_id
if not portal_user:
# Fallback: find the user that was just created
portal_user = self.env['res.users'].sudo().search([
('partner_id', '=', partner.id),
('share', '=', True),
('active', '=', True),
], limit=1)
if portal_user:
partner._assign_portal_role_groups(portal_user)
if not partner.authorizer_portal_user_id:
partner.authorizer_portal_user_id = portal_user
_logger.info("Assigned Fusion portal role groups to user %s (partner: %s)",
portal_user.login, partner.name)
return result
class ResUsers(models.Model):
_inherit = 'res.users'
def _generate_tutorial_articles(self):
"""Override to create custom welcome articles for internal staff
instead of the default Odoo Knowledge onboarding article.
"""
if 'knowledge.article' not in self.env:
return super()._generate_tutorial_articles()
for user in self:
company = user.company_id or self.env.company
render_ctx = {
'user_name': user.name or 'Team Member',
'company_name': company.name or 'Our Company',
'company_email': company.email or '',
'company_phone': company.phone or '',
}
try:
body = self.env['ir.qweb']._render(
'fusion_authorizer_portal.welcome_article_internal',
render_ctx,
minimal_qcontext=True,
raise_if_not_found=False,
)
if not body:
_logger.warning("Internal staff welcome template not found, using default")
return super()._generate_tutorial_articles()
self.env['knowledge.article'].sudo().create({
'name': f"Welcome {user.name} - {company.name}",
'icon': '🏢',
'body': body,
'internal_permission': 'none',
'is_article_visible_by_everyone': False,
'article_member_ids': [(0, 0, {
'partner_id': user.partner_id.id,
'permission': 'write',
})],
'favorite_ids': [(0, 0, {
'sequence': 0,
'user_id': user.id,
})],
})
_logger.info(f"Created custom welcome article for internal user {user.name}")
except Exception as e:
_logger.warning(f"Failed to create custom welcome article for {user.name}: {e}")
# Fall back to default
super(ResUsers, user)._generate_tutorial_articles()

View File

@@ -1,250 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
import logging
_logger = logging.getLogger(__name__)
class SaleOrder(models.Model):
_inherit = 'sale.order'
# Comments from portal users
portal_comment_ids = fields.One2many(
'fusion.authorizer.comment',
'sale_order_id',
string='Portal Comments',
)
portal_comment_count = fields.Integer(
string='Comment Count',
compute='_compute_portal_comment_count',
)
# Documents uploaded via portal
portal_document_ids = fields.One2many(
'fusion.adp.document',
'sale_order_id',
string='Portal Documents',
)
portal_document_count = fields.Integer(
string='Document Count',
compute='_compute_portal_document_count',
)
# Link to assessment
assessment_id = fields.Many2one(
'fusion.assessment',
string='Source Assessment',
readonly=True,
help='The assessment that created this sale order',
)
# Authorizer helper field (consolidates multiple possible fields)
portal_authorizer_id = fields.Many2one(
'res.partner',
string='Authorizer (Portal)',
compute='_compute_portal_authorizer_id',
store=True,
help='Consolidated authorizer field for portal access',
)
@api.depends('portal_comment_ids')
def _compute_portal_comment_count(self):
for order in self:
order.portal_comment_count = len(order.portal_comment_ids)
@api.depends('portal_document_ids')
def _compute_portal_document_count(self):
for order in self:
order.portal_document_count = len(order.portal_document_ids)
@api.depends('x_fc_authorizer_id')
def _compute_portal_authorizer_id(self):
"""Get authorizer from x_fc_authorizer_id field"""
for order in self:
order.portal_authorizer_id = order.x_fc_authorizer_id
def write(self, vals):
"""Override write to send notification when authorizer is assigned."""
old_authorizers = {
order.id: order.x_fc_authorizer_id.id if order.x_fc_authorizer_id else False
for order in self
}
result = super().write(vals)
# Check for authorizer changes
if 'x_fc_authorizer_id' in vals:
for order in self:
old_auth = old_authorizers.get(order.id)
new_auth = vals.get('x_fc_authorizer_id')
if new_auth and new_auth != old_auth:
order._send_authorizer_assignment_notification()
# NOTE: Generic status change notifications removed.
# Each status transition already sends its own detailed email
# from fusion_claims (approval, denial, submission, billed, etc.)
# A generic "status changed" email on top was redundant and lacked detail.
return result
def action_message_authorizer(self):
"""Open composer to send message to authorizer only"""
self.ensure_one()
if not self.x_fc_authorizer_id:
return {'type': 'ir.actions.act_window_close'}
return {
'type': 'ir.actions.act_window',
'name': 'Message Authorizer',
'res_model': 'mail.compose.message',
'view_mode': 'form',
'target': 'new',
'context': {
'default_model': 'sale.order',
'default_res_ids': [self.id],
'default_partner_ids': [self.x_fc_authorizer_id.id],
'default_composition_mode': 'comment',
'default_subtype_xmlid': 'mail.mt_note',
},
}
def _send_authorizer_assignment_notification(self):
"""Send email when an authorizer is assigned to the order"""
self.ensure_one()
if not self.x_fc_authorizer_id or not self.x_fc_authorizer_id.email:
return
try:
template = self.env.ref('fusion_authorizer_portal.mail_template_case_assigned', raise_if_not_found=False)
if template:
template.send_mail(self.id, force_send=False)
_logger.info(f"Sent case assignment notification to {self.x_fc_authorizer_id.email} for {self.name}")
except Exception as e:
_logger.error(f"Failed to send authorizer assignment notification: {e}")
# _send_status_change_notification removed -- redundant.
# Each workflow transition in fusion_claims sends its own detailed email.
def action_view_portal_comments(self):
"""View portal comments"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Portal Comments'),
'res_model': 'fusion.authorizer.comment',
'view_mode': 'list,form',
'domain': [('sale_order_id', '=', self.id)],
'context': {'default_sale_order_id': self.id},
}
def action_view_portal_documents(self):
"""View portal documents"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Portal Documents'),
'res_model': 'fusion.adp.document',
'view_mode': 'list,form',
'domain': [('sale_order_id', '=', self.id)],
'context': {'default_sale_order_id': self.id},
}
def get_portal_display_data(self):
"""Get data for portal display, excluding sensitive information"""
self.ensure_one()
return {
'id': self.id,
'name': self.name,
'date_order': self.date_order,
'state': self.state,
'state_display': dict(self._fields['state'].selection).get(self.state, self.state),
'partner_name': self.partner_id.name if self.partner_id else '',
'partner_address': self._get_partner_address_display(),
'client_reference_1': self.x_fc_client_ref_1 or '',
'client_reference_2': self.x_fc_client_ref_2 or '',
'claim_number': self.x_fc_claim_number or '',
'authorizer_name': self.x_fc_authorizer_id.name if self.x_fc_authorizer_id else '',
'sales_rep_name': self.user_id.name if self.user_id else '',
'product_lines': self._get_product_lines_for_portal(),
'comment_count': self.portal_comment_count,
'document_count': self.portal_document_count,
}
def _get_partner_address_display(self):
"""Get formatted partner address for display"""
if not self.partner_id:
return ''
parts = []
if self.partner_id.street:
parts.append(self.partner_id.street)
if self.partner_id.city:
city_part = self.partner_id.city
if self.partner_id.state_id:
city_part += f", {self.partner_id.state_id.name}"
if self.partner_id.zip:
city_part += f" {self.partner_id.zip}"
parts.append(city_part)
return ', '.join(parts)
def _get_product_lines_for_portal(self):
"""Get product lines for portal display (excluding costs)"""
lines = []
for line in self.order_line:
lines.append({
'id': line.id,
'product_name': line.product_id.name if line.product_id else line.name,
'quantity': line.product_uom_qty,
'uom': line.product_uom_id.name if line.product_uom_id else '',
'adp_code': line.x_fc_adp_device_code or '' if hasattr(line, 'x_fc_adp_device_code') else '',
'device_type': '',
'serial_number': line.x_fc_serial_number or '' if hasattr(line, 'x_fc_serial_number') else '',
})
return lines
@api.model
def get_authorizer_portal_cases(self, partner_id, search_query=None, limit=100, offset=0):
"""Get cases for authorizer portal with optional search"""
domain = [('x_fc_authorizer_id', '=', partner_id)]
# Add search if provided
if search_query:
search_domain = self._build_search_domain(search_query)
domain = ['&'] + domain + search_domain
orders = self.sudo().search(domain, limit=limit, offset=offset, order='date_order desc, id desc')
return orders
@api.model
def get_sales_rep_portal_cases(self, user_id, search_query=None, limit=100, offset=0):
"""Get cases for sales rep portal with optional search"""
domain = [('user_id', '=', user_id)]
# Add search if provided
if search_query:
search_domain = self._build_search_domain(search_query)
domain = domain + search_domain
orders = self.sudo().search(domain, limit=limit, offset=offset, order='date_order desc, id desc')
return orders
def _build_search_domain(self, query):
"""Build search domain for portal search"""
if not query or len(query) < 2:
return []
search_domain = [
'|', '|', '|', '|',
('partner_id.name', 'ilike', query),
('x_fc_claim_number', 'ilike', query),
('x_fc_client_ref_1', 'ilike', query),
('x_fc_client_ref_2', 'ilike', query),
]
return search_domain

View File

@@ -1,12 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_authorizer_comment_user,fusion.authorizer.comment.user,model_fusion_authorizer_comment,base.group_user,1,1,1,1
access_fusion_authorizer_comment_portal,fusion.authorizer.comment.portal,model_fusion_authorizer_comment,base.group_portal,1,1,1,0
access_fusion_adp_document_user,fusion.adp.document.user,model_fusion_adp_document,base.group_user,1,1,1,1
access_fusion_adp_document_portal,fusion.adp.document.portal,model_fusion_adp_document,base.group_portal,1,0,1,0
access_fusion_assessment_user,fusion.assessment.user,model_fusion_assessment,base.group_user,1,1,1,1
access_fusion_assessment_portal,fusion.assessment.portal,model_fusion_assessment,base.group_portal,1,1,1,0
access_fusion_accessibility_assessment_user,fusion.accessibility.assessment.user,model_fusion_accessibility_assessment,base.group_user,1,1,1,1
access_fusion_accessibility_assessment_portal,fusion.accessibility.assessment.portal,model_fusion_accessibility_assessment,base.group_portal,1,1,1,0
access_fusion_pdf_template_user,fusion.pdf.template.user,model_fusion_pdf_template,base.group_user,1,1,1,1
access_fusion_pdf_template_preview_user,fusion.pdf.template.preview.user,model_fusion_pdf_template_preview,base.group_user,1,1,1,1
access_fusion_pdf_template_field_user,fusion.pdf.template.field.user,model_fusion_pdf_template_field,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_authorizer_comment_user fusion.authorizer.comment.user model_fusion_authorizer_comment base.group_user 1 1 1 1
3 access_fusion_authorizer_comment_portal fusion.authorizer.comment.portal model_fusion_authorizer_comment base.group_portal 1 1 1 0
4 access_fusion_adp_document_user fusion.adp.document.user model_fusion_adp_document base.group_user 1 1 1 1
5 access_fusion_adp_document_portal fusion.adp.document.portal model_fusion_adp_document base.group_portal 1 0 1 0
6 access_fusion_assessment_user fusion.assessment.user model_fusion_assessment base.group_user 1 1 1 1
7 access_fusion_assessment_portal fusion.assessment.portal model_fusion_assessment base.group_portal 1 1 1 0
8 access_fusion_accessibility_assessment_user fusion.accessibility.assessment.user model_fusion_accessibility_assessment base.group_user 1 1 1 1
9 access_fusion_accessibility_assessment_portal fusion.accessibility.assessment.portal model_fusion_accessibility_assessment base.group_portal 1 1 1 0
10 access_fusion_pdf_template_user fusion.pdf.template.user model_fusion_pdf_template base.group_user 1 1 1 1
11 access_fusion_pdf_template_preview_user fusion.pdf.template.preview.user model_fusion_pdf_template_preview base.group_user 1 1 1 1
12 access_fusion_pdf_template_field_user fusion.pdf.template.field.user model_fusion_pdf_template_field base.group_user 1 1 1 1

View File

@@ -1,140 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Portal Groups - grouped under Fusion Claims privilege -->
<record id="group_authorizer_portal" model="res.groups">
<field name="name">Authorizer Portal User</field>
<field name="privilege_id" ref="fusion_claims.res_groups_privilege_fusion_claims"/>
<field name="comment">Portal users who are Authorizers (OTs/Therapists)</field>
</record>
<record id="group_sales_rep_portal" model="res.groups">
<field name="name">Sales Rep Portal User</field>
<field name="privilege_id" ref="fusion_claims.res_groups_privilege_fusion_claims"/>
<field name="comment">Portal users who are Sales Representatives</field>
</record>
<record id="group_technician_portal" model="res.groups">
<field name="name">Technician Portal User</field>
<field name="privilege_id" ref="fusion_claims.res_groups_privilege_fusion_claims"/>
<field name="comment">Portal users who are Field Technicians for deliveries</field>
</record>
<!-- Authorizer Comment Access Rules -->
<record id="rule_comment_authorizer_own" model="ir.rule">
<field name="name">Authorizer: Own Comments</field>
<field name="model_id" ref="model_fusion_authorizer_comment"/>
<field name="domain_force">[('author_id', '=', user.partner_id.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>
<record id="rule_comment_view_on_order" model="ir.rule">
<field name="name">Portal: View Comments on Assigned Orders</field>
<field name="model_id" ref="model_fusion_authorizer_comment"/>
<field name="domain_force">[
'|',
('sale_order_id.x_fc_authorizer_id', '=', user.partner_id.id),
('sale_order_id.user_id', '=', user.id),
('is_internal', '=', False)
]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- ADP Document Access Rules -->
<record id="rule_document_portal_read" model="ir.rule">
<field name="name">Portal: Read Documents on Assigned Orders</field>
<field name="model_id" ref="model_fusion_adp_document"/>
<field name="domain_force">[
'|',
('sale_order_id.x_fc_authorizer_id', '=', user.partner_id.id),
('sale_order_id.user_id', '=', user.id)
]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_document_authorizer_create" model="ir.rule">
<field name="name">Authorizer: Create Documents on Assigned Orders</field>
<field name="model_id" ref="model_fusion_adp_document"/>
<field name="domain_force">[
('sale_order_id.x_fc_authorizer_id', '=', user.partner_id.id),
('document_type', '!=', 'submitted_final')
]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="False"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Assessment Access Rules -->
<record id="rule_assessment_authorizer" model="ir.rule">
<field name="name">Authorizer: Own Assessments</field>
<field name="model_id" ref="model_fusion_assessment"/>
<field name="domain_force">[('authorizer_id', '=', user.partner_id.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>
<record id="rule_assessment_sales_rep" model="ir.rule">
<field name="name">Sales Rep: Own Assessments</field>
<field name="model_id" ref="model_fusion_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>
<!-- Sale Order Access - Extend for Portal -->
<record id="rule_sale_order_authorizer_portal" model="ir.rule">
<field name="name">Authorizer Portal: Assigned Orders</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="domain_force">[('x_fc_authorizer_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Technician Portal: Access orders assigned for delivery -->
<record id="rule_sale_order_technician_portal" model="ir.rule">
<field name="name">Technician Portal: Assigned Deliveries</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="domain_force">[('x_fc_delivery_technician_ids', 'in', [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="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Sales Rep Portal: Access own orders for POD -->
<record id="rule_sale_order_sales_rep_portal" model="ir.rule">
<field name="name">Sales Rep Portal: Own Orders</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="domain_force">[('user_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="False"/>
<field name="perm_unlink" eval="False"/>
</record>
</odoo>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -1,864 +0,0 @@
/* Fusion Authorizer Portal - Custom Styles */
/* Color Scheme: Dark Blue (#1a365d, #2c5282) with Green accents (#38a169) */
:root {
--portal-primary: #1a365d;
--portal-primary-light: #2c5282;
--portal-accent: #38a169;
--portal-accent-light: #48bb78;
--portal-dark: #1a202c;
--portal-gray: #718096;
--portal-light: #f7fafc;
}
/* Portal Header Styling - Only for Fusion Portal pages */
/* Removed global navbar styling to prevent affecting other portal pages */
/* Card Headers with Portal Theme */
.card-header.bg-dark {
background: linear-gradient(135deg, var(--portal-primary) 0%, var(--portal-primary-light) 100%) !important;
}
.card-header.bg-primary {
background: var(--portal-primary-light) !important;
}
.card-header.bg-success {
background: var(--portal-accent) !important;
}
/* Stat Cards */
.card.bg-primary {
background: linear-gradient(135deg, var(--portal-primary) 0%, var(--portal-primary-light) 100%) !important;
}
.card.bg-success {
background: linear-gradient(135deg, var(--portal-accent) 0%, var(--portal-accent-light) 100%) !important;
}
/* Table Styling */
.table-dark th {
background: var(--portal-primary) !important;
}
.table-success th {
background: var(--portal-accent) !important;
color: white !important;
}
.table-info th {
background: var(--portal-primary-light) !important;
color: white !important;
}
/* Badges */
.badge.bg-primary {
background: var(--portal-primary-light) !important;
}
.badge.bg-success {
background: var(--portal-accent) !important;
}
/* Buttons */
.btn-primary {
background: var(--portal-primary-light) !important;
border-color: var(--portal-primary-light) !important;
}
.btn-primary:hover {
background: var(--portal-primary) !important;
border-color: var(--portal-primary) !important;
}
.btn-success {
background: var(--portal-accent) !important;
border-color: var(--portal-accent) !important;
}
.btn-success:hover {
background: var(--portal-accent-light) !important;
border-color: var(--portal-accent-light) !important;
}
.btn-outline-primary {
color: var(--portal-primary-light) !important;
border-color: var(--portal-primary-light) !important;
}
.btn-outline-primary:hover {
background: var(--portal-primary-light) !important;
color: white !important;
}
/* Search Box Styling */
#portal-search-input {
border-radius: 25px 0 0 25px;
padding-left: 20px;
}
#portal-search-input:focus {
border-color: var(--portal-primary-light);
box-shadow: 0 0 0 0.2rem rgba(44, 82, 130, 0.25);
}
/* Case List Row Hover */
.table-hover tbody tr:hover {
background-color: rgba(44, 82, 130, 0.1);
}
/* Document Upload Area */
.document-upload-area {
border: 2px dashed var(--portal-gray);
border-radius: 10px;
padding: 20px;
text-align: center;
background: var(--portal-light);
transition: all 0.3s ease;
}
.document-upload-area:hover {
border-color: var(--portal-primary-light);
background: rgba(44, 82, 130, 0.05);
}
/* Comment Section */
.comment-item {
border-left: 4px solid var(--portal-primary-light);
padding-left: 15px;
margin-bottom: 15px;
}
.comment-item .comment-author {
font-weight: 600;
color: var(--portal-primary);
}
.comment-item .comment-date {
font-size: 0.85em;
color: var(--portal-gray);
}
/* Signature Pad */
.signature-pad-container {
border: 2px solid var(--portal-gray);
border-radius: 8px;
padding: 10px;
background: white;
touch-action: none;
}
.signature-pad-container canvas {
cursor: crosshair;
width: 100%;
height: 200px;
}
/* Progress Bar */
.progress {
border-radius: 15px;
overflow: hidden;
}
.progress-bar {
font-size: 0.75rem;
font-weight: 600;
}
/* Assessment Form Cards */
.assessment-section-card {
transition: all 0.3s ease;
}
.assessment-section-card:hover {
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
/* Status Badges */
.status-badge {
padding: 0.5em 1em;
border-radius: 20px;
font-weight: 500;
}
.status-draft {
background: #e2e8f0;
color: #4a5568;
}
.status-pending {
background: #faf089;
color: #744210;
}
.status-completed {
background: #c6f6d5;
color: #276749;
}
.status-cancelled {
background: #fed7d7;
color: #9b2c2c;
}
/* Quick Action Buttons */
.quick-action-btn {
min-width: 150px;
margin-bottom: 10px;
}
/* Loading Spinner */
.search-loading {
display: none;
position: absolute;
right: 50px;
top: 50%;
transform: translateY(-50%);
}
.search-loading.active {
display: block;
}
/* Mobile Responsiveness */
@media (max-width: 768px) {
.card-header {
font-size: 0.9rem;
}
.btn-lg {
font-size: 1rem;
padding: 0.5rem 1rem;
}
.table-responsive {
font-size: 0.85rem;
}
.signature-pad-container canvas {
height: 150px;
}
}
/* Animation for Search Results */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.search-result-row {
animation: fadeIn 0.3s ease;
}
/* Highlight matching text */
.search-highlight {
background-color: #faf089;
padding: 0 2px;
border-radius: 2px;
}
/* ========================================
EXPRESS ASSESSMENT FORM STYLES
======================================== */
.assessment-express-form .form-label {
color: #333;
font-size: 0.95rem;
}
.assessment-express-form .form-label.fw-bold {
font-weight: 600 !important;
}
.assessment-express-form .form-control,
.assessment-express-form .form-select {
border-radius: 6px;
border-color: #dee2e6;
padding: 0.625rem 0.875rem;
}
.assessment-express-form .form-control:focus,
.assessment-express-form .form-select:focus {
border-color: #2e7aad;
box-shadow: 0 0 0 3px rgba(67, 97, 238, 0.15);
}
.assessment-express-form .form-select-lg {
padding: 0.75rem 1rem;
font-size: 1.1rem;
}
/* Input Group with Inch suffix */
.assessment-express-form .input-group-text {
background-color: #f8f9fa;
border-color: #dee2e6;
color: #6c757d;
font-weight: 500;
}
/* Checkbox and Radio Styling */
.assessment-express-form .form-check {
padding-left: 1.75rem;
margin-bottom: 0.5rem;
}
.assessment-express-form .form-check-input {
width: 1.15rem;
height: 1.15rem;
margin-top: 0.15rem;
margin-left: -1.75rem;
}
.assessment-express-form .form-check-input:checked {
background-color: #2e7aad;
border-color: #2e7aad;
}
.assessment-express-form .form-check-label {
color: #333;
cursor: pointer;
}
/* Equipment Form Sections */
.assessment-express-form .equipment-form h2 {
color: #1a1a1a;
font-size: 1.5rem;
letter-spacing: 1px;
}
/* Card Styling */
.assessment-express-form .card {
border: none;
border-radius: 12px;
}
.assessment-express-form .card-header.bg-primary {
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%) !important;
border-radius: 12px 12px 0 0;
}
.assessment-express-form .card-body {
padding: 2rem;
}
.assessment-express-form .card-footer {
border-top: 1px solid #e9ecef;
padding: 1.25rem 2rem;
}
/* Button Styling */
.assessment-express-form .btn-primary {
background: #2e7aad !important;
border-color: #4361ee !important;
padding: 0.75rem 2rem;
font-weight: 600;
border-radius: 6px;
}
.assessment-express-form .btn-primary:hover {
background: #3451d1 !important;
border-color: #3451d1 !important;
}
.assessment-express-form .btn-success {
background: #10b981 !important;
border-color: #10b981 !important;
padding: 0.75rem 2rem;
font-weight: 600;
border-radius: 6px;
}
.assessment-express-form .btn-success:hover {
background: #059669 !important;
border-color: #059669 !important;
}
.assessment-express-form .btn-outline-secondary {
border-width: 2px;
font-weight: 500;
}
/* Progress Bar */
.assessment-express-form .progress {
height: 8px;
background-color: #e9ecef;
}
.assessment-express-form .progress-bar {
background: linear-gradient(90deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);
}
/* Section Separators */
.assessment-express-form hr {
border-color: #e9ecef;
opacity: 1;
}
/* Required Field Indicator */
.assessment-express-form .text-danger {
color: #dc3545 !important;
}
/* Section Headers within form */
.assessment-express-form h5.fw-bold {
color: #374151;
border-bottom: 2px solid #2e7aad;
padding-bottom: 0.5rem;
display: inline-block;
}
/* New Assessment Card on Portal Home */
.portal-new-assessment-card {
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%) !important;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.portal-new-assessment-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3) !important;
}
.portal-new-assessment-card .card-body {
background: transparent !important;
}
.portal-new-assessment-card h5,
.portal-new-assessment-card small {
color: #fff !important;
}
.portal-new-assessment-card .icon-circle {
width: 50px;
height: 50px;
background: rgba(255,255,255,0.25) !important;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.portal-new-assessment-card .icon-circle i {
color: #fff !important;
font-size: 1.25rem;
}
/* Authorizer Portal Card on Portal Home */
.portal-authorizer-card {
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%) !important;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.portal-authorizer-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(46, 122, 173, 0.3) !important;
}
.portal-authorizer-card .card-body {
background: transparent !important;
}
.portal-authorizer-card h5,
.portal-authorizer-card small {
color: #fff !important;
}
.portal-authorizer-card .icon-circle {
width: 50px;
height: 50px;
background: rgba(255,255,255,0.25) !important;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.portal-authorizer-card .icon-circle i {
color: #fff !important;
font-size: 1.25rem;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.assessment-express-form .card-body {
padding: 1.25rem;
}
.assessment-express-form .d-flex.flex-wrap.gap-4 {
gap: 0.5rem !important;
}
.assessment-express-form .row {
margin-left: -0.5rem;
margin-right: -0.5rem;
}
.assessment-express-form .col-md-4,
.assessment-express-form .col-md-6 {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
}
/* ================================================================== */
/* AUTHORIZER DASHBOARD - MOBILE-FIRST REDESIGN */
/* ================================================================== */
.auth-dash {
background: #f8f9fb;
min-height: 80vh;
}
/* Content Area */
.auth-dash-content {
padding-top: 24px;
padding-bottom: 40px;
}
/* Welcome Header */
.auth-dash-header {
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);
border-radius: 16px;
margin-bottom: 24px;
overflow: hidden;
}
.auth-dash-header-inner {
padding: 28px 30px 24px;
}
.auth-dash-greeting {
color: #fff;
font-size: 24px;
font-weight: 700;
margin: 0 0 4px 0;
letter-spacing: -0.3px;
}
.auth-dash-subtitle {
color: rgba(255,255,255,0.85);
font-size: 14px;
margin: 0;
font-weight: 400;
}
/* ---- Action Tiles ---- */
.auth-dash-tiles {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 28px;
}
.auth-tile {
display: flex;
align-items: center;
background: #fff;
border-radius: 14px;
padding: 18px 20px;
text-decoration: none !important;
color: #333 !important;
border: 1px solid #e8ecf1;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
min-height: 72px;
}
.auth-tile:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
border-color: #d0d5dd;
}
.auth-tile:active {
transform: scale(0.98);
}
.auth-tile-icon {
width: 48px;
height: 48px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 20px;
color: #fff;
margin-right: 16px;
}
.auth-tile-cases .auth-tile-icon {
background: linear-gradient(135deg, #2e7aad, #1a6b9a);
}
.auth-tile-assessments .auth-tile-icon {
background: linear-gradient(135deg, #5ba848, #4a9e3f);
}
.auth-tile-new .auth-tile-icon {
background: linear-gradient(135deg, #3a8fb7, #2e7aad);
}
.auth-tile-info {
flex: 1;
min-width: 0;
}
.auth-tile-title {
font-size: 16px;
font-weight: 600;
color: #1a1a2e;
line-height: 1.3;
}
.auth-tile-desc {
font-size: 13px;
color: #8b95a5;
line-height: 1.3;
margin-top: 3px;
}
.auth-tile-badge .badge {
background: #eef1f7;
color: #3949ab;
font-size: 15px;
font-weight: 700;
padding: 5px 14px;
border-radius: 20px;
margin-right: 10px;
}
.auth-tile-arrow {
color: #c5ccd6;
font-size: 14px;
flex-shrink: 0;
}
/* ---- Sections ---- */
.auth-dash-section {
background: #fff;
border-radius: 14px;
overflow: hidden;
margin-bottom: 20px;
border: 1px solid #e8ecf1;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
}
.auth-section-header {
padding: 16px 20px;
font-size: 15px;
font-weight: 600;
color: #444;
border-bottom: 1px solid #f0f2f5;
display: flex;
align-items: center;
}
.auth-section-attention {
color: #c0392b;
background: #fef5f5;
border-bottom-color: #fce4e4;
}
.auth-section-pending {
color: #d97706;
background: #fef9f0;
border-bottom-color: #fdecd0;
}
/* ---- Case List Items ---- */
.auth-case-list {
padding: 0;
}
.auth-case-item {
display: flex;
align-items: center;
padding: 16px 20px;
text-decoration: none !important;
color: inherit !important;
border-bottom: 1px solid #f3f4f6;
transition: background 0.1s ease;
cursor: pointer;
}
.auth-case-item:last-child {
border-bottom: none;
}
.auth-case-item:hover {
background: #f9fafb;
}
.auth-case-item:active {
background: #f0f2f5;
}
.auth-case-attention {
border-left: 3px solid #e74c3c;
}
.auth-case-main {
flex: 1;
min-width: 0;
}
.auth-case-client {
font-size: 15px;
font-weight: 600;
color: #1a1a2e;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.auth-case-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 5px;
align-items: center;
}
.auth-case-ref {
font-size: 12px;
color: #9ca3af;
font-weight: 500;
}
.auth-case-type {
font-size: 11px;
background: #e3f2fd;
color: #1565c0;
padding: 2px 10px;
border-radius: 10px;
font-weight: 500;
text-transform: uppercase;
}
.auth-case-status {
font-size: 11px;
background: #f3e5f5;
color: #7b1fa2;
padding: 2px 10px;
border-radius: 10px;
font-weight: 500;
}
.badge-attention {
font-size: 11px;
background: #fce4ec;
color: #c62828;
padding: 2px 10px;
border-radius: 10px;
font-weight: 600;
}
.auth-case-date {
font-size: 12px;
color: #9ca3af;
}
.auth-case-arrow {
color: #c5ccd6;
font-size: 14px;
flex-shrink: 0;
margin-left: 12px;
}
/* ---- Empty State ---- */
.auth-empty-state {
text-align: center;
padding: 60px 20px;
color: #aaa;
}
.auth-empty-state i {
font-size: 48px;
margin-bottom: 16px;
display: block;
}
.auth-empty-state h5 {
color: #666;
margin-bottom: 8px;
}
/* ---- Desktop Enhancements ---- */
@media (min-width: 768px) {
.auth-dash-header-inner {
padding: 32px 36px 28px;
}
.auth-dash-greeting {
font-size: 28px;
}
.auth-dash-tiles {
flex-direction: row;
gap: 16px;
}
.auth-tile {
flex: 1;
padding: 20px 22px;
}
.auth-dash-content {
padding-top: 28px;
}
}
/* ---- Mobile Optimizations ---- */
@media (max-width: 767px) {
.auth-dash-content {
padding-left: 12px;
padding-right: 12px;
padding-top: 16px;
}
.auth-dash-header {
border-radius: 0;
margin-left: -12px;
margin-right: -12px;
margin-top: -24px;
margin-bottom: 20px;
}
.auth-dash-header-inner {
padding: 22px 20px 20px;
}
.auth-dash-greeting {
font-size: 20px;
}
.auth-dash-subtitle {
font-size: 13px;
}
.auth-tile {
padding: 16px 18px;
min-height: 66px;
}
.auth-tile-icon {
width: 44px;
height: 44px;
font-size: 18px;
margin-right: 14px;
}
.auth-case-item {
padding: 14px 18px;
}
.auth-section-header {
padding: 14px 18px;
}
}

View File

@@ -1,540 +0,0 @@
/* ==========================================================================
Fusion Technician Portal - Mobile-First Styles (v2)
========================================================================== */
/* ---- Base & Mobile First ---- */
.tech-portal {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
max-width: 640px;
margin: 0 auto;
}
/* ---- Quick Stats Bar (Dashboard) ---- */
.tech-stats-bar {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.5rem;
scrollbar-width: none;
}
.tech-stats-bar::-webkit-scrollbar { display: none; }
.tech-stat-card {
flex: 0 0 auto;
min-width: 100px;
padding: 0.75rem 1rem;
border-radius: 12px;
text-align: center;
color: #fff;
font-weight: 600;
}
.tech-stat-card .stat-number {
font-size: 1.5rem;
line-height: 1.2;
}
.tech-stat-card .stat-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.9;
}
.tech-stat-total { background: linear-gradient(135deg, #5ba848, #3a8fb7); }
.tech-stat-remaining { background: linear-gradient(135deg, #3498db, #2980b9); }
.tech-stat-completed { background: linear-gradient(135deg, #27ae60, #219a52); }
.tech-stat-travel { background: linear-gradient(135deg, #8e44ad, #7d3c98); }
/* ---- Hero Card (Dashboard Current Task) ---- */
.tech-hero-card {
border: none;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
margin-bottom: 1.5rem;
}
.tech-hero-card .card-header {
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);
color: #fff;
padding: 1rem 1.25rem;
border: none;
}
.tech-hero-card .card-header h5 {
color: #fff;
margin: 0;
}
.tech-hero-card .card-body {
padding: 1.25rem;
}
.tech-hero-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.85;
margin-bottom: 0.15rem;
}
/* ---- Timeline (Dashboard) ---- */
.tech-timeline {
position: relative;
padding-left: 2rem;
}
.tech-timeline::before {
content: '';
position: absolute;
left: 0.75rem;
top: 0;
bottom: 0;
width: 2px;
background: #dee2e6;
}
.tech-timeline-item {
position: relative;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
}
.tech-timeline-dot {
position: absolute;
left: -1.55rem;
top: 0.35rem;
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 2px #dee2e6;
z-index: 1;
}
.tech-timeline-dot.status-scheduled { background: #6c757d; box-shadow: 0 0 0 2px #6c757d; }
.tech-timeline-dot.status-en_route { background: #3498db; box-shadow: 0 0 0 2px #3498db; }
.tech-timeline-dot.status-in_progress { background: #f39c12; box-shadow: 0 0 0 2px #f39c12; animation: pulse-dot 1.5s infinite; }
.tech-timeline-dot.status-completed { background: #27ae60; box-shadow: 0 0 0 2px #27ae60; }
.tech-timeline-dot.status-cancelled { background: #e74c3c; box-shadow: 0 0 0 2px #e74c3c; }
@keyframes pulse-dot {
0%, 100% { box-shadow: 0 0 0 2px #f39c12; }
50% { box-shadow: 0 0 0 6px rgba(243, 156, 18, 0.3); }
}
.tech-timeline-card {
border: 1px solid #e9ecef;
border-radius: 12px;
padding: 0.875rem 1rem;
background: #fff;
transition: box-shadow 0.2s, transform 0.15s;
text-decoration: none !important;
color: inherit !important;
display: block;
}
.tech-timeline-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
transform: translateY(-1px);
}
.tech-timeline-card.active {
border-color: #f39c12;
border-width: 2px;
box-shadow: 0 4px 16px rgba(243, 156, 18, 0.15);
}
.tech-timeline-time {
font-size: 0.85rem;
font-weight: 600;
color: #495057;
}
.tech-timeline-title {
font-size: 0.95rem;
font-weight: 600;
color: #212529;
margin: 0.15rem 0;
}
.tech-timeline-meta {
font-size: 0.8rem;
color: #6c757d;
}
/* Travel indicator between tasks */
.tech-travel-indicator {
padding: 0.35rem 0 0.35rem 0;
margin-left: -0.2rem;
font-size: 0.75rem;
color: #8e44ad;
}
/* ---- Task Type Badges ---- */
.tech-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 6px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tech-badge-delivery { background: #d4edda; color: #155724; }
.tech-badge-repair { background: #fff3cd; color: #856404; }
.tech-badge-pickup { background: #cce5ff; color: #004085; }
.tech-badge-troubleshoot { background: #f8d7da; color: #721c24; }
.tech-badge-assessment { background: #e2e3e5; color: #383d41; }
.tech-badge-installation { background: #d1ecf1; color: #0c5460; }
.tech-badge-maintenance { background: #e8daef; color: #6c3483; }
.tech-badge-other { background: #e9ecef; color: #495057; }
/* Status badges */
.tech-status-badge {
display: inline-block;
padding: 0.25rem 0.6rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
}
.tech-status-scheduled { background: #e9ecef; color: #495057; }
.tech-status-en_route { background: #cce5ff; color: #004085; }
.tech-status-in_progress { background: #fff3cd; color: #856404; }
.tech-status-completed { background: #d4edda; color: #155724; }
.tech-status-cancelled { background: #f8d7da; color: #721c24; }
/* ==========================================================================
Task Detail Page - v2 Redesign
========================================================================== */
/* ---- Back button ---- */
.tech-back-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: var(--o-main-bg-color, #f8f9fa);
color: var(--o-main-text-color, #495057);
text-decoration: none !important;
transition: background 0.15s;
border: 1px solid var(--o-main-border-color, #dee2e6);
}
.tech-back-btn:hover {
background: var(--o-main-border-color, #dee2e6);
}
/* ---- Task Hero Header ---- */
.tech-task-hero {
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--o-main-border-color, #eee);
}
/* ---- Quick Actions Row ---- */
.tech-quick-actions {
display: flex;
gap: 0.75rem;
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
padding: 0.25rem 0;
}
.tech-quick-actions::-webkit-scrollbar { display: none; }
.tech-quick-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
min-width: 68px;
padding: 0.6rem 0.5rem;
border-radius: 14px;
background: var(--o-main-bg-color, #f8f9fa);
border: 1px solid var(--o-main-border-color, #e9ecef);
color: var(--o-main-text-color, #495057) !important;
text-decoration: none !important;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
transition: all 0.15s;
flex-shrink: 0;
}
.tech-quick-btn i {
font-size: 1.15rem;
color: #3498db;
}
.tech-quick-btn:hover {
background: #e3f2fd;
border-color: #90caf9;
}
.tech-quick-btn:active {
transform: scale(0.95);
}
/* ---- Card (unified style for all sections) ---- */
.tech-card {
background: var(--o-main-card-bg, #fff);
border: 1px solid var(--o-main-border-color, #e9ecef);
border-radius: 14px;
padding: 1rem;
}
.tech-card-success {
border-color: #c3e6cb;
background: color-mix(in srgb, #d4edda 30%, var(--o-main-card-bg, #fff));
}
/* ---- Card icon (left gutter icon) ---- */
.tech-card-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 12px;
font-size: 1rem;
margin-right: 0.75rem;
flex-shrink: 0;
}
/* ---- Equipment highlight tag ---- */
.tech-equipment-tag {
background: color-mix(in srgb, #ffeeba 25%, var(--o-main-card-bg, #fff));
border: 1px solid #ffeeba;
border-radius: 10px;
padding: 0.75rem;
}
/* ---- Action Buttons (Large Touch Targets) ---- */
.tech-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 48px;
padding: 0.75rem 1.5rem;
border-radius: 14px;
font-weight: 600;
font-size: 0.95rem;
border: none;
cursor: pointer;
transition: all 0.15s;
text-decoration: none !important;
}
.tech-action-btn:active { transform: scale(0.97); }
.tech-btn-navigate {
background: #3498db;
color: #fff !important;
}
.tech-btn-navigate:hover { background: #2980b9; color: #fff !important; }
.tech-btn-start {
background: #27ae60;
color: #fff !important;
}
.tech-btn-start:hover { background: #219a52; color: #fff !important; }
.tech-btn-complete {
background: #f39c12;
color: #fff !important;
}
.tech-btn-complete:hover { background: #e67e22; color: #fff !important; }
.tech-btn-call {
background: #9b59b6;
color: #fff !important;
}
.tech-btn-call:hover { background: #8e44ad; color: #fff !important; }
.tech-btn-enroute {
background: #2980b9;
color: #fff !important;
}
.tech-btn-enroute:hover { background: #2471a3; color: #fff !important; }
/* ---- Bottom Action Bar (Fixed on mobile) ---- */
.tech-bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--o-main-card-bg, #fff);
border-top: 1px solid var(--o-main-border-color, #dee2e6);
padding: 0.75rem 1rem;
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));
z-index: 1050;
display: flex;
gap: 0.5rem;
box-shadow: 0 -4px 20px rgba(0,0,0,0.08);
}
.tech-bottom-bar .tech-action-btn {
flex: 1;
}
/* Padding to prevent content being hidden behind fixed bar */
.has-bottom-bar {
padding-bottom: 5rem;
}
/* ---- Completion Overlay ---- */
.tech-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 9999;
align-items: center;
justify-content: center;
}
.tech-overlay-card {
background: var(--o-main-card-bg, #fff);
border-radius: 20px;
padding: 2rem;
max-width: 400px;
width: 90%;
text-align: center;
animation: slideUp 0.3s ease;
}
.tech-overlay-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
/* ---- Voice Recording UI ---- */
.tech-voice-recorder {
border: 2px dashed var(--o-main-border-color, #dee2e6);
border-radius: 16px;
padding: 1.5rem 1rem;
text-align: center;
transition: all 0.3s;
}
.tech-voice-recorder.recording {
border-color: #e74c3c;
background: rgba(231, 76, 60, 0.04);
}
.tech-record-btn {
width: 64px;
height: 64px;
border-radius: 50%;
border: none;
background: #e74c3c;
color: #fff;
font-size: 1.3rem;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
}
.tech-record-btn:hover { transform: scale(1.05); }
.tech-record-btn:active { transform: scale(0.95); }
.tech-record-btn.recording {
animation: pulse-record 1.5s infinite;
}
@keyframes pulse-record {
0%, 100% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.4); }
50% { box-shadow: 0 0 0 15px rgba(231, 76, 60, 0); }
}
.tech-record-timer {
font-size: 1.25rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
margin-top: 0.5rem;
color: #e74c3c;
}
/* ---- Tomorrow Prep ---- */
.tech-prep-card {
border: 1px solid #e9ecef;
border-radius: 12px;
padding: 1rem;
margin-bottom: 0.75rem;
background: #fff;
}
.tech-prep-card .prep-time {
font-weight: 700;
font-size: 0.9rem;
}
.tech-prep-card .prep-type {
margin-left: 0.5rem;
}
.tech-prep-equipment {
background: #fff9e6;
border: 1px solid #ffeeba;
border-radius: 12px;
padding: 1rem;
}
/* ---- Responsive: Desktop enhancements ---- */
@media (min-width: 768px) {
.tech-stats-bar {
gap: 1rem;
}
.tech-stat-card {
min-width: 130px;
padding: 1rem 1.5rem;
}
.tech-stat-card .stat-number {
font-size: 2rem;
}
.tech-bottom-bar {
position: static;
box-shadow: none;
border: none;
padding: 0;
margin-top: 1rem;
}
.has-bottom-bar {
padding-bottom: 0;
}
.tech-timeline {
padding-left: 3rem;
}
.tech-timeline::before {
left: 1.25rem;
}
.tech-timeline-dot {
left: -2.05rem;
}
.tech-quick-btn {
min-width: 80px;
padding: 0.75rem 0.75rem;
}
}
/* ---- Legacy detail section support ---- */
.tech-detail-section {
background: var(--o-main-card-bg, #fff);
border: 1px solid var(--o-main-border-color, #e9ecef);
border-radius: 14px;
padding: 1rem;
margin-bottom: 1rem;
}
.tech-detail-section h6 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #6c757d;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--o-main-border-color, #f1f3f5);
}
.tech-detail-row {
display: flex;
justify-content: space-between;
padding: 0.3rem 0;
}
.tech-detail-label {
font-weight: 500;
color: var(--o-main-text-color, #495057);
font-size: 0.9rem;
}
.tech-detail-value {
color: var(--o-main-text-color, #212529);
font-size: 0.9rem;
text-align: right;
}

View File

@@ -1,109 +0,0 @@
/**
* Fusion Authorizer Portal - Assessment Form
*/
odoo.define('fusion_authorizer_portal.assessment_form', function (require) {
'use strict';
var publicWidget = require('web.public.widget');
publicWidget.registry.AssessmentForm = publicWidget.Widget.extend({
selector: '#assessment-form',
events: {
'change input, change select, change textarea': '_onFieldChange',
'submit': '_onSubmit',
},
init: function () {
this._super.apply(this, arguments);
this.hasUnsavedChanges = false;
},
start: function () {
this._super.apply(this, arguments);
this._initializeForm();
return Promise.resolve();
},
_initializeForm: function () {
var self = this;
// Warn before leaving with unsaved changes
window.addEventListener('beforeunload', function (e) {
if (self.hasUnsavedChanges) {
e.preventDefault();
e.returnValue = '';
return '';
}
});
// Auto-fill full name from first + last name
var firstNameInput = this.el.querySelector('[name="client_first_name"]');
var lastNameInput = this.el.querySelector('[name="client_last_name"]');
var fullNameInput = this.el.querySelector('[name="client_name"]');
if (firstNameInput && lastNameInput && fullNameInput) {
var updateFullName = function () {
var first = firstNameInput.value.trim();
var last = lastNameInput.value.trim();
if (first || last) {
fullNameInput.value = (first + ' ' + last).trim();
}
};
firstNameInput.addEventListener('blur', updateFullName);
lastNameInput.addEventListener('blur', updateFullName);
}
// Number input validation
var numberInputs = this.el.querySelectorAll('input[type="number"]');
numberInputs.forEach(function (input) {
input.addEventListener('input', function () {
var value = parseFloat(this.value);
var min = parseFloat(this.min) || 0;
var max = parseFloat(this.max) || 9999;
if (value < min) this.value = min;
if (value > max) this.value = max;
});
});
},
_onFieldChange: function (ev) {
this.hasUnsavedChanges = true;
// Visual feedback that form has changes
var saveBtn = this.el.querySelector('button[value="save"]');
if (saveBtn) {
saveBtn.classList.add('btn-warning');
saveBtn.classList.remove('btn-primary');
}
},
_onSubmit: function (ev) {
// Validate required fields
var requiredFields = this.el.querySelectorAll('[required]');
var isValid = true;
requiredFields.forEach(function (field) {
if (!field.value.trim()) {
field.classList.add('is-invalid');
isValid = false;
} else {
field.classList.remove('is-invalid');
}
});
if (!isValid) {
ev.preventDefault();
alert('Please fill in all required fields.');
return false;
}
this.hasUnsavedChanges = false;
return true;
}
});
return publicWidget.registry.AssessmentForm;
});

View File

@@ -1,37 +0,0 @@
/** @odoo-module **/
// Fusion Authorizer Portal - Message Authorizer Chatter Button
// Copyright 2026 Nexa Systems Inc.
// License OPL-1
//
// Patches the Chatter component to add a "Message Authorizer" button
// that opens the mail composer targeted at the assigned authorizer.
import { Chatter } from "@mail/chatter/web_portal/chatter";
import { patch } from "@web/core/utils/patch";
import { useService } from "@web/core/utils/hooks";
patch(Chatter.prototype, {
setup() {
super.setup(...arguments);
this._fapActionService = useService("action");
this._fapOrm = useService("orm");
},
async onClickMessageAuthorizer() {
const thread = this.state.thread;
if (!thread || thread.model !== "sale.order") return;
try {
const result = await this._fapOrm.call(
"sale.order",
"action_message_authorizer",
[thread.id],
);
if (result && result.type === "ir.actions.act_window") {
this._fapActionService.doAction(result);
}
} catch (e) {
console.warn("Message Authorizer action failed:", e);
}
},
});

View File

@@ -1,478 +0,0 @@
/** @odoo-module **/
import publicWidget from "@web/legacy/js/public/public_widget";
publicWidget.registry.LoanerPortal = publicWidget.Widget.extend({
selector: '#loanerSection, #btn_checkout_loaner, .btn-loaner-return',
start: function () {
this._super.apply(this, arguments);
this._allProducts = [];
this._initLoanerSection();
this._initCheckoutButton();
this._initReturnButtons();
this._initModal();
},
// =====================================================================
// MODAL: Initialize and wire up the loaner checkout modal
// =====================================================================
_initModal: function () {
var self = this;
var modal = document.getElementById('loanerCheckoutModal');
if (!modal) return;
var categorySelect = document.getElementById('modal_category_id');
var productSelect = document.getElementById('modal_product_id');
var lotSelect = document.getElementById('modal_lot_id');
var loanDays = document.getElementById('modal_loan_days');
var btnCheckout = document.getElementById('modal_btn_checkout');
var btnCreateProduct = document.getElementById('modal_btn_create_product');
var newCategorySelect = document.getElementById('modal_new_category_id');
var createResult = document.getElementById('modal_create_result');
// Load categories when modal opens
modal.addEventListener('show.bs.modal', function () {
self._loadCategories(categorySelect, newCategorySelect);
self._loadProducts(null, productSelect, lotSelect);
});
// Category change -> filter products
if (categorySelect) {
categorySelect.addEventListener('change', function () {
var catId = this.value ? parseInt(this.value) : null;
self._filterProducts(catId, productSelect, lotSelect);
});
}
// Product change -> filter lots
if (productSelect) {
productSelect.addEventListener('change', function () {
var prodId = this.value ? parseInt(this.value) : null;
self._filterLots(prodId, lotSelect, loanDays);
});
}
// Quick Create Product
if (btnCreateProduct) {
btnCreateProduct.addEventListener('click', function () {
var name = document.getElementById('modal_new_product_name').value.trim();
var serial = document.getElementById('modal_new_serial').value.trim();
var catId = newCategorySelect ? newCategorySelect.value : '';
if (!name || !serial) {
alert('Please enter both product name and serial number.');
return;
}
btnCreateProduct.disabled = true;
btnCreateProduct.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Creating...';
self._rpc('/my/loaner/create-product', {
product_name: name,
serial_number: serial,
category_id: catId || null,
}).then(function (result) {
if (result.success) {
// Add to product dropdown
var opt = document.createElement('option');
opt.value = result.product_id;
opt.text = result.product_name;
opt.selected = true;
productSelect.appendChild(opt);
// Add to lots
lotSelect.innerHTML = '';
var lotOpt = document.createElement('option');
lotOpt.value = result.lot_id;
lotOpt.text = result.lot_name;
lotOpt.selected = true;
lotSelect.appendChild(lotOpt);
// Add to internal data
self._allProducts.push({
id: result.product_id,
name: result.product_name,
category_id: catId ? parseInt(catId) : null,
period_days: 7,
lots: [{ id: result.lot_id, name: result.lot_name }],
});
if (createResult) {
createResult.style.display = '';
createResult.innerHTML = '<div class="alert alert-success py-2"><i class="fa fa-check me-1"></i> "' + result.product_name + '" (S/N: ' + result.lot_name + ') created!</div>';
}
// Clear fields
document.getElementById('modal_new_product_name').value = '';
document.getElementById('modal_new_serial').value = '';
} else {
if (createResult) {
createResult.style.display = '';
createResult.innerHTML = '<div class="alert alert-danger py-2">' + (result.error || 'Error') + '</div>';
}
}
btnCreateProduct.disabled = false;
btnCreateProduct.innerHTML = '<i class="fa fa-plus me-1"></i> Create Product';
});
});
}
// Checkout button
if (btnCheckout) {
btnCheckout.addEventListener('click', function () {
var productId = productSelect.value ? parseInt(productSelect.value) : null;
var lotId = lotSelect.value ? parseInt(lotSelect.value) : null;
var days = parseInt(loanDays.value) || 7;
var orderId = document.getElementById('modal_order_id').value;
var clientId = document.getElementById('modal_client_id').value;
if (!productId) {
alert('Please select a product.');
return;
}
btnCheckout.disabled = true;
btnCheckout.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Processing...';
self._rpc('/my/loaner/checkout', {
product_id: productId,
lot_id: lotId,
sale_order_id: orderId ? parseInt(orderId) : null,
client_id: clientId ? parseInt(clientId) : null,
loaner_period_days: days,
checkout_condition: 'good',
checkout_notes: '',
}).then(function (result) {
if (result.success) {
self._hideModal(modal);
alert(result.message);
location.reload();
} else {
alert('Error: ' + (result.error || 'Unknown'));
btnCheckout.disabled = false;
btnCheckout.innerHTML = '<i class="fa fa-check me-1"></i> Checkout Loaner';
}
});
});
}
},
_loadCategories: function (categorySelect, newCategorySelect) {
this._rpc('/my/loaner/categories', {}).then(function (categories) {
categories = categories || [];
// Main category dropdown
if (categorySelect) {
categorySelect.innerHTML = '<option value="">All Categories</option>';
categories.forEach(function (c) {
var opt = document.createElement('option');
opt.value = c.id;
opt.text = c.name;
categorySelect.appendChild(opt);
});
}
// Quick create category dropdown
if (newCategorySelect) {
newCategorySelect.innerHTML = '<option value="">-- Select --</option>';
categories.forEach(function (c) {
var opt = document.createElement('option');
opt.value = c.id;
opt.text = c.name;
newCategorySelect.appendChild(opt);
});
}
});
},
_loadProducts: function (categoryId, productSelect, lotSelect) {
var self = this;
var params = {};
if (categoryId) params.category_id = categoryId;
this._rpc('/my/loaner/products', params).then(function (products) {
self._allProducts = products || [];
self._renderProducts(self._allProducts, productSelect);
if (lotSelect) lotSelect.innerHTML = '<option value="">-- Select Serial --</option>';
});
},
_filterProducts: function (categoryId, productSelect, lotSelect) {
var filtered = this._allProducts;
if (categoryId) {
filtered = this._allProducts.filter(function (p) { return p.category_id === categoryId; });
}
this._renderProducts(filtered, productSelect);
if (lotSelect) lotSelect.innerHTML = '<option value="">-- Select Serial --</option>';
},
_renderProducts: function (products, productSelect) {
if (!productSelect) return;
productSelect.innerHTML = '<option value="">-- Select Product --</option>';
products.forEach(function (p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.text = p.name + ' (' + p.lots.length + ' avail)';
productSelect.appendChild(opt);
});
},
_filterLots: function (productId, lotSelect, loanDays) {
if (!lotSelect) return;
lotSelect.innerHTML = '<option value="">-- Select Serial --</option>';
if (!productId) return;
var product = this._allProducts.find(function (p) { return p.id === productId; });
if (product) {
product.lots.forEach(function (lot) {
var opt = document.createElement('option');
opt.value = lot.id;
opt.text = lot.name;
lotSelect.appendChild(opt);
});
if (loanDays && product.period_days) {
loanDays.value = product.period_days;
}
}
},
// =====================================================================
// CHECKOUT BUTTON: Opens the modal
// =====================================================================
_initCheckoutButton: function () {
var self = this;
var btns = document.querySelectorAll('#btn_checkout_loaner');
btns.forEach(function (btn) {
btn.addEventListener('click', function () {
var orderId = btn.dataset.orderId || '';
var clientId = btn.dataset.clientId || '';
// Set context in modal
var modalOrderId = document.getElementById('modal_order_id');
var modalClientId = document.getElementById('modal_client_id');
if (modalOrderId) modalOrderId.value = orderId;
if (modalClientId) modalClientId.value = clientId;
// Show modal
var modal = document.getElementById('loanerCheckoutModal');
self._showModal(modal);
});
});
},
// =====================================================================
// RETURN BUTTONS
// =====================================================================
_initReturnButtons: function () {
var self = this;
var returnModal = document.getElementById('loanerReturnModal');
if (!returnModal) return;
var btnSubmitReturn = document.getElementById('return_modal_btn_submit');
document.querySelectorAll('.btn-loaner-return').forEach(function (btn) {
btn.addEventListener('click', function () {
var checkoutId = parseInt(btn.dataset.checkoutId);
var productName = btn.dataset.productName || 'Loaner';
// Set modal values
document.getElementById('return_modal_checkout_id').value = checkoutId;
document.getElementById('return_modal_product_name').textContent = productName;
document.getElementById('return_modal_condition').value = 'good';
document.getElementById('return_modal_notes').value = '';
// Load locations
var locSelect = document.getElementById('return_modal_location_id');
locSelect.innerHTML = '<option value="">-- Loading... --</option>';
self._rpc('/my/loaner/locations', {}).then(function (locations) {
locations = locations || [];
locSelect.innerHTML = '<option value="">-- Select Location --</option>';
locations.forEach(function (l) {
var opt = document.createElement('option');
opt.value = l.id;
opt.text = l.name;
locSelect.appendChild(opt);
});
});
// Show modal
self._showModal(returnModal);
});
});
// Submit return
if (btnSubmitReturn) {
btnSubmitReturn.addEventListener('click', function () {
var checkoutId = parseInt(document.getElementById('return_modal_checkout_id').value);
var condition = document.getElementById('return_modal_condition').value;
var notes = document.getElementById('return_modal_notes').value;
var locationId = document.getElementById('return_modal_location_id').value;
btnSubmitReturn.disabled = true;
btnSubmitReturn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Processing...';
self._rpc('/my/loaner/return', {
checkout_id: checkoutId,
return_condition: condition,
return_notes: notes,
return_location_id: locationId ? parseInt(locationId) : null,
}).then(function (result) {
if (result.success) {
self._hideModal(returnModal);
alert(result.message);
location.reload();
} else {
alert('Error: ' + (result.error || 'Unknown'));
btnSubmitReturn.disabled = false;
btnSubmitReturn.innerHTML = '<i class="fa fa-check me-1"></i> Confirm Return';
}
});
});
}
},
// =====================================================================
// EXPRESS ASSESSMENT: Loaner Section
// =====================================================================
_initLoanerSection: function () {
var self = this;
var loanerSection = document.getElementById('loanerSection');
if (!loanerSection) return;
var productSelect = document.getElementById('loaner_product_id');
var lotSelect = document.getElementById('loaner_lot_id');
var periodInput = document.getElementById('loaner_period_days');
var checkoutFlag = document.getElementById('loaner_checkout');
var existingFields = document.getElementById('loaner_existing_fields');
var newFields = document.getElementById('loaner_new_fields');
var modeRadios = document.querySelectorAll('input[name="loaner_mode"]');
var btnCreate = document.getElementById('btn_create_loaner_product');
var createResult = document.getElementById('loaner_create_result');
var productsData = [];
loanerSection.addEventListener('show.bs.collapse', function () {
if (productSelect && productSelect.options.length <= 1) {
self._rpc('/my/loaner/products', {}).then(function (data) {
productsData = data || [];
productSelect.innerHTML = '<option value="">-- Select Product --</option>';
productsData.forEach(function (p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.text = p.name + ' (' + p.lots.length + ' avail)';
productSelect.appendChild(opt);
});
});
}
});
loanerSection.addEventListener('shown.bs.collapse', function () {
if (checkoutFlag) checkoutFlag.value = '1';
});
loanerSection.addEventListener('hidden.bs.collapse', function () {
if (checkoutFlag) checkoutFlag.value = '0';
});
modeRadios.forEach(function (radio) {
radio.addEventListener('change', function () {
if (this.value === 'existing') {
if (existingFields) existingFields.style.display = '';
if (newFields) newFields.style.display = 'none';
} else {
if (existingFields) existingFields.style.display = 'none';
if (newFields) newFields.style.display = '';
}
});
});
if (productSelect) {
productSelect.addEventListener('change', function () {
lotSelect.innerHTML = '<option value="">-- Select Serial --</option>';
var product = productsData.find(function (p) { return p.id === parseInt(productSelect.value); });
if (product) {
product.lots.forEach(function (lot) {
var opt = document.createElement('option');
opt.value = lot.id;
opt.text = lot.name;
lotSelect.appendChild(opt);
});
if (periodInput && product.period_days) periodInput.value = product.period_days;
}
});
}
if (btnCreate) {
btnCreate.addEventListener('click', function () {
var name = document.getElementById('loaner_new_product_name').value.trim();
var serial = document.getElementById('loaner_new_serial').value.trim();
if (!name || !serial) { alert('Enter both name and serial.'); return; }
btnCreate.disabled = true;
btnCreate.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Creating...';
self._rpc('/my/loaner/create-product', {
product_name: name, serial_number: serial,
}).then(function (result) {
if (result.success) {
var opt = document.createElement('option');
opt.value = result.product_id;
opt.text = result.product_name;
opt.selected = true;
productSelect.appendChild(opt);
lotSelect.innerHTML = '';
var lotOpt = document.createElement('option');
lotOpt.value = result.lot_id;
lotOpt.text = result.lot_name;
lotOpt.selected = true;
lotSelect.appendChild(lotOpt);
document.getElementById('loaner_existing').checked = true;
if (existingFields) existingFields.style.display = '';
if (newFields) newFields.style.display = 'none';
if (createResult) {
createResult.style.display = '';
createResult.innerHTML = '<div class="alert alert-success py-2">Created "' + result.product_name + '" (S/N: ' + result.lot_name + ')</div>';
}
} else {
if (createResult) {
createResult.style.display = '';
createResult.innerHTML = '<div class="alert alert-danger py-2">' + (result.error || 'Error') + '</div>';
}
}
btnCreate.disabled = false;
btnCreate.innerHTML = '<i class="fa fa-plus me-1"></i> Create Product';
});
});
}
},
// =====================================================================
// HELPERS
// =====================================================================
_rpc: function (url, params) {
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', id: 1, params: params }),
}).then(function (r) { return r.json(); }).then(function (d) { return d.result; });
},
_showModal: function (modalEl) {
if (!modalEl) return;
var Modal = window.bootstrap ? window.bootstrap.Modal : null;
if (Modal) {
var inst = Modal.getOrCreateInstance ? Modal.getOrCreateInstance(modalEl) : new Modal(modalEl);
inst.show();
} else if (window.$ || window.jQuery) {
(window.$ || window.jQuery)(modalEl).modal('show');
}
},
_hideModal: function (modalEl) {
if (!modalEl) return;
try {
var Modal = window.bootstrap ? window.bootstrap.Modal : null;
if (Modal && Modal.getInstance) {
var inst = Modal.getInstance(modalEl);
if (inst) inst.hide();
} else if (window.$ || window.jQuery) {
(window.$ || window.jQuery)(modalEl).modal('hide');
}
} catch (e) { /* non-blocking */ }
},
});

View File

@@ -1,716 +0,0 @@
/**
* Fusion PDF Field Position Editor
*
* Features:
* - Drag field types from sidebar palette onto PDF to create new fields
* - Drag existing fields to reposition them
* - Resize handles on each field (bottom-right corner)
* - Click to select and edit properties in right panel
* - Percentage-based positions (0.0-1.0), same as Odoo Sign module
* - Auto-save on every drag/resize
*/
document.addEventListener('DOMContentLoaded', function () {
'use strict';
var editor = document.getElementById('pdf_field_editor');
if (!editor) return;
var templateId = parseInt(editor.dataset.templateId);
var pageCount = parseInt(editor.dataset.pageCount) || 1;
var templateCategory = editor.dataset.category || 'other';
var currentPage = 1;
var fields = {};
var selectedFieldId = null;
var fieldCounter = 0;
var container = document.getElementById('pdf_canvas_container');
var pageImage = document.getElementById('pdf_page_image');
// ================================================================
// Colors per field type
// ================================================================
// ================================================================
// Available data keys, organized by template category
// ================================================================
var COMMON_KEYS = [
{ group: 'Client Info', keys: [
{ key: 'client_last_name', label: 'Last Name' },
{ key: 'client_first_name', label: 'First Name' },
{ key: 'client_middle_name', label: 'Middle Name' },
{ key: 'client_name', label: 'Full Name' },
{ key: 'client_street', label: 'Street' },
{ key: 'client_unit', label: 'Unit/Apt' },
{ key: 'client_city', label: 'City' },
{ key: 'client_state', label: 'Province' },
{ key: 'client_postal_code', label: 'Postal Code' },
{ key: 'client_phone', label: 'Phone' },
{ key: 'client_email', label: 'Email' },
]},
];
var CATEGORY_KEYS = {
adp: [
{ group: 'ADP - Client Details', keys: [
{ key: 'client_health_card', label: 'Health Card Number' },
{ key: 'client_health_card_version', label: 'Health Card Version' },
{ key: 'client_weight', label: 'Weight (lbs)' },
]},
{ group: 'ADP - Client Type', keys: [
{ key: 'client_type_reg', label: 'REG Checkbox' },
{ key: 'client_type_ods', label: 'ODS Checkbox' },
{ key: 'client_type_acs', label: 'ACS Checkbox' },
{ key: 'client_type_owp', label: 'OWP Checkbox' },
]},
{ group: 'ADP - Consent', keys: [
{ key: 'consent_applicant', label: 'Applicant Checkbox' },
{ key: 'consent_agent', label: 'Agent Checkbox' },
{ key: 'consent_date', label: 'Consent Date' },
]},
{ group: 'ADP - Agent Relationship', keys: [
{ key: 'agent_rel_spouse', label: 'Spouse Checkbox' },
{ key: 'agent_rel_parent', label: 'Parent Checkbox' },
{ key: 'agent_rel_child', label: 'Child Checkbox' },
{ key: 'agent_rel_poa', label: 'POA Checkbox' },
{ key: 'agent_rel_guardian', label: 'Guardian Checkbox' },
]},
{ group: 'ADP - Agent Info', keys: [
{ key: 'agent_last_name', label: 'Agent Last Name' },
{ key: 'agent_first_name', label: 'Agent First Name' },
{ key: 'agent_middle_initial', label: 'Agent Middle Initial' },
{ key: 'agent_unit', label: 'Agent Unit' },
{ key: 'agent_street_number', label: 'Agent Street No.' },
{ key: 'agent_street_name', label: 'Agent Street Name' },
{ key: 'agent_city', label: 'Agent City' },
{ key: 'agent_province', label: 'Agent Province' },
{ key: 'agent_postal_code', label: 'Agent Postal Code' },
{ key: 'agent_home_phone', label: 'Agent Home Phone' },
{ key: 'agent_business_phone', label: 'Agent Business Phone' },
{ key: 'agent_phone_ext', label: 'Agent Phone Ext' },
]},
{ group: 'ADP - Equipment', keys: [
{ key: 'equipment_type', label: 'Equipment Type' },
{ key: 'seat_width', label: 'Seat Width' },
{ key: 'seat_depth', label: 'Seat Depth' },
{ key: 'seat_to_floor_height', label: 'Seat to Floor Height' },
{ key: 'back_height', label: 'Back Height' },
{ key: 'legrest_length', label: 'Legrest Length' },
{ key: 'cane_height', label: 'Cane Height' },
]},
{ group: 'ADP - Dates', keys: [
{ key: 'assessment_start_date', label: 'Assessment Start Date' },
{ key: 'assessment_end_date', label: 'Assessment End Date' },
{ key: 'claim_authorization_date', label: 'Authorization Date' },
]},
{ group: 'ADP - Authorizer', keys: [
{ key: 'authorizer_name', label: 'Authorizer Name' },
{ key: 'authorizer_phone', label: 'Authorizer Phone' },
{ key: 'authorizer_email', label: 'Authorizer Email' },
]},
{ group: 'ADP - Signatures', keys: [
{ key: 'signature_page_11', label: 'Page 11 Signature' },
{ key: 'signature_page_12', label: 'Page 12 Signature' },
]},
{ group: 'ADP - Other', keys: [
{ key: 'reference', label: 'Assessment Reference' },
{ key: 'reason_for_application', label: 'Reason for Application' },
]},
],
odsp: [
{ group: 'ODSP - Signing Fields', keys: [
{ key: 'sa_client_name', label: 'Client Name (signing)' },
{ key: 'sa_sign_date', label: 'Signing Date' },
{ key: 'sa_signature', label: 'Client Signature' },
]},
{ group: 'ODSP - Client Details', keys: [
{ key: 'client_health_card', label: 'Health Card Number' },
{ key: 'client_health_card_version', label: 'Health Card Version' },
{ key: 'client_weight', label: 'Weight (lbs)' },
]},
{ group: 'ODSP - Dates', keys: [
{ key: 'assessment_start_date', label: 'Assessment Start Date' },
{ key: 'assessment_end_date', label: 'Assessment End Date' },
]},
{ group: 'ODSP - Authorizer', keys: [
{ key: 'authorizer_name', label: 'Authorizer Name' },
{ key: 'authorizer_phone', label: 'Authorizer Phone' },
{ key: 'authorizer_email', label: 'Authorizer Email' },
]},
],
mod: [
{ group: 'MOD - Dates', keys: [
{ key: 'assessment_start_date', label: 'Assessment Start Date' },
{ key: 'assessment_end_date', label: 'Assessment End Date' },
]},
{ group: 'MOD - Authorizer', keys: [
{ key: 'authorizer_name', label: 'Authorizer Name' },
{ key: 'authorizer_phone', label: 'Authorizer Phone' },
{ key: 'authorizer_email', label: 'Authorizer Email' },
]},
],
hardship: [
{ group: 'Hardship - Dates', keys: [
{ key: 'assessment_start_date', label: 'Assessment Start Date' },
{ key: 'assessment_end_date', label: 'Assessment End Date' },
]},
{ group: 'Hardship - Authorizer', keys: [
{ key: 'authorizer_name', label: 'Authorizer Name' },
{ key: 'authorizer_phone', label: 'Authorizer Phone' },
{ key: 'authorizer_email', label: 'Authorizer Email' },
]},
],
};
var DATA_KEYS = COMMON_KEYS.concat(CATEGORY_KEYS[templateCategory] || []);
// Build a flat lookup: key -> label
var KEY_LABELS = {};
DATA_KEYS.forEach(function (g) {
g.keys.forEach(function (k) { KEY_LABELS[k.key] = k.label; });
});
// Build <option> HTML for the data key dropdown
function buildDataKeyOptions(selectedKey) {
var html = '<option value="">(custom / none)</option>';
DATA_KEYS.forEach(function (g) {
html += '<optgroup label="' + g.group + '">';
g.keys.forEach(function (k) {
html += '<option value="' + k.key + '"'
+ (k.key === selectedKey ? ' selected' : '')
+ '>' + k.label + ' (' + k.key + ')</option>';
});
html += '</optgroup>';
});
return html;
}
var COLORS = {
text: { bg: 'rgba(52,152,219,0.25)', border: '#3498db' },
checkbox: { bg: 'rgba(46,204,113,0.25)', border: '#2ecc71' },
date: { bg: 'rgba(230,126,34,0.25)', border: '#e67e22' },
signature: { bg: 'rgba(155,89,182,0.25)', border: '#9b59b6' },
};
var DEFAULT_SIZES = {
text: { w: 0.150, h: 0.018 },
checkbox: { w: 0.018, h: 0.018 },
date: { w: 0.120, h: 0.018 },
signature: { w: 0.200, h: 0.050 },
};
// ================================================================
// JSONRPC helper
// ================================================================
function jsonrpc(url, params) {
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', id: 1, params: params || {} })
}).then(function (r) { return r.json(); })
.then(function (d) {
if (d.error) { console.error('RPC error', d.error); return null; }
return d.result;
});
}
// ================================================================
// Init
// ================================================================
function init() {
loadFields();
setupPageNavigation();
setupPaletteDrag();
setupContainerDrop();
setupPreviewButton();
buildDataKeysSidebar();
// Prevent the image from intercepting drag events
if (pageImage) {
pageImage.style.pointerEvents = 'none';
}
// Also prevent any child (except field markers) from blocking drops
container.querySelectorAll('#no_preview_placeholder').forEach(function (el) {
el.style.pointerEvents = 'none';
});
}
function buildDataKeysSidebar() {
var list = document.getElementById('dataKeysList');
if (!list) return;
var html = '';
DATA_KEYS.forEach(function (g) {
html += '<div class="mb-1 mt-2"><strong>' + g.group + ':</strong></div>';
g.keys.forEach(function (k) {
html += '<code class="d-block">' + k.key + '</code>';
});
});
list.innerHTML = html;
}
// ================================================================
// Load fields
// ================================================================
function loadFields() {
jsonrpc('/fusion/pdf-editor/fields', { template_id: templateId }).then(function (result) {
if (!result) return;
fields = {};
result.forEach(function (f) { fields[f.id] = f; fieldCounter++; });
renderFieldsForPage(currentPage);
});
}
// ================================================================
// Render fields on current page
// ================================================================
function renderFieldsForPage(page) {
container.querySelectorAll('.pdf-field-marker').forEach(function (el) { el.remove(); });
Object.values(fields).forEach(function (f) {
if (f.page === page) renderFieldMarker(f);
});
updateFieldCount();
}
function renderFieldMarker(field) {
var c = COLORS[field.field_type] || COLORS.text;
var marker = document.createElement('div');
marker.className = 'pdf-field-marker';
marker.dataset.fieldId = field.id;
marker.setAttribute('draggable', 'true');
Object.assign(marker.style, {
position: 'absolute',
left: (field.pos_x * 100) + '%',
top: (field.pos_y * 100) + '%',
width: (field.width * 100) + '%',
height: Math.max(field.height * 100, 1.5) + '%',
backgroundColor: c.bg,
border: '2px solid ' + c.border,
borderRadius: '3px',
cursor: 'move',
display: 'flex',
alignItems: 'center',
fontSize: '10px',
color: '#333',
fontWeight: '600',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
padding: '0 4px',
zIndex: 10,
boxSizing: 'border-box',
userSelect: 'none',
});
// Label text
var label = document.createElement('span');
label.style.pointerEvents = 'none';
label.style.flex = '1';
label.style.overflow = 'hidden';
label.style.textOverflow = 'ellipsis';
label.textContent = field.label || field.name;
marker.appendChild(label);
// Resize handle (bottom-right corner)
var handle = document.createElement('div');
Object.assign(handle.style, {
position: 'absolute',
right: '0',
bottom: '0',
width: '10px',
height: '10px',
backgroundColor: c.border,
cursor: 'nwse-resize',
borderRadius: '2px 0 2px 0',
opacity: '0.7',
});
handle.className = 'resize-handle';
handle.addEventListener('mousedown', function (e) {
e.preventDefault();
e.stopPropagation();
startResize(field.id, e);
});
marker.appendChild(handle);
// Tooltip
marker.title = (field.label || field.name) + '\nKey: ' + (field.field_key || 'unmapped') + '\nType: ' + field.field_type;
// Drag to reposition
marker.addEventListener('dragstart', function (e) { onFieldDragStart(e, field.id); });
marker.addEventListener('dragend', function (e) { e.target.style.opacity = ''; });
// Click to select
marker.addEventListener('click', function (e) {
e.stopPropagation();
selectField(field.id);
});
container.appendChild(marker);
// Highlight if selected
if (field.id === selectedFieldId) {
marker.style.boxShadow = '0 0 0 3px #007bff';
marker.style.zIndex = '20';
}
}
// ================================================================
// Drag existing fields to reposition
// ================================================================
var dragOffsetX = 0, dragOffsetY = 0;
var dragFieldId = null;
var dragSource = null; // 'field' or 'palette'
var dragFieldType = null;
function onFieldDragStart(e, fieldId) {
dragSource = 'field';
dragFieldId = fieldId;
var rect = e.target.getBoundingClientRect();
dragOffsetX = e.clientX - rect.left;
dragOffsetY = e.clientY - rect.top;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', 'field');
requestAnimationFrame(function () { e.target.style.opacity = '0.4'; });
}
// ================================================================
// Drag from palette to create new field
// ================================================================
function setupPaletteDrag() {
document.querySelectorAll('.pdf-palette-item').forEach(function (item) {
item.addEventListener('dragstart', function (e) {
dragSource = 'palette';
dragFieldType = e.currentTarget.dataset.fieldType;
dragFieldId = null;
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', 'palette');
e.currentTarget.style.opacity = '0.5';
});
item.addEventListener('dragend', function (e) {
e.currentTarget.style.opacity = '';
});
});
}
// ================================================================
// Drop handler on PDF container
// ================================================================
function setupContainerDrop() {
// Must preventDefault on dragover for drop to fire
container.addEventListener('dragover', function (e) {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = (dragSource === 'palette') ? 'copy' : 'move';
});
container.addEventListener('dragenter', function (e) {
e.preventDefault();
e.stopPropagation();
});
container.addEventListener('drop', function (e) {
e.preventDefault();
e.stopPropagation();
// Use the container rect as the reference area
// (the image has pointer-events:none, so we use the container which matches its size)
var rect = container.getBoundingClientRect();
if (dragSource === 'palette' && dragFieldType) {
// ---- CREATE new field at drop position ----
var defaults = DEFAULT_SIZES[dragFieldType] || DEFAULT_SIZES.text;
var posX = (e.clientX - rect.left) / rect.width;
var posY = (e.clientY - rect.top) / rect.height;
posX = normalize(posX, defaults.w);
posY = normalize(posY, defaults.h);
posX = round3(posX);
posY = round3(posY);
fieldCounter++;
var autoName = dragFieldType + '_' + fieldCounter;
var newField = {
template_id: templateId,
name: autoName,
label: autoName,
field_type: dragFieldType,
field_key: autoName,
page: currentPage,
pos_x: posX,
pos_y: posY,
width: defaults.w,
height: defaults.h,
font_size: 10,
};
jsonrpc('/fusion/pdf-editor/create-field', newField).then(function (res) {
if (res && res.id) {
newField.id = res.id;
fields[res.id] = newField;
renderFieldsForPage(currentPage);
selectField(res.id);
}
});
} else if (dragSource === 'field' && dragFieldId && fields[dragFieldId]) {
// ---- MOVE existing field ----
var field = fields[dragFieldId];
var posX = (e.clientX - rect.left - dragOffsetX) / rect.width;
var posY = (e.clientY - rect.top - dragOffsetY) / rect.height;
posX = normalize(posX, field.width);
posY = normalize(posY, field.height);
posX = round3(posX);
posY = round3(posY);
field.pos_x = posX;
field.pos_y = posY;
saveField(field.id, { pos_x: posX, pos_y: posY });
renderFieldsForPage(currentPage);
selectField(field.id);
}
dragSource = null;
dragFieldId = null;
dragFieldType = null;
});
}
// ================================================================
// Resize handles
// ================================================================
function startResize(fieldId, startEvent) {
var field = fields[fieldId];
if (!field) return;
var imgRect = container.getBoundingClientRect();
var startX = startEvent.clientX;
var startY = startEvent.clientY;
var startW = field.width;
var startH = field.height;
var marker = container.querySelector('[data-field-id="' + fieldId + '"]');
function onMove(e) {
var dx = (e.clientX - startX) / imgRect.width;
var dy = (e.clientY - startY) / imgRect.height;
var newW = Math.max(startW + dx, 0.010);
var newH = Math.max(startH + dy, 0.005);
// Clamp to page bounds
if (field.pos_x + newW > 1.0) newW = 1.0 - field.pos_x;
if (field.pos_y + newH > 1.0) newH = 1.0 - field.pos_y;
field.width = round3(newW);
field.height = round3(newH);
if (marker) {
marker.style.width = (field.width * 100) + '%';
marker.style.height = Math.max(field.height * 100, 1.5) + '%';
}
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
saveField(fieldId, { width: field.width, height: field.height });
renderFieldsForPage(currentPage);
selectField(fieldId);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
// ================================================================
// Select field and show properties
// ================================================================
function selectField(fieldId) {
selectedFieldId = fieldId;
var field = fields[fieldId];
if (!field) return;
// Re-render to update highlights
renderFieldsForPage(currentPage);
var panel = document.getElementById('field_props_body');
panel.innerHTML = ''
+ '<div class="mb-2">'
+ ' <label class="form-label fw-bold small mb-0">Data Key</label>'
+ ' <select class="form-select form-select-sm" id="prop_field_key">'
+ buildDataKeyOptions(field.field_key || '')
+ ' </select>'
+ '</div>'
+ row('Name', 'text', 'prop_name', field.name || '')
+ row('Label', 'text', 'prop_label', field.label || '')
+ '<div class="mb-2">'
+ ' <label class="form-label fw-bold small mb-0">Type</label>'
+ ' <select class="form-select form-select-sm" id="prop_type">'
+ ' <option value="text"' + sel(field.field_type, 'text') + '>Text</option>'
+ ' <option value="checkbox"' + sel(field.field_type, 'checkbox') + '>Checkbox</option>'
+ ' <option value="signature"' + sel(field.field_type, 'signature') + '>Signature</option>'
+ ' <option value="date"' + sel(field.field_type, 'date') + '>Date</option>'
+ ' </select>'
+ '</div>'
+ '<div class="row mb-2">'
+ ' <div class="col-6">' + row('Font Size', 'number', 'prop_font_size', field.font_size || 10, '0.5') + '</div>'
+ ' <div class="col-6">' + row('Page', 'number', 'prop_page', field.page || 1) + '</div>'
+ '</div>'
+ '<div class="row mb-2">'
+ ' <div class="col-6">' + row('Width', 'number', 'prop_width', field.width || 0.15, '0.005') + '</div>'
+ ' <div class="col-6">' + row('Height', 'number', 'prop_height', field.height || 0.015, '0.005') + '</div>'
+ '</div>'
+ '<div class="row mb-2">'
+ ' <div class="col-6"><label class="form-label fw-bold small mb-0">X</label>'
+ ' <input type="text" class="form-control form-control-sm" value="' + round3(field.pos_x) + '" readonly/></div>'
+ ' <div class="col-6"><label class="form-label fw-bold small mb-0">Y</label>'
+ ' <input type="text" class="form-control form-control-sm" value="' + round3(field.pos_y) + '" readonly/></div>'
+ '</div>'
+ '<div class="mb-2">'
+ ' <label class="form-label fw-bold small mb-0">Text Align</label>'
+ ' <div class="btn-group w-100" role="group">'
+ ' <button type="button" class="btn btn-sm btn-outline-secondary' + (field.text_align === 'left' || !field.text_align ? ' active' : '') + '" data-align="left"><i class="fa fa-align-left"></i></button>'
+ ' <button type="button" class="btn btn-sm btn-outline-secondary' + (field.text_align === 'center' ? ' active' : '') + '" data-align="center"><i class="fa fa-align-center"></i></button>'
+ ' <button type="button" class="btn btn-sm btn-outline-secondary' + (field.text_align === 'right' ? ' active' : '') + '" data-align="right"><i class="fa fa-align-right"></i></button>'
+ ' </div>'
+ '</div>'
+ '<div class="d-flex gap-2 mt-3">'
+ ' <button type="button" class="btn btn-primary btn-sm flex-grow-1" id="btn_save_props">'
+ ' <i class="fa fa-save me-1"></i>Save</button>'
+ ' <button type="button" class="btn btn-outline-danger btn-sm" id="btn_delete_field">'
+ ' <i class="fa fa-trash me-1"></i>Delete</button>'
+ '</div>';
// Auto-fill name and label when data key is selected
document.getElementById('prop_field_key').addEventListener('change', function () {
var selectedKey = this.value;
if (selectedKey && KEY_LABELS[selectedKey]) {
document.getElementById('prop_name').value = selectedKey;
document.getElementById('prop_label').value = KEY_LABELS[selectedKey];
}
});
var alignBtns = panel.querySelectorAll('[data-align]');
alignBtns.forEach(function (btn) {
btn.addEventListener('click', function () {
alignBtns.forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
});
});
document.getElementById('btn_save_props').addEventListener('click', function () {
var keySelect = document.getElementById('prop_field_key');
var selectedKey = keySelect ? keySelect.value : '';
var activeAlign = panel.querySelector('[data-align].active');
var vals = {
name: val('prop_name'),
label: val('prop_label'),
field_key: selectedKey,
field_type: val('prop_type'),
font_size: parseFloat(val('prop_font_size')) || 10,
page: parseInt(val('prop_page')) || 1,
width: parseFloat(val('prop_width')) || 0.15,
height: parseFloat(val('prop_height')) || 0.015,
text_align: activeAlign ? activeAlign.dataset.align : 'left',
};
Object.assign(field, vals);
saveField(fieldId, vals);
renderFieldsForPage(currentPage);
selectField(fieldId);
});
document.getElementById('btn_delete_field').addEventListener('click', function () {
if (!confirm('Delete "' + (field.label || field.name) + '"?')) return;
jsonrpc('/fusion/pdf-editor/delete-field', { field_id: fieldId }).then(function () {
delete fields[fieldId];
selectedFieldId = null;
renderFieldsForPage(currentPage);
panel.innerHTML = '<p class="text-muted small">Field deleted.</p>';
});
});
}
// ================================================================
// Save field to server
// ================================================================
function saveField(fieldId, values) {
jsonrpc('/fusion/pdf-editor/update-field', { field_id: fieldId, values: values });
}
// ================================================================
// Page navigation
// ================================================================
function setupPageNavigation() {
var prev = document.getElementById('btn_prev_page');
var next = document.getElementById('btn_next_page');
if (prev) prev.addEventListener('click', function () { if (currentPage > 1) switchPage(--currentPage); });
if (next) next.addEventListener('click', function () { if (currentPage < pageCount) switchPage(++currentPage); });
}
function switchPage(page) {
currentPage = page;
var d = document.getElementById('current_page_display');
if (d) d.textContent = page;
jsonrpc('/fusion/pdf-editor/page-image', { template_id: templateId, page: page }).then(function (r) {
if (r && r.image_url && pageImage) pageImage.src = r.image_url;
renderFieldsForPage(page);
});
}
// ================================================================
// Preview
// ================================================================
function setupPreviewButton() {
var btn = document.getElementById('btn_preview');
if (btn) btn.addEventListener('click', function () {
window.open('/fusion/pdf-editor/preview/' + templateId, '_blank');
});
}
// ================================================================
// Helpers
// ================================================================
function normalize(pos, dim) {
if (pos < 0) return 0;
if (pos + dim > 1.0) return 1.0 - dim;
return pos;
}
function round3(n) { return Math.round((n || 0) * 1000) / 1000; }
function val(id) { var el = document.getElementById(id); return el ? el.value : ''; }
function sel(current, option) { return current === option ? ' selected' : ''; }
function row(label, type, id, value, step) {
return '<div class="mb-2"><label class="form-label fw-bold small mb-0">' + label + '</label>'
+ '<input type="' + type + '" class="form-control form-control-sm" id="' + id + '"'
+ ' value="' + value + '"' + (step ? ' step="' + step + '"' : '') + '/></div>';
}
function updateFieldCount() {
var el = document.getElementById('field_count');
if (el) el.textContent = Object.keys(fields).length;
}
// ================================================================
// Start
// ================================================================
init();
});

View File

@@ -1,161 +0,0 @@
/**
* Fusion Authorizer Portal - Real-time Search
*/
odoo.define('fusion_authorizer_portal.portal_search', function (require) {
'use strict';
var publicWidget = require('web.public.widget');
var ajax = require('web.ajax');
publicWidget.registry.PortalSearch = publicWidget.Widget.extend({
selector: '#portal-search-input',
events: {
'input': '_onSearchInput',
'keydown': '_onKeyDown',
},
init: function () {
this._super.apply(this, arguments);
this.debounceTimer = null;
this.searchEndpoint = this._getSearchEndpoint();
this.resultsContainer = null;
},
start: function () {
this._super.apply(this, arguments);
this.resultsContainer = document.getElementById('cases-table-body');
return Promise.resolve();
},
_getSearchEndpoint: function () {
// Determine which portal we're on
var path = window.location.pathname;
if (path.includes('/my/authorizer')) {
return '/my/authorizer/cases/search';
} else if (path.includes('/my/sales')) {
return '/my/sales/cases/search';
}
return null;
},
_onSearchInput: function (ev) {
var self = this;
var query = ev.target.value.trim();
clearTimeout(this.debounceTimer);
if (query.length < 2) {
// If query is too short, reload original page
return;
}
// Debounce - wait 250ms before searching
this.debounceTimer = setTimeout(function () {
self._performSearch(query);
}, 250);
},
_onKeyDown: function (ev) {
if (ev.key === 'Enter') {
ev.preventDefault();
var query = ev.target.value.trim();
if (query.length >= 2) {
this._performSearch(query);
}
} else if (ev.key === 'Escape') {
ev.target.value = '';
window.location.reload();
}
},
_performSearch: function (query) {
var self = this;
if (!this.searchEndpoint) {
console.error('Search endpoint not found');
return;
}
// Show loading indicator
this._showLoading(true);
ajax.jsonRpc(this.searchEndpoint, 'call', {
query: query
}).then(function (response) {
self._showLoading(false);
if (response.error) {
console.error('Search error:', response.error);
return;
}
self._renderResults(response.results, query);
}).catch(function (error) {
self._showLoading(false);
console.error('Search failed:', error);
});
},
_showLoading: function (show) {
var spinner = document.querySelector('.search-loading');
if (spinner) {
spinner.classList.toggle('active', show);
}
},
_renderResults: function (results, query) {
if (!this.resultsContainer) {
return;
}
if (!results || results.length === 0) {
this.resultsContainer.innerHTML = `
<tr>
<td colspan="6" class="text-center py-4">
<i class="fa fa-search fa-2x text-muted mb-2"></i>
<p class="text-muted mb-0">No results found for "${query}"</p>
</td>
</tr>
`;
return;
}
var html = '';
var isAuthorizer = window.location.pathname.includes('/my/authorizer');
var baseUrl = isAuthorizer ? '/my/authorizer/case/' : '/my/sales/case/';
results.forEach(function (order) {
var stateClass = 'bg-secondary';
if (order.state === 'sent') stateClass = 'bg-primary';
else if (order.state === 'sale') stateClass = 'bg-success';
html += `
<tr class="search-result-row">
<td>${self._highlightMatch(order.name, query)}</td>
<td>${self._highlightMatch(order.partner_name, query)}</td>
<td>${order.date_order}</td>
<td>${self._highlightMatch(order.claim_number || '-', query)}</td>
<td><span class="badge ${stateClass}">${order.state_display}</span></td>
<td>
<a href="${baseUrl}${order.id}" class="btn btn-sm btn-primary">
<i class="fa fa-eye me-1"></i>View
</a>
</td>
</tr>
`;
});
this.resultsContainer.innerHTML = html;
},
_highlightMatch: function (text, query) {
if (!text || !query) return text || '';
var regex = new RegExp('(' + query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
return text.replace(regex, '<span class="search-highlight">$1</span>');
}
});
return publicWidget.registry.PortalSearch;
});

View File

@@ -1,167 +0,0 @@
/**
* Fusion Authorizer Portal - Signature Pad
* Touch-enabled digital signature capture
*/
odoo.define('fusion_authorizer_portal.signature_pad', function (require) {
'use strict';
var publicWidget = require('web.public.widget');
var ajax = require('web.ajax');
// Signature Pad Class
var SignaturePad = function (canvas, options) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.options = Object.assign({
strokeColor: '#000000',
strokeWidth: 2,
backgroundColor: '#ffffff'
}, options || {});
this.isDrawing = false;
this.lastX = 0;
this.lastY = 0;
this.points = [];
this._initialize();
};
SignaturePad.prototype = {
_initialize: function () {
var self = this;
// Set canvas size
this._resizeCanvas();
// Set drawing style
this.ctx.strokeStyle = this.options.strokeColor;
this.ctx.lineWidth = this.options.strokeWidth;
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
// Clear with background color
this.clear();
// Event listeners
this.canvas.addEventListener('mousedown', this._startDrawing.bind(this));
this.canvas.addEventListener('mousemove', this._draw.bind(this));
this.canvas.addEventListener('mouseup', this._stopDrawing.bind(this));
this.canvas.addEventListener('mouseout', this._stopDrawing.bind(this));
// Touch events
this.canvas.addEventListener('touchstart', this._startDrawing.bind(this), { passive: false });
this.canvas.addEventListener('touchmove', this._draw.bind(this), { passive: false });
this.canvas.addEventListener('touchend', this._stopDrawing.bind(this), { passive: false });
this.canvas.addEventListener('touchcancel', this._stopDrawing.bind(this), { passive: false });
// Resize handler
window.addEventListener('resize', this._resizeCanvas.bind(this));
},
_resizeCanvas: function () {
var rect = this.canvas.getBoundingClientRect();
var ratio = window.devicePixelRatio || 1;
this.canvas.width = rect.width * ratio;
this.canvas.height = rect.height * ratio;
this.ctx.scale(ratio, ratio);
this.canvas.style.width = rect.width + 'px';
this.canvas.style.height = rect.height + 'px';
// Restore drawing style after resize
this.ctx.strokeStyle = this.options.strokeColor;
this.ctx.lineWidth = this.options.strokeWidth;
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
},
_getPos: function (e) {
var rect = this.canvas.getBoundingClientRect();
var x, y;
if (e.touches && e.touches.length > 0) {
x = e.touches[0].clientX - rect.left;
y = e.touches[0].clientY - rect.top;
} else {
x = e.clientX - rect.left;
y = e.clientY - rect.top;
}
return { x: x, y: y };
},
_startDrawing: function (e) {
e.preventDefault();
this.isDrawing = true;
var pos = this._getPos(e);
this.lastX = pos.x;
this.lastY = pos.y;
this.points.push({ x: pos.x, y: pos.y, start: true });
},
_draw: function (e) {
e.preventDefault();
if (!this.isDrawing) return;
var pos = this._getPos(e);
this.ctx.beginPath();
this.ctx.moveTo(this.lastX, this.lastY);
this.ctx.lineTo(pos.x, pos.y);
this.ctx.stroke();
this.lastX = pos.x;
this.lastY = pos.y;
this.points.push({ x: pos.x, y: pos.y });
},
_stopDrawing: function (e) {
e.preventDefault();
this.isDrawing = false;
},
clear: function () {
this.ctx.fillStyle = this.options.backgroundColor;
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.points = [];
},
isEmpty: function () {
return this.points.length === 0;
},
toDataURL: function (type, quality) {
return this.canvas.toDataURL(type || 'image/png', quality || 1.0);
}
};
// Make SignaturePad available globally for inline scripts
window.SignaturePad = SignaturePad;
// Widget for signature pads in portal
publicWidget.registry.SignaturePadWidget = publicWidget.Widget.extend({
selector: '.signature-pad-container',
start: function () {
this._super.apply(this, arguments);
var canvas = this.el.querySelector('canvas');
if (canvas) {
this.signaturePad = new SignaturePad(canvas);
}
return Promise.resolve();
},
getSignaturePad: function () {
return this.signaturePad;
}
});
return {
SignaturePad: SignaturePad,
Widget: publicWidget.registry.SignaturePadWidget
};
});

View File

@@ -1,97 +0,0 @@
/**
* Technician Location Logger
* Logs GPS location every 5 minutes during working hours (9 AM - 6 PM)
* Only logs while the browser tab is visible.
*/
(function () {
'use strict';
var INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
var STORE_OPEN_HOUR = 9;
var STORE_CLOSE_HOUR = 18;
var locationTimer = null;
function isWorkingHours() {
var now = new Date();
var hour = now.getHours();
return hour >= STORE_OPEN_HOUR && hour < STORE_CLOSE_HOUR;
}
function isTechnicianPortal() {
// Check if we're on a technician portal page
return window.location.pathname.indexOf('/my/technician') !== -1;
}
function logLocation() {
if (!isWorkingHours()) {
return;
}
if (document.hidden) {
return;
}
if (!navigator.geolocation) {
return;
}
navigator.geolocation.getCurrentPosition(
function (position) {
var data = {
jsonrpc: '2.0',
method: 'call',
params: {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy || 0,
}
};
fetch('/my/technician/location/log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).catch(function () {
// Silently fail - location logging is best-effort
});
},
function () {
// Geolocation permission denied or error - silently ignore
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
);
}
function startLocationLogging() {
if (!isTechnicianPortal()) {
return;
}
// Log immediately on page load
logLocation();
// Set interval for periodic logging
locationTimer = setInterval(logLocation, INTERVAL_MS);
// Pause/resume on tab visibility change
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
// Tab hidden - clear interval to save battery
if (locationTimer) {
clearInterval(locationTimer);
locationTimer = null;
}
} else {
// Tab visible again - log immediately and restart interval
logLocation();
if (!locationTimer) {
locationTimer = setInterval(logLocation, INTERVAL_MS);
}
}
});
}
// Start when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startLocationLogging);
} else {
startLocationLogging();
}
})();

View File

@@ -1,96 +0,0 @@
/**
* Fusion Technician Portal - Push Notification Registration
* Registers service worker and subscribes to push notifications.
* Include this script on technician portal pages.
*/
(function() {
'use strict';
// Only run on technician portal pages
if (!document.querySelector('.tech-portal') && !window.location.pathname.startsWith('/my/technician')) {
return;
}
// Get VAPID public key from meta tag or page data
var vapidMeta = document.querySelector('meta[name="vapid-public-key"]');
var vapidPublicKey = vapidMeta ? vapidMeta.content : null;
if (!vapidPublicKey || !('serviceWorker' in navigator) || !('PushManager' in window)) {
return;
}
function urlBase64ToUint8Array(base64String) {
var padding = '='.repeat((4 - base64String.length % 4) % 4);
var base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
var rawData = window.atob(base64);
var outputArray = new Uint8Array(rawData.length);
for (var i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
async function registerPushSubscription() {
try {
// Register service worker
var registration = await navigator.serviceWorker.register(
'/fusion_authorizer_portal/static/src/js/technician_sw.js',
{scope: '/my/technician/'}
);
// Wait for service worker to be ready
await navigator.serviceWorker.ready;
// Check existing subscription
var subscription = await registration.pushManager.getSubscription();
if (!subscription) {
// Request permission
var permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.log('[TechPush] Notification permission denied');
return;
}
// Subscribe
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
}
// Send subscription to server
var key = subscription.getKey('p256dh');
var auth = subscription.getKey('auth');
var response = await fetch('/my/technician/push/subscribe', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: {
endpoint: subscription.endpoint,
p256dh: btoa(String.fromCharCode.apply(null, new Uint8Array(key))),
auth: btoa(String.fromCharCode.apply(null, new Uint8Array(auth))),
}
}),
});
var data = await response.json();
if (data.result && data.result.success) {
console.log('[TechPush] Push subscription registered successfully');
}
} catch (error) {
console.warn('[TechPush] Push registration failed:', error);
}
}
// Register after page load
if (document.readyState === 'complete') {
registerPushSubscription();
} else {
window.addEventListener('load', registerPushSubscription);
}
})();

View File

@@ -1,77 +0,0 @@
/**
* Fusion Technician Portal - Service Worker for Push Notifications
* Handles push events and notification clicks.
*/
self.addEventListener('push', function(event) {
if (!event.data) return;
var data;
try {
data = event.data.json();
} catch (e) {
data = {title: 'New Notification', body: event.data.text()};
}
var options = {
body: data.body || '',
icon: '/fusion_authorizer_portal/static/description/icon.png',
badge: '/fusion_authorizer_portal/static/description/icon.png',
tag: 'tech-task-' + (data.task_id || 'general'),
renotify: true,
data: {
url: data.url || '/my/technician',
taskId: data.task_id,
taskType: data.task_type,
},
actions: [],
};
// Add contextual actions based on task type
if (data.url) {
options.actions.push({action: 'view', title: 'View Task'});
}
if (data.task_type === 'delivery' || data.task_type === 'repair') {
options.actions.push({action: 'navigate', title: 'Navigate'});
}
event.waitUntil(
self.registration.showNotification(data.title || 'Fusion Technician', options)
);
});
self.addEventListener('notificationclick', function(event) {
event.notification.close();
var url = '/my/technician';
if (event.notification.data && event.notification.data.url) {
url = event.notification.data.url;
}
if (event.action === 'navigate' && event.notification.data && event.notification.data.taskId) {
// Open Google Maps for the task (will redirect through portal)
url = '/my/technician/task/' + event.notification.data.taskId;
}
event.waitUntil(
clients.matchAll({type: 'window', includeUncontrolled: true}).then(function(clientList) {
// Focus existing window if open
for (var i = 0; i < clientList.length; i++) {
var client = clientList[i];
if (client.url.indexOf('/my/technician') !== -1 && 'focus' in client) {
client.navigate(url);
return client.focus();
}
}
// Open new window
if (clients.openWindow) {
return clients.openWindow(url);
}
})
);
});
// Keep service worker alive
self.addEventListener('activate', function(event) {
event.waitUntil(self.clients.claim());
});

View File

@@ -1,27 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!--
Fusion Authorizer Portal - Message Authorizer Chatter Button
Copyright 2026 Nexa Systems Inc.
License OPL-1
Adds a "Message Authorizer" icon button to the chatter topbar
on sale.order forms (after the Activity button).
-->
<templates xml:space="preserve">
<t t-inherit="mail.Chatter" t-inherit-mode="extension">
<!-- Insert after the Activity button -->
<xpath expr="//button[hasclass('o-mail-Chatter-activity')]" position="after">
<button t-if="state.thread and state.thread.model === 'sale.order'"
class="o-mail-Chatter-messageAuthorizer btn btn-secondary text-nowrap me-1"
t-att-class="{ 'my-2': !props.compactHeight }"
t-on-click="onClickMessageAuthorizer"
title="Message Authorizer">
<span>Message Authorizer</span>
</button>
</xpath>
</t>
</templates>

View File

@@ -1,3 +0,0 @@
# -*- coding: utf-8 -*-
from . import pdf_filler

View File

@@ -1,146 +0,0 @@
# -*- coding: utf-8 -*-
# Fusion PDF Template Filler
# Generic utility for filling any PDF template with data overlays.
# Uses the same pattern as Odoo Enterprise Sign module (sign/utils/pdf_handling.py):
# - Read original PDF page dimensions from mediaBox
# - Create reportlab Canvas overlay at the same page size
# - Convert percentage positions (0.0-1.0) to absolute PDF coordinates
# - Merge overlay onto original page via mergePage()
import logging
from io import BytesIO
from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader
from odoo.tools.pdf import PdfFileReader, PdfFileWriter
_logger = logging.getLogger(__name__)
class PDFTemplateFiller:
"""Generic PDF template filler. Works with any template, any number of pages."""
@staticmethod
def fill_template(template_pdf_bytes, fields_by_page, context, signatures=None):
"""Fill a PDF template by overlaying text/checkmarks/signatures at configured positions.
Args:
template_pdf_bytes: bytes of the original PDF
fields_by_page: {page_num: [field_dicts]} where page_num is 1-based
Each field_dict has: field_key, pos_x, pos_y, width, height,
field_type, font_size, font_name
context: flat dict of {field_key: value} with all available data
signatures: dict of {field_key: binary_png} for signature image fields
Returns:
bytes of the filled PDF (all pages preserved)
"""
if signatures is None:
signatures = {}
try:
original = PdfFileReader(BytesIO(template_pdf_bytes))
except Exception as e:
_logger.error("Failed to read template PDF: %s", e)
raise
output = PdfFileWriter()
num_pages = original.getNumPages()
for page_idx in range(num_pages):
page = original.getPage(page_idx)
page_num = page_idx + 1 # 1-based page number
page_w = float(page.mediaBox.getWidth())
page_h = float(page.mediaBox.getHeight())
fields = fields_by_page.get(page_num, [])
if fields:
# Create a transparent overlay for this page
overlay_buf = BytesIO()
c = canvas.Canvas(overlay_buf, pagesize=(page_w, page_h))
for field in fields:
PDFTemplateFiller._draw_field(
c, field, context, signatures, page_w, page_h
)
c.save()
overlay_buf.seek(0)
# Merge overlay onto original page (same as sign module)
overlay_pdf = PdfFileReader(overlay_buf)
page.mergePage(overlay_pdf.getPage(0))
output.addPage(page)
result = BytesIO()
output.write(result)
return result.getvalue()
@staticmethod
def _draw_field(c, field, context, signatures, page_w, page_h):
"""Draw a single field onto the reportlab canvas.
Args:
c: reportlab Canvas
field: dict with field_key, pos_x, pos_y, width, height, field_type, etc.
context: data context dict
signatures: dict of {field_key: binary} for signature fields
page_w: page width in PDF points
page_h: page height in PDF points
"""
field_key = field.get('field_key') or field.get('field_name', '')
field_type = field.get('field_type', 'text')
value = context.get(field_key, field.get('default_value', ''))
if not value and field_type != 'signature':
return
# Convert percentage positions to absolute PDF coordinates
# pos_x/pos_y are 0.0-1.0 ratios from top-left
# PDF coordinate system: origin at bottom-left, Y goes up
abs_x = field['pos_x'] * page_w
abs_y = page_h - (field['pos_y'] * page_h) # flip Y axis
font_name = field.get('font_name', 'Helvetica')
font_size = field.get('font_size', 10.0)
if field_type in ('text', 'date'):
c.setFont(font_name, font_size)
text_val = str(value)
field_h = field.get('height', 0.018) * page_h
text_y = abs_y - field_h + (field_h - font_size) / 2
align = field.get('text_align', 'left')
if align == 'center':
center_x = abs_x + (field.get('width', 0.15) * page_w) / 2
c.drawCentredString(center_x, text_y, text_val)
elif align == 'right':
right_x = abs_x + field.get('width', 0.15) * page_w
c.drawRightString(right_x, text_y, text_val)
else:
c.drawString(abs_x, text_y, text_val)
elif field_type == 'checkbox':
if value:
c.setFont('ZapfDingbats', font_size)
cb_h = field.get('height', 0.018) * page_h
cb_y = abs_y - cb_h + (cb_h - font_size) / 2
c.drawString(abs_x, cb_y, '4')
elif field_type == 'signature':
sig_data = signatures.get(field_key)
if sig_data:
try:
img = ImageReader(BytesIO(sig_data))
sig_w = field.get('width', 0.15) * page_w
sig_h = field.get('height', 0.05) * page_h
# Draw signature image (position from top, so adjust Y)
c.drawImage(
img, abs_x, abs_y - sig_h,
width=sig_w, height=sig_h,
mask='auto',
)
except Exception as e:
_logger.warning("Failed to draw signature for %s: %s", field_key, e)

View File

@@ -1,258 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Assessment Tree View -->
<record id="view_fusion_assessment_tree" model="ir.ui.view">
<field name="name">fusion.assessment.tree</field>
<field name="model">fusion.assessment</field>
<field name="arch" type="xml">
<list string="Assessments" decoration-info="state == 'draft'" decoration-warning="state == 'pending_signature'" decoration-success="state == 'completed'" decoration-muted="state == 'cancelled'">
<field name="reference"/>
<field name="client_name"/>
<field name="assessment_date"/>
<field name="sales_rep_id"/>
<field name="authorizer_id"/>
<field name="state" widget="badge" decoration-info="state == 'draft'" decoration-warning="state == 'pending_signature'" decoration-success="state == 'completed'" decoration-danger="state == 'cancelled'"/>
<field name="signatures_complete" widget="boolean"/>
<field name="sale_order_id"/>
</list>
</field>
</record>
<!-- Assessment Form View -->
<record id="view_fusion_assessment_form" model="ir.ui.view">
<field name="name">fusion.assessment.form</field>
<field name="model">fusion.assessment</field>
<field name="arch" type="xml">
<form string="Assessment">
<header>
<button name="action_mark_pending_signature" type="object" string="Mark Pending Signature" class="btn-primary" invisible="state != 'draft'"/>
<button name="action_complete" type="object" string="Complete Assessment" class="btn-success" invisible="state not in ['draft', 'pending_signature']"/>
<button name="action_cancel" type="object" string="Cancel" invisible="state in ['completed', 'cancelled']"/>
<button name="action_reset_draft" type="object" string="Reset to Draft" invisible="state != 'cancelled'"/>
<field name="state" widget="statusbar" statusbar_visible="draft,pending_signature,completed"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_documents" type="object" class="oe_stat_button" icon="fa-file-pdf-o">
<field name="document_count" string="Documents" widget="statinfo"/>
</button>
<button name="action_view_sale_order" type="object" class="oe_stat_button" icon="fa-shopping-cart" invisible="not sale_order_id">
<span class="o_stat_text">Sale Order</span>
</button>
</div>
<div class="oe_title">
<h1>
<field name="reference" readonly="1"/>
</h1>
</div>
<group>
<group string="Client Information">
<field name="client_name"/>
<field name="client_first_name"/>
<field name="client_last_name"/>
<field name="client_phone"/>
<field name="client_mobile"/>
<field name="client_email"/>
<field name="client_dob"/>
<field name="client_health_card"/>
</group>
<group string="Address">
<field name="client_street"/>
<field name="client_city"/>
<field name="client_state"/>
<field name="client_postal_code"/>
<field name="client_country_id"/>
</group>
</group>
<group>
<group string="Assessment Details">
<field name="assessment_date"/>
<field name="assessment_location"/>
<field name="assessment_location_notes"/>
</group>
<group string="Participants">
<field name="sales_rep_id"/>
<field name="authorizer_id"/>
<field name="partner_id"/>
<field name="create_new_partner"/>
</group>
</group>
<group>
<group string="Client References">
<field name="client_reference_1"/>
<field name="client_reference_2"/>
</group>
</group>
<notebook>
<page string="Wheelchair Specifications" name="specs">
<group>
<group string="Seat Measurements">
<field name="seat_width"/>
<field name="seat_depth"/>
<field name="seat_to_floor_height"/>
<field name="seat_angle"/>
</group>
<group string="Back &amp; Arms">
<field name="back_height"/>
<field name="back_angle"/>
<field name="armrest_height"/>
<field name="footrest_length"/>
</group>
</group>
<group>
<group string="Overall Dimensions">
<field name="overall_width"/>
<field name="overall_length"/>
<field name="overall_height"/>
</group>
<group string="Client Measurements">
<field name="client_weight"/>
<field name="client_height"/>
</group>
</group>
</page>
<page string="Product Types" name="products">
<group>
<group>
<field name="cushion_type"/>
<field name="cushion_notes"/>
<field name="backrest_type"/>
<field name="backrest_notes"/>
</group>
<group>
<field name="frame_type"/>
<field name="frame_notes"/>
<field name="wheel_type"/>
<field name="wheel_notes"/>
</group>
</group>
</page>
<page string="Needs &amp; Requirements" name="needs">
<group>
<field name="diagnosis"/>
<field name="mobility_notes"/>
<field name="accessibility_notes"/>
<field name="special_requirements"/>
</group>
</page>
<page string="Signatures" name="signatures">
<group>
<group string="Page 11 - Authorizer Signature">
<field name="signature_page_11" widget="image" class="oe_avatar"/>
<field name="signature_page_11_name"/>
<field name="signature_page_11_date"/>
</group>
<group string="Page 12 - Client Signature">
<field name="signature_page_12" widget="image" class="oe_avatar"/>
<field name="signature_page_12_name"/>
<field name="signature_page_12_date"/>
</group>
</group>
<group>
<field name="signatures_complete"/>
</group>
</page>
<page string="Documents" name="documents">
<field name="document_ids">
<list string="Documents">
<field name="document_type"/>
<field name="filename"/>
<field name="revision"/>
<field name="upload_date"/>
<field name="uploaded_by"/>
</list>
</field>
</page>
<page string="Comments" name="comments">
<field name="comment_ids">
<list string="Comments">
<field name="create_date"/>
<field name="author_id"/>
<field name="comment"/>
</list>
</field>
</page>
</notebook>
<group invisible="not sale_order_id">
<field name="sale_order_id"/>
</group>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- Assessment Search View -->
<record id="view_fusion_assessment_search" model="ir.ui.view">
<field name="name">fusion.assessment.search</field>
<field name="model">fusion.assessment</field>
<field name="arch" type="xml">
<search string="Search Assessments">
<field name="reference"/>
<field name="client_name"/>
<field name="client_email"/>
<field name="sales_rep_id"/>
<field name="authorizer_id"/>
<separator/>
<filter string="In Progress" name="draft" domain="[('state', '=', 'draft')]"/>
<filter string="Pending Signature" name="pending" domain="[('state', '=', 'pending_signature')]"/>
<filter string="Completed" name="completed" domain="[('state', '=', 'completed')]"/>
<filter string="Cancelled" name="cancelled" domain="[('state', '=', 'cancelled')]"/>
<separator/>
<filter string="My Assessments" name="my_assessments" domain="[('sales_rep_id', '=', uid)]"/>
<separator/>
<filter string="Status" name="group_state" context="{'group_by': 'state'}"/>
<filter string="Sales Rep" name="group_sales_rep" context="{'group_by': 'sales_rep_id'}"/>
<filter string="Authorizer" name="group_authorizer" context="{'group_by': 'authorizer_id'}"/>
<filter string="Date" name="group_date" context="{'group_by': 'assessment_date:month'}"/>
</search>
</field>
</record>
<!-- Assessment Action -->
<record id="action_fusion_assessment" model="ir.actions.act_window">
<field name="name">Assessments</field>
<field name="res_model">fusion.assessment</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fusion_assessment_search"/>
<field name="context">{'search_default_my_assessments': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first assessment
</p>
<p>
Assessments are used to record wheelchair specifications and client needs.
Once completed, they will create a draft sale order for review.
</p>
</field>
</record>
<!-- Menu Items -->
<menuitem id="menu_fusion_assessment_root"
name="Assessments"
parent="fusion_claims.menu_adp_claims_root"
sequence="42"/>
<menuitem id="menu_fusion_assessment_list"
name="All Assessments"
parent="menu_fusion_assessment_root"
action="action_fusion_assessment"
sequence="10"/>
</odoo>

View File

@@ -1,171 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- PDF Template - Form View -->
<!-- ============================================================ -->
<record id="fusion_pdf_template_form" model="ir.ui.view">
<field name="name">fusion.pdf.template.form</field>
<field name="model">fusion.pdf.template</field>
<field name="arch" type="xml">
<form string="PDF Template">
<header>
<button name="action_activate" string="Activate" type="object"
class="btn-primary"
invisible="state != 'draft'"/>
<button name="action_archive" string="Archive" type="object"
class="btn-secondary"
invisible="state != 'active'"/>
<button name="action_reset_draft" string="Reset to Draft" type="object"
class="btn-secondary"
invisible="state != 'archived'"/>
<field name="state" widget="statusbar" statusbar_visible="draft,active"/>
</header>
<sheet>
<div class="oe_title">
<h1>
<field name="name" placeholder="e.g., ADP Page 11 - Consent"/>
</h1>
</div>
<group>
<group>
<field name="category"/>
<field name="version"/>
<field name="page_count"/>
</group>
<group>
<field name="pdf_file" filename="pdf_filename"/>
<field name="pdf_filename" invisible="1"/>
<field name="field_count"/>
</group>
</group>
<div class="alert alert-info" role="alert" style="margin: 10px 0;">
<strong>How to use:</strong>
1. Upload the agency's PDF form above.
2. Upload page preview images (screenshots/photos of each page) in the Previews tab.
3. Click "Open Field Editor" to visually position fields on the PDF.
4. Activate the template when ready.
</div>
<div class="d-flex gap-2 mb-3">
<button name="action_generate_previews" string="Generate Page Previews"
type="object" class="btn-secondary"
icon="fa-image"
invisible="not pdf_file"/>
<button name="action_open_field_editor" string="Open Field Editor"
type="object" class="btn-primary"
icon="fa-pencil-square-o"
invisible="not pdf_file"/>
</div>
<notebook>
<page string="Fields" name="fields">
<field name="field_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="page"/>
<field name="name"/>
<field name="label"/>
<field name="field_type"/>
<field name="field_key"/>
<field name="pos_x"/>
<field name="pos_y"/>
<field name="width"/>
<field name="height"/>
<field name="font_size"/>
<field name="font_name"/>
<field name="default_value"/>
<field name="is_active"/>
</list>
</field>
</page>
<page string="Page Previews" name="previews">
<field name="preview_ids">
<list editable="bottom">
<field name="page"/>
<field name="image" widget="image" options="{'size': [200, 200]}"/>
<field name="image_filename"/>
</list>
</field>
</page>
<page string="Notes" name="notes">
<field name="notes" placeholder="Usage notes, which assessments use this template..."/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- ============================================================ -->
<!-- PDF Template - List View -->
<!-- ============================================================ -->
<record id="fusion_pdf_template_list" model="ir.ui.view">
<field name="name">fusion.pdf.template.list</field>
<field name="model">fusion.pdf.template</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="category"/>
<field name="version"/>
<field name="page_count"/>
<field name="field_count"/>
<field name="state" widget="badge"
decoration-success="state == 'active'"
decoration-info="state == 'draft'"
decoration-muted="state == 'archived'"/>
</list>
</field>
</record>
<!-- ============================================================ -->
<!-- PDF Template - Search View -->
<!-- ============================================================ -->
<record id="fusion_pdf_template_search" model="ir.ui.view">
<field name="name">fusion.pdf.template.search</field>
<field name="model">fusion.pdf.template</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="category"/>
<filter name="active_templates" string="Active" domain="[('state', '=', 'active')]"/>
<filter name="draft_templates" string="Draft" domain="[('state', '=', 'draft')]"/>
<separator/>
<filter name="group_category" string="Agency" context="{'group_by': 'category'}"/>
<filter name="group_state" string="Status" context="{'group_by': 'state'}"/>
</search>
</field>
</record>
<!-- ============================================================ -->
<!-- PDF Template - Action -->
<!-- ============================================================ -->
<record id="action_fusion_pdf_template" model="ir.actions.act_window">
<field name="name">PDF Templates</field>
<field name="res_model">fusion.pdf.template</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="fusion_pdf_template_search"/>
<field name="context">{'search_default_active_templates': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first PDF template
</p>
<p>
Upload a funding agency's PDF form, position fields on it using the
visual editor, and generate filled PDFs automatically from assessment data.
</p>
</field>
</record>
<!-- ============================================================ -->
<!-- Menu Item (under Fusion Claims > Configuration) -->
<!-- ============================================================ -->
<!-- Direct under Fusion Claims > Configuration -->
<menuitem id="menu_fusion_pdf_templates"
name="PDF Templates"
parent="fusion_claims.menu_adp_config"
action="action_fusion_pdf_template"
sequence="40"/>
</odoo>

View File

@@ -1,712 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
Accessibility Assessment Portal Templates
-->
<odoo>
<!-- ============================================================= -->
<!-- ASSESSMENT TYPE SELECTOR PAGE -->
<!-- ============================================================= -->
<template id="portal_accessibility_selector" name="Accessibility Assessment Selector">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<t t-set="no_breadcrumbs" t-value="True"/>
<div class="container py-4">
<!-- Custom Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/my">Dashboard</a></li>
<li class="breadcrumb-item active">Accessibility Assessment</li>
</ol>
</nav>
<div class="row">
<div class="col-12">
<h2 class="mb-4">
<i class="fa fa-wheelchair text-primary"></i>
Accessibility Assessment
</h2>
<p class="text-muted mb-4">
Select the type of accessibility assessment you want to perform.
</p>
</div>
</div>
<div class="row">
<!-- Stair Lifts -->
<div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<div class="mb-3">
<i class="fa fa-level-up fa-3x text-primary"></i>
</div>
<h5 class="card-title">Straight Stair Lift</h5>
<p class="card-text text-muted small">
Standard stair lift for straight staircases.
Includes track length calculation.
</p>
<a href="/my/accessibility/stairlift/straight" class="btn btn-primary btn-block">
<i class="fa fa-plus-circle"></i> Start Assessment
</a>
</div>
</div>
</div>
<div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<div class="mb-3">
<i class="fa fa-refresh fa-3x text-info"></i>
</div>
<h5 class="card-title">Curved Stair Lift</h5>
<p class="card-text text-muted small">
Custom curved stair lift with parking options.
Includes curve and step calculations.
</p>
<a href="/my/accessibility/stairlift/curved" class="btn btn-info btn-block">
<i class="fa fa-plus-circle"></i> Start Assessment
</a>
</div>
</div>
</div>
<!-- VPL -->
<div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<div class="mb-3">
<i class="fa fa-arrows-v fa-3x text-success"></i>
</div>
<h5 class="card-title">Vertical Platform Lift</h5>
<p class="card-text text-muted small">
VPL assessment with room dimensions,
power requirements, and certification.
</p>
<a href="/my/accessibility/vpl" class="btn btn-success btn-block">
<i class="fa fa-plus-circle"></i> Start Assessment
</a>
</div>
</div>
</div>
<!-- Ceiling Lift -->
<div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<div class="mb-3">
<i class="fa fa-cloud-upload fa-3x text-warning"></i>
</div>
<h5 class="card-title">Ceiling Lift</h5>
<p class="card-text text-muted small">
Ceiling lift with track length, movement type,
and additional features.
</p>
<a href="/my/accessibility/ceiling-lift" class="btn btn-warning btn-block">
<i class="fa fa-plus-circle"></i> Start Assessment
</a>
</div>
</div>
</div>
<!-- Custom Ramp -->
<div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<div class="mb-3">
<i class="fa fa-road fa-3x text-danger"></i>
</div>
<h5 class="card-title">Custom Ramp</h5>
<p class="card-text text-muted small">
Ramp with Ontario Building Code compliance.
Auto-calculates length and landings.
</p>
<a href="/my/accessibility/ramp" class="btn btn-danger btn-block">
<i class="fa fa-plus-circle"></i> Start Assessment
</a>
</div>
</div>
</div>
<!-- Bathroom Modifications -->
<div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<div class="mb-3">
<i class="fa fa-bath fa-3x text-secondary"></i>
</div>
<h5 class="card-title">Bathroom Modification</h5>
<p class="card-text text-muted small">
General bathroom modifications.
Free-form description with photos.
</p>
<a href="/my/accessibility/bathroom" class="btn btn-secondary btn-block">
<i class="fa fa-plus-circle"></i> Start Assessment
</a>
</div>
</div>
</div>
<!-- Tub Cutout -->
<div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<div class="mb-3">
<i class="fa fa-cut fa-3x" style="color: #6c5ce7;"></i>
</div>
<h5 class="card-title">Tub Cutout</h5>
<p class="card-text text-muted small">
Tub cutout assessment with internal and
external height measurements.
</p>
<a href="/my/accessibility/tub-cutout" class="btn btn-block" style="background: #6c5ce7; color: white;">
<i class="fa fa-plus-circle"></i> Start Assessment
</a>
</div>
</div>
</div>
</div>
<!-- View All Assessments Link -->
<div class="row mt-4">
<div class="col-12 text-center">
<a href="/my/accessibility/list" class="btn btn-outline-primary">
<i class="fa fa-list"></i> View All Assessments
</a>
</div>
</div>
</div>
</t>
</template>
<!-- ============================================================= -->
<!-- ASSESSMENT LIST PAGE -->
<!-- ============================================================= -->
<template id="portal_accessibility_list" name="Accessibility Assessment List">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<t t-set="no_breadcrumbs" t-value="True"/>
<div class="container py-4">
<!-- Custom Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/my">Dashboard</a></li>
<li class="breadcrumb-item"><a href="/my/accessibility">Accessibility</a></li>
<li class="breadcrumb-item active">All Assessments</li>
</ol>
</nav>
<div class="row mb-4">
<div class="col-md-8">
<h2>
<i class="fa fa-list text-primary"></i>
My Accessibility Assessments
</h2>
</div>
<div class="col-md-4 text-right">
<a href="/my/accessibility" class="btn btn-primary">
<i class="fa fa-plus"></i> New Assessment
</a>
</div>
</div>
<t t-if="assessments">
<div class="table-responsive">
<table class="table table-hover">
<thead class="thead-light">
<tr>
<th>Reference</th>
<th>Type</th>
<th>Client</th>
<th>Date</th>
<th>Status</th>
<th>Sale Order</th>
</tr>
</thead>
<tbody>
<t t-foreach="assessments" t-as="assessment">
<tr class="o_portal_my_doc_table">
<td>
<span t-field="assessment.reference"/>
</td>
<td>
<t t-if="assessment.assessment_type == 'stairlift_straight'">
<span class="badge badge-primary">Straight Stair Lift</span>
</t>
<t t-elif="assessment.assessment_type == 'stairlift_curved'">
<span class="badge badge-info">Curved Stair Lift</span>
</t>
<t t-elif="assessment.assessment_type == 'vpl'">
<span class="badge badge-success">VPL</span>
</t>
<t t-elif="assessment.assessment_type == 'ceiling_lift'">
<span class="badge badge-warning">Ceiling Lift</span>
</t>
<t t-elif="assessment.assessment_type == 'ramp'">
<span class="badge badge-danger">Ramp</span>
</t>
<t t-elif="assessment.assessment_type == 'bathroom'">
<span class="badge badge-secondary">Bathroom</span>
</t>
<t t-elif="assessment.assessment_type == 'tub_cutout'">
<span class="badge" style="background: #6c5ce7; color: white;">Tub Cutout</span>
</t>
</td>
<td><span t-field="assessment.client_name"/></td>
<td><span t-field="assessment.assessment_date"/></td>
<td>
<t t-if="assessment.state == 'draft'">
<span class="badge badge-secondary">Draft</span>
</t>
<t t-elif="assessment.state == 'completed'">
<span class="badge badge-success">Completed</span>
</t>
<t t-elif="assessment.state == 'cancelled'">
<span class="badge badge-danger">Cancelled</span>
</t>
</td>
<td>
<t t-if="assessment.sale_order_id">
<a t-attf-href="/my/sales/case/#{assessment.sale_order_id.id}">
<span t-field="assessment.sale_order_id.name"/>
</a>
</t>
<t t-else="">-</t>
</td>
</tr>
</t>
</tbody>
</table>
</div>
<!-- Pager -->
<div class="text-center">
<t t-call="portal.pager"/>
</div>
</t>
<t t-else="">
<div class="alert alert-info text-center">
<i class="fa fa-info-circle"></i>
No accessibility assessments found.
<a href="/my/accessibility" class="alert-link">Create your first assessment</a>.
</div>
</t>
</div>
</t>
</template>
<!-- ============================================================= -->
<!-- SHARED FORM COMPONENTS -->
<!-- ============================================================= -->
<!-- Client Information Section (shared across all forms) -->
<template id="accessibility_client_info_section" name="Accessibility Client Info Section">
<div class="card mb-4">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="fa fa-user"></i> Client Information</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Client Name <span class="text-danger">*</span></label>
<input type="text" name="client_name" class="form-control" required="required" placeholder="Full name"/>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Assessment Date</label>
<input type="date" name="assessment_date" class="form-control" t-att-value="today"/>
</div>
</div>
<div class="row">
<div class="col-md-8 mb-3">
<label class="form-label">Street Address</label>
<input type="text" name="client_address" id="client_address" class="form-control address-autocomplete"
placeholder="Start typing address..."/>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Unit/Apt/Suite</label>
<input type="text" name="client_unit" id="client_unit" class="form-control"
placeholder="e.g., Unit 5, Apt 302"/>
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">City</label>
<input type="text" name="client_address_city" id="client_address_city" class="form-control" placeholder="City"/>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Province</label>
<select name="client_address_province" id="client_address_province" class="form-select">
<option value="">-- Select --</option>
<option value="ON">Ontario</option>
<option value="QC">Quebec</option>
<option value="BC">British Columbia</option>
<option value="AB">Alberta</option>
<option value="MB">Manitoba</option>
<option value="SK">Saskatchewan</option>
<option value="NS">Nova Scotia</option>
<option value="NB">New Brunswick</option>
<option value="NL">Newfoundland and Labrador</option>
<option value="PE">Prince Edward Island</option>
<option value="NT">Northwest Territories</option>
<option value="YT">Yukon</option>
<option value="NU">Nunavut</option>
</select>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Postal Code</label>
<input type="text" name="client_address_postal" id="client_address_postal" class="form-control" placeholder="A1A 1A1"/>
</div>
</div>
<input type="hidden" name="client_address_street" id="client_address_street"/>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Phone</label>
<input type="tel" name="client_phone" class="form-control" placeholder="(xxx) xxx-xxxx"/>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Email</label>
<input type="email" name="client_email" class="form-control" placeholder="email@example.com"/>
</div>
</div>
</div>
</div>
</template>
<!-- Photo Upload Section (shared across all forms) -->
<template id="accessibility_photo_section" name="Accessibility Photo Section">
<div class="card mb-4">
<div class="card-header" style="background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%); color: white;">
<h5 class="mb-0"><i class="fa fa-camera"></i> Photos</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-12">
<label class="form-label">Attach Photos</label>
<input type="file" id="photo_upload" class="form-control" accept="image/*" multiple="multiple"/>
<small class="text-muted">You can select multiple photos. Accepted formats: JPG, PNG, GIF, WEBP</small>
</div>
</div>
<div id="photo_preview" class="row mt-3">
<!-- Photo previews will appear here -->
</div>
</div>
</div>
</template>
<!-- Notes Section (shared across all forms) -->
<template id="accessibility_notes_section" name="Accessibility Notes Section">
<div class="card mb-4">
<div class="card-header bg-light">
<h5 class="mb-0"><i class="fa fa-sticky-note"></i> General Notes</h5>
</div>
<div class="card-body">
<textarea name="notes" class="form-control" rows="3" placeholder="Any additional notes..."></textarea>
</div>
</div>
</template>
<!-- Form Submit Buttons -->
<template id="accessibility_submit_buttons" name="Accessibility Submit Buttons">
<div class="row mt-4">
<div class="col-12">
<div class="d-flex justify-content-between">
<a href="/my/accessibility" class="btn btn-outline-secondary">
<i class="fa fa-arrow-left"></i> Cancel
</a>
<div>
<button type="button" class="btn btn-outline-primary mr-2" onclick="saveAssessment(false)">
<i class="fa fa-save"></i> Save Draft
</button>
<button type="button" class="btn btn-success" onclick="saveAssessment(true)">
<i class="fa fa-check-circle"></i> Complete &amp; Create Sale Order
</button>
</div>
</div>
</div>
</div>
</template>
<!-- Google Maps + Form JavaScript -->
<template id="accessibility_form_scripts" name="Accessibility Form Scripts">
<!-- Google Maps Places API -->
<t t-if="google_maps_api_key">
<script t-attf-src="https://maps.googleapis.com/maps/api/js?key=#{google_maps_api_key}&amp;libraries=places&amp;callback=initAddressAutocomplete" async="async" defer="defer"></script>
</t>
<script type="text/javascript">
// Photo handling - Main photos
var photoDataArray = [];
// Additional photo arrays for curved stair lift
var topLandingPhotos = [];
var bottomLandingPhotos = [];
var assessmentVideoData = null;
var assessmentVideoFilename = null;
document.getElementById('photo_upload').addEventListener('change', function(e) {
var files = e.target.files;
var previewContainer = document.getElementById('photo_preview');
for (var i = 0; i &lt; files.length; i++) {
var file = files[i];
var reader = new FileReader();
reader.onload = (function(idx) {
return function(e) {
photoDataArray.push(e.target.result);
var col = document.createElement('div');
col.className = 'col-6 col-md-3 mb-3';
col.innerHTML = '&lt;div class="position-relative"&gt;' +
'&lt;img src="' + e.target.result + '" class="img-fluid rounded" style="max-height: 150px; object-fit: cover; width: 100%;"&gt;' +
'&lt;button type="button" class="btn btn-danger btn-sm position-absolute" style="top: 5px; right: 5px;" onclick="removePhoto(' + (photoDataArray.length - 1) + ', this)"&gt;' +
'&lt;i class="fa fa-times"&gt;&lt;/i&gt;' +
'&lt;/button&gt;' +
'&lt;/div&gt;';
previewContainer.appendChild(col);
};
})(i);
reader.readAsDataURL(file);
}
});
function removePhoto(index, btn) {
photoDataArray[index] = null;
btn.closest('.col-6').remove();
}
// Initialize landing photo handlers (for curved stair lift forms)
document.addEventListener('DOMContentLoaded', function() {
// Top landing photos
var topLandingInput = document.getElementById('top_landing_photos');
if (topLandingInput) {
topLandingInput.addEventListener('change', function(e) {
handleLandingPhotos(e.target.files, 'top_landing_preview', topLandingPhotos);
});
}
// Bottom landing photos
var bottomLandingInput = document.getElementById('bottom_landing_photos');
if (bottomLandingInput) {
bottomLandingInput.addEventListener('change', function(e) {
handleLandingPhotos(e.target.files, 'bottom_landing_preview', bottomLandingPhotos);
});
}
// Video upload
var videoInput = document.getElementById('assessment_video');
if (videoInput) {
videoInput.addEventListener('change', function(e) {
handleVideoUpload(e.target.files[0]);
});
}
});
function handleLandingPhotos(files, previewId, photoArray) {
var previewContainer = document.getElementById(previewId);
if (!previewContainer) return;
// Clear previous previews
previewContainer.innerHTML = '';
photoArray.length = 0;
for (var i = 0; i &lt; files.length; i++) {
var file = files[i];
var reader = new FileReader();
reader.onload = (function(arr) {
return function(e) {
arr.push(e.target.result);
var col = document.createElement('div');
col.className = 'col-3 mb-2';
col.innerHTML = '&lt;img src="' + e.target.result + '" class="img-thumbnail" style="max-height: 80px; object-fit: cover;"/&gt;';
previewContainer.appendChild(col);
};
})(photoArray);
reader.readAsDataURL(file);
}
}
function handleVideoUpload(file) {
if (!file) return;
var videoPreview = document.getElementById('video_preview');
var videoPlayer = document.getElementById('video_player');
var compressStatus = document.getElementById('video_compress_status');
// Check file size (max 100MB)
var maxSize = 100 * 1024 * 1024;
if (file.size &gt; maxSize) {
alert('Video file is too large. Maximum size is 100MB.');
document.getElementById('assessment_video').value = '';
return;
}
// Show preview
if (videoPlayer &amp;&amp; videoPreview) {
videoPlayer.src = URL.createObjectURL(file);
videoPreview.style.display = 'block';
}
// Store video as base64
var reader = new FileReader();
reader.onload = function(e) {
assessmentVideoData = e.target.result;
assessmentVideoFilename = file.name;
console.log('Video loaded: ' + file.name + ' (' + (file.size / (1024 * 1024)).toFixed(2) + ' MB)');
};
reader.readAsDataURL(file);
}
// Address autocomplete
function initAddressAutocomplete() {
var addressInput = document.getElementById('client_address');
if (!addressInput) return;
var autocomplete = new google.maps.places.Autocomplete(addressInput, {
componentRestrictions: { country: 'ca' },
types: ['address']
});
autocomplete.addListener('place_changed', function() {
var place = autocomplete.getPlace();
if (!place.address_components) return;
var streetNumber = '';
var streetName = '';
var city = '';
var province = '';
var postalCode = '';
for (var i = 0; i &lt; place.address_components.length; i++) {
var component = place.address_components[i];
var types = component.types;
if (types.includes('street_number')) {
streetNumber = component.long_name;
} else if (types.includes('route')) {
streetName = component.long_name;
} else if (types.includes('locality')) {
city = component.long_name;
} else if (types.includes('administrative_area_level_1')) {
province = component.short_name;
} else if (types.includes('postal_code')) {
postalCode = component.long_name;
}
}
// Update street address (hidden field stores original, visible shows formatted)
document.getElementById('client_address_street').value = (streetNumber + ' ' + streetName).trim();
// Update city field
var cityField = document.getElementById('client_address_city');
if (cityField) cityField.value = city;
// Update province select - match by code or name
var provinceSelect = document.getElementById('client_address_province');
if (provinceSelect) {
for (var j = 0; j &lt; provinceSelect.options.length; j++) {
var optVal = provinceSelect.options[j].value.toUpperCase();
var optText = provinceSelect.options[j].text.toLowerCase();
if (optVal === province.toUpperCase() || optText === province.toLowerCase()) {
provinceSelect.selectedIndex = j;
break;
}
}
}
// Update postal code field
var postalField = document.getElementById('client_address_postal');
if (postalField) postalField.value = postalCode;
});
}
// Fallback if Google Maps not loaded
window.initAddressAutocomplete = window.initAddressAutocomplete || function() {};
// Form submission
function saveAssessment(createSaleOrder) {
var form = document.getElementById('accessibility_form');
var formData = new FormData(form);
// Convert to JSON
var data = {};
formData.forEach(function(value, key) {
data[key] = value;
});
// Add main photos
data.photos = photoDataArray.filter(function(p) { return p !== null; });
// Add top landing photos (curved stair lift)
if (topLandingPhotos.length &gt; 0) {
data.top_landing_photos = topLandingPhotos.filter(function(p) { return p !== null; });
}
// Add bottom landing photos (curved stair lift)
if (bottomLandingPhotos.length &gt; 0) {
data.bottom_landing_photos = bottomLandingPhotos.filter(function(p) { return p !== null; });
}
// Add video (curved stair lift)
if (assessmentVideoData) {
data.assessment_video = assessmentVideoData;
data.assessment_video_filename = assessmentVideoFilename;
}
// Add sale order flag
data.create_sale_order = createSaleOrder;
// Show loading
var submitBtns = document.querySelectorAll('button[onclick*="saveAssessment"]');
submitBtns.forEach(function(btn) {
btn.disabled = true;
btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin"&gt;&lt;/i&gt; Saving...';
});
// Send request
fetch('/my/accessibility/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: data,
id: Math.floor(Math.random() * 1000000000)
})
})
.then(function(response) { return response.json(); })
.then(function(result) {
if (result.result &amp;&amp; result.result.success) {
// Show success and redirect
alert(result.result.message);
window.location.href = result.result.redirect_url;
} else {
var errorMsg = result.result ? result.result.error : 'Unknown error';
alert('Error: ' + errorMsg);
submitBtns.forEach(function(btn) {
btn.disabled = false;
});
}
})
.catch(function(error) {
alert('Error saving assessment: ' + error);
submitBtns.forEach(function(btn) {
btn.disabled = false;
});
});
}
</script>
</template>
</odoo>

View File

@@ -1,153 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Public Assessment Booking Page -->
<template id="portal_book_assessment" name="Book an Assessment">
<t t-call="website.layout">
<div class="container py-5" style="max-width: 700px;">
<!-- Success Message -->
<t t-if="success">
<div class="alert alert-success text-center" role="alert">
<h4 class="alert-heading"><i class="fa fa-check-circle"></i> Assessment Booked!</h4>
<p>Thank you! Your assessment has been scheduled. We will confirm the date and time shortly.</p>
<p>You will receive a text message confirmation.</p>
<hr/>
<a href="/book-assessment" class="btn btn-outline-success">Book Another</a>
</div>
</t>
<!-- Error Message -->
<t t-if="error">
<div class="alert alert-danger" role="alert">
<i class="fa fa-exclamation-triangle"></i> <t t-esc="error"/>
</div>
</t>
<!-- Booking Form -->
<t t-if="not success">
<div class="text-center mb-4">
<h2 style="color: #1a5276;">Book an Accessibility Assessment</h2>
<p class="text-muted">Fill out the form below and our team will schedule a home visit.</p>
</div>
<form action="/book-assessment/submit" method="post" class="card shadow-sm">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="card-body p-4">
<!-- Assessment Type -->
<div class="mb-3">
<label class="form-label fw-bold">What type of modification?<span class="text-danger"> *</span></label>
<select name="assessment_type" class="form-select" required="">
<option value="">Select...</option>
<t t-foreach="assessment_types" t-as="atype">
<option t-att-value="atype[0]"><t t-esc="atype[1]"/></option>
</t>
</select>
</div>
<div class="mb-3">
<label class="form-label fw-bold">What are you looking for?</label>
<textarea name="modification_requested" class="form-control" rows="2"
placeholder="e.g. Curved stairlift for 2-storey home, bathroom grab bars..."></textarea>
</div>
<hr/>
<h5 class="mb-3" style="color: #1a5276;">Client Information</h5>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Client Name<span class="text-danger"> *</span></label>
<input type="text" name="client_name" class="form-control" required="" placeholder="Full name"/>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Phone Number<span class="text-danger"> *</span></label>
<input type="tel" name="client_phone" class="form-control" required="" placeholder="e.g. 416-555-1234"/>
</div>
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" name="client_email" class="form-control" placeholder="Optional"/>
</div>
<div class="mb-3">
<label class="form-label">Street Address</label>
<input type="text" name="client_street" class="form-control" placeholder="e.g. 5 Nottawasaga Cres"/>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">City</label>
<input type="text" name="client_city" class="form-control" placeholder="e.g. Brampton"/>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Province</label>
<input type="text" name="client_province" class="form-control" value="Ontario"/>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Postal Code</label>
<input type="text" name="client_postal" class="form-control" placeholder="e.g. L6Y 4G1"/>
</div>
</div>
<hr/>
<h5 class="mb-3" style="color: #1a5276;">Assessment Details</h5>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Preferred Assessment Date</label>
<input type="date" name="assessment_date" class="form-control"/>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Assign Sales Rep</label>
<select name="sales_rep_id" class="form-select">
<option value="">Auto-assign</option>
<t t-foreach="sales_reps" t-as="rep">
<option t-att-value="rep.id"><t t-esc="rep.name"/></option>
</t>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Booking Source</label>
<select name="booking_source" class="form-select">
<option value="portal" selected="">Online Booking</option>
<option value="phone_authorizer">Phone - Authorizer Calling</option>
<option value="phone_client">Phone - Client Calling</option>
<option value="walk_in">Walk-In</option>
</select>
</div>
<hr/>
<h5 class="mb-3" style="color: #1a5276;">Authorizer / OT (Optional)</h5>
<p class="text-muted small">If an authorizer or occupational therapist is involved, provide their details.</p>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Authorizer Name</label>
<input type="text" name="authorizer_name" class="form-control"/>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Authorizer Email</label>
<input type="email" name="authorizer_email" class="form-control"/>
</div>
</div>
<div class="mb-3">
<label class="form-label">Authorizer Phone</label>
<input type="tel" name="authorizer_phone" class="form-control"/>
</div>
</div>
<div class="card-footer text-center bg-white border-top-0 pb-4">
<button type="submit" class="btn btn-lg" style="background-color: #1a5276; color: white;">
<i class="fa fa-calendar-check-o"></i> Book Assessment
</button>
</div>
</form>
</t>
</div>
</t>
</template>
</odoo>

View File

@@ -1,184 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- Visual PDF Field Position Editor -->
<!-- Drag field types from sidebar onto PDF, resize on page -->
<!-- ============================================================ -->
<template id="portal_pdf_field_editor" name="PDF Field Editor">
<t t-call="web.frontend_layout">
<t t-set="title">PDF Field Editor</t>
<div class="container-fluid py-3" id="pdf_field_editor"
t-att-data-template-id="template.id"
t-att-data-page-count="template.page_count or 1"
t-att-data-current-page="1"
t-att-data-category="template.category or 'other'">
<!-- Header Bar -->
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h3 class="mb-0">
<i class="fa fa-pencil-square-o me-2"/>
<t t-esc="template.name"/>
<small class="text-muted ms-2">v<t t-esc="template.version"/></small>
</h3>
<small class="text-muted">
<t t-esc="template.page_count or 0"/> page(s) |
<span id="field_count"><t t-esc="len(fields)"/></span> field(s)
</small>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-info btn-sm" id="btn_preview">
<i class="fa fa-eye me-1"/>Preview PDF
</button>
<a t-att-href="'/web#id=%d&amp;model=fusion.pdf.template&amp;view_type=form' % template.id"
class="btn btn-secondary btn-sm">
<i class="fa fa-arrow-left me-1"/>Back
</a>
</div>
</div>
<!-- Page Navigation -->
<div class="d-flex justify-content-center align-items-center mb-3 gap-3"
t-if="(template.page_count or 1) > 1">
<button type="button" class="btn btn-sm btn-outline-secondary" id="btn_prev_page">
<i class="fa fa-chevron-left"/>
</button>
<span>
Page <strong id="current_page_display">1</strong>
of <strong><t t-esc="template.page_count or 1"/></strong>
</span>
<button type="button" class="btn btn-sm btn-outline-secondary" id="btn_next_page">
<i class="fa fa-chevron-right"/>
</button>
</div>
<div class="row">
<!-- Left Sidebar: Field Type Palette -->
<div class="col-md-2">
<!-- Draggable Field Types -->
<div class="card mb-3">
<div class="card-header bg-dark text-white py-2">
<h6 class="mb-0"><i class="fa fa-th-list me-1"/>Field Types</h6>
</div>
<div class="card-body p-2">
<p class="text-muted small mb-2">Drag a field onto the PDF</p>
<div class="d-grid gap-2">
<div class="pdf-palette-item" draggable="true"
data-field-type="text"
style="padding: 8px 10px; border: 2px solid #3498db; border-radius: 5px;
background: rgba(52,152,219,0.1); cursor: grab; font-size: 13px; font-weight: 600;">
<i class="fa fa-font me-2" style="color: #3498db;"/>Text Field
</div>
<div class="pdf-palette-item" draggable="true"
data-field-type="checkbox"
style="padding: 8px 10px; border: 2px solid #2ecc71; border-radius: 5px;
background: rgba(46,204,113,0.1); cursor: grab; font-size: 13px; font-weight: 600;">
<i class="fa fa-check-square-o me-2" style="color: #2ecc71;"/>Checkbox
</div>
<div class="pdf-palette-item" draggable="true"
data-field-type="date"
style="padding: 8px 10px; border: 2px solid #e67e22; border-radius: 5px;
background: rgba(230,126,34,0.1); cursor: grab; font-size: 13px; font-weight: 600;">
<i class="fa fa-calendar me-2" style="color: #e67e22;"/>Date Field
</div>
<div class="pdf-palette-item" draggable="true"
data-field-type="signature"
style="padding: 8px 10px; border: 2px solid #9b59b6; border-radius: 5px;
background: rgba(155,89,182,0.1); cursor: grab; font-size: 13px; font-weight: 600;">
<i class="fa fa-pencil me-2" style="color: #9b59b6;"/>Signature
</div>
</div>
</div>
</div>
<!-- Data Keys Reference (collapsible, populated by JS) -->
<div class="card">
<div class="card-header py-2" style="cursor: pointer;"
data-bs-toggle="collapse" data-bs-target="#dataKeysCollapse">
<h6 class="mb-0">
<i class="fa fa-key me-1"/>Data Keys
<i class="fa fa-chevron-down float-end mt-1" style="font-size: 10px;"/>
</h6>
</div>
<div id="dataKeysCollapse" class="collapse">
<div class="card-body p-2" id="dataKeysList"
style="max-height: 300px; overflow-y: auto; font-size: 11px;">
</div>
</div>
</div>
</div>
<!-- PDF Page Canvas Area -->
<div class="col-md-7">
<div class="card">
<div class="card-body p-0">
<div id="pdf_canvas_container"
style="position: relative; width: 100%; overflow: hidden;
border: 2px solid #dee2e6; background: #f8f9fa;
min-height: 600px;">
<!-- PDF page image -->
<img id="pdf_page_image"
style="width: 100%; display: block; user-select: none;"
draggable="false"
t-att-src="preview_url or ''"
t-attf-alt="Page #{1}"
t-if="preview_url"/>
<!-- Upload form when no preview exists -->
<div t-if="not preview_url" class="text-center py-5" id="no_preview_placeholder">
<div class="mb-3">
<i class="fa fa-file-image-o fa-3x text-muted"/>
<p class="text-muted mt-2 mb-1">No page preview image yet.</p>
<p class="text-muted small">
Upload a screenshot/photo of the PDF page, or click
"Generate Page Previews" in the backend form.
</p>
</div>
<form t-attf-action="/fusion/pdf-editor/upload-preview"
method="post" enctype="multipart/form-data"
class="d-inline-block">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="template_id" t-att-value="template.id"/>
<input type="hidden" name="page" value="1"/>
<div class="input-group" style="max-width: 400px; margin: 0 auto;">
<input type="file" name="preview_image" class="form-control"
accept="image/png,image/jpeg,image/jpg" required="required"/>
<button type="submit" class="btn btn-primary">
<i class="fa fa-upload me-1"/>Upload
</button>
</div>
</form>
</div>
<!-- Field markers rendered by JS -->
</div>
</div>
</div>
</div>
<!-- Right Sidebar: Field Properties -->
<div class="col-md-3">
<div class="card" id="field_properties_panel">
<div class="card-header bg-primary text-white py-2">
<h6 class="mb-0"><i class="fa fa-cog me-1"/>Field Properties</h6>
</div>
<div class="card-body" id="field_props_body">
<p class="text-muted small">Click a field on the PDF to edit its properties.</p>
</div>
</div>
</div>
</div>
</div>
<!-- JavaScript -->
<script type="text/javascript" src="/fusion_authorizer_portal/static/src/js/pdf_field_editor.js"/>
</t>
</template>
</odoo>

View File

@@ -1,123 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Extend Partner Form to add Portal Role fields -->
<record id="view_partner_form_portal_roles" model="ir.ui.view">
<field name="name">res.partner.form.portal.roles</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="priority">50</field>
<field name="arch" type="xml">
<!-- Add Send Portal Invitation button to the button box -->
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_grant_portal_access"
type="object"
string="Send Portal Invitation"
class="oe_stat_button"
icon="fa-envelope"
invisible="authorizer_portal_user_id or not email"/>
</xpath>
<xpath expr="//page[@name='internal_notes']" position="after">
<page string="Portal Access" name="portal_access">
<group>
<group string="Portal Roles">
<field name="is_authorizer"/>
<field name="is_sales_rep_portal"/>
<field name="is_client_portal"/>
<field name="is_technician_portal"/>
<field name="authorizer_portal_user_id" readonly="1"/>
<field name="portal_access_status" widget="badge"
decoration-danger="portal_access_status == 'no_access'"
decoration-warning="portal_access_status == 'invited'"
decoration-success="portal_access_status == 'active'"/>
</group>
<group string="Statistics" invisible="not is_authorizer and not is_sales_rep_portal and not is_technician_portal">
<field name="assigned_case_count" invisible="not is_authorizer and not is_sales_rep_portal"/>
<field name="assessment_count" invisible="not is_authorizer and not is_sales_rep_portal"/>
<field name="assigned_delivery_count" invisible="not is_technician_portal"/>
</group>
</group>
<group invisible="not is_technician_portal">
<group string="Technician Settings">
<field name="x_fc_start_address" placeholder="e.g. 123 Main St, Brampton, ON"/>
</group>
</group>
<group>
<button name="action_grant_portal_access"
type="object"
string="Send Portal Invitation"
class="btn-primary"
invisible="authorizer_portal_user_id or not email"
icon="fa-envelope"/>
<button name="action_resend_portal_invitation"
type="object"
string="Resend Portal Invitation"
class="btn-secondary"
invisible="not authorizer_portal_user_id"
icon="fa-refresh"/>
<button name="action_view_assigned_cases"
type="object"
string="View Assigned Cases"
class="btn-secondary"
invisible="assigned_case_count == 0"
icon="fa-list"/>
<button name="action_view_assessments"
type="object"
string="View Assessments"
class="btn-secondary"
invisible="assessment_count == 0"
icon="fa-clipboard"/>
</group>
<!-- Warning if no email -->
<div class="alert alert-warning" role="alert" invisible="email">
<strong>Email Required:</strong> An email address is required to send a portal invitation.
</div>
<!-- Info if already has access -->
<div class="alert alert-success" role="alert" invisible="not authorizer_portal_user_id">
<strong>Portal Access Granted:</strong> This contact has portal access.
<field name="authorizer_portal_user_id" readonly="1" nolabel="1" widget="many2one"/>
</div>
</page>
</xpath>
</field>
</record>
<!-- Search view for authorizers -->
<record id="view_partner_search_authorizer" model="ir.ui.view">
<field name="name">res.partner.search.authorizer</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_res_partner_filter"/>
<field name="arch" type="xml">
<xpath expr="//filter[@name='type_company']" position="after">
<separator/>
<filter string="Authorizers" name="authorizers" domain="[('is_authorizer', '=', True)]"/>
<filter string="Sales Reps (Portal)" name="sales_reps_portal" domain="[('is_sales_rep_portal', '=', True)]"/>
<filter string="Technicians (Portal)" name="technicians_portal" domain="[('is_technician_portal', '=', True)]"/>
<separator/>
<filter string="Portal: No Access" name="portal_no_access" domain="[('is_authorizer', '=', True), ('portal_access_status', '=', 'no_access')]"/>
<filter string="Portal: Invited" name="portal_invited" domain="[('is_authorizer', '=', True), ('portal_access_status', '=', 'invited')]"/>
<filter string="Portal: Active" name="portal_active" domain="[('is_authorizer', '=', True), ('portal_access_status', '=', 'active')]"/>
</xpath>
</field>
</record>
<!-- Add portal status to contact list view -->
<record id="view_partner_list_portal_status" model="ir.ui.view">
<field name="name">res.partner.list.portal.status</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='email']" position="after">
<field name="is_authorizer" string="Authorizer" optional="hide"/>
<field name="portal_access_status" string="Portal Status" optional="hide"
decoration-danger="portal_access_status == 'no_access'"
decoration-warning="portal_access_status == 'invited'"
decoration-success="portal_access_status == 'active'"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -1,49 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Extend Sale Order Form to add Portal Documents Tab and Message Buttons -->
<record id="view_sale_order_form_portal_docs" model="ir.ui.view">
<field name="name">sale.order.form.portal.docs</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="priority">99</field>
<field name="arch" type="xml">
<!-- Message Authorizer button moved to chatter topbar (icon-only) -->
<!-- See static/src/js/chatter_message_authorizer.js -->
<xpath expr="//page[@name='other_information']" position="after">
<page string="Portal" name="portal_info">
<group>
<group string="Portal Activity">
<field name="portal_document_count" string="Documents Uploaded"/>
<field name="portal_comment_count" string="Comments"/>
</group>
<group string="Source">
<field name="assessment_id" readonly="1"/>
</group>
</group>
<group>
<button name="action_view_portal_documents"
type="object"
string="View Portal Documents"
class="btn-secondary"
icon="fa-file-pdf-o"
invisible="portal_document_count == 0"/>
<button name="action_view_portal_comments"
type="object"
string="View Portal Comments"
class="btn-secondary"
icon="fa-comments"
invisible="portal_comment_count == 0"/>
</group>
</page>
</xpath>
<!-- Filter authorizer dropdown to only show actual authorizers -->
<xpath expr="//field[@name='x_fc_authorizer_id']" position="attributes">
<attribute name="domain">[('is_company', '=', False), ('is_authorizer', '=', True)]</attribute>
</xpath>
</field>
</record>
</odoo>

View File

@@ -7,4 +7,5 @@ from . import adp_document
from . import assessment
from . import accessibility_assessment
from . import sale_order
from . import loaner_checkout
from . import pdf_template

View File

@@ -1302,8 +1302,14 @@ class FusionAssessment(models.Model):
add_row('Legrest Length', f'{self.legrest_length}"')
if self.seat_to_floor_height:
add_row('Seat to Floor Height', f'{self.seat_to_floor_height}"')
if self.cane_height:
add_row('Cane Height', f'{self.cane_height}" (Ground to Canes)')
if self.back_height:
add_row('Back Height', f'{self.back_height}"')
if self.armrest_height:
add_row('Armrest Height', f'{self.armrest_height}"')
if self.footrest_length:
add_row('Footrest Length', f'{self.footrest_length}"')
if self.seatbelt_type:
belt_labels = dict(self._fields['seatbelt_type'].selection)
@@ -1323,6 +1329,12 @@ class FusionAssessment(models.Model):
if opt:
add_row(opt, 'Yes')
# Cushion & Backrest (wheelchair/powerchair)
if self.cushion_info:
add_row('Cushion', self.cushion_info)
if self.backrest_info:
add_row('Backrest', self.backrest_info)
# Additional customization notes
if self.additional_customization:
add_row('Additional Information/Customization', self.additional_customization)

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
class FusionLoanerCheckoutAssessment(models.Model):
_inherit = 'fusion.loaner.checkout'
assessment_id = fields.Many2one(
'fusion.assessment',
string='Assessment',
ondelete='set null',
tracking=True,
help='Assessment during which this loaner was issued',
)
def action_view_assessment(self):
self.ensure_one()
if not self.assessment_id:
return
return {
'name': self.assessment_id.display_name,
'type': 'ir.actions.act_window',
'res_model': 'fusion.assessment',
'view_mode': 'form',
'res_id': self.assessment_id.id,
}

View File

@@ -235,6 +235,7 @@ class FusionPdfTemplate(models.Model):
'field_type': field.field_type,
'font_size': field.font_size,
'font_name': field.font_name or 'Helvetica',
'text_align': field.text_align or 'left',
})
return PDFTemplateFiller.fill_template(
@@ -307,6 +308,11 @@ class FusionPdfTemplateField(models.Model):
('Courier', 'Courier'),
('Times-Roman', 'Times Roman'),
], string='Font', default='Helvetica')
text_align = fields.Selection([
('left', 'Left'),
('center', 'Center'),
('right', 'Right'),
], string='Text Alignment', default='left')
# Data mapping
field_key = fields.Char(

View File

@@ -339,7 +339,7 @@
}
.assessment-express-form .card-header.bg-primary {
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%) !important;
background: var(--fc-portal-gradient, linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%)) !important;
border-radius: 12px 12px 0 0;
}
@@ -391,7 +391,7 @@
}
.assessment-express-form .progress-bar {
background: linear-gradient(90deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);
background: var(--fc-portal-gradient, linear-gradient(90deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%));
}
/* Section Separators */
@@ -415,7 +415,7 @@
/* New Assessment Card on Portal Home */
.portal-new-assessment-card {
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%) !important;
background: var(--fc-portal-gradient, linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%)) !important;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
@@ -450,7 +450,7 @@
/* Authorizer Portal Card on Portal Home */
.portal-authorizer-card {
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%) !important;
background: var(--fc-portal-gradient, linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%)) !important;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
@@ -522,7 +522,7 @@
/* Welcome Header */
.auth-dash-header {
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);
background: var(--fc-portal-gradient, linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%));
border-radius: 16px;
margin-bottom: 24px;
overflow: hidden;

View File

@@ -18,6 +18,7 @@ document.addEventListener('DOMContentLoaded', function () {
var templateId = parseInt(editor.dataset.templateId);
var pageCount = parseInt(editor.dataset.pageCount) || 1;
var templateCategory = editor.dataset.category || 'other';
var currentPage = 1;
var fields = {};
var selectedFieldId = null;
@@ -30,17 +31,15 @@ document.addEventListener('DOMContentLoaded', function () {
// ================================================================
// ================================================================
// Available data keys (grouped for the dropdown)
// Available data keys, organized by template category
// ================================================================
var DATA_KEYS = [
var COMMON_KEYS = [
{ group: 'Client Info', keys: [
{ key: 'client_last_name', label: 'Last Name' },
{ key: 'client_first_name', label: 'First Name' },
{ key: 'client_middle_name', label: 'Middle Name' },
{ key: 'client_name', label: 'Full Name' },
{ key: 'client_health_card', label: 'Health Card Number' },
{ key: 'client_health_card_version', label: 'Health Card Version' },
{ key: 'client_street', label: 'Street' },
{ key: 'client_unit', label: 'Unit/Apt' },
{ key: 'client_city', label: 'City' },
@@ -48,69 +47,126 @@ document.addEventListener('DOMContentLoaded', function () {
{ key: 'client_postal_code', label: 'Postal Code' },
{ key: 'client_phone', label: 'Phone' },
{ key: 'client_email', label: 'Email' },
{ key: 'client_weight', label: 'Weight (lbs)' },
]},
{ group: 'Client Type', keys: [
{ key: 'client_type_reg', label: 'REG Checkbox' },
{ key: 'client_type_ods', label: 'ODS Checkbox' },
{ key: 'client_type_acs', label: 'ACS Checkbox' },
{ key: 'client_type_owp', label: 'OWP Checkbox' },
]},
{ group: 'Consent', keys: [
{ key: 'consent_applicant', label: 'Applicant Checkbox' },
{ key: 'consent_agent', label: 'Agent Checkbox' },
{ key: 'consent_date', label: 'Consent Date' },
]},
{ group: 'Agent Relationship', keys: [
{ key: 'agent_rel_spouse', label: 'Spouse Checkbox' },
{ key: 'agent_rel_parent', label: 'Parent Checkbox' },
{ key: 'agent_rel_child', label: 'Child Checkbox' },
{ key: 'agent_rel_poa', label: 'POA Checkbox' },
{ key: 'agent_rel_guardian', label: 'Guardian Checkbox' },
]},
{ group: 'Agent Info', keys: [
{ key: 'agent_last_name', label: 'Agent Last Name' },
{ key: 'agent_first_name', label: 'Agent First Name' },
{ key: 'agent_middle_initial', label: 'Agent Middle Initial' },
{ key: 'agent_unit', label: 'Agent Unit' },
{ key: 'agent_street_number', label: 'Agent Street No.' },
{ key: 'agent_street_name', label: 'Agent Street Name' },
{ key: 'agent_city', label: 'Agent City' },
{ key: 'agent_province', label: 'Agent Province' },
{ key: 'agent_postal_code', label: 'Agent Postal Code' },
{ key: 'agent_home_phone', label: 'Agent Home Phone' },
{ key: 'agent_business_phone', label: 'Agent Business Phone' },
{ key: 'agent_phone_ext', label: 'Agent Phone Ext' },
]},
{ group: 'Equipment', keys: [
{ key: 'equipment_type', label: 'Equipment Type' },
{ key: 'seat_width', label: 'Seat Width' },
{ key: 'seat_depth', label: 'Seat Depth' },
{ key: 'seat_to_floor_height', label: 'Seat to Floor Height' },
{ key: 'back_height', label: 'Back Height' },
{ key: 'legrest_length', label: 'Legrest Length' },
{ key: 'cane_height', label: 'Cane Height' },
]},
{ group: 'Dates', keys: [
{ key: 'assessment_start_date', label: 'Assessment Start Date' },
{ key: 'assessment_end_date', label: 'Assessment End Date' },
{ key: 'claim_authorization_date', label: 'Authorization Date' },
]},
{ group: 'Authorizer', keys: [
{ key: 'authorizer_name', label: 'Authorizer Name' },
{ key: 'authorizer_phone', label: 'Authorizer Phone' },
{ key: 'authorizer_email', label: 'Authorizer Email' },
]},
{ group: 'Signatures', keys: [
{ key: 'signature_page_11', label: 'Page 11 Signature' },
{ key: 'signature_page_12', label: 'Page 12 Signature' },
]},
{ group: 'Other', keys: [
{ key: 'reference', label: 'Assessment Reference' },
{ key: 'reason_for_application', label: 'Reason for Application' },
]},
];
var CATEGORY_KEYS = {
adp: [
{ group: 'ADP - Client Details', keys: [
{ key: 'client_health_card', label: 'Health Card Number' },
{ key: 'client_health_card_version', label: 'Health Card Version' },
{ key: 'client_weight', label: 'Weight (lbs)' },
]},
{ group: 'ADP - Client Type', keys: [
{ key: 'client_type_reg', label: 'REG Checkbox' },
{ key: 'client_type_ods', label: 'ODS Checkbox' },
{ key: 'client_type_acs', label: 'ACS Checkbox' },
{ key: 'client_type_owp', label: 'OWP Checkbox' },
]},
{ group: 'ADP - Consent', keys: [
{ key: 'consent_applicant', label: 'Applicant Checkbox' },
{ key: 'consent_agent', label: 'Agent Checkbox' },
{ key: 'consent_date', label: 'Consent Date' },
]},
{ group: 'ADP - Agent Relationship', keys: [
{ key: 'agent_rel_spouse', label: 'Spouse Checkbox' },
{ key: 'agent_rel_parent', label: 'Parent Checkbox' },
{ key: 'agent_rel_child', label: 'Child Checkbox' },
{ key: 'agent_rel_poa', label: 'POA Checkbox' },
{ key: 'agent_rel_guardian', label: 'Guardian Checkbox' },
]},
{ group: 'ADP - Agent Info', keys: [
{ key: 'agent_last_name', label: 'Agent Last Name' },
{ key: 'agent_first_name', label: 'Agent First Name' },
{ key: 'agent_middle_initial', label: 'Agent Middle Initial' },
{ key: 'agent_unit', label: 'Agent Unit' },
{ key: 'agent_street_number', label: 'Agent Street No.' },
{ key: 'agent_street_name', label: 'Agent Street Name' },
{ key: 'agent_city', label: 'Agent City' },
{ key: 'agent_province', label: 'Agent Province' },
{ key: 'agent_postal_code', label: 'Agent Postal Code' },
{ key: 'agent_home_phone', label: 'Agent Home Phone' },
{ key: 'agent_business_phone', label: 'Agent Business Phone' },
{ key: 'agent_phone_ext', label: 'Agent Phone Ext' },
]},
{ group: 'ADP - Equipment', keys: [
{ key: 'equipment_type', label: 'Equipment Type' },
{ key: 'seat_width', label: 'Seat Width' },
{ key: 'seat_depth', label: 'Seat Depth' },
{ key: 'seat_to_floor_height', label: 'Seat to Floor Height' },
{ key: 'back_height', label: 'Back Height' },
{ key: 'legrest_length', label: 'Legrest Length' },
{ key: 'cane_height', label: 'Cane Height' },
]},
{ group: 'ADP - Dates', keys: [
{ key: 'assessment_start_date', label: 'Assessment Start Date' },
{ key: 'assessment_end_date', label: 'Assessment End Date' },
{ key: 'claim_authorization_date', label: 'Authorization Date' },
]},
{ group: 'ADP - Authorizer', keys: [
{ key: 'authorizer_name', label: 'Authorizer Name' },
{ key: 'authorizer_phone', label: 'Authorizer Phone' },
{ key: 'authorizer_email', label: 'Authorizer Email' },
]},
{ group: 'ADP - Signatures', keys: [
{ key: 'signature_page_11', label: 'Page 11 Signature' },
{ key: 'signature_page_12', label: 'Page 12 Signature' },
]},
{ group: 'ADP - Other', keys: [
{ key: 'reference', label: 'Assessment Reference' },
{ key: 'reason_for_application', label: 'Reason for Application' },
]},
],
odsp: [
{ group: 'ODSP - Signing Fields', keys: [
{ key: 'sa_client_name', label: 'Client Name (signing)' },
{ key: 'sa_sign_date', label: 'Signing Date' },
{ key: 'sa_signature', label: 'Client Signature' },
]},
{ group: 'ODSP - Client Details', keys: [
{ key: 'client_health_card', label: 'Health Card Number' },
{ key: 'client_health_card_version', label: 'Health Card Version' },
{ key: 'client_weight', label: 'Weight (lbs)' },
]},
{ group: 'ODSP - Dates', keys: [
{ key: 'assessment_start_date', label: 'Assessment Start Date' },
{ key: 'assessment_end_date', label: 'Assessment End Date' },
]},
{ group: 'ODSP - Authorizer', keys: [
{ key: 'authorizer_name', label: 'Authorizer Name' },
{ key: 'authorizer_phone', label: 'Authorizer Phone' },
{ key: 'authorizer_email', label: 'Authorizer Email' },
]},
],
mod: [
{ group: 'MOD - Dates', keys: [
{ key: 'assessment_start_date', label: 'Assessment Start Date' },
{ key: 'assessment_end_date', label: 'Assessment End Date' },
]},
{ group: 'MOD - Authorizer', keys: [
{ key: 'authorizer_name', label: 'Authorizer Name' },
{ key: 'authorizer_phone', label: 'Authorizer Phone' },
{ key: 'authorizer_email', label: 'Authorizer Email' },
]},
],
hardship: [
{ group: 'Hardship - Dates', keys: [
{ key: 'assessment_start_date', label: 'Assessment Start Date' },
{ key: 'assessment_end_date', label: 'Assessment End Date' },
]},
{ group: 'Hardship - Authorizer', keys: [
{ key: 'authorizer_name', label: 'Authorizer Name' },
{ key: 'authorizer_phone', label: 'Authorizer Phone' },
{ key: 'authorizer_email', label: 'Authorizer Email' },
]},
],
};
var DATA_KEYS = COMMON_KEYS.concat(CATEGORY_KEYS[templateCategory] || []);
// Build a flat lookup: key -> label
var KEY_LABELS = {};
DATA_KEYS.forEach(function (g) {
@@ -172,6 +228,7 @@ document.addEventListener('DOMContentLoaded', function () {
setupPaletteDrag();
setupContainerDrop();
setupPreviewButton();
buildDataKeysSidebar();
// Prevent the image from intercepting drag events
if (pageImage) {
@@ -183,6 +240,19 @@ document.addEventListener('DOMContentLoaded', function () {
});
}
function buildDataKeysSidebar() {
var list = document.getElementById('dataKeysList');
if (!list) return;
var html = '';
DATA_KEYS.forEach(function (g) {
html += '<div class="mb-1 mt-2"><strong>' + g.group + ':</strong></div>';
g.keys.forEach(function (k) {
html += '<code class="d-block">' + k.key + '</code>';
});
});
list.innerHTML = html;
}
// ================================================================
// Load fields
// ================================================================
@@ -507,10 +577,20 @@ document.addEventListener('DOMContentLoaded', function () {
+ ' <div class="col-6"><label class="form-label fw-bold small mb-0">Y</label>'
+ ' <input type="text" class="form-control form-control-sm" value="' + round3(field.pos_y) + '" readonly/></div>'
+ '</div>'
+ '<button type="button" class="btn btn-primary btn-sm w-100 mb-2" id="btn_save_props">'
+ ' <i class="fa fa-save me-1"/>Save</button>'
+ '<button type="button" class="btn btn-outline-danger btn-sm w-100" id="btn_delete_field">'
+ ' <i class="fa fa-trash me-1"/>Delete</button>';
+ '<div class="mb-2">'
+ ' <label class="form-label fw-bold small mb-0">Text Align</label>'
+ ' <div class="btn-group w-100" role="group">'
+ ' <button type="button" class="btn btn-sm btn-outline-secondary' + (field.text_align === 'left' || !field.text_align ? ' active' : '') + '" data-align="left"><i class="fa fa-align-left"></i></button>'
+ ' <button type="button" class="btn btn-sm btn-outline-secondary' + (field.text_align === 'center' ? ' active' : '') + '" data-align="center"><i class="fa fa-align-center"></i></button>'
+ ' <button type="button" class="btn btn-sm btn-outline-secondary' + (field.text_align === 'right' ? ' active' : '') + '" data-align="right"><i class="fa fa-align-right"></i></button>'
+ ' </div>'
+ '</div>'
+ '<div class="d-flex gap-2 mt-3">'
+ ' <button type="button" class="btn btn-primary btn-sm flex-grow-1" id="btn_save_props">'
+ ' <i class="fa fa-save me-1"></i>Save</button>'
+ ' <button type="button" class="btn btn-outline-danger btn-sm" id="btn_delete_field">'
+ ' <i class="fa fa-trash me-1"></i>Delete</button>'
+ '</div>';
// Auto-fill name and label when data key is selected
document.getElementById('prop_field_key').addEventListener('change', function () {
@@ -521,9 +601,18 @@ document.addEventListener('DOMContentLoaded', function () {
}
});
var alignBtns = panel.querySelectorAll('[data-align]');
alignBtns.forEach(function (btn) {
btn.addEventListener('click', function () {
alignBtns.forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
});
});
document.getElementById('btn_save_props').addEventListener('click', function () {
var keySelect = document.getElementById('prop_field_key');
var selectedKey = keySelect ? keySelect.value : '';
var activeAlign = panel.querySelector('[data-align].active');
var vals = {
name: val('prop_name'),
label: val('prop_label'),
@@ -533,6 +622,7 @@ document.addEventListener('DOMContentLoaded', function () {
page: parseInt(val('prop_page')) || 1,
width: parseFloat(val('prop_width')) || 0.15,
height: parseFloat(val('prop_height')) || 0.015,
text_align: activeAlign ? activeAlign.dataset.align : 'left',
};
Object.assign(field, vals);
saveField(fieldId, vals);

View File

@@ -109,13 +109,25 @@ class PDFTemplateFiller:
if field_type in ('text', 'date'):
c.setFont(font_name, font_size)
c.drawString(abs_x, abs_y, str(value))
text_val = str(value)
field_h = field.get('height', 0.018) * page_h
text_y = abs_y - field_h + (field_h - font_size) / 2
align = field.get('text_align', 'left')
if align == 'center':
center_x = abs_x + (field.get('width', 0.15) * page_w) / 2
c.drawCentredString(center_x, text_y, text_val)
elif align == 'right':
right_x = abs_x + field.get('width', 0.15) * page_w
c.drawRightString(right_x, text_y, text_val)
else:
c.drawString(abs_x, text_y, text_val)
elif field_type == 'checkbox':
if value: # Only draw if truthy
# Checkmark using ZapfDingbats (same as sign module)
if value:
c.setFont('ZapfDingbats', font_size)
c.drawString(abs_x, abs_y, '4') # checkmark character
cb_h = field.get('height', 0.018) * page_h
cb_y = abs_y - cb_h + (cb_h - font_size) / 2
c.drawString(abs_x, cb_y, '4')
elif field_type == 'signature':
sig_data = signatures.get(field_key)

View File

@@ -6,15 +6,26 @@
<field name="name">fusion.assessment.tree</field>
<field name="model">fusion.assessment</field>
<field name="arch" type="xml">
<list string="Assessments" decoration-info="state == 'draft'" decoration-warning="state == 'pending_signature'" decoration-success="state == 'completed'" decoration-muted="state == 'cancelled'">
<list string="Assessments" default_order="assessment_date desc, id desc"
decoration-info="state == 'draft'"
decoration-warning="state == 'pending_signature'"
decoration-success="state == 'completed'"
decoration-muted="state == 'cancelled'">
<field name="reference"/>
<field name="client_name"/>
<field name="equipment_type" optional="show"/>
<field name="client_type" optional="show"/>
<field name="assessment_date"/>
<field name="sales_rep_id"/>
<field name="authorizer_id"/>
<field name="state" widget="badge" decoration-info="state == 'draft'" decoration-warning="state == 'pending_signature'" decoration-success="state == 'completed'" decoration-danger="state == 'cancelled'"/>
<field name="signatures_complete" widget="boolean"/>
<field name="sale_order_id"/>
<field name="reason_for_application" optional="hide"/>
<field name="state" widget="badge"
decoration-info="state == 'draft'"
decoration-warning="state == 'pending_signature'"
decoration-success="state == 'completed'"
decoration-danger="state == 'cancelled'"/>
<field name="signatures_complete" widget="boolean" optional="show"/>
<field name="sale_order_id" optional="show"/>
</list>
</field>
</record>
@@ -26,124 +37,310 @@
<field name="arch" type="xml">
<form string="Assessment">
<header>
<button name="action_mark_pending_signature" type="object" string="Mark Pending Signature" class="btn-primary" invisible="state != 'draft'"/>
<button name="action_complete" type="object" string="Complete Assessment" class="btn-success" invisible="state not in ['draft', 'pending_signature']"/>
<button name="action_cancel" type="object" string="Cancel" invisible="state in ['completed', 'cancelled']"/>
<button name="action_reset_draft" type="object" string="Reset to Draft" invisible="state != 'cancelled'"/>
<field name="state" widget="statusbar" statusbar_visible="draft,pending_signature,completed"/>
<button name="action_mark_pending_signature" type="object"
string="Mark Pending Signature" class="btn-primary"
invisible="state != 'draft'"/>
<button name="action_complete" type="object"
string="Complete Assessment" class="btn-success"
invisible="state not in ['draft', 'pending_signature']"/>
<button name="action_complete_express" type="object"
string="Express Complete" class="btn-warning"
invisible="state not in ['draft', 'pending_signature']"
confirm="This will complete the assessment without requiring signatures. Continue?"/>
<button name="action_cancel" type="object"
string="Cancel"
invisible="state in ['completed', 'cancelled']"/>
<button name="action_reset_draft" type="object"
string="Reset to Draft"
invisible="state != 'cancelled'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,pending_signature,completed"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_documents" type="object" class="oe_stat_button" icon="fa-file-pdf-o">
<button name="action_view_documents" type="object"
class="oe_stat_button" icon="fa-file-pdf-o">
<field name="document_count" string="Documents" widget="statinfo"/>
</button>
<button name="action_view_sale_order" type="object" class="oe_stat_button" icon="fa-shopping-cart" invisible="not sale_order_id">
<button name="action_view_sale_order" type="object"
class="oe_stat_button" icon="fa-shopping-cart"
invisible="not sale_order_id">
<span class="o_stat_text">Sale Order</span>
</button>
</div>
<div class="oe_title">
<widget name="web_ribbon" title="Completed" bg_color="text-bg-success"
invisible="state != 'completed'"/>
<widget name="web_ribbon" title="Cancelled" bg_color="text-bg-danger"
invisible="state != 'cancelled'"/>
<div class="oe_title mb-3">
<h1>
<field name="reference" readonly="1"/>
<field name="reference" readonly="1" class="me-3"/>
</h1>
<h2 class="text-muted" invisible="not client_name">
<field name="client_name" readonly="1"/>
</h2>
</div>
<!-- ============ TOP SUMMARY ============ -->
<group>
<group string="Client Information">
<field name="client_name"/>
<field name="client_first_name"/>
<field name="client_last_name"/>
<field name="client_phone"/>
<field name="client_mobile"/>
<field name="client_email"/>
<field name="client_dob"/>
<field name="client_health_card"/>
<group string="Equipment">
<field name="equipment_type"/>
<field name="rollator_type" invisible="equipment_type != 'rollator'"/>
<field name="wheelchair_type" invisible="equipment_type != 'wheelchair'"/>
<field name="powerchair_type" invisible="equipment_type != 'powerchair'"/>
<field name="client_type"/>
<field name="reason_for_application"/>
<field name="previous_funding_date"
invisible="reason_for_application not in ['replace_status','replace_size','replace_worn','replace_lost','replace_stolen','replace_damaged','replace_no_longer_meets']"/>
</group>
<group string="Address">
<field name="client_street"/>
<field name="client_city"/>
<field name="client_state"/>
<field name="client_postal_code"/>
<field name="client_country_id"/>
</group>
</group>
<group>
<group string="Assessment Details">
<group string="Assessment Info">
<field name="assessment_date"/>
<field name="assessment_location"/>
<field name="assessment_location_notes"/>
</group>
<group string="Participants">
<field name="sales_rep_id"/>
<field name="authorizer_id"/>
<field name="partner_id"/>
<field name="create_new_partner"/>
</group>
</group>
<group>
<group string="Client References">
<field name="client_reference_1"/>
<field name="client_reference_2"/>
<field name="sale_order_id" readonly="1"/>
</group>
</group>
<notebook>
<page string="Wheelchair Specifications" name="specs">
<!-- ============ CLIENT INFORMATION ============ -->
<page string="Client" name="client_info">
<group>
<group string="Seat Measurements">
<group string="Personal Details">
<field name="client_first_name"/>
<field name="client_middle_name"/>
<field name="client_last_name"/>
<field name="client_dob"/>
<field name="client_phone"/>
<field name="client_mobile"/>
<field name="client_email" widget="email"/>
</group>
<group string="Health Card">
<field name="client_health_card"/>
<field name="client_health_card_version"/>
<field name="client_weight"/>
<field name="client_height"/>
</group>
</group>
<group>
<group string="Address">
<field name="client_street"/>
<field name="client_unit"/>
<field name="client_city"/>
<field name="client_state"/>
<field name="client_postal_code"/>
<field name="client_country_id"/>
</group>
<group string="References &amp; Linking">
<field name="client_reference_1"/>
<field name="client_reference_2"/>
<field name="partner_id"/>
<field name="create_new_partner"/>
</group>
</group>
</page>
<!-- ============ MEASUREMENTS & SPECS ============ -->
<page string="Measurements" name="measurements">
<!-- Rollator Measurements -->
<group string="Rollator Measurements"
invisible="equipment_type != 'rollator'">
<group>
<field name="rollator_handle_height"/>
<field name="rollator_seat_height"/>
</group>
<group>
<field name="rollator_addons" placeholder="e.g. Basket, Tray, Backrest pad..."/>
</group>
</group>
<!-- Wheelchair / Powerchair Measurements -->
<group string="Seat Measurements"
invisible="equipment_type not in ['wheelchair', 'powerchair']">
<group>
<field name="seat_width"/>
<field name="seat_depth"/>
<field name="seat_to_floor_height"/>
<field name="seat_angle"/>
</group>
<group string="Back &amp; Arms">
<group>
<field name="back_height"/>
<field name="back_angle"/>
<field name="armrest_height"/>
<field name="footrest_length"/>
</group>
</group>
<group>
<group string="Overall Dimensions">
<group string="Leg &amp; Foot"
invisible="equipment_type not in ['wheelchair', 'powerchair']">
<group>
<field name="footrest_length"/>
<field name="legrest_length"/>
<field name="cane_height"/>
</group>
</group>
<group string="Overall Dimensions"
invisible="equipment_type not in ['wheelchair', 'powerchair']">
<group>
<field name="overall_width"/>
<field name="overall_length"/>
<field name="overall_height"/>
</group>
<group string="Client Measurements">
<field name="client_weight"/>
<field name="client_height"/>
</group>
</page>
<!-- ============ OPTIONS & ACCESSORIES ============ -->
<page string="Options" name="options" invisible="equipment_type not in ['wheelchair', 'powerchair']">
<group invisible="equipment_type != 'wheelchair'">
<group string="Frame Options">
<field name="frame_options" nolabel="1"
placeholder="e.g. Recliner Option, Dynamic Tilt Frame, Titanium Frame"/>
</group>
<group string="Wheel Options">
<field name="wheel_options" nolabel="1"
placeholder="e.g. Quick Release Axle, Mag Wheels, Anti-Tip..."/>
</group>
</group>
<group invisible="equipment_type != 'wheelchair'">
<group string="Legrest Accessories">
<field name="legrest_options" nolabel="1"
placeholder="e.g. Elevating Legrest, Swing Away..."/>
</group>
<group string="Additional ADP Options">
<field name="additional_adp_options" nolabel="1"/>
</group>
</group>
<group invisible="equipment_type != 'powerchair'">
<group string="Powerchair Options">
<field name="powerchair_options" nolabel="1"/>
</group>
<group string="Specialty Controls">
<field name="specialty_controls" nolabel="1"
placeholder="Rationale required for specialty components"/>
</group>
</group>
<group>
<group string="Seating">
<field name="seatbelt_type"/>
<field name="cushion_info"/>
<field name="backrest_info"/>
</group>
<group string="Additional Customization">
<field name="additional_customization" nolabel="1"
placeholder="Free-form notes for any customization..."/>
</group>
</group>
</page>
<!-- ============ PRODUCT TYPES ============ -->
<page string="Product Types" name="products">
<group>
<group>
<group string="Cushion">
<field name="cushion_type"/>
<field name="cushion_notes"/>
<field name="cushion_notes" placeholder="Cushion details..."
invisible="not cushion_type"/>
</group>
<group string="Backrest">
<field name="backrest_type"/>
<field name="backrest_notes"/>
<field name="backrest_notes" placeholder="Backrest details..."
invisible="not backrest_type"/>
</group>
</group>
<group>
<group string="Frame">
<field name="frame_type"/>
<field name="frame_notes" placeholder="Frame details..."
invisible="not frame_type"/>
</group>
<group string="Wheels">
<field name="wheel_type"/>
<field name="wheel_notes" placeholder="Wheel details..."
invisible="not wheel_type"/>
</group>
</group>
</page>
<!-- ============ CLINICAL NOTES ============ -->
<page string="Clinical Notes" name="needs">
<group>
<group>
<field name="diagnosis" placeholder="Relevant medical diagnosis or conditions..."/>
</group>
<group>
<field name="frame_type"/>
<field name="frame_notes"/>
<field name="wheel_type"/>
<field name="wheel_notes"/>
<field name="mobility_notes" placeholder="Document mobility needs and challenges..."/>
</group>
</group>
<group>
<group>
<field name="accessibility_notes" placeholder="Accessibility requirements and home environment..."/>
</group>
<group>
<field name="special_requirements" placeholder="Any special requirements or customizations..."/>
</group>
</group>
</page>
<page string="Needs &amp; Requirements" name="needs">
<!-- ============ KEY DATES ============ -->
<page string="Dates" name="dates">
<group>
<field name="diagnosis"/>
<field name="mobility_notes"/>
<field name="accessibility_notes"/>
<field name="special_requirements"/>
<group string="Assessment Period">
<field name="assessment_start_date"/>
<field name="assessment_end_date"/>
</group>
<group string="Authorization">
<field name="claim_authorization_date"/>
</group>
</group>
</page>
<!-- ============ CONSENT & DECLARATION (PAGE 11) ============ -->
<page string="Consent &amp; Declaration" name="consent">
<group>
<group string="Consent Details">
<field name="consent_signed_by"/>
<field name="consent_declaration_accepted"/>
<field name="consent_date"/>
</group>
</group>
<group string="Agent Details"
invisible="consent_signed_by != 'agent'">
<group>
<field name="agent_relationship"/>
<field name="agent_first_name"/>
<field name="agent_middle_initial"/>
<field name="agent_last_name"/>
</group>
<group>
<field name="agent_street_number"/>
<field name="agent_street_name"/>
<field name="agent_unit"/>
<field name="agent_city"/>
<field name="agent_province"/>
<field name="agent_postal_code"/>
</group>
</group>
<group string="Agent Contact"
invisible="consent_signed_by != 'agent'">
<group>
<field name="agent_home_phone"/>
<field name="agent_business_phone"/>
<field name="agent_phone_ext"/>
</group>
</group>
</page>
<!-- ============ SIGNATURES ============ -->
<page string="Signatures" name="signatures">
<group>
<group string="Page 11 - Authorizer Signature">
@@ -158,13 +355,17 @@
</group>
</group>
<group>
<field name="signatures_complete"/>
<field name="signatures_complete" readonly="1"/>
<field name="signed_page_11_pdf" filename="signed_page_11_pdf_filename"
invisible="not signed_page_11_pdf"/>
<field name="signed_page_11_pdf_filename" invisible="1"/>
</group>
</page>
<!-- ============ DOCUMENTS ============ -->
<page string="Documents" name="documents">
<field name="document_ids">
<list string="Documents">
<list string="Documents" editable="bottom">
<field name="document_type"/>
<field name="filename"/>
<field name="revision"/>
@@ -174,26 +375,20 @@
</field>
</page>
<!-- ============ COMMENTS ============ -->
<page string="Comments" name="comments">
<field name="comment_ids">
<list string="Comments">
<field name="create_date"/>
<list string="Comments" editable="bottom">
<field name="create_date" string="Date"/>
<field name="author_id"/>
<field name="comment"/>
</list>
</field>
</page>
</notebook>
<group invisible="not sale_order_id">
<field name="sale_order_id"/>
</group>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
<chatter/>
</form>
</field>
</record>
@@ -207,8 +402,10 @@
<field name="reference"/>
<field name="client_name"/>
<field name="client_email"/>
<field name="client_health_card"/>
<field name="sales_rep_id"/>
<field name="authorizer_id"/>
<field name="sale_order_id"/>
<separator/>
<filter string="In Progress" name="draft" domain="[('state', '=', 'draft')]"/>
<filter string="Pending Signature" name="pending" domain="[('state', '=', 'pending_signature')]"/>
@@ -216,11 +413,19 @@
<filter string="Cancelled" name="cancelled" domain="[('state', '=', 'cancelled')]"/>
<separator/>
<filter string="My Assessments" name="my_assessments" domain="[('sales_rep_id', '=', uid)]"/>
<filter string="Has Sale Order" name="has_so" domain="[('sale_order_id', '!=', False)]"/>
<filter string="Signatures Pending" name="sigs_pending" domain="[('signatures_complete', '=', False), ('state', '!=', 'cancelled')]"/>
<separator/>
<filter string="Wheelchair" name="filter_wheelchair" domain="[('equipment_type', '=', 'wheelchair')]"/>
<filter string="Powerchair" name="filter_powerchair" domain="[('equipment_type', '=', 'powerchair')]"/>
<filter string="Rollator" name="filter_rollator" domain="[('equipment_type', '=', 'rollator')]"/>
<separator/>
<filter string="Status" name="group_state" context="{'group_by': 'state'}"/>
<filter string="Equipment Type" name="group_equipment" context="{'group_by': 'equipment_type'}"/>
<filter string="Client Type" name="group_client_type" context="{'group_by': 'client_type'}"/>
<filter string="Sales Rep" name="group_sales_rep" context="{'group_by': 'sales_rep_id'}"/>
<filter string="Authorizer" name="group_authorizer" context="{'group_by': 'authorizer_id'}"/>
<filter string="Date" name="group_date" context="{'group_by': 'assessment_date:month'}"/>
<filter string="Month" name="group_date" context="{'group_by': 'assessment_date:month'}"/>
</search>
</field>
</record>
@@ -237,8 +442,8 @@
Create your first assessment
</p>
<p>
Assessments are used to record wheelchair specifications and client needs.
Once completed, they will create a draft sale order for review.
Assessments record wheelchair, powerchair, and rollator specifications
along with client needs. Once completed, a draft sale order is created.
</p>
</field>
</record>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add Assessment button and field to Loaner Checkout form -->
<record id="view_fusion_loaner_checkout_form_assessment" model="ir.ui.view">
<field name="name">fusion.loaner.checkout.form.assessment</field>
<field name="model">fusion.loaner.checkout</field>
<field name="inherit_id" ref="fusion_claims.view_fusion_loaner_checkout_form"/>
<field name="arch" type="xml">
<xpath expr="//button[@name='action_view_partner']" position="before">
<button name="action_view_assessment" type="object"
class="oe_stat_button" icon="fa-clipboard"
invisible="not assessment_id">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">Assessment</span>
</div>
</button>
</xpath>
<xpath expr="//field[@name='sale_order_id']" position="after">
<field name="assessment_id"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -1310,48 +1310,42 @@
handleReasonChange();
}
function disableFormInputs(form) {
if (!form) return;
form.style.display = 'none';
form.querySelectorAll('input, select, textarea').forEach(function(el) {
el.disabled = true;
if (el.hasAttribute('required')) {
el.removeAttribute('required');
el.dataset.wasRequired = 'true';
}
});
}
function enableFormInputs(form) {
if (!form) return;
form.style.display = 'block';
form.querySelectorAll('input, select, textarea').forEach(function(el) {
el.disabled = false;
if (el.dataset.wasRequired === 'true') {
el.setAttribute('required', 'required');
}
});
}
function showEquipmentForm() {
var value = equipmentSelect ? equipmentSelect.value : '';
// Hide all forms and disable required on hidden fields
if (rollatorForm) {
rollatorForm.style.display = 'none';
rollatorForm.querySelectorAll('[required]').forEach(function(el) {
el.removeAttribute('required');
el.dataset.wasRequired = 'true';
});
}
if (wheelchairForm) {
wheelchairForm.style.display = 'none';
wheelchairForm.querySelectorAll('[required]').forEach(function(el) {
el.removeAttribute('required');
el.dataset.wasRequired = 'true';
});
}
if (powerchairForm) {
powerchairForm.style.display = 'none';
powerchairForm.querySelectorAll('[required]').forEach(function(el) {
el.removeAttribute('required');
el.dataset.wasRequired = 'true';
});
}
disableFormInputs(rollatorForm);
disableFormInputs(wheelchairForm);
disableFormInputs(powerchairForm);
// Show selected form and restore required
if (value === 'rollator' &amp;&amp; rollatorForm) {
rollatorForm.style.display = 'block';
rollatorForm.querySelectorAll('[data-was-required]').forEach(function(el) {
el.setAttribute('required', 'required');
});
} else if (value === 'wheelchair' &amp;&amp; wheelchairForm) {
wheelchairForm.style.display = 'block';
wheelchairForm.querySelectorAll('[data-was-required]').forEach(function(el) {
el.setAttribute('required', 'required');
});
} else if (value === 'powerchair' &amp;&amp; powerchairForm) {
powerchairForm.style.display = 'block';
powerchairForm.querySelectorAll('[data-was-required]').forEach(function(el) {
el.setAttribute('required', 'required');
});
if (value === 'rollator') {
enableFormInputs(rollatorForm);
} else if (value === 'wheelchair') {
enableFormInputs(wheelchairForm);
} else if (value === 'powerchair') {
enableFormInputs(powerchairForm);
}
}

View File

@@ -12,7 +12,8 @@
<div class="container-fluid py-3" id="pdf_field_editor"
t-att-data-template-id="template.id"
t-att-data-page-count="template.page_count or 1"
t-att-data-current-page="1">
t-att-data-current-page="1"
t-att-data-category="template.category or 'other'">
<!-- Header Bar -->
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -96,7 +97,7 @@
</div>
</div>
<!-- Data Keys Reference (collapsible) -->
<!-- Data Keys Reference (collapsible, populated by JS) -->
<div class="card">
<div class="card-header py-2" style="cursor: pointer;"
data-bs-toggle="collapse" data-bs-target="#dataKeysCollapse">
@@ -106,36 +107,8 @@
</h6>
</div>
<div id="dataKeysCollapse" class="collapse">
<div class="card-body p-2" style="max-height: 250px; overflow-y: auto; font-size: 11px;">
<div class="mb-1"><strong>Client:</strong></div>
<code class="d-block">client_last_name</code>
<code class="d-block">client_first_name</code>
<code class="d-block">client_middle_name</code>
<code class="d-block">client_health_card</code>
<code class="d-block">client_health_card_version</code>
<code class="d-block">client_street</code>
<code class="d-block">client_unit</code>
<code class="d-block">client_city</code>
<code class="d-block">client_state</code>
<code class="d-block">client_postal_code</code>
<code class="d-block">client_phone</code>
<code class="d-block">client_email</code>
<code class="d-block">client_weight</code>
<div class="mb-1 mt-2"><strong>Consent:</strong></div>
<code class="d-block">consent_applicant</code>
<code class="d-block">consent_agent</code>
<code class="d-block">consent_date</code>
<code class="d-block">agent_rel_spouse</code>
<code class="d-block">agent_rel_parent</code>
<code class="d-block">agent_rel_child</code>
<code class="d-block">agent_rel_poa</code>
<code class="d-block">agent_rel_guardian</code>
<code class="d-block">agent_last_name</code>
<code class="d-block">agent_first_name</code>
<code class="d-block">agent_middle_initial</code>
<div class="mb-1 mt-2"><strong>Signatures:</strong></div>
<code class="d-block">signature_page_11</code>
<code class="d-block">signature_page_12</code>
<div class="card-body p-2" id="dataKeysList"
style="max-height: 300px; overflow-y: auto; font-size: 11px;">
</div>
</div>
</div>

View File

@@ -0,0 +1,322 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="portal_ltc_repair_form"
name="LTC Repair Form">
<t t-call="website.layout">
<div id="wrap" class="oe_structure">
<section class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="text-center mb-4">
<h1>LTC Repairs Request</h1>
<p class="lead text-muted">
Submit a repair request for medical equipment at your facility.
</p>
</div>
<t t-if="request.params.get('error') == 'facility'">
<div class="alert alert-danger">Please select a facility.</div>
</t>
<t t-if="request.params.get('error') == 'name'">
<div class="alert alert-danger">Patient name is required.</div>
</t>
<t t-if="request.params.get('error') == 'description'">
<div class="alert alert-danger">Issue description is required.</div>
</t>
<t t-if="request.params.get('error') == 'photos'">
<div class="alert alert-danger">At least one before photo is required.</div>
</t>
<t t-if="request.params.get('error') == 'server'">
<div class="alert alert-danger">
An error occurred. Please try again or contact us.
</div>
</t>
<form action="/repair-form/submit" method="POST"
enctype="multipart/form-data"
class="card shadow-sm">
<input type="hidden" name="csrf_token"
t-att-value="request.csrf_token()"/>
<div class="card-body p-4">
<div class="mb-4">
<div class="form-check">
<input type="checkbox" class="form-check-input"
id="is_emergency" name="is_emergency"/>
<label class="form-check-label fw-bold text-danger"
for="is_emergency">
Is this an Emergency Repair Request?
</label>
</div>
<small class="text-muted">
Emergency visits may be chargeable at an extra rate.
</small>
</div>
<hr/>
<div class="mb-3">
<label for="facility_id" class="form-label fw-bold">
Facility Location *
</label>
<select name="facility_id" id="facility_id"
class="form-select" required="required">
<option value="">-- Select Facility --</option>
<t t-foreach="facilities" t-as="fac">
<option t-att-value="fac.id">
<t t-esc="fac.name"/>
</option>
</t>
</select>
</div>
<div class="mb-3">
<label for="client_name" class="form-label fw-bold">
Patient Name *
</label>
<input type="text" name="client_name" id="client_name"
class="form-control" required="required"
placeholder="Enter patient name"/>
</div>
<div class="mb-3">
<label for="room_number" class="form-label fw-bold">
Room Number *
</label>
<input type="text" name="room_number" id="room_number"
class="form-control" required="required"
placeholder="e.g. 305"/>
</div>
<div class="mb-3">
<label for="issue_description" class="form-label fw-bold">
Describe the Issue *
</label>
<textarea name="issue_description" id="issue_description"
class="form-control" rows="4"
required="required"
placeholder="Please provide as much detail as possible about the issue."/>
</div>
<div class="mb-3">
<label for="issue_reported_date" class="form-label fw-bold">
Issue Reported Date *
</label>
<input type="date" name="issue_reported_date"
id="issue_reported_date"
class="form-control" required="required"
t-att-value="today"/>
</div>
<div class="mb-3">
<label for="product_serial" class="form-label fw-bold">
Product Serial # *
</label>
<input type="text" name="product_serial"
id="product_serial"
class="form-control" required="required"
placeholder="Serial number is required for repairs"/>
</div>
<div class="mb-3">
<label for="before_photos" class="form-label fw-bold">
Before Photos (Reported Condition) *
</label>
<input type="file" name="before_photos" id="before_photos"
class="form-control" multiple="multiple"
accept="image/*" required="required"/>
<small class="text-muted">
At least 1 photo required. Up to 4 photos (max 10MB each).
</small>
</div>
<hr/>
<h5>Family / POA Contact</h5>
<div class="row">
<div class="col-md-6 mb-3">
<label for="poa_name" class="form-label">
Relative/POA Name
</label>
<input type="text" name="poa_name" id="poa_name"
class="form-control"
placeholder="Contact name"/>
</div>
<div class="col-md-6 mb-3">
<label for="poa_phone" class="form-label">
Relative/POA Phone
</label>
<input type="tel" name="poa_phone" id="poa_phone"
class="form-control"
placeholder="Phone number"/>
</div>
</div>
<t t-if="is_technician">
<hr/>
<div class="bg-light p-3 rounded mb-3">
<p class="fw-bold text-muted mb-2">
FOR TECHNICIAN USE ONLY
</p>
<div class="mb-3">
<label class="form-label">
Has the issue been resolved?
</label>
<div class="form-check form-check-inline">
<input type="radio" name="resolved" value="yes"
class="form-check-input" id="resolved_yes"/>
<label class="form-check-label"
for="resolved_yes">Yes</label>
</div>
<div class="form-check form-check-inline">
<input type="radio" name="resolved" value="no"
class="form-check-input" id="resolved_no"
checked="checked"/>
<label class="form-check-label"
for="resolved_no">No</label>
</div>
</div>
<div id="resolution_section"
style="display: none;">
<div class="mb-3">
<label for="resolution_description"
class="form-label">
Describe the Solution
</label>
<textarea name="resolution_description"
id="resolution_description"
class="form-control" rows="3"
placeholder="How was the issue resolved?"/>
</div>
<div class="mb-3">
<label for="after_photos" class="form-label fw-bold">
After Photos (Completed Repair)
</label>
<input type="file" name="after_photos" id="after_photos"
class="form-control" multiple="multiple"
accept="image/*"/>
<small class="text-muted">
Attach after repair is completed. Up to 4 photos (max 10MB each).
</small>
</div>
</div>
</div>
</t>
<div class="text-center mt-4">
<button type="submit" class="btn btn-primary btn-lg px-5">
Submit Repair Request
</button>
</div>
</div>
</form>
</div>
</div>
</section>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var section = document.getElementById('resolution_section');
if (!section) return;
var radios = document.querySelectorAll('input[name="resolved"]');
radios.forEach(function(r) {
r.addEventListener('change', function() {
section.style.display = this.value === 'yes' ? 'block' : 'none';
});
});
});
</script>
</t>
</template>
<template id="portal_ltc_repair_thank_you"
name="Repair Request Submitted">
<t t-call="website.layout">
<div id="wrap" class="oe_structure">
<section class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-6 text-center">
<div class="mb-4">
<i class="fa fa-check-circle text-success"
style="font-size: 4rem;"/>
</div>
<h2>Thank You!</h2>
<p class="lead text-muted">
Your repair request has been submitted successfully.
</p>
<div class="card mt-4">
<div class="card-body">
<p><strong>Reference:</strong>
<t t-esc="repair.name"/></p>
<p><strong>Facility:</strong>
<t t-esc="repair.facility_id.name"/></p>
<p><strong>Patient:</strong>
<t t-esc="repair.display_client_name"/></p>
<p><strong>Room:</strong>
<t t-esc="repair.room_number"/></p>
</div>
</div>
<a href="/repair-form" class="btn btn-outline-primary mt-4">
Submit Another Request
</a>
</div>
</div>
</section>
</div>
</t>
</template>
<template id="portal_ltc_repair_password"
name="LTC Repair Form - Password">
<t t-call="website.layout">
<div id="wrap" class="oe_structure">
<section class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-5">
<div class="card shadow-sm">
<div class="card-body p-4 text-center">
<div class="mb-3">
<i class="fa fa-lock text-primary"
style="font-size: 3rem;"/>
</div>
<h3>LTC Repairs Request</h3>
<p class="text-muted">
Please enter the access password to continue.
</p>
<t t-if="error">
<div class="alert alert-danger">
Incorrect password. Please try again.
</div>
</t>
<form action="/repair-form/auth" method="POST"
class="mt-3">
<input type="hidden" name="csrf_token"
t-att-value="request.csrf_token()"/>
<div class="mb-3">
<input type="password" name="password"
class="form-control form-control-lg text-center"
placeholder="Enter password"
minlength="4" required="required"
autofocus="autofocus"/>
</div>
<button type="submit"
class="btn btn-primary btn-lg w-100">
Access Form
</button>
</form>
</div>
</div>
</div>
</div>
</section>
</div>
</t>
</template>
</odoo>

View File

@@ -171,12 +171,12 @@
<!-- Quick Links -->
<div class="row g-2 mb-4">
<div class="col-6">
<div class="col-4">
<a href="/my/technician/tasks" class="btn btn-outline-primary w-100 py-3">
<i class="fa fa-list me-1"/>All Tasks
</a>
</div>
<div class="col-6">
<div class="col-4">
<a href="/my/technician/tomorrow" class="btn btn-outline-secondary w-100 py-3">
<i class="fa fa-calendar me-1"/>Tomorrow
<t t-if="tomorrow_count">
@@ -184,6 +184,11 @@
</t>
</a>
</div>
<div class="col-4">
<a href="/repair-form" class="btn btn-outline-warning w-100 py-3">
<i class="fa fa-wrench me-1"/>Repair Form
</a>
</div>
</div>
<!-- My Start Location -->

View File

@@ -4,13 +4,17 @@
<!-- ==================== PORTAL HOME EXTENSION ==================== -->
<template id="portal_my_home_authorizer" inherit_id="portal.portal_my_home" priority="40">
<!-- Insert Fusion content at the very top, before alert category -->
<xpath expr="//div[@id='portal_alert_category']" position="before">
<!-- Insert Fusion content before the default portal docs grid -->
<xpath expr="//div[hasclass('o_portal_docs')]" position="before">
<t t-if="request.env.user.partner_id.is_authorizer or request.env.user.partner_id.is_sales_rep_portal or request.env.user.partner_id.is_client_portal or request.env.user.partner_id.is_technician_portal">
<t t-set="fc_gradient" t-value="portal_gradient or 'linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%)'"/>
<style>
:root { --fc-portal-gradient: <t t-out="fc_gradient"/>; }
</style>
<!-- Welcome Banner -->
<div class="row mb-4">
<div class="row g-3 mb-4">
<div class="col-12">
<div class="border-0 shadow-sm p-4" style="background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%); border-radius: 12px;">
<div class="border-0 shadow-sm p-4" t-attf-style="background: {{fc_gradient}}; border-radius: 12px;">
<div class="d-flex align-items-center text-white">
<div class="flex-grow-1">
<h4 class="mb-1" style="color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.1);">
@@ -71,7 +75,7 @@
<a href="/my/accessibility" class="card h-100 border-0 shadow-sm text-decoration-none" style="border-radius: 12px; min-height: 100px;">
<div class="card-body d-flex align-items-center p-4">
<div class="me-3">
<div class="rounded-circle d-flex align-items-center justify-content-center" style="width: 50px; height: 50px; background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);">
<div class="rounded-circle d-flex align-items-center justify-content-center" t-attf-style="width: 50px; height: 50px; background: {{fc_gradient}};">
<i class="fa fa-wheelchair fa-lg text-white"/>
</div>
</div>
@@ -90,7 +94,7 @@
<a href="/my/sales" class="card h-100 border-0 shadow-sm text-decoration-none" style="border-radius: 12px; min-height: 100px;">
<div class="card-body d-flex align-items-center p-4">
<div class="me-3">
<div class="rounded-circle d-flex align-items-center justify-content-center" style="width: 50px; height: 50px; background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);">
<div class="rounded-circle d-flex align-items-center justify-content-center" t-attf-style="width: 50px; height: 50px; background: {{fc_gradient}};">
<i class="fa fa-briefcase fa-lg text-white"/>
</div>
</div>
@@ -109,7 +113,7 @@
<a href="/my/assessments" class="card h-100 border-0 shadow-sm text-decoration-none" style="border-radius: 12px; min-height: 100px;">
<div class="card-body d-flex align-items-center p-4">
<div class="me-3">
<div class="rounded-circle d-flex align-items-center justify-content-center" style="width: 50px; height: 50px; background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);">
<div class="rounded-circle d-flex align-items-center justify-content-center" t-attf-style="width: 50px; height: 50px; background: {{fc_gradient}};">
<i class="fa fa-clipboard fa-lg text-white"/>
</div>
</div>
@@ -131,7 +135,7 @@
</t>
<div class="card-body d-flex align-items-center p-4">
<div class="me-3">
<div class="rounded-circle d-flex align-items-center justify-content-center" style="width: 50px; height: 50px; background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);">
<div class="rounded-circle d-flex align-items-center justify-content-center" t-attf-style="width: 50px; height: 50px; background: {{fc_gradient}};">
<i class="fa fa-pencil-square-o fa-lg text-white"/>
</div>
</div>
@@ -172,7 +176,7 @@
<a href="/my/technician" class="card h-100 border-0 shadow-sm text-decoration-none" style="border-radius: 12px; min-height: 100px;">
<div class="card-body d-flex align-items-center p-4">
<div class="me-3">
<div class="rounded-circle d-flex align-items-center justify-content-center" style="width: 50px; height: 50px; background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);">
<div class="rounded-circle d-flex align-items-center justify-content-center" t-attf-style="width: 50px; height: 50px; background: {{fc_gradient}};">
<i class="fa fa-truck fa-lg text-white"/>
</div>
</div>
@@ -191,7 +195,7 @@
<a href="/my/funding-claims" class="card h-100 border-0 shadow-sm text-decoration-none" style="border-radius: 12px; min-height: 100px;">
<div class="card-body d-flex align-items-center p-4">
<div class="me-3">
<div class="rounded-circle d-flex align-items-center justify-content-center" style="width: 50px; height: 50px; background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);">
<div class="rounded-circle d-flex align-items-center justify-content-center" t-attf-style="width: 50px; height: 50px; background: {{fc_gradient}};">
<i class="fa fa-file-text-o fa-lg text-white"/>
</div>
</div>
@@ -206,16 +210,16 @@
</div>
<!-- ADP Posting Schedule Card -->
<div class="row mb-4" t-if="next_posting_date">
<div class="row g-3 mb-4" t-if="next_posting_date">
<div class="col-12">
<div class="card shadow-sm border-0 overflow-hidden" style="border-radius: 12px;">
<div class="card-header py-3" style="background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);">
<div class="card-header py-3" t-attf-style="background: {{fc_gradient}};">
<h5 class="mb-0 text-white"><i class="fa fa-calendar me-2"/>Upcoming ADP Posting Schedule</h5>
</div>
<div class="card-body" style="background: linear-gradient(180deg, #f8f9fa 0%, #ffffff 100%);">
<div class="row align-items-center">
<div class="col-md-4 text-center mb-3 mb-md-0">
<div class="rounded-3 p-4" style="background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);">
<div class="rounded-3 p-4" t-attf-style="background: {{fc_gradient}};">
<small class="d-block" style="color: rgba(255,255,255,0.8);">Next ADP Posting</small>
<h2 class="mb-1 text-white"><t t-out="next_posting_display"/></h2>
<small style="color: rgba(255,255,255,0.8);"><t t-out="next_posting_weekday"/></small>

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Claims',
'version': '19.0.5.0.0',
'version': '19.0.6.0.0',
'category': 'Sales',
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
'description': """
@@ -77,13 +77,17 @@
'sale',
'sale_management',
'sale_margin',
'purchase',
'account',
'sales_team',
'stock',
'calendar',
'ai',
'fusion_faxes',
'fusion_ringcentral',
],
'external_dependencies': {
'python': ['pdf2image', 'PIL'],
},
'data': [
'security/security.xml',
'security/ir.model.access.csv',
@@ -123,20 +127,27 @@
'wizard/odsp_submit_to_odsp_wizard_views.xml',
'wizard/odsp_pre_approved_wizard_views.xml',
'wizard/odsp_ready_delivery_wizard_views.xml',
'wizard/ltc_repair_create_so_wizard_views.xml',
'views/res_partner_views.xml',
'views/pdf_template_inherit_views.xml',
'views/dashboard_views.xml',
'views/client_profile_views.xml',
'wizard/xml_import_wizard_views.xml',
'views/ltc_facility_views.xml',
'views/ltc_repair_views.xml',
'views/ltc_cleanup_views.xml',
'views/ltc_form_submission_views.xml',
'views/adp_claims_views.xml',
'views/submission_history_views.xml',
'views/fusion_loaner_views.xml',
'views/technician_task_views.xml',
'views/task_sync_views.xml',
'views/technician_location_views.xml',
'report/report_actions.xml',
'report/report_templates.xml',
'report/sale_report_portrait.xml',
'report/sale_report_landscape.xml',
'report/sale_report_ltc_repair.xml',
'report/invoice_report_portrait.xml',
'report/invoice_report_landscape.xml',
'report/report_proof_of_delivery.xml',
@@ -147,6 +158,9 @@
'report/report_accessibility_contract.xml',
'report/report_mod_quotation.xml',
'report/report_mod_invoice.xml',
'data/ltc_data.xml',
'report/report_ltc_nursing_station.xml',
'data/ltc_report_data.xml',
'data/mail_template_data.xml',
'data/ai_agent_data.xml',
],

View File

@@ -147,5 +147,17 @@
<field name="value">1-888-222-5099</field>
</record>
<!-- Cross-instance task sync: unique ID for this Odoo instance -->
<record id="config_sync_instance_id" model="ir.config_parameter">
<field name="key">fusion_claims.sync_instance_id</field>
<field name="value"></field>
</record>
<!-- LTC Portal Form Password -->
<record id="config_ltc_form_password" model="ir.config_parameter">
<field name="key">fusion_claims.ltc_form_password</field>
<field name="value"></field>
</record>
</data>
</odoo>

View File

@@ -156,5 +156,28 @@
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<!-- Cron Job: Pull Remote Technician Tasks (cross-instance sync) -->
<record id="ir_cron_task_sync_pull" model="ir.cron">
<field name="name">Fusion Claims: Sync Remote Tasks (Pull)</field>
<field name="model_id" ref="model_fusion_task_sync_config"/>
<field name="state">code</field>
<field name="code">model._cron_pull_remote_tasks()</field>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<!-- Cron Job: Cleanup Old Shadow Tasks (30+ days) -->
<record id="ir_cron_task_sync_cleanup" model="ir.cron">
<field name="name">Fusion Claims: Cleanup Old Shadow Tasks</field>
<field name="model_id" ref="model_fusion_task_sync_config"/>
<field name="state">code</field>
<field name="code">model._cron_cleanup_old_shadows()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
<field name="nextcall" eval="DateTime.now().replace(hour=3, minute=0, second=0)"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- ================================================================== -->
<!-- SEQUENCES -->
<!-- ================================================================== -->
<record id="seq_ltc_facility" model="ir.sequence">
<field name="name">LTC Facility</field>
<field name="code">fusion.ltc.facility</field>
<field name="prefix">LTC-</field>
<field name="padding">4</field>
<field name="company_id" eval="False"/>
</record>
<record id="seq_ltc_repair" model="ir.sequence">
<field name="name">LTC Repair</field>
<field name="code">fusion.ltc.repair</field>
<field name="prefix">LTC-RPR-</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
<record id="seq_ltc_cleanup" model="ir.sequence">
<field name="name">LTC Cleanup</field>
<field name="code">fusion.ltc.cleanup</field>
<field name="prefix">LTC-CLN-</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
<!-- ================================================================== -->
<!-- DEFAULT LTC REPAIR SERVICE PRODUCT -->
<!-- ================================================================== -->
<record id="product_ltc_repair_service" model="product.template">
<field name="name">REPAIRS AT LTC HOME</field>
<field name="default_code">LTC-REPAIR</field>
<field name="type">service</field>
<field name="list_price">0.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<!-- ================================================================== -->
<!-- DEFAULT REPAIR STAGES -->
<!-- ================================================================== -->
<record id="ltc_repair_stage_new" model="fusion.ltc.repair.stage">
<field name="name">New</field>
<field name="sequence">1</field>
<field name="fold">False</field>
<field name="description">Newly submitted repair requests awaiting review.</field>
</record>
<record id="ltc_repair_stage_in_review" model="fusion.ltc.repair.stage">
<field name="name">In Review</field>
<field name="sequence">2</field>
<field name="fold">False</field>
<field name="description">Under review by office staff before assignment.</field>
</record>
<record id="ltc_repair_stage_in_progress" model="fusion.ltc.repair.stage">
<field name="name">In Progress</field>
<field name="sequence">3</field>
<field name="fold">False</field>
<field name="description">Technician has been assigned and repair is underway.</field>
</record>
<record id="ltc_repair_stage_completed" model="fusion.ltc.repair.stage">
<field name="name">Completed</field>
<field name="sequence">4</field>
<field name="fold">True</field>
<field name="description">Repair has been completed by the technician.</field>
</record>
<record id="ltc_repair_stage_invoiced" model="fusion.ltc.repair.stage">
<field name="name">Invoiced</field>
<field name="sequence">5</field>
<field name="fold">True</field>
<field name="description">Sale order created and invoiced for this repair.</field>
</record>
<record id="ltc_repair_stage_declined" model="fusion.ltc.repair.stage">
<field name="name">Declined/No Response</field>
<field name="sequence">6</field>
<field name="fold">True</field>
<field name="description">Repair was declined or no response was received.</field>
</record>
<!-- ================================================================== -->
<!-- CRON: Cleanup scheduling -->
<!-- ================================================================== -->
<record id="ir_cron_ltc_cleanup_schedule" model="ir.cron">
<field name="name">LTC: Auto-Schedule Cleanups</field>
<field name="model_id" ref="model_fusion_ltc_cleanup"/>
<field name="state">code</field>
<field name="code">model._cron_schedule_cleanups()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Landscape paper format for nursing station report -->
<record id="paperformat_ltc_nursing_station" model="report.paperformat">
<field name="name">LTC Nursing Station (Landscape)</field>
<field name="default">False</field>
<field name="format">Letter</field>
<field name="orientation">Landscape</field>
<field name="margin_top">20</field>
<field name="margin_bottom">15</field>
<field name="margin_left">10</field>
<field name="margin_right">10</field>
<field name="header_line">False</field>
<field name="header_spacing">10</field>
<field name="dpi">90</field>
</record>
<!-- Nursing Station Report action -->
<record id="action_report_ltc_nursing_station" model="ir.actions.report">
<field name="name">Nursing Station Repair Log</field>
<field name="model">fusion.ltc.facility</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_claims.report_ltc_nursing_station_document</field>
<field name="report_file">fusion_claims.report_ltc_nursing_station_document</field>
<field name="binding_model_id" ref="model_fusion_ltc_facility"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_ltc_nursing_station"/>
</record>
<!-- Repair Summary Report action -->
<record id="action_report_ltc_repairs_summary" model="ir.actions.report">
<field name="name">Repair Summary</field>
<field name="model">fusion.ltc.facility</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_claims.report_ltc_repairs_summary_document</field>
<field name="report_file">fusion_claims.report_ltc_repairs_summary_document</field>
<field name="binding_model_id" ref="model_fusion_ltc_facility"/>
<field name="binding_type">report</field>
</record>
</odoo>

View File

@@ -49,19 +49,9 @@
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">Please review the attached quotation. If you have any questions or need assistance, do not hesitate to contact us.</p>
</div>
<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:24px 0 0 0;">
Best regards,<br/>
<strong><t t-out="object.user_id.name or object.company_id.name"/></strong><br/>
<span style="color:#718096;"><t t-out="object.company_id.name"/></span>
</p>
</div>
<div style="padding:16px 28px;text-align:center;">
<p style="color:#a0aec0;font-size:11px;line-height:1.5;margin:0;">
<t t-out="object.company_id.name"/>
<t t-if="object.company_id.phone"> · <t t-out="object.company_id.phone"/></t>
<t t-if="object.company_id.email"> · <t t-out="object.company_id.email"/></t><br/>
This is an automated notification from the ADP Claims Management System.
</p>
<t t-if="not is_html_empty(object.user_id.signature)" data-o-mail-quote-container="1">
<div data-o-mail-quote="1">--<br data-o-mail-quote="1"/><t t-out="object.user_id.signature or ''" data-o-mail-quote="1"/></div>
</t>
</div>
</div>
</field>
@@ -109,19 +99,9 @@
<div style="border-left:3px solid #38a169;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">Your order is being processed. We will keep you updated on the delivery status and any updates from the Assistive Devices Program.</p>
</div>
<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:24px 0 0 0;">
Best regards,<br/>
<strong><t t-out="object.user_id.name or object.company_id.name"/></strong><br/>
<span style="color:#718096;"><t t-out="object.company_id.name"/></span>
</p>
</div>
<div style="padding:16px 28px;text-align:center;">
<p style="color:#a0aec0;font-size:11px;line-height:1.5;margin:0;">
<t t-out="object.company_id.name"/>
<t t-if="object.company_id.phone"> · <t t-out="object.company_id.phone"/></t>
<t t-if="object.company_id.email"> · <t t-out="object.company_id.email"/></t><br/>
This is an automated notification from the ADP Claims Management System.
</p>
<t t-if="not is_html_empty(object.user_id.signature)" data-o-mail-quote-container="1">
<div data-o-mail-quote="1">--<br data-o-mail-quote="1"/><t t-out="object.user_id.signature or ''" data-o-mail-quote="1"/></div>
</t>
</div>
</div>
</field>
@@ -178,19 +158,10 @@
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">Please review the attached invoice and process payment at your earliest convenience. Contact us if you have any questions.</p>
</div>
</t>
<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:24px 0 0 0;">
Best regards,<br/>
<strong><t t-out="object.invoice_user_id.name or object.company_id.name"/></strong><br/>
<span style="color:#718096;"><t t-out="object.company_id.name"/></span>
</p>
</div>
<div style="padding:16px 28px;text-align:center;">
<p style="color:#a0aec0;font-size:11px;line-height:1.5;margin:0;">
<t t-out="object.company_id.name"/>
<t t-if="object.company_id.phone"> · <t t-out="object.company_id.phone"/></t>
<t t-if="object.company_id.email"> · <t t-out="object.company_id.email"/></t><br/>
This is an automated notification from the ADP Claims Management System.
</p>
<t t-set="sig" t-value="object.invoice_user_id.signature or object.user_id.signature"/>
<t t-if="not is_html_empty(sig)" data-o-mail-quote-container="1">
<div data-o-mail-quote="1">--<br data-o-mail-quote="1"/><t t-out="sig or ''" data-o-mail-quote="1"/></div>
</t>
</div>
</div>
</field>

View File

@@ -1,18 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="sa_sig_template_default" model="fusion.sa.signature.template">
<field name="name">SA Mobility Standard</field>
<field name="notes">Default signature positions for SA Mobility approval forms from ODSP.</field>
<field name="sa_default_sig_page">2</field>
<field name="sa_sig_name_x">105</field>
<field name="sa_sig_name_y">97</field>
<field name="sa_sig_date_x">430</field>
<field name="sa_sig_date_y">97</field>
<field name="sa_sig_x">72</field>
<field name="sa_sig_y">72</field>
<field name="sa_sig_w">190</field>
<field name="sa_sig_h">25</field>
</record>
</data>
<!-- ODSP SA Mobility signing template is created via the UI or odoo shell.
It lives in fusion.pdf.template (category=odsp) with 3 fields:
sa_client_name (text), sa_sign_date (date), sa_signature (signature)
Managed via Configuration > PDF Templates using the drag-and-drop editor. -->
</odoo>

View File

@@ -1,30 +0,0 @@
Odoo Proprietary License v1.0
This software and associated files (the "Software") may only be used (executed,
modified, executed after modifications) if you have purchased a valid license
from the authors, typically via Odoo Apps, or if you have received a written
agreement from the authors of the Software.
You may develop Odoo modules that use the Software as a library (typically
by depending on it, importing it and using its resources), but without copying
any source code or material from the Software. You may distribute those
modules under the license of your choice, provided that this license is
compatible with the terms of the Odoo Proprietary License (For example:
LGPL, MIT, or proprietary licenses similar to this one).
It is forbidden to publish, distribute, sublicense, or sell copies of the Software
or modified copies of the Software.
The above copyright notice and this permission notice must be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
Copyright 2024-2025 Nexa Systems Inc.

View File

@@ -1,199 +0,0 @@
# Fusion Central
**Complete ADP Billing Solution for Odoo 19**
Part of the Fusion Claim Assistant product family by Nexa Systems Inc.
## Overview
Fusion Central is a comprehensive solution for managing ADP (Assistive Devices Program) billing in Odoo. It provides automatic calculation of ADP and client portions, professional PDF reports, and seamless integration with your existing workflow.
## Features
### Automatic Portion Calculations
- **Per-line calculations** on sale orders and invoices
- **Client type based rules:**
- REG: 75% ADP / 25% Client
- ODS, OWP, ACS, LTC, SEN, CCA: 100% ADP / 0% Client
- Automatic recalculation on quantity or price changes
- Totals displayed on document headers
### Professional PDF Reports
- **Portrait and Landscape** orientations available
- Quotation, Sale Order, and Invoice reports
- Uses Odoo's default company header/footer via `web.external_layout`
- Includes:
- Company logo and address (from company settings)
- Billing and delivery address boxes with borders
- ADP device codes from products (`x_adp_code`)
- Per-line ADP and client portions with color coding
- Serial number column
- Authorizer and sales rep information
- Payment terms
- Signature section (if signed)
### Report Templates
| Report | Template ID | Model | Description |
|--------|-------------|-------|-------------|
| Quotation/Order (Portrait) | `fusion_claims.report_saleorder_portrait` | sale.order | Standard portrait layout |
| Quotation/Order (Landscape - ADP) | `fusion_claims.report_saleorder_landscape` | sale.order | Landscape with full ADP columns |
| Invoice (Portrait) | `fusion_claims.report_invoice_portrait` | account.move | Standard portrait layout |
| Invoice (Landscape) | `fusion_claims.report_invoice_landscape` | account.move | Landscape with full ADP columns |
### Report Styling
All reports follow a consistent style:
- **Font**: Arial, 10-12pt depending on section
- **Headers**: Blue background (#0066a1) with white text
- **ADP Portion Column**: Blue header (#1976d2), light blue rows (#e3f2fd)
- **Client Portion Column**: Orange header (#e65100), light orange rows (#fff3e0)
- **Bordered Tables**: All tables have 1px solid black borders
- **Totals**: Right-aligned with proper borders
### Configurable Settings
- Field mappings for existing Odoo Studio fields
- One-click field creation for new installations
- HTML editors for payment terms and refund policy
- Store address configuration
- Field mapping configuration
### ADP Claim Export
- Export invoices to comma-separated TXT format
- Compatible with ADP submission systems
- Automatic file naming with submission numbers
- Optional Documents app integration
## Installation
1. Copy the `fusion_claims` folder to your Odoo addons directory
2. Update the apps list in Odoo
3. Install "Fusion Central" from the Apps menu
## Configuration
### Quick Setup (Settings → Sales → Fusion Central)
1. **Create Fields:**
- Click "Create Sale Order Fields"
- Click "Create Invoice Fields"
- Click "Create Product Fields"
2. **Or Detect Existing Fields:**
- Click "Detect Existing Fields" if you have existing custom fields
3. **Configure Company Info:**
- Store Address Line 1 & 2
- Company Tagline
- E-Transfer Email
- Cheque Payable To
4. **Add Payment Terms:**
- Use the HTML editor to format your payment terms
5. **Add Refund Policy:**
- Enable "Include Refund Policy Page"
- Paste your refund policy HTML
### Field Mappings
The module uses the following fields:
#### Sale Order Fields
| Field | Technical Name | Description |
|-------|---------------|-------------|
| Sale Type | x_fc_sale_type | ADP, ADP/ODSP, REG, etc. |
| Client Type | x_fc_client_type | REG, ODS, OWP, ACS, LTC, SEN, CCA |
| Authorizer | x_fc_authorizer_id | Many2one to res.partner |
| Claim Number | x_fc_claim_number | ADP claim reference |
| Delivery Date | x_fc_adp_delivery_date | Device delivery date |
| Client Ref 1 | x_fc_client_ref_1 | Client reference 1 |
| Client Ref 2 | x_fc_client_ref_2 | Client reference 2 |
| Service Start | x_fc_service_start_date | Service start date |
| Service End | x_fc_service_end_date | Service end date |
#### Sale Order Line Fields
| Field | Technical Name | Description |
|-------|---------------|-------------|
| Serial Number | x_fc_serial_number | Device serial number |
| ADP Portion | x_fc_adp_portion | Calculated ADP amount |
| Client Portion | x_fc_client_portion | Calculated client amount |
| Device Placement | x_fc_device_placement | L/R/N/A placement |
#### Invoice Fields
| Field | Technical Name | Description |
|-------|---------------|-------------|
| Invoice Type | x_fc_invoice_type | ADP, ADP Client Portion, ODSP, WSIB, etc. |
| Claim Number | x_fc_claim_number | ADP claim reference |
| Authorizer | x_fc_authorizer_id | Many2one to res.partner |
| Delivery Date | x_fc_adp_delivery_date | Device delivery date |
#### Product Fields
| Field | Technical Name | Description |
|-------|---------------|-------------|
| ADP Code | x_adp_code | ADP device code for billing |
## Usage
### Sale Orders
1. Create a sale order
2. Set Sale Type to "ADP" or "ADP/ODSP"
3. Set Client Type (REG, ODS, etc.)
4. Add products - ADP and Client portions calculate automatically
5. Print using Portrait or Landscape report
### Invoices
1. Create invoice (or generate from sale order)
2. Set Invoice Type to "ADP" or "ADP/ODSP"
3. Portions display automatically
4. Use "Export ADP" button to generate claim file
5. Print using Portrait or Landscape report
## Odoo 19 Compatibility Notes
This module has been updated for Odoo 19 with the following changes:
1. **QWeb Templates**:
- Removed `hasattr` calls (not supported in Odoo 19 QWeb)
- Changed `product_uom` to `product_uom_id`
- Changed `tax_id` to `tax_ids`
- Use `t-else=""` instead of checking for `display_type == False`
2. **Report Templates**:
- Use only `web.external_layout` for headers/footers
- Removed duplicate header/footer template calls
- Added explicit CSS borders (Bootstrap border classes don't render in PDF)
3. **Field Names**:
- All fields use the `x_fc_*` naming convention
## Requirements
- Odoo 19.0
- Dependencies: base, sale, sale_management, account
## Changelog
### Version 19.0.1.0.0 (December 2025)
- Updated for Odoo 19 compatibility
- Rewrote all report templates with consistent styling
- Fixed QWeb template issues (hasattr, field names)
- Added explicit CSS borders for PDF rendering
- Improved authorizer field display (Many2one handling)
- Removed duplicate header/footer calls
- Standardized all field mappings to x_fc_* fields
### Version 18.0.1.0.0
- Initial release for Odoo 18
## Support
**Developer:** Nexa Systems Inc.
**Website:** https://www.nexasystems.ca
**Email:** support@nexasystems.ca
## License
Odoo Proprietary License v1.0 (OPL-1)
Copyright © 2024-2025 Nexa Systems Inc. All rights reserved.

View File

@@ -1,15 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family.
from . import models
from . import wizard
def _load_adp_device_codes(env):
"""
Post-init hook to load ADP Mobility Manual device codes.
Called on module install AND upgrade.
"""
env['fusion.adp.device.code']._load_packaged_device_codes()

View File

@@ -1,180 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family.
{
'name': 'Fusion Claims',
'version': '19.0.5.0.0',
'category': 'Sales',
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
'description': """
Fusion Claims
=============
Part of the Fusion Claim Assistant product family by Nexa Systems Inc.
Fusion Claims is a comprehensive standalone application for managing ADP (Assistive Devices Program)
claims in Odoo. It provides its own sales management views, dashboard, claim workflow management,
automatic calculation of ADP and client portions, and professional PDF reports.
Key Features:
-------------
• Standalone application with its own menu and views
• Dashboard for claims overview and KPIs
• Integrated sales management (custom views separate from standard Sales app)
• Complete ADP claim workflow (Quotation → Ready for Submission → Application Submitted →
Application Approved → Ready to Bill → Billed to ADP → Case Closed)
• Automatic ADP/Client portion calculations with deductions (PCT/AMT)
• Support for multiple client types (REG=75%/25%, ODS/OWP/ACS=100%/0%)
• Split invoicing (Client Invoice + ADP Invoice)
• ADP Device Codes reference from Mobility Manual (JSON/CSV import)
• Device Approval Verification wizard - confirm which devices were approved by ADP
• Approval status tracking on order lines and invoices with visual indicators
• Professional PDF reports in Portrait and Landscape orientations
• ADP claim export to TXT format for billing
• Kanban board for ADP claim status tracking
• Field mapping for existing Odoo Studio fields
• Data persistence - fields survive module uninstall
Sales Management:
-----------------
• Custom quotation and order views with ADP fields
• ADP portion and client portion columns
• Serial number tracking per line item
• Device placement (Left/Right/N/A)
• Client type selection and automatic calculations
Claim Fields:
-------------
• Claim Number, Client Reference 1 & 2
• ADP Delivery Date, Service Start/End Dates
• Authorizer tracking
• Deduction Type (Percentage or Amount)
Report Features:
----------------
• Company logo and multi-store addresses
• Billing and delivery address boxes
• Authorizer and sales rep information
• Per-line ADP portion, client portion, taxes, and totals
• ADP device codes from products
• Serial numbers section
• Customizable payment terms
• Optional refund policy page
Copyright © 2024-2025 Nexa Systems Inc. All rights reserved.
""",
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'maintainer': 'Nexa Systems Inc.',
'support': 'support@nexasystems.ca',
'license': 'OPL-1',
'price': 0.00,
'currency': 'CAD',
'depends': [
'base',
'sale',
'sale_management',
'sale_margin',
'account',
'sales_team',
'stock',
'calendar',
'ai',
'fusion_ringcentral',
],
'external_dependencies': {
'python': ['pdf2image', 'PIL'],
},
'data': [
'security/security.xml',
'security/ir.model.access.csv',
'data/ir_config_parameter_data.xml',
'data/pdf_template_data.xml',
'data/mail_activity_type_data.xml',
'data/ir_cron_data.xml',
'data/ir_actions_server_data.xml',
'data/stock_location_data.xml',
'data/product_labor_data.xml',
'wizard/status_change_reason_wizard_views.xml',
'views/res_company_views.xml',
'views/res_config_settings_views.xml',
'views/sale_order_views.xml',
'views/account_move_views.xml',
'views/account_journal_views.xml',
'wizard/adp_export_wizard_views.xml',
'wizard/device_import_wizard_views.xml',
'wizard/device_approval_wizard_views.xml',
'wizard/submission_verification_wizard_views.xml',
'wizard/account_payment_register_views.xml',
'wizard/case_close_verification_wizard_views.xml',
'wizard/schedule_assessment_wizard_views.xml',
'wizard/assessment_completed_wizard_views.xml',
'wizard/application_received_wizard_views.xml',
'wizard/ready_for_submission_wizard_views.xml',
'wizard/ready_to_bill_wizard_views.xml',
'wizard/field_mapping_config_wizard_views.xml',
'wizard/ready_for_delivery_wizard_views.xml',
'wizard/send_to_mod_wizard_views.xml',
'wizard/mod_awaiting_funding_wizard_views.xml',
'wizard/mod_funding_approved_wizard_views.xml',
'wizard/mod_pca_received_wizard_views.xml',
'wizard/odsp_sa_mobility_wizard_views.xml',
'wizard/odsp_discretionary_wizard_views.xml',
'wizard/odsp_submit_to_odsp_wizard_views.xml',
'wizard/odsp_pre_approved_wizard_views.xml',
'wizard/odsp_ready_delivery_wizard_views.xml',
'views/res_partner_views.xml',
'views/pdf_template_inherit_views.xml',
'views/dashboard_views.xml',
'views/client_profile_views.xml',
'wizard/xml_import_wizard_views.xml',
'views/adp_claims_views.xml',
'views/submission_history_views.xml',
'views/fusion_loaner_views.xml',
'views/technician_task_views.xml',
'views/task_sync_views.xml',
'views/technician_location_views.xml',
'report/report_actions.xml',
'report/report_templates.xml',
'report/sale_report_portrait.xml',
'report/sale_report_landscape.xml',
'report/invoice_report_portrait.xml',
'report/invoice_report_landscape.xml',
'report/report_proof_of_delivery.xml',
'report/report_proof_of_delivery_standard.xml',
'report/report_proof_of_pickup.xml',
'report/report_rental_agreement.xml',
'report/report_grab_bar_waiver.xml',
'report/report_accessibility_contract.xml',
'report/report_mod_quotation.xml',
'report/report_mod_invoice.xml',
'data/mail_template_data.xml',
'data/ai_agent_data.xml',
],
'assets': {
'web.assets_backend': [
'fusion_claims/static/src/scss/fusion_claims.scss',
'fusion_claims/static/src/css/fusion_task_map_view.scss',
'fusion_claims/static/src/js/chatter_resize.js',
'fusion_claims/static/src/js/document_preview.js',
'fusion_claims/static/src/js/preview_button_widget.js',
'fusion_claims/static/src/js/status_selection_filter.js',
'fusion_claims/static/src/js/gallery_preview.js',
'fusion_claims/static/src/js/tax_totals_patch.js',
'fusion_claims/static/src/js/google_address_autocomplete.js',
'fusion_claims/static/src/js/calendar_store_hours.js',
'fusion_claims/static/src/js/fusion_task_map_view.js',
'fusion_claims/static/src/js/attachment_image_compress.js',
'fusion_claims/static/src/xml/document_preview.xml',
'fusion_claims/static/src/xml/fusion_task_map_view.xml',
],
},
'images': ['static/description/icon.png'],
'installable': True,
'auto_install': False,
'application': True,
'post_init_hook': '_load_adp_device_codes',
}

View File

@@ -1,93 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ================================================================= -->
<!-- AI TOOLS: Server Actions for Client Data Queries (MUST BE FIRST) -->
<!-- ================================================================= -->
<!-- Tool 1: Search Client Profiles -->
<record id="ai_tool_search_clients" model="ir.actions.server">
<field name="name">Fusion: Search Client Profiles</field>
<field name="state">code</field>
<field name="use_in_ai" eval="True"/>
<field name="model_id" ref="ai.model_ai_agent"/>
<field name="code">
ai['result'] = record._fc_tool_search_clients(search_term, city_filter, condition_filter)
</field>
<field name="ai_tool_description">Search for client profiles in Fusion Claims. Can search by name, health card number, city, or medical condition. Returns matching profiles with basic info and financial summaries.</field>
<field name="ai_tool_schema">{"type": "object", "properties": {"search_term": {"type": "string", "description": "Search term to match against name, health card number, or city"}, "city_filter": {"type": "string", "description": "Filter by city name"}, "condition_filter": {"type": "string", "description": "Filter by medical condition (e.g., CVA, diabetes)"}}, "required": []}</field>
</record>
<!-- Tool 2: Get Client Details -->
<record id="ai_tool_client_details" model="ir.actions.server">
<field name="name">Fusion: Get Client Details</field>
<field name="state">code</field>
<field name="use_in_ai" eval="True"/>
<field name="model_id" ref="ai.model_ai_agent"/>
<field name="code">
ai['result'] = record._fc_tool_client_details(profile_id)
</field>
<field name="ai_tool_description">Get detailed information about a specific client profile including personal info, medical status, benefits, claims history, and ADP application history. Requires profile_id from a previous search.</field>
<field name="ai_tool_schema">{"type": "object", "properties": {"profile_id": {"type": "number", "description": "ID of the client profile to get details for"}}, "required": ["profile_id"]}</field>
</record>
<!-- Tool 3: Get Aggregated Stats -->
<record id="ai_tool_client_stats" model="ir.actions.server">
<field name="name">Fusion: Get Claims Statistics</field>
<field name="state">code</field>
<field name="use_in_ai" eval="True"/>
<field name="model_id" ref="ai.model_ai_agent"/>
<field name="code">
ai['result'] = record._fc_tool_claims_stats()
</field>
<field name="ai_tool_description">Get aggregated statistics about Fusion Claims data: total profiles, total orders, breakdown by sale type, breakdown by workflow status, and top cities by client count. No parameters needed.</field>
<field name="ai_tool_schema">{"type": "object", "properties": {}, "required": []}</field>
</record>
<!-- ================================================================= -->
<!-- AI TOPIC (references tools above) -->
<!-- ================================================================= -->
<record id="ai_topic_client_intelligence" model="ai.topic">
<field name="name">Fusion Claims Client Intelligence</field>
<field name="description">Query client profiles, ADP claims, funding history, medical conditions, and device information.</field>
<field name="instructions">You help users find information about ADP clients, claims, medical conditions, devices, and funding history. Use the Fusion search/details/stats tools to query data.</field>
<field name="tool_ids" eval="[(6, 0, [
ref('fusion_claims.ai_tool_search_clients'),
ref('fusion_claims.ai_tool_client_details'),
ref('fusion_claims.ai_tool_client_stats'),
])]"/>
</record>
<!-- ================================================================= -->
<!-- AI AGENT (references topic above) -->
<!-- ================================================================= -->
<record id="ai_agent_fusion_claims" model="ai.agent">
<field name="name">Fusion Claims Intelligence</field>
<field name="subtitle">Ask about clients, ADP claims, funding history, medical conditions, and devices.</field>
<field name="llm_model">gpt-4.1</field>
<field name="response_style">analytical</field>
<field name="restrict_to_sources" eval="False"/>
<field name="system_prompt">You are Fusion Claims Intelligence, an AI assistant for ADP claims management.
You help staff find information about clients, medical conditions, mobility devices, funding history, and claim status.
Capabilities:
1. Search client profiles by name, health card number, city, or medical condition
2. Get detailed client information including claims history and ADP applications
3. Provide aggregated statistics about claims, funding types, and demographics
Response guidelines:
- Be concise and data-driven
- Format monetary values with $ and commas
- Include key identifiers (name, health card, city) when listing clients
- Include order number, status, and amounts when discussing claims
- If asked about a specific client, search first, then get details
- Always provide the profile ID for record lookup
Key terminology:
- ADP = Assistive Devices Program (Ontario government)
- Client Type REG = Regular (75% ADP / 25% Client), ODS/OWP/ACS = 100% ADP
- Sale Types: ADP, ODSP, WSIB, Insurance, March of Dimes, Muscular Dystrophy, Hardship Funding
- Sections: 2a = Walkers, 2b = Manual Wheelchairs, 2c = Power Bases/Scooters, 2d = Seating</field>
<field name="topic_ids" eval="[(6, 0, [ref('ai_topic_client_intelligence')])]"/>
</record>
</odoo>

View File

@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Server Action: Sync ADP Fields to Invoices -->
<!-- This appears in the Action menu on Sale Orders -->
<record id="action_sync_adp_fields_server" model="ir.actions.server">
<field name="name">Sync to Invoices</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_view_types">form,list</field>
<field name="state">code</field>
<field name="code">
if records:
# Filter to only ADP sales
adp_records = records.filtered(lambda r: r.x_fc_is_adp_sale and r.state == 'sale')
if adp_records:
action = adp_records.action_sync_adp_fields()
else:
action = {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'No ADP Sales',
'message': 'Selected orders are not confirmed ADP sales.',
'type': 'warning',
'sticky': False,
}
}
</field>
</record>
</odoo>

Some files were not shown because too many files have changed in this diff Show More