Compare commits
3 Commits
d6bac8e623
...
9d9453b5c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d9453b5c8 | ||
|
|
f85658c03a | ||
|
|
e8e554de95 |
@@ -1,5 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Copyright 2026 Nexa Systems Inc.
|
|
||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
|
||||||
|
|
||||||
from . import models
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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 ''
|
|
||||||
@@ -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),
|
|
||||||
]),
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
@@ -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'},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,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 |
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
BIN
fusion_authorizer_portal/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
{
|
{
|
||||||
'name': 'Fusion Authorizer & Sales Portal',
|
'name': 'Fusion Authorizer & Sales Portal',
|
||||||
'version': '19.0.2.0.9',
|
'version': '19.0.2.2.0',
|
||||||
'category': 'Sales/Portal',
|
'category': 'Sales/Portal',
|
||||||
'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms',
|
'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -66,6 +66,7 @@ This module provides external portal access for:
|
|||||||
'views/res_partner_views.xml',
|
'views/res_partner_views.xml',
|
||||||
'views/sale_order_views.xml',
|
'views/sale_order_views.xml',
|
||||||
'views/assessment_views.xml',
|
'views/assessment_views.xml',
|
||||||
|
'views/loaner_checkout_views.xml',
|
||||||
'views/pdf_template_views.xml',
|
'views/pdf_template_views.xml',
|
||||||
# Portal Templates
|
# Portal Templates
|
||||||
'views/portal_templates.xml',
|
'views/portal_templates.xml',
|
||||||
@@ -75,6 +76,7 @@ This module provides external portal access for:
|
|||||||
'views/portal_accessibility_forms.xml',
|
'views/portal_accessibility_forms.xml',
|
||||||
'views/portal_technician_templates.xml',
|
'views/portal_technician_templates.xml',
|
||||||
'views/portal_book_assessment.xml',
|
'views/portal_book_assessment.xml',
|
||||||
|
'views/portal_repair_form.xml',
|
||||||
],
|
],
|
||||||
'assets': {
|
'assets': {
|
||||||
'web.assets_backend': [
|
'web.assets_backend': [
|
||||||
|
|||||||
@@ -3,3 +3,4 @@
|
|||||||
from . import portal_main
|
from . import portal_main
|
||||||
from . import portal_assessment
|
from . import portal_assessment
|
||||||
from . import pdf_editor
|
from . import pdf_editor
|
||||||
|
from . import portal_repair
|
||||||
@@ -35,6 +35,7 @@ class FusionPdfEditorController(http.Controller):
|
|||||||
fields = template.field_ids.read([
|
fields = template.field_ids.read([
|
||||||
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
|
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
|
||||||
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
|
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
|
||||||
|
'text_align',
|
||||||
])
|
])
|
||||||
|
|
||||||
return request.render('fusion_authorizer_portal.portal_pdf_field_editor', {
|
return request.render('fusion_authorizer_portal.portal_pdf_field_editor', {
|
||||||
@@ -56,6 +57,7 @@ class FusionPdfEditorController(http.Controller):
|
|||||||
return template.field_ids.read([
|
return template.field_ids.read([
|
||||||
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
|
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
|
||||||
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
|
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
|
||||||
|
'text_align',
|
||||||
])
|
])
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
@@ -73,6 +75,7 @@ class FusionPdfEditorController(http.Controller):
|
|||||||
allowed = {
|
allowed = {
|
||||||
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
|
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
|
||||||
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
|
'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}
|
safe_values = {k: v for k, v in values.items() if k in allowed}
|
||||||
if safe_values:
|
if safe_values:
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
response.qcontext['sign_count'] = sign_count
|
response.qcontext['sign_count'] = sign_count
|
||||||
response.qcontext['sign_module_available'] = sign_module_available
|
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
|
return response
|
||||||
|
|
||||||
def _prepare_home_portal_values(self, counters):
|
def _prepare_home_portal_values(self, counters):
|
||||||
|
|||||||
182
fusion_authorizer_portal/controllers/portal_repair.py
Normal file
182
fusion_authorizer_portal/controllers/portal_repair.py
Normal 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')
|
||||||
@@ -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*
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
from . import models
|
|
||||||
from . import controllers
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
from . import portal_main
|
|
||||||
from . import portal_assessment
|
|
||||||
from . import pdf_editor
|
|
||||||
@@ -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')
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 & 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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
|
|
||||||
@@ -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'})
|
|
||||||
@@ -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')
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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 — 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 — 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 — 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 & 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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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,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 |
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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 */ }
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
});
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -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());
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
from . import pdf_filler
|
|
||||||
@@ -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)
|
|
||||||
@@ -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 & 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 & 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>
|
|
||||||
@@ -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>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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 & 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}&libraries=places&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 < 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 = '<div class="position-relative">' +
|
|
||||||
'<img src="' + e.target.result + '" class="img-fluid rounded" style="max-height: 150px; object-fit: cover; width: 100%;">' +
|
|
||||||
'<button type="button" class="btn btn-danger btn-sm position-absolute" style="top: 5px; right: 5px;" onclick="removePhoto(' + (photoDataArray.length - 1) + ', this)">' +
|
|
||||||
'<i class="fa fa-times"></i>' +
|
|
||||||
'</button>' +
|
|
||||||
'</div>';
|
|
||||||
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 < 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 = '<img src="' + e.target.result + '" class="img-thumbnail" style="max-height: 80px; object-fit: cover;"/>';
|
|
||||||
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 > maxSize) {
|
|
||||||
alert('Video file is too large. Maximum size is 100MB.');
|
|
||||||
document.getElementById('assessment_video').value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show preview
|
|
||||||
if (videoPlayer && 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 < 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 < 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 > 0) {
|
|
||||||
data.top_landing_photos = topLandingPhotos.filter(function(p) { return p !== null; });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add bottom landing photos (curved stair lift)
|
|
||||||
if (bottomLandingPhotos.length > 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 = '<i class="fa fa-spinner fa-spin"></i> 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 && 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>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
|
||||||
@@ -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&model=fusion.pdf.template&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>
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -7,4 +7,5 @@ from . import adp_document
|
|||||||
from . import assessment
|
from . import assessment
|
||||||
from . import accessibility_assessment
|
from . import accessibility_assessment
|
||||||
from . import sale_order
|
from . import sale_order
|
||||||
|
from . import loaner_checkout
|
||||||
from . import pdf_template
|
from . import pdf_template
|
||||||
@@ -1302,8 +1302,14 @@ class FusionAssessment(models.Model):
|
|||||||
add_row('Legrest Length', f'{self.legrest_length}"')
|
add_row('Legrest Length', f'{self.legrest_length}"')
|
||||||
if self.seat_to_floor_height:
|
if self.seat_to_floor_height:
|
||||||
add_row('Seat to Floor Height', f'{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:
|
if self.back_height:
|
||||||
add_row('Back Height', f'{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:
|
if self.seatbelt_type:
|
||||||
belt_labels = dict(self._fields['seatbelt_type'].selection)
|
belt_labels = dict(self._fields['seatbelt_type'].selection)
|
||||||
@@ -1323,6 +1329,12 @@ class FusionAssessment(models.Model):
|
|||||||
if opt:
|
if opt:
|
||||||
add_row(opt, 'Yes')
|
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
|
# Additional customization notes
|
||||||
if self.additional_customization:
|
if self.additional_customization:
|
||||||
add_row('Additional Information/Customization', self.additional_customization)
|
add_row('Additional Information/Customization', self.additional_customization)
|
||||||
|
|||||||
27
fusion_authorizer_portal/models/loaner_checkout.py
Normal file
27
fusion_authorizer_portal/models/loaner_checkout.py
Normal 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,
|
||||||
|
}
|
||||||
@@ -235,6 +235,7 @@ class FusionPdfTemplate(models.Model):
|
|||||||
'field_type': field.field_type,
|
'field_type': field.field_type,
|
||||||
'font_size': field.font_size,
|
'font_size': field.font_size,
|
||||||
'font_name': field.font_name or 'Helvetica',
|
'font_name': field.font_name or 'Helvetica',
|
||||||
|
'text_align': field.text_align or 'left',
|
||||||
})
|
})
|
||||||
|
|
||||||
return PDFTemplateFiller.fill_template(
|
return PDFTemplateFiller.fill_template(
|
||||||
@@ -307,6 +308,11 @@ class FusionPdfTemplateField(models.Model):
|
|||||||
('Courier', 'Courier'),
|
('Courier', 'Courier'),
|
||||||
('Times-Roman', 'Times Roman'),
|
('Times-Roman', 'Times Roman'),
|
||||||
], string='Font', default='Helvetica')
|
], string='Font', default='Helvetica')
|
||||||
|
text_align = fields.Selection([
|
||||||
|
('left', 'Left'),
|
||||||
|
('center', 'Center'),
|
||||||
|
('right', 'Right'),
|
||||||
|
], string='Text Alignment', default='left')
|
||||||
|
|
||||||
# Data mapping
|
# Data mapping
|
||||||
field_key = fields.Char(
|
field_key = fields.Char(
|
||||||
|
|||||||
@@ -339,7 +339,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.assessment-express-form .card-header.bg-primary {
|
.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;
|
border-radius: 12px 12px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,7 +391,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.assessment-express-form .progress-bar {
|
.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 */
|
/* Section Separators */
|
||||||
@@ -415,7 +415,7 @@
|
|||||||
|
|
||||||
/* New Assessment Card on Portal Home */
|
/* New Assessment Card on Portal Home */
|
||||||
.portal-new-assessment-card {
|
.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;
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,7 +450,7 @@
|
|||||||
|
|
||||||
/* Authorizer Portal Card on Portal Home */
|
/* Authorizer Portal Card on Portal Home */
|
||||||
.portal-authorizer-card {
|
.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;
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,7 +522,7 @@
|
|||||||
|
|
||||||
/* Welcome Header */
|
/* Welcome Header */
|
||||||
.auth-dash-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;
|
border-radius: 16px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
|
|
||||||
var templateId = parseInt(editor.dataset.templateId);
|
var templateId = parseInt(editor.dataset.templateId);
|
||||||
var pageCount = parseInt(editor.dataset.pageCount) || 1;
|
var pageCount = parseInt(editor.dataset.pageCount) || 1;
|
||||||
|
var templateCategory = editor.dataset.category || 'other';
|
||||||
var currentPage = 1;
|
var currentPage = 1;
|
||||||
var fields = {};
|
var fields = {};
|
||||||
var selectedFieldId = null;
|
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: [
|
{ group: 'Client Info', keys: [
|
||||||
{ key: 'client_last_name', label: 'Last Name' },
|
{ key: 'client_last_name', label: 'Last Name' },
|
||||||
{ key: 'client_first_name', label: 'First Name' },
|
{ key: 'client_first_name', label: 'First Name' },
|
||||||
{ key: 'client_middle_name', label: 'Middle Name' },
|
{ key: 'client_middle_name', label: 'Middle Name' },
|
||||||
{ key: 'client_name', label: 'Full 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_street', label: 'Street' },
|
||||||
{ key: 'client_unit', label: 'Unit/Apt' },
|
{ key: 'client_unit', label: 'Unit/Apt' },
|
||||||
{ key: 'client_city', label: 'City' },
|
{ key: 'client_city', label: 'City' },
|
||||||
@@ -48,27 +47,35 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
{ key: 'client_postal_code', label: 'Postal Code' },
|
{ key: 'client_postal_code', label: 'Postal Code' },
|
||||||
{ key: 'client_phone', label: 'Phone' },
|
{ key: 'client_phone', label: 'Phone' },
|
||||||
{ key: 'client_email', label: 'Email' },
|
{ 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)' },
|
{ key: 'client_weight', label: 'Weight (lbs)' },
|
||||||
]},
|
]},
|
||||||
{ group: 'Client Type', keys: [
|
{ group: 'ADP - Client Type', keys: [
|
||||||
{ key: 'client_type_reg', label: 'REG Checkbox' },
|
{ key: 'client_type_reg', label: 'REG Checkbox' },
|
||||||
{ key: 'client_type_ods', label: 'ODS Checkbox' },
|
{ key: 'client_type_ods', label: 'ODS Checkbox' },
|
||||||
{ key: 'client_type_acs', label: 'ACS Checkbox' },
|
{ key: 'client_type_acs', label: 'ACS Checkbox' },
|
||||||
{ key: 'client_type_owp', label: 'OWP Checkbox' },
|
{ key: 'client_type_owp', label: 'OWP Checkbox' },
|
||||||
]},
|
]},
|
||||||
{ group: 'Consent', keys: [
|
{ group: 'ADP - Consent', keys: [
|
||||||
{ key: 'consent_applicant', label: 'Applicant Checkbox' },
|
{ key: 'consent_applicant', label: 'Applicant Checkbox' },
|
||||||
{ key: 'consent_agent', label: 'Agent Checkbox' },
|
{ key: 'consent_agent', label: 'Agent Checkbox' },
|
||||||
{ key: 'consent_date', label: 'Consent Date' },
|
{ key: 'consent_date', label: 'Consent Date' },
|
||||||
]},
|
]},
|
||||||
{ group: 'Agent Relationship', keys: [
|
{ group: 'ADP - Agent Relationship', keys: [
|
||||||
{ key: 'agent_rel_spouse', label: 'Spouse Checkbox' },
|
{ key: 'agent_rel_spouse', label: 'Spouse Checkbox' },
|
||||||
{ key: 'agent_rel_parent', label: 'Parent Checkbox' },
|
{ key: 'agent_rel_parent', label: 'Parent Checkbox' },
|
||||||
{ key: 'agent_rel_child', label: 'Child Checkbox' },
|
{ key: 'agent_rel_child', label: 'Child Checkbox' },
|
||||||
{ key: 'agent_rel_poa', label: 'POA Checkbox' },
|
{ key: 'agent_rel_poa', label: 'POA Checkbox' },
|
||||||
{ key: 'agent_rel_guardian', label: 'Guardian Checkbox' },
|
{ key: 'agent_rel_guardian', label: 'Guardian Checkbox' },
|
||||||
]},
|
]},
|
||||||
{ group: 'Agent Info', keys: [
|
{ group: 'ADP - Agent Info', keys: [
|
||||||
{ key: 'agent_last_name', label: 'Agent Last Name' },
|
{ key: 'agent_last_name', label: 'Agent Last Name' },
|
||||||
{ key: 'agent_first_name', label: 'Agent First Name' },
|
{ key: 'agent_first_name', label: 'Agent First Name' },
|
||||||
{ key: 'agent_middle_initial', label: 'Agent Middle Initial' },
|
{ key: 'agent_middle_initial', label: 'Agent Middle Initial' },
|
||||||
@@ -82,7 +89,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
{ key: 'agent_business_phone', label: 'Agent Business Phone' },
|
{ key: 'agent_business_phone', label: 'Agent Business Phone' },
|
||||||
{ key: 'agent_phone_ext', label: 'Agent Phone Ext' },
|
{ key: 'agent_phone_ext', label: 'Agent Phone Ext' },
|
||||||
]},
|
]},
|
||||||
{ group: 'Equipment', keys: [
|
{ group: 'ADP - Equipment', keys: [
|
||||||
{ key: 'equipment_type', label: 'Equipment Type' },
|
{ key: 'equipment_type', label: 'Equipment Type' },
|
||||||
{ key: 'seat_width', label: 'Seat Width' },
|
{ key: 'seat_width', label: 'Seat Width' },
|
||||||
{ key: 'seat_depth', label: 'Seat Depth' },
|
{ key: 'seat_depth', label: 'Seat Depth' },
|
||||||
@@ -91,25 +98,74 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
{ key: 'legrest_length', label: 'Legrest Length' },
|
{ key: 'legrest_length', label: 'Legrest Length' },
|
||||||
{ key: 'cane_height', label: 'Cane Height' },
|
{ key: 'cane_height', label: 'Cane Height' },
|
||||||
]},
|
]},
|
||||||
{ group: 'Dates', keys: [
|
{ group: 'ADP - Dates', keys: [
|
||||||
{ key: 'assessment_start_date', label: 'Assessment Start Date' },
|
{ key: 'assessment_start_date', label: 'Assessment Start Date' },
|
||||||
{ key: 'assessment_end_date', label: 'Assessment End Date' },
|
{ key: 'assessment_end_date', label: 'Assessment End Date' },
|
||||||
{ key: 'claim_authorization_date', label: 'Authorization Date' },
|
{ key: 'claim_authorization_date', label: 'Authorization Date' },
|
||||||
]},
|
]},
|
||||||
{ group: 'Authorizer', keys: [
|
{ group: 'ADP - Authorizer', keys: [
|
||||||
{ key: 'authorizer_name', label: 'Authorizer Name' },
|
{ key: 'authorizer_name', label: 'Authorizer Name' },
|
||||||
{ key: 'authorizer_phone', label: 'Authorizer Phone' },
|
{ key: 'authorizer_phone', label: 'Authorizer Phone' },
|
||||||
{ key: 'authorizer_email', label: 'Authorizer Email' },
|
{ key: 'authorizer_email', label: 'Authorizer Email' },
|
||||||
]},
|
]},
|
||||||
{ group: 'Signatures', keys: [
|
{ group: 'ADP - Signatures', keys: [
|
||||||
{ key: 'signature_page_11', label: 'Page 11 Signature' },
|
{ key: 'signature_page_11', label: 'Page 11 Signature' },
|
||||||
{ key: 'signature_page_12', label: 'Page 12 Signature' },
|
{ key: 'signature_page_12', label: 'Page 12 Signature' },
|
||||||
]},
|
]},
|
||||||
{ group: 'Other', keys: [
|
{ group: 'ADP - Other', keys: [
|
||||||
{ key: 'reference', label: 'Assessment Reference' },
|
{ key: 'reference', label: 'Assessment Reference' },
|
||||||
{ key: 'reason_for_application', label: 'Reason for Application' },
|
{ 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
|
// Build a flat lookup: key -> label
|
||||||
var KEY_LABELS = {};
|
var KEY_LABELS = {};
|
||||||
@@ -172,6 +228,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
setupPaletteDrag();
|
setupPaletteDrag();
|
||||||
setupContainerDrop();
|
setupContainerDrop();
|
||||||
setupPreviewButton();
|
setupPreviewButton();
|
||||||
|
buildDataKeysSidebar();
|
||||||
|
|
||||||
// Prevent the image from intercepting drag events
|
// Prevent the image from intercepting drag events
|
||||||
if (pageImage) {
|
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
|
// 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>'
|
+ ' <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>'
|
+ ' <input type="text" class="form-control form-control-sm" value="' + round3(field.pos_y) + '" readonly/></div>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<button type="button" class="btn btn-primary btn-sm w-100 mb-2" id="btn_save_props">'
|
+ '<div class="mb-2">'
|
||||||
+ ' <i class="fa fa-save me-1"/>Save</button>'
|
+ ' <label class="form-label fw-bold small mb-0">Text Align</label>'
|
||||||
+ '<button type="button" class="btn btn-outline-danger btn-sm w-100" id="btn_delete_field">'
|
+ ' <div class="btn-group w-100" role="group">'
|
||||||
+ ' <i class="fa fa-trash me-1"/>Delete</button>';
|
+ ' <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
|
// Auto-fill name and label when data key is selected
|
||||||
document.getElementById('prop_field_key').addEventListener('change', function () {
|
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 () {
|
document.getElementById('btn_save_props').addEventListener('click', function () {
|
||||||
var keySelect = document.getElementById('prop_field_key');
|
var keySelect = document.getElementById('prop_field_key');
|
||||||
var selectedKey = keySelect ? keySelect.value : '';
|
var selectedKey = keySelect ? keySelect.value : '';
|
||||||
|
var activeAlign = panel.querySelector('[data-align].active');
|
||||||
var vals = {
|
var vals = {
|
||||||
name: val('prop_name'),
|
name: val('prop_name'),
|
||||||
label: val('prop_label'),
|
label: val('prop_label'),
|
||||||
@@ -533,6 +622,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
page: parseInt(val('prop_page')) || 1,
|
page: parseInt(val('prop_page')) || 1,
|
||||||
width: parseFloat(val('prop_width')) || 0.15,
|
width: parseFloat(val('prop_width')) || 0.15,
|
||||||
height: parseFloat(val('prop_height')) || 0.015,
|
height: parseFloat(val('prop_height')) || 0.015,
|
||||||
|
text_align: activeAlign ? activeAlign.dataset.align : 'left',
|
||||||
};
|
};
|
||||||
Object.assign(field, vals);
|
Object.assign(field, vals);
|
||||||
saveField(fieldId, vals);
|
saveField(fieldId, vals);
|
||||||
|
|||||||
@@ -109,13 +109,25 @@ class PDFTemplateFiller:
|
|||||||
|
|
||||||
if field_type in ('text', 'date'):
|
if field_type in ('text', 'date'):
|
||||||
c.setFont(font_name, font_size)
|
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':
|
elif field_type == 'checkbox':
|
||||||
if value: # Only draw if truthy
|
if value:
|
||||||
# Checkmark using ZapfDingbats (same as sign module)
|
|
||||||
c.setFont('ZapfDingbats', font_size)
|
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':
|
elif field_type == 'signature':
|
||||||
sig_data = signatures.get(field_key)
|
sig_data = signatures.get(field_key)
|
||||||
|
|||||||
@@ -6,15 +6,26 @@
|
|||||||
<field name="name">fusion.assessment.tree</field>
|
<field name="name">fusion.assessment.tree</field>
|
||||||
<field name="model">fusion.assessment</field>
|
<field name="model">fusion.assessment</field>
|
||||||
<field name="arch" type="xml">
|
<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="reference"/>
|
||||||
<field name="client_name"/>
|
<field name="client_name"/>
|
||||||
|
<field name="equipment_type" optional="show"/>
|
||||||
|
<field name="client_type" optional="show"/>
|
||||||
<field name="assessment_date"/>
|
<field name="assessment_date"/>
|
||||||
<field name="sales_rep_id"/>
|
<field name="sales_rep_id"/>
|
||||||
<field name="authorizer_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="reason_for_application" optional="hide"/>
|
||||||
<field name="signatures_complete" widget="boolean"/>
|
<field name="state" widget="badge"
|
||||||
<field name="sale_order_id"/>
|
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>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
@@ -26,124 +37,310 @@
|
|||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<form string="Assessment">
|
<form string="Assessment">
|
||||||
<header>
|
<header>
|
||||||
<button name="action_mark_pending_signature" type="object" string="Mark Pending Signature" class="btn-primary" invisible="state != 'draft'"/>
|
<button name="action_mark_pending_signature" type="object"
|
||||||
<button name="action_complete" type="object" string="Complete Assessment" class="btn-success" invisible="state not in ['draft', 'pending_signature']"/>
|
string="Mark Pending Signature" class="btn-primary"
|
||||||
<button name="action_cancel" type="object" string="Cancel" invisible="state in ['completed', 'cancelled']"/>
|
invisible="state != 'draft'"/>
|
||||||
<button name="action_reset_draft" type="object" string="Reset to Draft" invisible="state != 'cancelled'"/>
|
<button name="action_complete" type="object"
|
||||||
<field name="state" widget="statusbar" statusbar_visible="draft,pending_signature,completed"/>
|
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>
|
</header>
|
||||||
<sheet>
|
<sheet>
|
||||||
<div class="oe_button_box" name="button_box">
|
<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"/>
|
<field name="document_count" string="Documents" widget="statinfo"/>
|
||||||
</button>
|
</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>
|
<span class="o_stat_text">Sale Order</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
<h1>
|
||||||
<field name="reference" readonly="1"/>
|
<field name="reference" readonly="1" class="me-3"/>
|
||||||
</h1>
|
</h1>
|
||||||
|
<h2 class="text-muted" invisible="not client_name">
|
||||||
|
<field name="client_name" readonly="1"/>
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ TOP SUMMARY ============ -->
|
||||||
<group>
|
<group>
|
||||||
<group string="Client Information">
|
<group string="Equipment">
|
||||||
<field name="client_name"/>
|
<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="Assessment Info">
|
||||||
|
<field name="assessment_date"/>
|
||||||
|
<field name="assessment_location"/>
|
||||||
|
<field name="assessment_location_notes"/>
|
||||||
|
<field name="sales_rep_id"/>
|
||||||
|
<field name="authorizer_id"/>
|
||||||
|
<field name="sale_order_id" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<notebook>
|
||||||
|
|
||||||
|
<!-- ============ CLIENT INFORMATION ============ -->
|
||||||
|
<page string="Client" name="client_info">
|
||||||
|
<group>
|
||||||
|
<group string="Personal Details">
|
||||||
<field name="client_first_name"/>
|
<field name="client_first_name"/>
|
||||||
|
<field name="client_middle_name"/>
|
||||||
<field name="client_last_name"/>
|
<field name="client_last_name"/>
|
||||||
|
<field name="client_dob"/>
|
||||||
<field name="client_phone"/>
|
<field name="client_phone"/>
|
||||||
<field name="client_mobile"/>
|
<field name="client_mobile"/>
|
||||||
<field name="client_email"/>
|
<field name="client_email" widget="email"/>
|
||||||
<field name="client_dob"/>
|
|
||||||
<field name="client_health_card"/>
|
|
||||||
</group>
|
</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">
|
<group string="Address">
|
||||||
<field name="client_street"/>
|
<field name="client_street"/>
|
||||||
|
<field name="client_unit"/>
|
||||||
<field name="client_city"/>
|
<field name="client_city"/>
|
||||||
<field name="client_state"/>
|
<field name="client_state"/>
|
||||||
<field name="client_postal_code"/>
|
<field name="client_postal_code"/>
|
||||||
<field name="client_country_id"/>
|
<field name="client_country_id"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
<group string="References & Linking">
|
||||||
|
<field name="client_reference_1"/>
|
||||||
<group>
|
<field name="client_reference_2"/>
|
||||||
<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="partner_id"/>
|
||||||
<field name="create_new_partner"/>
|
<field name="create_new_partner"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<!-- ============ MEASUREMENTS & SPECS ============ -->
|
||||||
|
<page string="Measurements" name="measurements">
|
||||||
|
|
||||||
|
<!-- Rollator Measurements -->
|
||||||
|
<group string="Rollator Measurements"
|
||||||
|
invisible="equipment_type != 'rollator'">
|
||||||
<group>
|
<group>
|
||||||
<group string="Client References">
|
<field name="rollator_handle_height"/>
|
||||||
<field name="client_reference_1"/>
|
<field name="rollator_seat_height"/>
|
||||||
<field name="client_reference_2"/>
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="rollator_addons" placeholder="e.g. Basket, Tray, Backrest pad..."/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
<notebook>
|
<!-- Wheelchair / Powerchair Measurements -->
|
||||||
<page string="Wheelchair Specifications" name="specs">
|
<group string="Seat Measurements"
|
||||||
|
invisible="equipment_type not in ['wheelchair', 'powerchair']">
|
||||||
<group>
|
<group>
|
||||||
<group string="Seat Measurements">
|
|
||||||
<field name="seat_width"/>
|
<field name="seat_width"/>
|
||||||
<field name="seat_depth"/>
|
<field name="seat_depth"/>
|
||||||
<field name="seat_to_floor_height"/>
|
<field name="seat_to_floor_height"/>
|
||||||
<field name="seat_angle"/>
|
<field name="seat_angle"/>
|
||||||
</group>
|
</group>
|
||||||
<group string="Back & Arms">
|
<group>
|
||||||
<field name="back_height"/>
|
<field name="back_height"/>
|
||||||
<field name="back_angle"/>
|
<field name="back_angle"/>
|
||||||
<field name="armrest_height"/>
|
<field name="armrest_height"/>
|
||||||
<field name="footrest_length"/>
|
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
|
<group string="Leg & 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>
|
<group>
|
||||||
<group string="Overall Dimensions">
|
|
||||||
<field name="overall_width"/>
|
<field name="overall_width"/>
|
||||||
<field name="overall_length"/>
|
<field name="overall_length"/>
|
||||||
<field name="overall_height"/>
|
<field name="overall_height"/>
|
||||||
</group>
|
</group>
|
||||||
<group string="Client Measurements">
|
</group>
|
||||||
<field name="client_weight"/>
|
|
||||||
<field name="client_height"/>
|
</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>
|
||||||
</group>
|
</group>
|
||||||
</page>
|
</page>
|
||||||
|
|
||||||
|
<!-- ============ PRODUCT TYPES ============ -->
|
||||||
<page string="Product Types" name="products">
|
<page string="Product Types" name="products">
|
||||||
<group>
|
<group>
|
||||||
<group>
|
<group string="Cushion">
|
||||||
<field name="cushion_type"/>
|
<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_type"/>
|
||||||
<field name="backrest_notes"/>
|
<field name="backrest_notes" placeholder="Backrest details..."
|
||||||
|
invisible="not backrest_type"/>
|
||||||
|
</group>
|
||||||
</group>
|
</group>
|
||||||
<group>
|
<group>
|
||||||
|
<group string="Frame">
|
||||||
<field name="frame_type"/>
|
<field name="frame_type"/>
|
||||||
<field name="frame_notes"/>
|
<field name="frame_notes" placeholder="Frame details..."
|
||||||
|
invisible="not frame_type"/>
|
||||||
|
</group>
|
||||||
|
<group string="Wheels">
|
||||||
<field name="wheel_type"/>
|
<field name="wheel_type"/>
|
||||||
<field name="wheel_notes"/>
|
<field name="wheel_notes" placeholder="Wheel details..."
|
||||||
|
invisible="not wheel_type"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
</page>
|
</page>
|
||||||
|
|
||||||
<page string="Needs & Requirements" name="needs">
|
<!-- ============ CLINICAL NOTES ============ -->
|
||||||
|
<page string="Clinical Notes" name="needs">
|
||||||
<group>
|
<group>
|
||||||
<field name="diagnosis"/>
|
<group>
|
||||||
<field name="mobility_notes"/>
|
<field name="diagnosis" placeholder="Relevant medical diagnosis or conditions..."/>
|
||||||
<field name="accessibility_notes"/>
|
</group>
|
||||||
<field name="special_requirements"/>
|
<group>
|
||||||
|
<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>
|
</group>
|
||||||
</page>
|
</page>
|
||||||
|
|
||||||
|
<!-- ============ KEY DATES ============ -->
|
||||||
|
<page string="Dates" name="dates">
|
||||||
|
<group>
|
||||||
|
<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 & 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">
|
<page string="Signatures" name="signatures">
|
||||||
<group>
|
<group>
|
||||||
<group string="Page 11 - Authorizer Signature">
|
<group string="Page 11 - Authorizer Signature">
|
||||||
@@ -158,13 +355,17 @@
|
|||||||
</group>
|
</group>
|
||||||
</group>
|
</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>
|
</group>
|
||||||
</page>
|
</page>
|
||||||
|
|
||||||
|
<!-- ============ DOCUMENTS ============ -->
|
||||||
<page string="Documents" name="documents">
|
<page string="Documents" name="documents">
|
||||||
<field name="document_ids">
|
<field name="document_ids">
|
||||||
<list string="Documents">
|
<list string="Documents" editable="bottom">
|
||||||
<field name="document_type"/>
|
<field name="document_type"/>
|
||||||
<field name="filename"/>
|
<field name="filename"/>
|
||||||
<field name="revision"/>
|
<field name="revision"/>
|
||||||
@@ -174,26 +375,20 @@
|
|||||||
</field>
|
</field>
|
||||||
</page>
|
</page>
|
||||||
|
|
||||||
|
<!-- ============ COMMENTS ============ -->
|
||||||
<page string="Comments" name="comments">
|
<page string="Comments" name="comments">
|
||||||
<field name="comment_ids">
|
<field name="comment_ids">
|
||||||
<list string="Comments">
|
<list string="Comments" editable="bottom">
|
||||||
<field name="create_date"/>
|
<field name="create_date" string="Date"/>
|
||||||
<field name="author_id"/>
|
<field name="author_id"/>
|
||||||
<field name="comment"/>
|
<field name="comment"/>
|
||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
</page>
|
</page>
|
||||||
</notebook>
|
|
||||||
|
|
||||||
<group invisible="not sale_order_id">
|
</notebook>
|
||||||
<field name="sale_order_id"/>
|
|
||||||
</group>
|
|
||||||
</sheet>
|
</sheet>
|
||||||
<div class="oe_chatter">
|
<chatter/>
|
||||||
<field name="message_follower_ids"/>
|
|
||||||
<field name="activity_ids"/>
|
|
||||||
<field name="message_ids"/>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
@@ -207,8 +402,10 @@
|
|||||||
<field name="reference"/>
|
<field name="reference"/>
|
||||||
<field name="client_name"/>
|
<field name="client_name"/>
|
||||||
<field name="client_email"/>
|
<field name="client_email"/>
|
||||||
|
<field name="client_health_card"/>
|
||||||
<field name="sales_rep_id"/>
|
<field name="sales_rep_id"/>
|
||||||
<field name="authorizer_id"/>
|
<field name="authorizer_id"/>
|
||||||
|
<field name="sale_order_id"/>
|
||||||
<separator/>
|
<separator/>
|
||||||
<filter string="In Progress" name="draft" domain="[('state', '=', 'draft')]"/>
|
<filter string="In Progress" name="draft" domain="[('state', '=', 'draft')]"/>
|
||||||
<filter string="Pending Signature" name="pending" domain="[('state', '=', 'pending_signature')]"/>
|
<filter string="Pending Signature" name="pending" domain="[('state', '=', 'pending_signature')]"/>
|
||||||
@@ -216,11 +413,19 @@
|
|||||||
<filter string="Cancelled" name="cancelled" domain="[('state', '=', 'cancelled')]"/>
|
<filter string="Cancelled" name="cancelled" domain="[('state', '=', 'cancelled')]"/>
|
||||||
<separator/>
|
<separator/>
|
||||||
<filter string="My Assessments" name="my_assessments" domain="[('sales_rep_id', '=', uid)]"/>
|
<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/>
|
<separator/>
|
||||||
<filter string="Status" name="group_state" context="{'group_by': 'state'}"/>
|
<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="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="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>
|
</search>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
@@ -237,8 +442,8 @@
|
|||||||
Create your first assessment
|
Create your first assessment
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Assessments are used to record wheelchair specifications and client needs.
|
Assessments record wheelchair, powerchair, and rollator specifications
|
||||||
Once completed, they will create a draft sale order for review.
|
along with client needs. Once completed, a draft sale order is created.
|
||||||
</p>
|
</p>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|||||||
25
fusion_authorizer_portal/views/loaner_checkout_views.xml
Normal file
25
fusion_authorizer_portal/views/loaner_checkout_views.xml
Normal 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>
|
||||||
@@ -1310,48 +1310,42 @@
|
|||||||
handleReasonChange();
|
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() {
|
function showEquipmentForm() {
|
||||||
var value = equipmentSelect ? equipmentSelect.value : '';
|
var value = equipmentSelect ? equipmentSelect.value : '';
|
||||||
|
|
||||||
// Hide all forms and disable required on hidden fields
|
disableFormInputs(rollatorForm);
|
||||||
if (rollatorForm) {
|
disableFormInputs(wheelchairForm);
|
||||||
rollatorForm.style.display = 'none';
|
disableFormInputs(powerchairForm);
|
||||||
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';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show selected form and restore required
|
if (value === 'rollator') {
|
||||||
if (value === 'rollator' && rollatorForm) {
|
enableFormInputs(rollatorForm);
|
||||||
rollatorForm.style.display = 'block';
|
} else if (value === 'wheelchair') {
|
||||||
rollatorForm.querySelectorAll('[data-was-required]').forEach(function(el) {
|
enableFormInputs(wheelchairForm);
|
||||||
el.setAttribute('required', 'required');
|
} else if (value === 'powerchair') {
|
||||||
});
|
enableFormInputs(powerchairForm);
|
||||||
} else if (value === 'wheelchair' && wheelchairForm) {
|
|
||||||
wheelchairForm.style.display = 'block';
|
|
||||||
wheelchairForm.querySelectorAll('[data-was-required]').forEach(function(el) {
|
|
||||||
el.setAttribute('required', 'required');
|
|
||||||
});
|
|
||||||
} else if (value === 'powerchair' && powerchairForm) {
|
|
||||||
powerchairForm.style.display = 'block';
|
|
||||||
powerchairForm.querySelectorAll('[data-was-required]').forEach(function(el) {
|
|
||||||
el.setAttribute('required', 'required');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
<div class="container-fluid py-3" id="pdf_field_editor"
|
<div class="container-fluid py-3" id="pdf_field_editor"
|
||||||
t-att-data-template-id="template.id"
|
t-att-data-template-id="template.id"
|
||||||
t-att-data-page-count="template.page_count or 1"
|
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 -->
|
<!-- Header Bar -->
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
@@ -96,7 +97,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Data Keys Reference (collapsible) -->
|
<!-- Data Keys Reference (collapsible, populated by JS) -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header py-2" style="cursor: pointer;"
|
<div class="card-header py-2" style="cursor: pointer;"
|
||||||
data-bs-toggle="collapse" data-bs-target="#dataKeysCollapse">
|
data-bs-toggle="collapse" data-bs-target="#dataKeysCollapse">
|
||||||
@@ -106,36 +107,8 @@
|
|||||||
</h6>
|
</h6>
|
||||||
</div>
|
</div>
|
||||||
<div id="dataKeysCollapse" class="collapse">
|
<div id="dataKeysCollapse" class="collapse">
|
||||||
<div class="card-body p-2" style="max-height: 250px; overflow-y: auto; font-size: 11px;">
|
<div class="card-body p-2" id="dataKeysList"
|
||||||
<div class="mb-1"><strong>Client:</strong></div>
|
style="max-height: 300px; overflow-y: auto; font-size: 11px;">
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
322
fusion_authorizer_portal/views/portal_repair_form.xml
Normal file
322
fusion_authorizer_portal/views/portal_repair_form.xml
Normal 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>
|
||||||
@@ -171,12 +171,12 @@
|
|||||||
|
|
||||||
<!-- Quick Links -->
|
<!-- Quick Links -->
|
||||||
<div class="row g-2 mb-4">
|
<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">
|
<a href="/my/technician/tasks" class="btn btn-outline-primary w-100 py-3">
|
||||||
<i class="fa fa-list me-1"/>All Tasks
|
<i class="fa fa-list me-1"/>All Tasks
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-4">
|
||||||
<a href="/my/technician/tomorrow" class="btn btn-outline-secondary w-100 py-3">
|
<a href="/my/technician/tomorrow" class="btn btn-outline-secondary w-100 py-3">
|
||||||
<i class="fa fa-calendar me-1"/>Tomorrow
|
<i class="fa fa-calendar me-1"/>Tomorrow
|
||||||
<t t-if="tomorrow_count">
|
<t t-if="tomorrow_count">
|
||||||
@@ -184,6 +184,11 @@
|
|||||||
</t>
|
</t>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- My Start Location -->
|
<!-- My Start Location -->
|
||||||
|
|||||||
@@ -4,13 +4,17 @@
|
|||||||
<!-- ==================== PORTAL HOME EXTENSION ==================== -->
|
<!-- ==================== PORTAL HOME EXTENSION ==================== -->
|
||||||
|
|
||||||
<template id="portal_my_home_authorizer" inherit_id="portal.portal_my_home" priority="40">
|
<template id="portal_my_home_authorizer" inherit_id="portal.portal_my_home" priority="40">
|
||||||
<!-- Insert Fusion content at the very top, before alert category -->
|
<!-- Insert Fusion content before the default portal docs grid -->
|
||||||
<xpath expr="//div[@id='portal_alert_category']" position="before">
|
<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-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 -->
|
<!-- Welcome Banner -->
|
||||||
<div class="row mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-12">
|
<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="d-flex align-items-center text-white">
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<h4 class="mb-1" style="color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.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;">
|
<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="card-body d-flex align-items-center p-4">
|
||||||
<div class="me-3">
|
<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"/>
|
<i class="fa fa-wheelchair fa-lg text-white"/>
|
||||||
</div>
|
</div>
|
||||||
</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;">
|
<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="card-body d-flex align-items-center p-4">
|
||||||
<div class="me-3">
|
<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"/>
|
<i class="fa fa-briefcase fa-lg text-white"/>
|
||||||
</div>
|
</div>
|
||||||
</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;">
|
<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="card-body d-flex align-items-center p-4">
|
||||||
<div class="me-3">
|
<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"/>
|
<i class="fa fa-clipboard fa-lg text-white"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +135,7 @@
|
|||||||
</t>
|
</t>
|
||||||
<div class="card-body d-flex align-items-center p-4">
|
<div class="card-body d-flex align-items-center p-4">
|
||||||
<div class="me-3">
|
<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"/>
|
<i class="fa fa-pencil-square-o fa-lg text-white"/>
|
||||||
</div>
|
</div>
|
||||||
</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;">
|
<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="card-body d-flex align-items-center p-4">
|
||||||
<div class="me-3">
|
<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"/>
|
<i class="fa fa-truck fa-lg text-white"/>
|
||||||
</div>
|
</div>
|
||||||
</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;">
|
<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="card-body d-flex align-items-center p-4">
|
||||||
<div class="me-3">
|
<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"/>
|
<i class="fa fa-file-text-o fa-lg text-white"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,16 +210,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ADP Posting Schedule Card -->
|
<!-- 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="col-12">
|
||||||
<div class="card shadow-sm border-0 overflow-hidden" style="border-radius: 12px;">
|
<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>
|
<h5 class="mb-0 text-white"><i class="fa fa-calendar me-2"/>Upcoming ADP Posting Schedule</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body" style="background: linear-gradient(180deg, #f8f9fa 0%, #ffffff 100%);">
|
<div class="card-body" style="background: linear-gradient(180deg, #f8f9fa 0%, #ffffff 100%);">
|
||||||
<div class="row align-items-center">
|
<div class="row align-items-center">
|
||||||
<div class="col-md-4 text-center mb-3 mb-md-0">
|
<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>
|
<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>
|
<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>
|
<small style="color: rgba(255,255,255,0.8);"><t t-out="next_posting_weekday"/></small>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Claims',
|
'name': 'Fusion Claims',
|
||||||
'version': '19.0.5.0.0',
|
'version': '19.0.6.0.0',
|
||||||
'category': 'Sales',
|
'category': 'Sales',
|
||||||
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
|
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -77,13 +77,17 @@
|
|||||||
'sale',
|
'sale',
|
||||||
'sale_management',
|
'sale_management',
|
||||||
'sale_margin',
|
'sale_margin',
|
||||||
|
'purchase',
|
||||||
'account',
|
'account',
|
||||||
'sales_team',
|
'sales_team',
|
||||||
'stock',
|
'stock',
|
||||||
'calendar',
|
'calendar',
|
||||||
'ai',
|
'ai',
|
||||||
'fusion_faxes',
|
'fusion_ringcentral',
|
||||||
],
|
],
|
||||||
|
'external_dependencies': {
|
||||||
|
'python': ['pdf2image', 'PIL'],
|
||||||
|
},
|
||||||
'data': [
|
'data': [
|
||||||
'security/security.xml',
|
'security/security.xml',
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
@@ -123,20 +127,27 @@
|
|||||||
'wizard/odsp_submit_to_odsp_wizard_views.xml',
|
'wizard/odsp_submit_to_odsp_wizard_views.xml',
|
||||||
'wizard/odsp_pre_approved_wizard_views.xml',
|
'wizard/odsp_pre_approved_wizard_views.xml',
|
||||||
'wizard/odsp_ready_delivery_wizard_views.xml',
|
'wizard/odsp_ready_delivery_wizard_views.xml',
|
||||||
|
'wizard/ltc_repair_create_so_wizard_views.xml',
|
||||||
'views/res_partner_views.xml',
|
'views/res_partner_views.xml',
|
||||||
'views/pdf_template_inherit_views.xml',
|
'views/pdf_template_inherit_views.xml',
|
||||||
'views/dashboard_views.xml',
|
'views/dashboard_views.xml',
|
||||||
'views/client_profile_views.xml',
|
'views/client_profile_views.xml',
|
||||||
'wizard/xml_import_wizard_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/adp_claims_views.xml',
|
||||||
'views/submission_history_views.xml',
|
'views/submission_history_views.xml',
|
||||||
'views/fusion_loaner_views.xml',
|
'views/fusion_loaner_views.xml',
|
||||||
'views/technician_task_views.xml',
|
'views/technician_task_views.xml',
|
||||||
|
'views/task_sync_views.xml',
|
||||||
'views/technician_location_views.xml',
|
'views/technician_location_views.xml',
|
||||||
'report/report_actions.xml',
|
'report/report_actions.xml',
|
||||||
'report/report_templates.xml',
|
'report/report_templates.xml',
|
||||||
'report/sale_report_portrait.xml',
|
'report/sale_report_portrait.xml',
|
||||||
'report/sale_report_landscape.xml',
|
'report/sale_report_landscape.xml',
|
||||||
|
'report/sale_report_ltc_repair.xml',
|
||||||
'report/invoice_report_portrait.xml',
|
'report/invoice_report_portrait.xml',
|
||||||
'report/invoice_report_landscape.xml',
|
'report/invoice_report_landscape.xml',
|
||||||
'report/report_proof_of_delivery.xml',
|
'report/report_proof_of_delivery.xml',
|
||||||
@@ -147,6 +158,9 @@
|
|||||||
'report/report_accessibility_contract.xml',
|
'report/report_accessibility_contract.xml',
|
||||||
'report/report_mod_quotation.xml',
|
'report/report_mod_quotation.xml',
|
||||||
'report/report_mod_invoice.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/mail_template_data.xml',
|
||||||
'data/ai_agent_data.xml',
|
'data/ai_agent_data.xml',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -147,5 +147,17 @@
|
|||||||
<field name="value">1-888-222-5099</field>
|
<field name="value">1-888-222-5099</field>
|
||||||
</record>
|
</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>
|
</data>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -156,5 +156,28 @@
|
|||||||
<field name="interval_type">minutes</field>
|
<field name="interval_type">minutes</field>
|
||||||
<field name="active">True</field>
|
<field name="active">True</field>
|
||||||
</record>
|
</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>
|
</data>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
103
fusion_claims/data/ltc_data.xml
Normal file
103
fusion_claims/data/ltc_data.xml
Normal 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>
|
||||||
42
fusion_claims/data/ltc_report_data.xml
Normal file
42
fusion_claims/data/ltc_report_data.xml
Normal 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>
|
||||||
@@ -49,19 +49,9 @@
|
|||||||
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
|
<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>
|
<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>
|
</div>
|
||||||
<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:24px 0 0 0;">
|
<t t-if="not is_html_empty(object.user_id.signature)" data-o-mail-quote-container="1">
|
||||||
Best regards,<br/>
|
<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>
|
||||||
<strong><t t-out="object.user_id.name or object.company_id.name"/></strong><br/>
|
</t>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</field>
|
</field>
|
||||||
@@ -109,19 +99,9 @@
|
|||||||
<div style="border-left:3px solid #38a169;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
|
<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>
|
<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>
|
</div>
|
||||||
<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:24px 0 0 0;">
|
<t t-if="not is_html_empty(object.user_id.signature)" data-o-mail-quote-container="1">
|
||||||
Best regards,<br/>
|
<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>
|
||||||
<strong><t t-out="object.user_id.name or object.company_id.name"/></strong><br/>
|
</t>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</field>
|
</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>
|
<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>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:24px 0 0 0;">
|
<t t-set="sig" t-value="object.invoice_user_id.signature or object.user_id.signature"/>
|
||||||
Best regards,<br/>
|
<t t-if="not is_html_empty(sig)" data-o-mail-quote-container="1">
|
||||||
<strong><t t-out="object.invoice_user_id.name or object.company_id.name"/></strong><br/>
|
<div data-o-mail-quote="1">--<br data-o-mail-quote="1"/><t t-out="sig or ''" data-o-mail-quote="1"/></div>
|
||||||
<span style="color:#718096;"><t t-out="object.company_id.name"/></span>
|
</t>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</field>
|
</field>
|
||||||
|
|||||||
@@ -1,18 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<odoo>
|
<odoo>
|
||||||
<data noupdate="1">
|
<!-- ODSP SA Mobility signing template is created via the UI or odoo shell.
|
||||||
<record id="sa_sig_template_default" model="fusion.sa.signature.template">
|
It lives in fusion.pdf.template (category=odsp) with 3 fields:
|
||||||
<field name="name">SA Mobility Standard</field>
|
sa_client_name (text), sa_sign_date (date), sa_signature (signature)
|
||||||
<field name="notes">Default signature positions for SA Mobility approval forms from ODSP.</field>
|
Managed via Configuration > PDF Templates using the drag-and-drop editor. -->
|
||||||
<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>
|
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|
||||||
@@ -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.
|
|
||||||
@@ -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()
|
|
||||||
@@ -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',
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
Reference in New Issue
Block a user