This commit is contained in:
gsinghpal
2026-02-27 14:32:32 -05:00
parent b649246e81
commit b925766966
80 changed files with 7831 additions and 1041 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Claims',
'version': '19.0.7.0.0',
'version': '19.0.7.2.0',
'category': 'Sales',
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
'description': """
@@ -153,7 +153,8 @@
'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_approved_items.xml',
'report/report_grab_bar_waiver.xml',
'report/report_accessibility_contract.xml',
'report/report_mod_quotation.xml',
@@ -178,6 +179,7 @@
'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/js/debug_required_fields.js',
'fusion_claims/static/src/xml/document_preview.xml',
'fusion_claims/static/src/xml/fusion_task_map_view.xml',
],

View File

@@ -105,9 +105,11 @@ class AccountMove(models.Model):
try:
report = self.env.ref('fusion_claims.action_report_mod_invoice')
pdf_content, _ = report._render_qweb_pdf(report.id, [self.id])
client_name = (so.partner_id.name or 'Client').replace(' ', '_').replace(',', '')
name_parts = (so.partner_id.name or 'Client').strip().split()
first = name_parts[0] if name_parts else 'Client'
last = name_parts[-1] if len(name_parts) > 1 else ''
att = Attachment.create({
'name': f'Invoice - {client_name} - {self.name}.pdf',
'name': f'{first}_{last}_MOD_Invoice_{self.name}.pdf',
'type': 'binary',
'datas': base64.b64encode(pdf_content),
'res_model': 'account.move',

View File

@@ -2,8 +2,12 @@
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
import requests
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class ResPartner(models.Model):
_inherit = 'res.partner'
@@ -14,6 +18,65 @@ class ResPartner(models.Model):
'Used as origin for first travel time calculation. '
'If empty, the company default HQ address is used.',
)
x_fc_start_address_lat = fields.Float(
string='Start Latitude', digits=(10, 7),
)
x_fc_start_address_lng = fields.Float(
string='Start Longitude', digits=(10, 7),
)
def _geocode_start_address(self, address):
if not address or not address.strip():
return 0.0, 0.0
api_key = self.env['ir.config_parameter'].sudo().get_param(
'fusion_claims.google_maps_api_key', '')
if not api_key:
return 0.0, 0.0
try:
resp = requests.get(
'https://maps.googleapis.com/maps/api/geocode/json',
params={'address': address.strip(), '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']
return loc['lat'], loc['lng']
except Exception as e:
_logger.warning("Start address geocoding failed for '%s': %s", address, e)
return 0.0, 0.0
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
for rec, vals in zip(records, vals_list):
addr = vals.get('x_fc_start_address')
if addr:
lat, lng = rec._geocode_start_address(addr)
if lat and lng:
rec.write({
'x_fc_start_address_lat': lat,
'x_fc_start_address_lng': lng,
})
return records
def write(self, vals):
res = super().write(vals)
if 'x_fc_start_address' in vals:
addr = vals['x_fc_start_address']
if addr and addr.strip():
lat, lng = self._geocode_start_address(addr)
if lat and lng:
super().write({
'x_fc_start_address_lat': lat,
'x_fc_start_address_lng': lng,
})
else:
super().write({
'x_fc_start_address_lat': 0.0,
'x_fc_start_address_lng': 0.0,
})
return res
# ==========================================================================
# CONTACT TYPE

View File

@@ -22,7 +22,7 @@ class SaleOrder(models.Model):
for order in self:
name = order.name or ''
if order.partner_id and order.partner_id.name:
name = f"{name} -- {order.partner_id.name}"
name = f"{name} - {order.partner_id.name}"
order.display_name = name
# ==========================================================================
@@ -1318,17 +1318,12 @@ class SaleOrder(models.Model):
att_ids = []
att_names = []
# 1. Signed SA Form -- reuse existing attachment created by attachment=True
signed_field = 'x_fc_sa_signed_form' if self.x_fc_sa_signed_form else (
'x_fc_sa_physical_signed_copy' if self.x_fc_sa_physical_signed_copy else None)
if signed_field:
att = Attachment.search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
('res_field', '=', signed_field),
], order='id desc', limit=1)
if att:
att_ids.append(att.id)
att_id = self._get_and_prepare_field_attachment(signed_field, 'Signed SA Form')
if att_id:
att_ids.append(att_id)
att_names.append('Signed SA Form')
# 2. Internal POD -- generate on-the-fly from the standard report
@@ -1353,12 +1348,14 @@ class SaleOrder(models.Model):
try:
report = self.env.ref('account.account_invoices')
pdf_content, _ct = report._render_qweb_pdf(report.id, [invoice.id])
first, last = self._get_client_name_parts()
att = Attachment.create({
'name': f'Invoice_{invoice.name}.pdf',
'name': f'{first}_{last}_Invoice_{invoice.name}.pdf',
'type': 'binary',
'datas': base64.b64encode(pdf_content),
'res_model': 'sale.order',
'res_id': self.id,
'mimetype': 'application/pdf',
})
att_ids.append(att.id)
att_names.append(f'Invoice ({invoice.name})')
@@ -1388,16 +1385,10 @@ class SaleOrder(models.Model):
att_ids = []
att_names = []
# 1. Approval document
if self.x_fc_odsp_approval_document:
att = Attachment.search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
('res_field', '=', 'x_fc_odsp_approval_document'),
], order='id desc', limit=1)
if att:
att_ids.append(att.id)
att_names.append('ODSP Approval Document')
att_id = self._get_and_prepare_field_attachment('x_fc_odsp_approval_document', 'ODSP Approval Document')
if att_id:
att_ids.append(att_id)
att_names.append('ODSP Approval Document')
# 2. Internal POD
try:
@@ -1426,12 +1417,14 @@ class SaleOrder(models.Model):
try:
report = self.env.ref('account.account_invoices')
pdf_content, _ct = report._render_qweb_pdf(report.id, [invoice.id])
first, last = self._get_client_name_parts()
att = Attachment.create({
'name': f'Invoice_{invoice.name}.pdf',
'name': f'{first}_{last}_Invoice_{invoice.name}.pdf',
'type': 'binary',
'datas': base64.b64encode(pdf_content),
'res_model': 'sale.order',
'res_id': self.id,
'mimetype': 'application/pdf',
})
att_ids.append(att.id)
att_names.append(f'Invoice ({invoice.name})')
@@ -1680,44 +1673,6 @@ class SaleOrder(models.Model):
)
_logger.info("POD signature applied to approval form for %s", self.name)
# ==========================================================================
# DELIVERY STATUS FIELDS
# ==========================================================================
x_fc_delivery_status = fields.Selection(
selection=[
('waiting', 'Waiting'),
('waiting_approval', 'Waiting for Approval'),
('ready', 'Ready for Delivery'),
('scheduled', 'Delivery Scheduled'),
('shipped_warehouse', 'Shipped to Warehouse'),
('received_warehouse', 'Received in Warehouse'),
('delivered', 'Delivered'),
('hold', 'Hold'),
('cancelled', 'Cancelled'),
],
string='Delivery Status',
tracking=True,
help='Current delivery status of the order',
)
x_fc_delivery_datetime = fields.Datetime(
string='Delivery Date & Time',
tracking=True,
help='Scheduled or actual delivery date and time',
)
# Computed field to show/hide delivery datetime
x_fc_show_delivery_datetime = fields.Boolean(
compute='_compute_show_delivery_datetime',
string='Show Delivery DateTime',
)
@api.depends('x_fc_delivery_status')
def _compute_show_delivery_datetime(self):
"""Compute whether to show delivery datetime field."""
for order in self:
order.x_fc_show_delivery_datetime = order.x_fc_delivery_status in ('scheduled', 'delivered')
# ==========================================================================
# ADP CLAIM FIELDS
# ==========================================================================
@@ -1729,11 +1684,11 @@ class SaleOrder(models.Model):
)
x_fc_client_ref_1 = fields.Char(
string='Client Reference 1',
help='Primary client reference (e.g., Health Card Number)',
help='First two letters of the client\'s first name and last two letters of their last name. Example: John Doe = JODO',
)
x_fc_client_ref_2 = fields.Char(
string='Client Reference 2',
help='Secondary client reference',
help='Last four digits of the client\'s health card number. Example: 1234',
)
x_fc_adp_delivery_date = fields.Date(
string='ADP Delivery Date',
@@ -2988,10 +2943,136 @@ class SaleOrder(models.Model):
# ==========================================================================
# PDF DOCUMENT PREVIEW ACTIONS (opens in new tab using browser/system PDF handler)
# ==========================================================================
MIME_TO_EXT = {
'application/pdf': '.pdf',
'application/xml': '.xml',
'text/xml': '.xml',
'text/plain': '.txt',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
'application/msword': '.doc',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
'application/vnd.ms-excel': '.xls',
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'application/zip': '.zip',
'application/octet-stream': '',
}
FIELD_NAME_TEMPLATE = {
'x_fc_final_submitted_application': '{first}_{last}.pdf',
'x_fc_xml_file': '{first}_{last}_data.xml',
'x_fc_original_application': '{first}_{last}_Original_Application.pdf',
'x_fc_signed_pages_11_12': '{first}_{last}_Signed_Pages.pdf',
'x_fc_proof_of_delivery': '{first}_{last}_Proof_of_Delivery.pdf',
'x_fc_approval_letter': '{first}_{last}_Approval_Letter.pdf',
'x_fc_sa_signed_form': '{first}_{last}_SA_Form_Signed.pdf',
'x_fc_sa_physical_signed_copy': '{first}_{last}_SA_Form_Signed.pdf',
'x_fc_sa_approval_form': '{first}_{last}_SA_Approval.pdf',
'x_fc_odsp_approval_document': '{first}_{last}_ODSP_Approval.pdf',
'x_fc_odsp_authorizer_letter': '{first}_{last}_ODSP_Authorizer_Letter.pdf',
'x_fc_ow_discretionary_form': '{first}_{last}_OW_Discretionary_Form.pdf',
'x_fc_ow_authorizer_letter': '{first}_{last}_OW_Authorizer_Letter.pdf',
'x_fc_mod_drawing': '{first}_{last}_Drawing.pdf',
'x_fc_mod_initial_photos': '{first}_{last}_Initial_Photos.pdf',
'x_fc_mod_pca_document': '{first}_{last}_PCA_Document.pdf',
'x_fc_mod_proof_of_delivery': '{first}_{last}_Proof_of_Delivery.pdf',
'x_fc_mod_completion_photos': '{first}_{last}_Completion_Photos.pdf',
}
FIELD_FILENAME_MAP = {
'x_fc_original_application': 'x_fc_original_application_filename',
'x_fc_signed_pages_11_12': 'x_fc_signed_pages_filename',
'x_fc_final_submitted_application': 'x_fc_final_application_filename',
'x_fc_xml_file': 'x_fc_xml_filename',
'x_fc_proof_of_delivery': 'x_fc_proof_of_delivery_filename',
'x_fc_approval_letter': 'x_fc_approval_letter_filename',
'x_fc_sa_signed_form': 'x_fc_sa_signed_form_filename',
'x_fc_sa_physical_signed_copy': 'x_fc_sa_physical_signed_copy_filename',
'x_fc_sa_approval_form': 'x_fc_sa_approval_form_filename',
'x_fc_odsp_approval_document': 'x_fc_odsp_approval_document_filename',
'x_fc_odsp_authorizer_letter': 'x_fc_odsp_authorizer_letter_filename',
'x_fc_ow_discretionary_form': 'x_fc_ow_discretionary_form_filename',
'x_fc_ow_authorizer_letter': 'x_fc_ow_authorizer_letter_filename',
'x_fc_mod_drawing': 'x_fc_mod_drawing_filename',
'x_fc_mod_initial_photos': 'x_fc_mod_initial_photos_filename',
'x_fc_mod_pca_document': 'x_fc_mod_pca_filename',
'x_fc_mod_proof_of_delivery': 'x_fc_mod_pod_filename',
'x_fc_mod_completion_photos': 'x_fc_mod_completion_photos_filename',
}
def _get_ext_from_mime(self, mimetype):
"""Return a file extension (with dot) for a MIME type."""
return self.MIME_TO_EXT.get(mimetype or '', '')
def _get_client_name_parts(self):
"""Return (first_name, last_name) cleaned for filenames."""
full_name = (self.partner_id.name or 'Client').strip()
parts = full_name.split()
first = parts[0] if parts else 'Client'
last = parts[-1] if len(parts) > 1 else ''
clean = lambda s: s.replace(',', '').replace("'", '').replace('"', '')
return clean(first), clean(last)
def _build_attachment_name(self, field_name, mimetype=None):
"""Build the proper filename for a field attachment.
Uses FIELD_NAME_TEMPLATE for known fields with Firstname_Lastname convention.
For the XML file, respects the actual mimetype (could be .xml, .docx, .txt).
"""
first, last = self._get_client_name_parts()
template = self.FIELD_NAME_TEMPLATE.get(field_name)
if template:
name = template.format(first=first, last=last)
if field_name == 'x_fc_xml_file' and mimetype:
ext = self._get_ext_from_mime(mimetype)
if ext and ext != '.xml':
name = f'{first}_{last}_data{ext}'
return name
ext = self._get_ext_from_mime(mimetype) if mimetype else '.pdf'
return f'{first}_{last}_Document{ext}'
def _prepare_attachment_for_email(self, attachment, field_name=None, label=None):
"""Rename an attachment to a clean, professional filename.
Always renames to the standard convention (Firstname_Lastname pattern)
so recipients get consistently named files regardless of what was uploaded.
"""
if not attachment:
return None
new_name = self._build_attachment_name(field_name, attachment.mimetype)
if attachment.name == new_name:
return attachment.id
try:
attachment.sudo().write({'name': new_name})
except Exception:
_logger.warning("Could not rename attachment %s to %s", attachment.id, new_name)
return attachment.id
def _get_and_prepare_field_attachment(self, field_name, label=None):
"""Find the ir.attachment for a binary field, rename it properly, return its id.
Convenience wrapper combining _get_document_attachment + _prepare_attachment_for_email.
Returns None if the field has no data or attachment is not found.
"""
self.ensure_one()
if not getattr(self, field_name, None):
return None
attachment = self._get_document_attachment(field_name)
if not attachment:
return None
return self._prepare_attachment_for_email(attachment, field_name=field_name, label=label)
def _get_document_attachment(self, field_name):
"""Get the ir.attachment record for a binary field stored as attachment."""
self.ensure_one()
# Find the attachment by field name - get most recent one
attachment = self.env['ir.attachment'].sudo().search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
@@ -3001,9 +3082,9 @@ class SaleOrder(models.Model):
def _get_or_create_attachment(self, field_name, document_label):
"""Get the current attachment for a binary field (attachment=True).
For attachment=True fields, Odoo creates attachments automatically.
We find the one with res_field set and return it.
We find the one with res_field set, ensure it has a proper name, and return it.
"""
self.ensure_one()
@@ -3011,43 +3092,20 @@ class SaleOrder(models.Model):
if not data:
return None
# For attachment=True fields, Odoo creates/updates an attachment with res_field set
attachment = self.env['ir.attachment'].sudo().search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
('res_field', '=', field_name),
], order='id desc', limit=1)
if attachment:
# If attachment name is the field name (Odoo default), use the actual filename
if attachment.name == field_name:
filename_mapping = {
'x_fc_original_application': 'x_fc_original_application_filename',
'x_fc_signed_pages_11_12': 'x_fc_signed_pages_filename',
'x_fc_final_submitted_application': 'x_fc_final_application_filename',
'x_fc_xml_file': 'x_fc_xml_filename',
'x_fc_proof_of_delivery': 'x_fc_proof_of_delivery_filename',
}
filename_field = filename_mapping.get(field_name)
if filename_field:
filename = getattr(self, filename_field, None)
if filename and filename != field_name:
attachment.sudo().write({'name': filename})
self._prepare_attachment_for_email(attachment, field_name=field_name, label=document_label)
return attachment
# Fallback: create attachment manually (shouldn't happen for attachment=True fields)
filename_mapping = {
'x_fc_original_application': 'x_fc_original_application_filename',
'x_fc_signed_pages_11_12': 'x_fc_signed_pages_filename',
'x_fc_final_submitted_application': 'x_fc_final_application_filename',
'x_fc_xml_file': 'x_fc_xml_filename',
'x_fc_proof_of_delivery': 'x_fc_proof_of_delivery_filename',
}
filename_field = filename_mapping.get(field_name)
filename = getattr(self, filename_field) if filename_field else f'{document_label}.pdf'
filename = self._build_attachment_name(field_name)
attachment = self.env['ir.attachment'].sudo().create({
'name': filename or f'{document_label}.pdf',
'name': filename,
'datas': data,
'res_model': 'sale.order',
'res_id': self.id,
@@ -3399,16 +3457,20 @@ class SaleOrder(models.Model):
}
def action_complete_assessment(self):
"""Open wizard to mark assessment as completed with date."""
"""Open wizard to mark assessment as completed with date.
Allowed from 'quotation' (override) or 'assessment_scheduled' (normal flow)."""
self.ensure_one()
if self.x_fc_adp_application_status != 'assessment_scheduled':
raise UserError("Can only complete assessment from 'Assessment Scheduled' status.")
if self.x_fc_adp_application_status not in ('quotation', 'assessment_scheduled'):
raise UserError(
_("Can only complete assessment from 'Quotation' or 'Assessment Scheduled' status.")
)
return {
'name': 'Assessment Completed',
'type': 'ir.actions.act_window',
'res_model': 'fusion_claims.assessment.completed.wizard',
'view_mode': 'form',
'views': [(False, 'form')],
'target': 'new',
'context': {
'active_id': self.id,
@@ -4627,82 +4689,67 @@ class SaleOrder(models.Model):
# ==========================================================================
# DOCUMENT CHATTER POSTING
# ==========================================================================
def _post_document_to_chatter(self, field_name, document_label=None):
"""Post a document attachment to the chatter with a link.
def _post_document_to_chatter(self, field_name, document_label=None, preserve_copy=False):
"""Post a document to the chatter, reusing the existing field attachment.
By default, references the existing ir.attachment (created by Odoo for
attachment=True fields) instead of creating a duplicate.
Args:
field_name: The binary field name (e.g., 'x_fc_final_submitted_application')
document_label: Optional label for the document (defaults to field string)
preserve_copy: If True, creates a separate copy (used when the original
is about to be deleted/replaced and we need to keep a snapshot).
"""
self.ensure_one()
# Map field names to filename fields
filename_mapping = {
'x_fc_original_application': 'x_fc_original_application_filename',
'x_fc_signed_pages_11_12': 'x_fc_signed_pages_filename',
'x_fc_final_submitted_application': 'x_fc_final_application_filename',
'x_fc_xml_file': 'x_fc_xml_filename',
'x_fc_proof_of_delivery': 'x_fc_proof_of_delivery_filename',
}
data_field = field_name
filename_field = filename_mapping.get(field_name, field_name + '_filename')
data = getattr(self, data_field, None)
original_filename = getattr(self, filename_field, None) or 'document'
data = getattr(self, field_name, None)
if not data:
return
# Get document label from field definition if not provided
if not document_label:
field_obj = self._fields.get(data_field)
document_label = field_obj.string if field_obj else data_field
# Check for existing attachments with same name for revision numbering
existing_count = self.env['ir.attachment'].sudo().search_count([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
('name', '=like', original_filename.rsplit('.', 1)[0] + '%'),
])
# Add revision number if this is a replacement
if existing_count > 0 and '(replaced)' in (document_label or ''):
# This is an old document being replaced - add revision number
base_name, ext = original_filename.rsplit('.', 1) if '.' in original_filename else (original_filename, '')
filename = f"R{existing_count}_{base_name}.{ext}" if ext else f"R{existing_count}_{base_name}"
field_obj = self._fields.get(field_name)
document_label = field_obj.string if field_obj else field_name
if preserve_copy:
proper_name = self._build_attachment_name(field_name)
base, _, ext = proper_name.rpartition('.')
if base:
copy_name = f"{base}_archived.{ext}"
else:
copy_name = f"{proper_name}_archived"
attachment = self.env['ir.attachment'].sudo().create({
'name': copy_name,
'datas': data,
'res_model': 'sale.order',
'res_id': self.id,
})
else:
filename = original_filename
# Create attachment with the original/revised filename
attachment = self.env['ir.attachment'].sudo().create({
'name': filename,
'datas': data,
'res_model': 'sale.order',
'res_id': self.id,
})
# Post message with attachment (shows as native Odoo attachment with preview)
attachment = self._get_document_attachment(field_name)
if not attachment:
return
self._prepare_attachment_for_email(attachment, field_name=field_name)
user_name = self.env.user.name
now = fields.Datetime.now()
body = Markup("""
<p><strong>{label}</strong> uploaded by <b>{user}</b></p>
<p class="text-muted small">{timestamp}</p>
""").format(
body = Markup(
'<p><strong>{label}</strong> uploaded by <b>{user}</b></p>'
'<p class="text-muted small">{timestamp}</p>'
).format(
label=document_label,
user=user_name,
timestamp=now.strftime('%Y-%m-%d %H:%M:%S')
timestamp=now.strftime('%Y-%m-%d %H:%M:%S'),
)
# Use attachment_ids to show as native attachment with preview capability
self.message_post(
body=body,
message_type='notification',
subtype_xmlid='mail.mt_note',
attachment_ids=[attachment.id],
)
return attachment
# ==========================================================================
@@ -4844,28 +4891,15 @@ class SaleOrder(models.Model):
if not to_emails and not cc_emails:
return False
# Reuse existing field attachments (created by Odoo for attachment=True fields)
# instead of creating duplicates
attachments = []
attachment_names = []
Attachment = self.env['ir.attachment'].sudo()
if self.x_fc_final_submitted_application:
att = Attachment.search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
('res_field', '=', 'x_fc_final_submitted_application'),
], order='id desc', limit=1)
if att:
attachments.append(att.id)
att_id = self._get_and_prepare_field_attachment('x_fc_final_submitted_application', 'ADP Application')
if att_id:
attachments.append(att_id)
attachment_names.append('Final ADP Application (PDF)')
if self.x_fc_xml_file:
att = Attachment.search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
('res_field', '=', 'x_fc_xml_file'),
], order='id desc', limit=1)
if att:
attachments.append(att.id)
att_id = self._get_and_prepare_field_attachment('x_fc_xml_file', 'ADP XML Data')
if att_id:
attachments.append(att_id)
attachment_names.append('XML Data File')
client_name = recipients.get('client', self.partner_id).name or 'Client'
@@ -5113,8 +5147,144 @@ class SaleOrder(models.Model):
_logger.error(f"Failed to send billed email for {self.name}: {e}")
return False
def _build_approved_items_html(self, for_pdf=False):
"""Build an HTML table of approved order line items.
Columns: S/N, ADP Code, Device Type, Product Name, Qty,
ADP Portion, Client Portion, Deduction.
"""
self.ensure_one()
lines = self.order_line.filtered(
lambda l: l.product_id and l.display_type not in ('line_section', 'line_note')
)
if not lines:
return ''
font = "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;"
if for_pdf:
font = "font-family:Arial,Helvetica,sans-serif;"
hdr_style = (
f'style="background:#2d3748;color:#fff;padding:8px 10px;'
f'font-size:11px;font-weight:600;text-align:left;'
f'border-bottom:2px solid #4a5568;{font}"'
)
cell_style = (
'style="padding:7px 10px;font-size:12px;color:#2d3748;'
'border-bottom:1px solid #e2e8f0;"'
)
alt_row = 'style="background:#f7fafc;"'
amt_style = (
'style="padding:7px 10px;font-size:12px;color:#2d3748;'
'border-bottom:1px solid #e2e8f0;text-align:right;"'
)
hdr_r = hdr_style.replace('text-align:left', 'text-align:right')
has_deduction = any(
l.x_fc_deduction_type and l.x_fc_deduction_type != 'none'
for l in lines
)
html = (
'<div style="margin:20px 0;">'
f'<h3 style="color:#1a202c;font-size:15px;font-weight:700;'
f'margin:0 0 10px 0;{font}">Approved Items</h3>'
'<table style="width:100%;border-collapse:collapse;border:1px solid #e2e8f0;">'
'<thead><tr>'
f'<th {hdr_style}>S/N</th>'
f'<th {hdr_style}>ADP Code</th>'
f'<th {hdr_style}>Device Type</th>'
f'<th {hdr_style}>Product</th>'
f'<th {hdr_r}>Qty</th>'
f'<th {hdr_r}>ADP Portion</th>'
f'<th {hdr_r}>Client Portion</th>'
)
if has_deduction:
html += f'<th {hdr_r}>Deduction</th>'
html += '</tr></thead><tbody>'
total_adp = 0.0
total_client = 0.0
for idx, line in enumerate(lines, 1):
row_attr = alt_row if idx % 2 == 0 else ''
adp_code = line._get_adp_code_for_report()
device_type = line._get_adp_device_type()
product_name = line.product_id.name or '-'
if len(product_name) > 40 and not for_pdf:
product_name = product_name[:37] + '...'
qty = int(line.product_uom_qty) if line.product_uom_qty == int(line.product_uom_qty) else line.product_uom_qty
adp_portion = line.x_fc_adp_portion or 0.0
client_portion = line.x_fc_client_portion or 0.0
total_adp += adp_portion
total_client += client_portion
deduction_str = '-'
if line.x_fc_deduction_type == 'pct' and line.x_fc_deduction_value:
deduction_str = f'{line.x_fc_deduction_value:.0f}%'
elif line.x_fc_deduction_type == 'amt' and line.x_fc_deduction_value:
deduction_str = f'${line.x_fc_deduction_value:,.2f}'
html += f'<tr {row_attr}>'
html += f'<td {cell_style}>{idx}</td>'
html += f'<td {cell_style}>{adp_code}</td>'
html += f'<td {cell_style}>{device_type}</td>'
html += f'<td {cell_style}>{product_name}</td>'
html += f'<td {amt_style}>{qty}</td>'
html += f'<td {amt_style}>${adp_portion:,.2f}</td>'
html += f'<td {amt_style}>${client_portion:,.2f}</td>'
if has_deduction:
html += f'<td {amt_style}>{deduction_str}</td>'
html += '</tr>'
# Totals row
colspan = 5
total_style = (
'style="padding:8px 10px;font-size:12px;font-weight:700;'
'color:#1a202c;border-top:2px solid #2d3748;text-align:right;"'
)
total_label_style = (
f'style="padding:8px 10px;font-size:12px;font-weight:700;'
f'color:#1a202c;border-top:2px solid #2d3748;text-align:right;"'
)
html += f'<tr style="background:#edf2f7;">'
html += f'<td colspan="{colspan}" {total_label_style}>Total</td>'
html += f'<td {total_style}>${total_adp:,.2f}</td>'
html += f'<td {total_style}>${total_client:,.2f}</td>'
if has_deduction:
html += f'<td {total_style}></td>'
html += '</tr>'
html += '</tbody></table></div>'
return html
def _generate_approved_items_pdf(self):
"""Generate the Approved Items PDF using the QWeb report and return an ir.attachment id."""
self.ensure_one()
import base64
first, last = self._get_client_name_parts()
try:
report = self.env.ref('fusion_claims.action_report_approved_items')
pdf_content, _ = report._render_qweb_pdf(report.id, [self.id])
except Exception as e:
_logger.error("Failed to generate approved items PDF for %s: %s", self.name, e)
return None
filename = f'{first}_{last}_Approved_Items.pdf'
att = self.env['ir.attachment'].sudo().create({
'name': filename,
'type': 'binary',
'datas': base64.b64encode(pdf_content) if isinstance(pdf_content, bytes) else pdf_content,
'res_model': 'sale.order',
'res_id': self.id,
'mimetype': 'application/pdf',
})
return att.id
def _send_approval_email(self):
"""Send notification when ADP application is approved."""
"""Send notification when ADP application is approved, with approved items report."""
self.ensure_one()
if not self._is_email_notifications_enabled():
return False
@@ -5138,27 +5308,46 @@ class SaleOrder(models.Model):
'contact you with the details and next steps for delivery.'
)
items_html = self._build_approved_items_html()
body_html = self._email_build(
title='Application Approved',
summary=f'The ADP application for <strong>{client_name}</strong> has been '
f'<strong>{status_label.lower()}</strong>.',
email_type='success',
sections=[('Case Details', self._build_case_detail_rows(include_amounts=True))],
extra_html=items_html,
note=note_text,
note_color='#38a169',
attachments_note='Approved Items Report (PDF)' if items_html else None,
button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
sender_name=sales_rep_name,
)
attachment_ids = []
try:
att_id = self._generate_approved_items_pdf()
if att_id:
attachment_ids.append(att_id)
except Exception as e:
_logger.warning("Could not generate approved items PDF for %s: %s", self.name, e)
email_to = ', '.join(to_emails)
email_cc = ', '.join(cc_emails) if cc_emails else ''
try:
self.env['mail.mail'].sudo().create({
mail_vals = {
'subject': f'Application {status_label} - {client_name} - {self.name}',
'body_html': body_html,
'email_to': email_to, 'email_cc': email_cc,
'model': 'sale.order', 'res_id': self.id,
}).send()
self._email_chatter_log(f'Application {status_label} email sent', email_to, email_cc)
}
if attachment_ids:
mail_vals['attachment_ids'] = [(6, 0, attachment_ids)]
self.env['mail.mail'].sudo().create(mail_vals).send()
self._email_chatter_log(
f'Application {status_label} email sent', email_to, email_cc,
['Attached: Approved Items Report'] if attachment_ids else None,
)
return True
except Exception as e:
_logger.error(f"Failed to send approval email for {self.name}: {e}")
@@ -5492,6 +5681,18 @@ class SaleOrder(models.Model):
# ==========================================================================
# OVERRIDE WRITE
# ==========================================================================
def web_save(self, vals, specification):
"""TEMP DEBUG: Intercept web_save to diagnose 'Missing required fields' on old orders."""
_logger.warning(
"DEBUG web_save() on %s: vals keys = %s",
[r.name for r in self], list(vals.keys())
)
try:
return super().web_save(vals, specification)
except Exception as e:
_logger.error("DEBUG web_save() FAILED on %s: %s", [r.name for r in self], e)
raise
def write(self, vals):
"""Override write to handle ADP status changes, date auto-population, and document tracking."""
from datetime import date as date_class
@@ -5690,19 +5891,17 @@ class SaleOrder(models.Model):
label = document_labels.get(field_name, field_name)
if old_data and new_data:
# REPLACEMENT: Old document being replaced with new one
# Preserve old document in chatter as attachment
order._post_document_to_chatter(
field_name,
f"{label} (replaced)"
field_name,
f"{label} (replaced)",
preserve_copy=True,
)
elif old_data and not new_data:
# DELETION: Document is being deleted
# Preserve the deleted document in chatter
order._post_document_to_chatter(
field_name,
f"{label} (DELETED)"
field_name,
f"{label} (DELETED)",
preserve_copy=True,
)
# Post deletion notice
@@ -5737,11 +5936,17 @@ class SaleOrder(models.Model):
for order in self:
# Post existing final application to chatter before clearing
if order.x_fc_final_submitted_application:
order._post_document_to_chatter('x_fc_final_submitted_application',
'Final Application (before correction)')
order._post_document_to_chatter(
'x_fc_final_submitted_application',
'Final Application (before correction)',
preserve_copy=True,
)
if order.x_fc_xml_file:
order._post_document_to_chatter('x_fc_xml_file',
'XML File (before correction)')
order._post_document_to_chatter(
'x_fc_xml_file',
'XML File (before correction)',
preserve_copy=True,
)
# Clear the document fields AND submission date
# Use _correction_cleared to prevent the audit trail from posting duplicates
@@ -6131,8 +6336,9 @@ class SaleOrder(models.Model):
for order in self:
order._send_correction_needed_email()
elif new_app_status == 'case_closed':
for order in self:
order._send_case_closed_email()
if not self.env.context.get('skip_status_emails'):
for order in self:
order._send_case_closed_email()
# ==================================================================
# MARCH OF DIMES STATUS-TRIGGERED EMAILS & SMS
@@ -6679,7 +6885,7 @@ class SaleOrder(models.Model):
for order in orders_to_close:
try:
# Use context to skip status validation for automated process
order.with_context(skip_status_validation=True).write({
order.with_context(skip_status_validation=True, skip_status_emails=True).write({
'x_fc_adp_application_status': 'case_closed',
})
@@ -6725,7 +6931,7 @@ class SaleOrder(models.Model):
if order.x_fc_odsp_division != 'ontario_works' and status != 'payment_received':
continue
try:
order._odsp_advance_status(
order.with_context(skip_status_emails=True)._odsp_advance_status(
'case_closed',
"Case automatically closed 7 days after %s." % status.replace('_', ' '),
)

View File

@@ -306,6 +306,49 @@ class SaleOrderLine(models.Model):
# 5. Final fallback - return default_code even if not in ADP database
return self.product_id.default_code or ''
def _get_adp_code_for_report(self):
"""Return the ADP device code for display on reports.
Uses the product's x_fc_adp_device_code field (not default_code).
Returns 'NON-FUNDED' for non-ADP products.
"""
self.ensure_one()
if not self.product_id:
return 'NON-FUNDED'
if self.product_id.is_non_adp_funded():
return 'NON-FUNDED'
product_tmpl = self.product_id.product_tmpl_id
code = ''
if hasattr(product_tmpl, 'x_fc_adp_device_code'):
code = getattr(product_tmpl, 'x_fc_adp_device_code', '') or ''
if not code and hasattr(product_tmpl, 'x_adp_code'):
code = getattr(product_tmpl, 'x_adp_code', '') or ''
if not code:
return 'NON-FUNDED'
ADPDevice = self.env['fusion.adp.device.code'].sudo()
if ADPDevice.search_count([('device_code', '=', code), ('active', '=', True)]) > 0:
return code
return 'NON-FUNDED'
def _get_adp_device_type(self):
"""Live lookup of device type from the ADP device code table.
Returns 'No Funding Available' for non-ADP products.
"""
self.ensure_one()
if not self.product_id or self.product_id.is_non_adp_funded():
return 'No Funding Available'
code = self._get_adp_code_for_report()
if code == 'NON-FUNDED':
return 'No Funding Available'
if self.x_fc_adp_device_type:
return self.x_fc_adp_device_type
adp_device = self.env['fusion.adp.device.code'].sudo().search([
('device_code', '=', code),
('active', '=', True),
], limit=1)
return adp_device.device_type if adp_device else 'No Funding Available'
def _get_serial_number(self):
"""Get serial number from mapped field or native field."""
self.ensure_one()

View File

@@ -350,6 +350,13 @@ class FusionTechnicianTask(models.Model):
compute='_compute_address_display',
)
# In-store flag -- uses company address instead of client address
is_in_store = fields.Boolean(
string='In Store',
default=False,
help='Task takes place at the store/office. Uses company address automatically.',
)
# Geocoding
address_lat = fields.Float(string='Latitude', digits=(10, 7))
address_lng = fields.Float(string='Longitude', digits=(10, 7))
@@ -382,6 +389,30 @@ class FusionTechnicianTask(models.Model):
string='Completed At',
tracking=True,
)
# GPS location captured at task actions
started_latitude = fields.Float(
string='Started Latitude', digits=(10, 7), readonly=True,
)
started_longitude = fields.Float(
string='Started Longitude', digits=(10, 7), readonly=True,
)
completed_latitude = fields.Float(
string='Completed Latitude', digits=(10, 7), readonly=True,
)
completed_longitude = fields.Float(
string='Completed Longitude', digits=(10, 7), readonly=True,
)
action_latitude = fields.Float(
string='Last Action Latitude', digits=(10, 7), readonly=True,
)
action_longitude = fields.Float(
string='Last Action Longitude', digits=(10, 7), readonly=True,
)
action_location_accuracy = fields.Float(
string='Location Accuracy (m)', readonly=True,
)
voice_note_audio = fields.Binary(
string='Voice Recording',
attachment=True,
@@ -961,9 +992,21 @@ class FusionTechnicianTask(models.Model):
# ONCHANGE - Auto-fill address from client
# ------------------------------------------------------------------
@api.onchange('is_in_store')
def _onchange_is_in_store(self):
"""Auto-fill company address when task is marked as in-store."""
if self.is_in_store:
company_partner = self.env.company.partner_id
if company_partner and company_partner.street:
self._fill_address_from_partner(company_partner)
else:
self.address_street = self.env.company.name or 'In Store'
@api.onchange('partner_id')
def _onchange_partner_id(self):
"""Auto-fill address fields from the selected client's address."""
if self.is_in_store:
return
if self.partner_id:
addr = self.partner_id
self.address_partner_id = addr.id
@@ -1046,6 +1089,19 @@ class FusionTechnicianTask(models.Model):
"A task must be linked to either a Sale Order (Case) or a Purchase Order."
))
@api.constrains('address_street', 'address_lat', 'address_lng', 'is_in_store')
def _check_address_required(self):
"""Non-in-store tasks must have a geocoded address."""
for task in self:
if task.x_fc_sync_source:
continue
if task.is_in_store:
continue
if not task.address_street:
raise ValidationError(_(
"A valid address is required. If this task is at the store, "
"please check the 'In Store' option."
))
@api.constrains('technician_id', 'additional_technician_ids',
'scheduled_date', 'time_start', 'time_end')
@@ -1334,6 +1390,14 @@ class FusionTechnicianTask(models.Model):
vals['name'] = self.env['ir.sequence'].next_by_code('fusion.technician.task') or _('New')
if not vals.get('x_fc_sync_uuid') and not vals.get('x_fc_sync_source'):
vals['x_fc_sync_uuid'] = str(uuid.uuid4())
# In-store tasks: auto-fill company address
if vals.get('is_in_store') and not vals.get('address_street'):
company_partner = self.env.company.partner_id
if company_partner and company_partner.street:
self._fill_address_vals(vals, company_partner)
else:
vals['address_street'] = self.env.company.name or 'In Store'
# Auto-populate address from sale order if not provided
if vals.get('sale_order_id') and not vals.get('address_street'):
order = self.env['sale.order'].browse(vals['sale_order_id'])
@@ -1676,6 +1740,22 @@ class FusionTechnicianTask(models.Model):
"Please complete previous task %s first before starting this one."
) % earlier_incomplete.name)
def _write_action_location(self, extra_vals=None):
"""Write GPS coordinates from context onto the task record."""
ctx = self.env.context
lat = ctx.get('action_latitude', 0)
lng = ctx.get('action_longitude', 0)
acc = ctx.get('action_accuracy', 0)
vals = {
'action_latitude': lat,
'action_longitude': lng,
'action_location_accuracy': acc,
}
if extra_vals:
vals.update(extra_vals)
if lat and lng:
self.with_context(skip_travel_recalc=True).write(vals)
def action_start_en_route(self):
"""Mark task as En Route."""
for task in self:
@@ -1683,6 +1763,7 @@ class FusionTechnicianTask(models.Model):
raise UserError(_("Only scheduled tasks can be marked as En Route."))
task._check_previous_tasks_completed()
task.status = 'en_route'
task._write_action_location()
task._post_status_message('en_route')
def action_start_task(self):
@@ -1692,6 +1773,11 @@ class FusionTechnicianTask(models.Model):
raise UserError(_("Task must be scheduled or en route to start."))
task._check_previous_tasks_completed()
task.status = 'in_progress'
ctx = self.env.context
task._write_action_location({
'started_latitude': ctx.get('action_latitude', 0),
'started_longitude': ctx.get('action_longitude', 0),
})
task._post_status_message('in_progress')
def action_view_sale_order(self):
@@ -1742,9 +1828,15 @@ class FusionTechnicianTask(models.Model):
"technician portal first."
))
ctx = self.env.context
task.with_context(skip_travel_recalc=True).write({
'status': 'completed',
'completion_datetime': fields.Datetime.now(),
'completed_latitude': ctx.get('action_latitude', 0),
'completed_longitude': ctx.get('action_longitude', 0),
'action_latitude': ctx.get('action_latitude', 0),
'action_longitude': ctx.get('action_longitude', 0),
'action_location_accuracy': ctx.get('action_accuracy', 0),
})
task._post_status_message('completed')
if task.completion_notes and (task.sale_order_id or task.purchase_order_id):
@@ -1811,6 +1903,7 @@ class FusionTechnicianTask(models.Model):
if task.status == 'completed':
raise UserError(_("Cannot cancel a completed task."))
task.status = 'cancelled'
task._write_action_location()
task._post_status_message('cancelled')
# If this was a delivery task linked to a sale order that is
# currently in "Ready for Delivery" -- revert the order back.
@@ -2302,20 +2395,144 @@ class FusionTechnicianTask(models.Model):
base_domain,
['name', 'partner_id', 'technician_id', 'task_type',
'address_lat', 'address_lng', 'address_display',
'time_start', 'time_start_display', 'time_end_display',
'time_start', 'time_end', 'time_start_display', 'time_end_display',
'status', 'scheduled_date', 'travel_time_minutes',
'x_fc_sync_client_name', 'x_fc_is_shadow', 'x_fc_sync_source'],
order='scheduled_date asc NULLS LAST, time_start asc',
limit=500,
)
locations = self.env['fusion.technician.location'].get_latest_locations()
tech_starts = self._get_tech_start_locations(tasks, api_key)
return {
'api_key': api_key,
'tasks': tasks,
'locations': locations,
'local_instance_id': local_instance,
'tech_start_locations': tech_starts,
}
@api.model
def _get_tech_start_locations(self, tasks, api_key):
"""Build a dict of technician start locations for route origins.
Priority per technician:
1. Today's fusion_clock check-in location (if module installed)
2. Personal start address (x_fc_start_address with cached lat/lng)
3. Company default HQ address
"""
tech_ids = {
t['technician_id'][0]
for t in tasks
if t.get('technician_id')
}
if not tech_ids:
return {}
result = {}
today = fields.Date.today()
clock_locations = self._get_clock_in_locations(tech_ids, today)
hq_address = (
self.env['ir.config_parameter'].sudo()
.get_param('fusion_claims.technician_start_address', '') or ''
).strip()
hq_lat, hq_lng = 0.0, 0.0
for uid in tech_ids:
if uid in clock_locations:
result[uid] = clock_locations[uid]
continue
user = self.env['res.users'].sudo().browse(uid)
if not user.exists():
continue
partner = user.partner_id
if partner.x_fc_start_address and partner.x_fc_start_address.strip():
lat = partner.x_fc_start_address_lat
lng = partner.x_fc_start_address_lng
if not lat or not lng:
lat, lng = self._geocode_address_string(
partner.x_fc_start_address, api_key)
if lat and lng:
partner.sudo().write({
'x_fc_start_address_lat': lat,
'x_fc_start_address_lng': lng,
})
if lat and lng:
result[uid] = {
'lat': lat, 'lng': lng,
'address': partner.x_fc_start_address.strip(),
'source': 'start_address',
}
continue
if hq_address:
if not hq_lat and not hq_lng:
hq_lat, hq_lng = self._geocode_address_string(
hq_address, api_key)
if hq_lat and hq_lng:
result[uid] = {
'lat': hq_lat, 'lng': hq_lng,
'address': hq_address,
'source': 'company_hq',
}
return result
@api.model
def _get_clock_in_locations(self, tech_ids, today):
"""Get today's clock-in lat/lng from fusion_clock if installed."""
result = {}
try:
module = self.env['ir.module.module'].sudo().search([
('name', '=', 'fusion_clock'),
('state', '=', 'installed'),
], limit=1)
if not module:
return result
except Exception:
return result
try:
Attendance = self.env['hr.attendance'].sudo()
Employee = self.env['hr.employee'].sudo()
except KeyError:
return result
employees = Employee.search([
('user_id', 'in', list(tech_ids)),
])
emp_to_user = {e.id: e.user_id.id for e in employees}
if not employees:
return result
today_start = dt_datetime.combine(today, dt_datetime.min.time())
today_end = today_start + timedelta(days=1)
attendances = Attendance.search([
('employee_id', 'in', employees.ids),
('check_in', '>=', today_start),
('check_in', '<', today_end),
], order='check_in asc')
for att in attendances:
uid = emp_to_user.get(att.employee_id.id)
if not uid or uid in result:
continue
loc = att.x_fclk_location_id if hasattr(att, 'x_fclk_location_id') else False
if loc and loc.latitude and loc.longitude:
result[uid] = {
'lat': loc.latitude,
'lng': loc.longitude,
'address': loc.address or loc.name or '',
'source': 'clock_in',
}
return result
def _geocode_address(self):
"""Geocode the task address using Google Geocoding API."""
self.ensure_one()
@@ -2573,12 +2790,14 @@ class FusionTechnicianTask(models.Model):
return f'{display_hour}:{minutes:02d} {period}'
def get_google_maps_url(self):
"""Get Google Maps navigation URL. Uses lat/lng coordinates to
navigate to the exact location (text addresses cause Google to
resolve to nearby business names instead)."""
"""Get Google Maps navigation URL using the text address so the
destination shows a proper street name instead of raw coordinates.
Returns a google.com/maps URL that Android auto-opens in the app;
iOS handling is done client-side via JS to launch comgooglemaps://."""
self.ensure_one()
if self.address_display:
addr = urllib.parse.quote(self.address_display)
return f'https://www.google.com/maps/dir/?api=1&destination={addr}&travelmode=driving'
if self.address_lat and self.address_lng:
return f'https://www.google.com/maps/dir/?api=1&destination={self.address_lat},{self.address_lng}&travelmode=driving'
elif self.address_display:
return f'https://www.google.com/maps/dir/?api=1&destination={urllib.parse.quote(self.address_display)}&travelmode=driving'
return ''

View File

@@ -32,7 +32,7 @@
<field name="binding_type">report</field>
</record>
<!-- Landscape report - REMOVED FROM MENU (no binding) -->
<!-- Landscape ADP report - also attached to quotation/order emails -->
<record id="action_report_saleorder_landscape" model="ir.actions.report">
<field name="name">Quotation / Order (Landscape - ADP)</field>
<field name="model">sale.order</field>
@@ -40,7 +40,7 @@
<field name="report_name">fusion_claims.report_saleorder_landscape</field>
<field name="report_file">fusion_claims.report_saleorder_landscape</field>
<field name="print_report_name">'%s - %s' % (object.name, object.partner_id.name)</field>
<!-- No binding_model_id - removed from print menu -->
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_a4_landscape"/>
</record>
@@ -127,19 +127,6 @@
<field name="binding_type">report</field>
</record>
<!-- =============================================================== -->
<!-- Rental Agreement Report -->
<!-- =============================================================== -->
<record id="action_report_rental_agreement" model="ir.actions.report">
<field name="name">Rental Agreement</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_claims.report_rental_agreement</field>
<field name="report_file">fusion_claims.report_rental_agreement</field>
<field name="print_report_name">'Rental Agreement - %s' % object.name</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
</record>
<!-- =============================================================== -->
<!-- Grab Bar Installation Waiver Report -->
@@ -169,6 +156,21 @@
<field name="binding_type">report</field>
</record>
<!-- =============================================================== -->
<!-- Approved Items Report (Landscape) -->
<!-- =============================================================== -->
<record id="action_report_approved_items" model="ir.actions.report">
<field name="name">Approved Items Report</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_claims.report_approved_items</field>
<field name="report_file">fusion_claims.report_approved_items</field>
<field name="print_report_name">'Approved Items - %s - %s' % (object.name, object.partner_id.name)</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_a4_landscape"/>
</record>
<!-- =============================================================== -->
<!-- March of Dimes Quotation Report -->
<!-- =============================================================== -->

View File

@@ -0,0 +1,162 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2025 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
Approved Items Report - Landscape
-->
<odoo>
<template id="report_approved_items">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="company" t-value="doc.company_id"/>
<t t-set="is_deduction" t-value="doc.x_fc_adp_application_status == 'approved_deduction'"/>
<t t-set="lines" t-value="doc.order_line.filtered(lambda l: l.product_id and l.display_type not in ('line_section', 'line_note'))"/>
<t t-set="has_deduction" t-value="any(l.x_fc_deduction_type and l.x_fc_deduction_type != 'none' for l in lines)"/>
<style>
.fc-ai { font-family: Arial, sans-serif; font-size: 10pt; }
.fc-ai h2 { color: #0066a1; font-size: 16pt; text-align: center; margin: 25px 0 20px 0; }
.fc-ai table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
.fc-ai table.bordered, .fc-ai table.bordered th, .fc-ai table.bordered td { border: 1px solid #000; }
.fc-ai th { background-color: #0066a1; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
.fc-ai td { padding: 5px 6px; vertical-align: top; font-size: 9pt; }
.fc-ai .text-center { text-align: center; }
.fc-ai .text-end { text-align: right; }
.fc-ai .info-label { font-weight: bold; background-color: #f5f5f5; width: 18%; }
.fc-ai .alt-row { background-color: #f7fafc; }
.fc-ai .total-row { background-color: #edf2f7; font-weight: bold; }
.fc-ai .status-approved { color: #38a169; font-weight: bold; }
.fc-ai .status-deduction { color: #d69e2e; font-weight: bold; }
</style>
<div class="fc-ai">
<div class="page">
<div style="height: 30px;"></div>
<h2>APPROVED ITEMS REPORT</h2>
<!-- Case Info -->
<table class="bordered" style="margin-bottom: 15px;">
<thead>
<tr>
<th colspan="4">CASE DETAILS</th>
</tr>
</thead>
<tbody>
<tr>
<td class="info-label">Case</td>
<td><t t-esc="doc.name"/></td>
<td class="info-label">Client</td>
<td><t t-esc="doc.partner_id.name"/></td>
</tr>
<tr>
<td class="info-label">Claim Number</td>
<td><t t-esc="doc.x_fc_claim_number or 'N/A'"/></td>
<td class="info-label">Status</td>
<td>
<t t-if="is_deduction">
<span class="status-deduction">Approved with Deduction</span>
</t>
<t t-else="">
<span class="status-approved">Approved</span>
</t>
</td>
</tr>
<tr>
<td class="info-label">Assessment Date</td>
<td>
<t t-if="doc.x_fc_assessment_end_date"><span t-field="doc.x_fc_assessment_end_date" t-options="{'widget': 'date'}"/></t>
<t t-else="">N/A</t>
</td>
<td class="info-label">Approval Date</td>
<td>
<t t-if="doc.x_fc_claim_approval_date"><span t-field="doc.x_fc_claim_approval_date" t-options="{'widget': 'date'}"/></t>
<t t-else="">N/A</t>
</td>
</tr>
<tr>
<td class="info-label">Client Ref 1</td>
<td><t t-esc="doc.x_fc_client_ref_1 or 'N/A'"/></td>
<td class="info-label">Client Ref 2</td>
<td><t t-esc="doc.x_fc_client_ref_2 or 'N/A'"/></td>
</tr>
</tbody>
</table>
<!-- Items Table -->
<table class="bordered">
<thead>
<tr>
<th class="text-center" style="width: 5%;">S/N</th>
<th style="width: 12%;">ADP CODE</th>
<th style="width: 15%;">DEVICE TYPE</th>
<th style="width: 28%;">PRODUCT NAME</th>
<th class="text-end" style="width: 5%;">QTY</th>
<th class="text-end" style="width: 12%;">ADP PORTION</th>
<th class="text-end" style="width: 13%;">CLIENT PORTION</th>
<t t-if="has_deduction">
<th class="text-end" style="width: 10%;">DEDUCTION</th>
</t>
</tr>
</thead>
<tbody>
<t t-set="total_adp" t-value="0"/>
<t t-set="total_client" t-value="0"/>
<t t-set="idx" t-value="0"/>
<t t-foreach="lines" t-as="line">
<t t-set="idx" t-value="idx + 1"/>
<t t-set="total_adp" t-value="total_adp + (line.x_fc_adp_portion or 0)"/>
<t t-set="total_client" t-value="total_client + (line.x_fc_client_portion or 0)"/>
<tr t-attf-class="#{ 'alt-row' if idx % 2 == 0 else '' }">
<td class="text-center"><t t-esc="idx"/></td>
<td><t t-esc="line._get_adp_code_for_report()"/></td>
<td><t t-esc="line._get_adp_device_type()"/></td>
<td><t t-esc="line.product_id.name or '-'"/></td>
<td class="text-end">
<t t-esc="int(line.product_uom_qty) if line.product_uom_qty == int(line.product_uom_qty) else line.product_uom_qty"/>
</td>
<td class="text-end">
$<t t-esc="'%.2f' % (line.x_fc_adp_portion or 0)"/>
</td>
<td class="text-end">
$<t t-esc="'%.2f' % (line.x_fc_client_portion or 0)"/>
</td>
<t t-if="has_deduction">
<td class="text-end">
<t t-if="line.x_fc_deduction_type == 'pct' and line.x_fc_deduction_value">
<t t-esc="'%.0f' % line.x_fc_deduction_value"/>%
</t>
<t t-elif="line.x_fc_deduction_type == 'amt' and line.x_fc_deduction_value">
$<t t-esc="'%.2f' % line.x_fc_deduction_value"/>
</t>
<t t-else="">-</t>
</td>
</t>
</tr>
</t>
<!-- Totals -->
<tr class="total-row">
<td colspan="5" class="text-end" style="border-top: 2px solid #000;">TOTAL</td>
<td class="text-end" style="border-top: 2px solid #000;">
$<t t-esc="'%.2f' % total_adp"/>
</td>
<td class="text-end" style="border-top: 2px solid #000;">
$<t t-esc="'%.2f' % total_client"/>
</td>
<t t-if="has_deduction">
<td style="border-top: 2px solid #000;"></td>
</t>
</tr>
</tbody>
</table>
</div>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -1,365 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2025 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
Rental Agreement Document - Compact 2-Page Layout
-->
<odoo>
<template id="report_rental_agreement">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="company" t-value="doc.company_id"/>
<style>
.fc-rental { font-family: Arial, sans-serif; font-size: 8pt; line-height: 1.3; }
.fc-rental h1 { color: #0066a1; font-size: 14pt; text-align: center; margin: 5px 0 10px 0; }
.fc-rental h2 { color: #0066a1; font-size: 9pt; margin: 6px 0 3px 0; font-weight: bold; }
.fc-rental p { margin: 2px 0; text-align: justify; }
.fc-rental .parties { font-size: 8pt; margin-bottom: 8px; }
.fc-rental .intro { margin-bottom: 8px; font-size: 8pt; }
.fc-rental table { width: 100%; border-collapse: collapse; }
.fc-rental table.bordered, .fc-rental table.bordered th, .fc-rental table.bordered td { border: 1px solid #000; }
.fc-rental th { background-color: #0066a1; color: white; padding: 4px 6px; font-weight: bold; font-size: 8pt; }
.fc-rental td { padding: 3px 5px; vertical-align: top; font-size: 8pt; }
.fc-rental .text-center { text-align: center; }
.fc-rental .info-header { background-color: #f5f5f5; color: #333; font-weight: bold; }
/* Two-column layout for terms */
.fc-rental .terms-container { column-count: 2; column-gap: 20px; margin-top: 10px; }
.fc-rental .term-section { break-inside: avoid; margin-bottom: 8px; }
/* Credit card section - 15% taller */
.fc-rental .cc-section { margin-top: 12px; padding: 12px; border: 2px solid #0066a1; background-color: #f8f9fa; }
.fc-rental .cc-title { font-size: 10pt; font-weight: bold; color: #0066a1; margin-bottom: 10px; text-align: center; }
.fc-rental .cc-box { border: 1px solid #000; display: inline-block; width: 21px; height: 21px; text-align: center; background: white; }
.fc-rental .authorization-text { font-size: 7pt; margin-top: 10px; font-style: italic; }
/* Signature - 40% taller */
.fc-rental .signature-section { margin-top: 15px; }
.fc-rental .signature-box { border: 1px solid #000; padding: 12px; }
.fc-rental .signature-line { border-bottom: 1px solid #000; min-height: 35px; margin-bottom: 5px; }
.fc-rental .signature-label { font-size: 7pt; color: #666; }
</style>
<div class="fc-rental">
<div class="page">
<!-- ============================================================ -->
<!-- PAGE 1: TERMS AND CONDITIONS -->
<!-- ============================================================ -->
<h1>RENTAL AGREEMENT</h1>
<!-- Parties - Compact -->
<div class="parties">
<strong>BETWEEN:</strong> <t t-esc="company.name"/> ("Company")
<strong style="margin-left: 20px;">AND:</strong> <t t-esc="doc.partner_id.name"/> ("Renter")
</div>
<!-- Introduction -->
<div class="intro">
<p><t t-esc="company.name"/> rents to the Renter medical equipment (hospital beds, patient lifts, trapeze, over-bed tables, mobility scooters, electric wheelchairs, manual wheelchairs, stairlifts, ceiling lifts and lift chairs) subject to the terms and conditions set forth in this Rental Agreement.</p>
</div>
<!-- Terms and Conditions in Two Columns -->
<div class="terms-container">
<div class="term-section">
<h2>1. Ownership and Condition of Equipment</h2>
<p>The medical equipment is the property of <t t-esc="company.name"/> and is provided in good condition. The Renter shall return the equipment in the same condition as when received, subject to normal wear and tear. <t t-esc="company.name"/> reserves the right to inspect the equipment upon its return and may repossess it without prior notice if it is being used in violation of this agreement.</p>
</div>
<div class="term-section">
<h2>2. Cancellation Policy</h2>
<p>The Renter may cancel the order before delivery and will be charged twenty-five percent (25%) of the total rental cost. If the order is canceled during the rental period after delivery, no refund will be provided.</p>
</div>
<div class="term-section">
<h2>3. Security Deposit</h2>
<p>The security deposit will be returned after an inspection of the equipment. If the equipment has any damage, the cost of repairs will be deducted from the security deposit. If the security deposit is insufficient to cover the damages, the credit card on file will be charged for the remaining amount. Security deposit refunds may take 4 to 15 business days to process. <t t-esc="company.name"/> is not responsible for delays caused by the Renter's financial institution.</p>
</div>
<div class="term-section">
<h2>4. Liability for Loss or Damage</h2>
<p><t t-esc="company.name"/> shall not be liable for any loss of or damage to property left, lost, damaged, stolen, stored, or transported by the Renter or any other person using the medical equipment. The Renter assumes all risks associated with such loss or damage and waives any claims against <t t-esc="company.name"/>. The Renter agrees to defend, indemnify, and hold <t t-esc="company.name"/> harmless against all claims arising from such loss or damage.</p>
</div>
<div class="term-section">
<h2>5. Risk and Liability</h2>
<p>The Renter assumes all risk and liability for any loss, damage, injury, or death resulting from the use or operation of the medical equipment. <t t-esc="company.name"/> is not responsible for any acts or omissions of the Renter or the Renter's agents, servants, or employees.</p>
</div>
<div class="term-section">
<h2>6. Renter Responsibilities</h2>
<p>The Renter is responsible for the full cost of replacement for any damage, loss, theft, or destruction of the medical equipment. <t t-esc="company.name"/> may charge the Renter's credit card for repair or replacement costs as deemed necessary. The equipment must not be used by individuals under the age of 18, under the influence of intoxicants or narcotics, or in an unsafe manner.</p>
</div>
<div class="term-section">
<h2>7. Indemnification</h2>
<p>The Renter shall indemnify, defend, and hold harmless <t t-esc="company.name"/>, its agents, officers, and employees, from any claims, demands, actions, or causes of action arising from the use or operation of the medical equipment, except where caused by <t t-esc="company.name"/>'s gross negligence or willful misconduct.</p>
</div>
<div class="term-section">
<h2>8. Accident Notification</h2>
<p>The Renter must immediately notify <t t-esc="company.name"/> of any accidents, damages, or incidents involving the medical equipment.</p>
</div>
<div class="term-section">
<h2>9. Costs and Expenses</h2>
<p>The Renter agrees to cover all costs, expenses, and attorney's fees incurred by <t t-esc="company.name"/> in collecting overdue payments, recovering possession of the equipment, or enforcing claims for damage or loss.</p>
</div>
<div class="term-section">
<h2>10. Independent Status</h2>
<p>The Renter or any driver of the equipment shall not be considered an agent or employee of <t t-esc="company.name"/>.</p>
</div>
<div class="term-section">
<h2>11. Binding Obligations</h2>
<p>Any individual signing this agreement on behalf of a corporation or other entity shall be personally liable for all obligations under this agreement. This agreement is binding upon the heirs, executors, administrators, and assigns of the Renter.</p>
</div>
<div class="term-section">
<h2>12. Refusal of Service</h2>
<p><t t-esc="company.name"/> reserves the right to refuse rental to any individual or entity at its sole discretion.</p>
</div>
<div class="term-section">
<h2>13. Governing Law</h2>
<p>This Agreement shall be governed by and construed in accordance with the laws of the jurisdiction in which <t t-esc="company.name"/> operates.</p>
</div>
<div class="term-section">
<h2>14. Entire Agreement</h2>
<p>This Agreement constitutes the entire understanding between the parties concerning the rental of medical equipment and supersedes all prior agreements, representations, or understandings, whether written or oral.</p>
</div>
</div>
<!-- ============================================================ -->
<!-- PAGE 2: RENTAL DETAILS, PAYMENT, AND SIGNATURE -->
<!-- ============================================================ -->
<div style="page-break-before: always;"></div>
<h1>RENTAL DETAILS</h1>
<!-- Customer Info and Rental Period Side by Side -->
<table style="width: 100%; margin-bottom: 10px;">
<tr>
<td style="width: 50%; vertical-align: top; padding-right: 10px;">
<table class="bordered" style="width: 100%;">
<tr>
<th colspan="2" class="info-header" style="background-color: #0066a1; color: white;">RENTER INFORMATION</th>
</tr>
<tr>
<td style="width: 35%; font-weight: bold; background-color: #f5f5f5;">Name</td>
<td><t t-esc="doc.partner_id.name"/></td>
</tr>
<tr>
<td style="font-weight: bold; background-color: #f5f5f5;">Address</td>
<td>
<div t-field="doc.partner_shipping_id"
t-options="{'widget': 'contact', 'fields': ['address'], 'no_marker': True}"/>
</td>
</tr>
<tr>
<td style="font-weight: bold; background-color: #f5f5f5;">Phone</td>
<td><t t-esc="doc.partner_id.phone or doc.partner_id.mobile or ''"/></td>
</tr>
<tr>
<td style="font-weight: bold; background-color: #f5f5f5;">Order Ref</td>
<td><t t-esc="doc.name"/></td>
</tr>
</table>
</td>
<td style="width: 50%; vertical-align: top; padding-left: 10px;">
<table class="bordered" style="width: 100%;">
<tr>
<th colspan="2" class="info-header" style="background-color: #0066a1; color: white;">RENTAL PERIOD</th>
</tr>
<tr>
<td style="width: 40%; font-weight: bold; background-color: #f5f5f5;">Start Date</td>
<td>
<t t-if="doc.rental_start_date">
<span t-field="doc.rental_start_date" t-options="{'widget': 'date'}"/>
</t>
<t t-else=""><span style="color: #999;">Not specified</span></t>
</td>
</tr>
<tr>
<td style="font-weight: bold; background-color: #f5f5f5;">Return Date</td>
<td>
<t t-if="doc.rental_return_date">
<span t-field="doc.rental_return_date" t-options="{'widget': 'date'}"/>
</t>
<t t-else=""><span style="color: #999;">Not specified</span></t>
</td>
</tr>
<tr>
<td style="font-weight: bold; background-color: #f5f5f5;">Duration</td>
<td>
<t t-if="doc.duration_days">
<span t-esc="doc.duration_days"/> Day<t t-if="doc.duration_days != 1">s</t>
<t t-if="doc.remaining_hours and doc.remaining_hours > 0">
, <t t-esc="doc.remaining_hours"/> Hr<t t-if="doc.remaining_hours != 1">s</t>
</t>
</t>
<t t-elif="doc.rental_start_date and doc.rental_return_date"><span>Less than 1 day</span></t>
<t t-else=""><span style="color: #999;">Not specified</span></t>
</td>
</tr>
<tr>
<td style="font-weight: bold; background-color: #f5f5f5;">Total Amount</td>
<td><strong><span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/></strong></td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Equipment List - Compact -->
<table class="bordered" style="margin-bottom: 10px;">
<thead>
<tr>
<th class="text-center" style="width: 15%;">PRODUCT CODE</th>
<th style="width: 55%;">DESCRIPTION</th>
<th class="text-center" style="width: 15%;">SERIAL #</th>
<th class="text-center" style="width: 15%;">QTY</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.order_line" t-as="line">
<t t-if="not line.display_type">
<tr>
<td class="text-center">
<span t-esc="line.product_id.default_code or ''"/>
</td>
<td>
<t t-if="line.name">
<t t-set="clean_name" t-value="line.name"/>
<t t-if="'] ' in clean_name">
<t t-set="clean_name" t-value="clean_name.split('] ', 1)[1]"/>
</t>
<t t-if="' to ' in clean_name and '\n' in clean_name">
<t t-set="clean_name" t-value="clean_name.split('\n')[0]"/>
</t>
<t t-esc="clean_name"/>
</t>
</td>
<td class="text-center">
<span t-esc="line.x_fc_serial_number or ''"/>
</td>
<td class="text-center">
<span t-esc="int(line.product_uom_qty) if line.product_uom_qty == int(line.product_uom_qty) else line.product_uom_qty"/>
</td>
</tr>
</t>
</t>
</tbody>
</table>
<!-- Credit Card Authorization - Compact -->
<div class="cc-section">
<div class="cc-title">CREDIT CARD PAYMENT AUTHORIZATION</div>
<table style="width: 100%; border: none;">
<tr>
<td style="width: 20%; padding: 5px 4px; border: none;"><strong>Card #:</strong></td>
<td style="padding: 5px 4px; border: none;">
<t t-if="doc.rental_payment_token_id">
<span style="font-size: 14px;">**** **** **** <t t-out="doc._get_card_last_four() or '****'">1234</t></span>
</t>
<t t-else="">
<span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span>
<span style="margin: 0 3px;">-</span>
<span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span>
<span style="margin: 0 3px;">-</span>
<span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span>
<span style="margin: 0 3px;">-</span>
<span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span>
</t>
</td>
</tr>
<tr>
<td style="padding: 5px 4px; border: none;"><strong>Exp Date:</strong></td>
<td style="padding: 5px 4px; border: none;">
<span class="cc-box"></span><span class="cc-box"></span>
<span style="margin: 0 2px;">/</span>
<span class="cc-box"></span><span class="cc-box"></span>
<span style="margin-left: 20px;"><strong>CVV:</strong></span>
<span>***</span>
<t t-set="deposit_lines" t-value="doc.order_line.filtered(lambda l: l.is_security_deposit)"/>
<span style="margin-left: 20px;"><strong>Security Deposit:</strong>
<t t-if="deposit_lines">
$<t t-out="'%.2f' % sum(deposit_lines.mapped('price_unit'))">0.00</t>
</t>
<t t-else="">$___________</t>
</span>
</td>
</tr>
<tr>
<td style="padding: 5px 4px; border: none;"><strong>Cardholder:</strong></td>
<td style="padding: 5px 4px; border: none;">
<t t-if="doc.rental_agreement_signer_name">
<span t-out="doc.rental_agreement_signer_name">Name</span>
</t>
<t t-else="">
<div style="border-bottom: 1px solid #000; min-height: 18px; width: 100%;"></div>
</t>
</td>
</tr>
<tr>
<td colspan="2" style="padding: 5px 4px; border: none;">
<strong>Billing Address (if different):</strong>
<div style="border-bottom: 1px solid #000; min-height: 18px; width: 100%; margin-top: 4px;"></div>
</td>
</tr>
</table>
<div class="authorization-text">
<p>I authorize <t t-esc="company.name"/> to charge the credit card indicated in this authorization form according to the terms outlined above. I certify that I am an authorized user of this credit card and will not dispute the payment. By signing this form, I acknowledge that I have read the rental agreement and understand the terms and conditions. I understand that if the rented item is not returned on the agreed return date, additional charges will be incurred. *Payments for monthly rental items will be charged on the re-rental date until the item is returned.</p>
</div>
</div>
<!-- Signature Section - Compact -->
<div class="signature-section">
<div class="signature-box">
<table style="width: 100%; border: none;">
<tr>
<td style="width: 40%; padding: 5px; border: none;">
<div class="signature-label">FULL NAME (PRINT)</div>
<t t-if="doc.rental_agreement_signer_name">
<div style="min-height: 18px; font-size: 14px;" t-out="doc.rental_agreement_signer_name">Name</div>
</t>
<t t-else=""><div class="signature-line"></div></t>
</td>
<td style="width: 40%; padding: 5px; border: none;">
<div class="signature-label">SIGNATURE</div>
<t t-if="doc.rental_agreement_signature">
<img t-att-src="'data:image/png;base64,' + doc.rental_agreement_signature.decode('utf-8') if doc.rental_agreement_signature else ''" style="max-height: 50px; max-width: 100%;"/>
</t>
<t t-else=""><div class="signature-line"></div></t>
</td>
<td style="width: 20%; padding: 5px; border: none;">
<div class="signature-label">DATE</div>
<t t-if="doc.rental_agreement_signed_date">
<div style="min-height: 18px; font-size: 14px;" t-out="doc.rental_agreement_signed_date.strftime('%m/%d/%Y')">Date</div>
</t>
<t t-else=""><div class="signature-line"></div></t>
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -320,6 +320,25 @@ $transition-speed: .25s;
.fa { opacity: .8; }
}
.fc_task_edit_btn {
display: inline-flex;
align-items: center;
font-size: 10px;
font-weight: 600;
color: var(--btn-primary-color, #fff);
background: var(--btn-primary-bg, #{$primary});
padding: 2px 10px;
border-radius: 4px;
cursor: pointer;
margin-left: auto;
transition: all .15s;
&:hover {
opacity: .85;
filter: brightness(1.15);
}
}
// ── Map area ────────────────────────────────────────────────────────
.fc_map_area {
flex: 1 1 auto;
@@ -341,15 +360,21 @@ $transition-speed: .25s;
min-height: 400px;
}
// ── Google Maps InfoWindow override (always light bg) ───────────────
// InfoWindow is rendered by Google outside our DOM; we style via
// the .gm-style-iw container that Google injects.
// ── Google Maps InfoWindow override ──────────────────────────────────
.gm-style-iw-d {
overflow: auto !important;
}
.gm-style .gm-style-iw-c {
padding: 0 !important;
border-radius: 10px !important;
overflow: hidden !important;
box-shadow: 0 4px 20px rgba(0,0,0,.15) !important;
}
.gm-style .gm-style-iw-tc {
display: none !important;
}
.gm-style .gm-ui-hover-effect {
display: none !important;
}
// ── Responsive ──────────────────────────────────────────────────────

View File

@@ -0,0 +1,20 @@
/** @odoo-module **/
import { Record } from "@web/model/relational_model/record";
import { patch } from "@web/core/utils/patch";
patch(Record.prototype, {
_displayInvalidFieldNotification() {
const fieldNames = [];
for (const fieldName of this._invalidFields) {
const fieldDef = this.fields[fieldName];
const label = fieldDef?.string || fieldName;
fieldNames.push(`${label} (${fieldName})`);
}
const message = fieldNames.length
? `Missing required fields:\n${fieldNames.join(", ")}`
: "Missing required fields (unknown)";
console.error("FUSION DEBUG:", message, Array.from(this._invalidFields));
return this.model.notification.add(message, { type: "danger" });
},
});

View File

@@ -203,11 +203,12 @@ function groupTasks(tasksData, localInstanceId) {
};
}
let globalIdx = 0;
const dayCounters = {};
for (const task of sorted) {
globalIdx++;
const g = classifyTask(task);
task._scheduleNum = globalIdx;
const dayKey = task.scheduled_date || "none";
dayCounters[dayKey] = (dayCounters[dayKey] || 0) + 1;
task._scheduleNum = dayCounters[dayKey];
task._group = g;
task._dayColor = DAY_COLORS[g] || "#6b7280"; // Pin colour by day
task._statusColor = STATUS_COLORS[task.status] || "#6b7280";
@@ -255,6 +256,7 @@ export class FusionTaskMapController extends Component {
showTasks: true,
showTechnicians: true,
showTraffic: true,
showRoute: true,
taskCount: 0,
techCount: 0,
// Sidebar
@@ -264,11 +266,11 @@ export class FusionTaskMapController extends Component {
activeTaskId: null, // Highlighted task
// Day filters for map pins (which groups show on map)
visibleGroups: {
[GROUP_YESTERDAY]: false, // hidden by default
[GROUP_YESTERDAY]: false,
[GROUP_TODAY]: true,
[GROUP_TOMORROW]: true,
[GROUP_THIS_WEEK]: false, // hidden by default
[GROUP_LATER]: false, // hidden by default
[GROUP_TOMORROW]: false,
[GROUP_THIS_WEEK]: false,
[GROUP_LATER]: false,
},
});
@@ -280,7 +282,11 @@ export class FusionTaskMapController extends Component {
this.taskMarkers = [];
this.taskMarkerMap = {}; // id → marker
this.techMarkers = [];
this.routeLines = []; // route polylines
this.routeLabels = []; // travel time overlay labels
this.routeAnimFrameId = null;
this.infoWindow = null;
this.techStartLocations = {};
this.apiKey = "";
this.tasksData = [];
this.locationsData = [];
@@ -312,6 +318,7 @@ export class FusionTaskMapController extends Component {
});
onWillUnmount(() => {
this._clearMarkers();
this._clearRoute();
window.__fusionMapOpenTask = () => {};
});
}
@@ -327,17 +334,22 @@ export class FusionTaskMapController extends Component {
}
// ── Data ─────────────────────────────────────────────────────────
_storeResult(result) {
this.localInstanceId = result.local_instance_id || this.localInstanceId || "";
this.tasksData = result.tasks || [];
this.locationsData = result.locations || [];
this.techStartLocations = result.tech_start_locations || {};
this.state.taskCount = this.tasksData.length;
this.state.techCount = this.locationsData.length;
this.state.groups = groupTasks(this.tasksData, this.localInstanceId);
}
async _loadAndRender() {
try {
const domain = this._getDomain();
const result = await this.orm.call("fusion.technician.task", "get_map_data", [domain]);
this.apiKey = result.api_key;
this.localInstanceId = result.local_instance_id || "";
this.tasksData = result.tasks || [];
this.locationsData = result.locations || [];
this.state.taskCount = this.tasksData.length;
this.state.techCount = this.locationsData.length;
this.state.groups = groupTasks(this.tasksData, this.localInstanceId);
this._storeResult(result);
if (!this.apiKey) {
this.state.error = _t("Google Maps API key not configured. Go to Settings > Fusion Claims.");
@@ -345,7 +357,11 @@ export class FusionTaskMapController extends Component {
return;
}
await loadGoogleMaps(this.apiKey);
if (this.mapRef.el) this._initMap();
if (this.map) {
this._renderMarkers();
} else if (this.mapRef.el) {
this._initMap();
}
this.state.loading = false;
} catch (e) {
console.error("FusionTaskMap load error:", e);
@@ -354,17 +370,33 @@ export class FusionTaskMapController extends Component {
}
}
async _softRefresh() {
if (!this.map) return;
try {
const center = this.map.getCenter();
const zoom = this.map.getZoom();
const domain = this._getDomain();
const result = await this.orm.call("fusion.technician.task", "get_map_data", [domain]);
this._storeResult(result);
this._placeMarkers();
if (center && zoom != null) {
this.map.setCenter(center);
this.map.setZoom(zoom);
}
} catch (e) {
console.error("FusionTaskMap soft refresh error:", e);
}
}
async _onModelUpdate() {
if (!this.map) return;
try {
const domain = this._getDomain();
const result = await this.orm.call("fusion.technician.task", "get_map_data", [domain]);
this.localInstanceId = result.local_instance_id || this.localInstanceId || "";
this.tasksData = result.tasks || [];
this.locationsData = result.locations || [];
this.state.taskCount = this.tasksData.length;
this.state.techCount = this.locationsData.length;
this.state.groups = groupTasks(this.tasksData, this.localInstanceId);
this._storeResult(result);
this._renderMarkers();
} catch (e) {
console.error("FusionTaskMap update error:", e);
@@ -407,12 +439,27 @@ export class FusionTaskMapController extends Component {
this.techMarkers = [];
}
_renderMarkers() {
this._clearMarkers();
_clearRoute() {
if (this.routeAnimFrameId) {
cancelAnimationFrame(this.routeAnimFrameId);
this.routeAnimFrameId = null;
}
for (const l of this.routeLines) l.setMap(null);
this.routeLines = [];
for (const lb of this.routeLabels) lb.setMap(null);
this.routeLabels = [];
}
_placeMarkers() {
for (const m of this.taskMarkers) m.setMap(null);
for (const m of this.techMarkers) m.setMap(null);
this.taskMarkers = [];
this.taskMarkerMap = {};
this.techMarkers = [];
const bounds = new google.maps.LatLngBounds();
let hasBounds = false;
// Task pins: only show groups that are enabled in the day filter
if (this.state.showTasks) {
for (const group of this.state.groups) {
const groupVisible = this.state.visibleGroups[group.key] !== false;
@@ -444,7 +491,6 @@ export class FusionTaskMapController extends Component {
}
}
// Technician markers
if (this.state.showTechnicians) {
for (const loc of this.locationsData) {
if (!loc.latitude || !loc.longitude) continue;
@@ -485,45 +531,410 @@ export class FusionTaskMapController extends Component {
}
}
const starts = this.techStartLocations || {};
for (const uid of Object.keys(starts)) {
const sl = starts[uid];
if (sl && sl.lat && sl.lng) {
bounds.extend({ lat: sl.lat, lng: sl.lng });
hasBounds = true;
}
}
return { bounds, hasBounds };
}
_renderMarkers() {
this._clearRoute();
const { bounds, hasBounds } = this._placeMarkers();
if (this.state.showRoute && this.state.showTasks) {
this._renderRoute();
}
if (hasBounds) {
this.map.fitBounds(bounds);
if (this.taskMarkers.length + this.techMarkers.length === 1) {
this.map.setZoom(14);
try {
this.map.fitBounds(bounds);
if (this.taskMarkers.length + this.techMarkers.length === 1) {
this.map.setZoom(14);
}
} catch (_e) {
// bounds not ready yet
}
}
}
_renderRoute() {
this._clearRoute();
const routeSegments = {};
for (const group of this.state.groups) {
if (this.state.visibleGroups[group.key] === false) continue;
for (const task of group.tasks) {
if (!task._hasCoords) continue;
const techId = task.technician_id ? task.technician_id[0] : 0;
if (!techId) continue;
const dayKey = task.scheduled_date || "none";
const segKey = `${techId}_${dayKey}`;
if (!routeSegments[segKey]) {
routeSegments[segKey] = {
name: task._techName, day: dayKey,
techId, tasks: [],
};
}
routeSegments[segKey].tasks.push(task);
}
}
const LEG_COLORS = [
"#3b82f6", "#f59e0b", "#8b5cf6", "#ec4899",
"#f97316", "#0ea5e9", "#d946ef", "#06b6d4",
"#a855f7", "#6366f1", "#eab308", "#0284c7",
"#c026d3", "#7c3aed", "#2563eb", "#db2777",
"#9333ea", "#0891b2", "#4f46e5", "#be185d",
];
let globalLegIdx = 0;
if (!this._directionsService) {
this._directionsService = new google.maps.DirectionsService();
}
const allAnimLines = [];
const starts = this.techStartLocations || {};
for (const segKey of Object.keys(routeSegments)) {
const seg = routeSegments[segKey];
const tasks = seg.tasks;
tasks.sort((a, b) => (a.time_start || 0) - (b.time_start || 0));
const startLoc = starts[seg.techId];
const hasStart = startLoc && startLoc.lat && startLoc.lng;
if (tasks.length < 2 && !hasStart) continue;
if (tasks.length < 1) continue;
const segBaseColor = LEG_COLORS[globalLegIdx % LEG_COLORS.length];
let origin, destination, waypoints, hasStartLeg;
if (hasStart) {
origin = { lat: startLoc.lat, lng: startLoc.lng };
destination = {
lat: tasks[tasks.length - 1].address_lat,
lng: tasks[tasks.length - 1].address_lng,
};
waypoints = tasks.slice(0, -1).map(t => ({
location: { lat: t.address_lat, lng: t.address_lng },
stopover: true,
}));
hasStartLeg = true;
} else {
origin = { lat: tasks[0].address_lat, lng: tasks[0].address_lng };
destination = {
lat: tasks[tasks.length - 1].address_lat,
lng: tasks[tasks.length - 1].address_lng,
};
waypoints = tasks.slice(1, -1).map(t => ({
location: { lat: t.address_lat, lng: t.address_lng },
stopover: true,
}));
hasStartLeg = false;
}
if (hasStart) {
const startSvg =
`<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36">` +
`<circle cx="18" cy="18" r="16" fill="${segBaseColor}" stroke="#fff" stroke-width="3"/>` +
`<text x="18" y="23" text-anchor="middle" fill="#fff" font-size="16" font-family="Arial,sans-serif">&#x2302;</text>` +
`</svg>`;
const startMarker = new google.maps.Marker({
position: origin,
map: this.map,
title: `${seg.name} - Start`,
icon: {
url: "data:image/svg+xml;charset=UTF-8," + encodeURIComponent(startSvg),
scaledSize: new google.maps.Size(32, 32),
anchor: new google.maps.Point(16, 16),
},
zIndex: 5,
});
startMarker.addListener("click", () => {
this.infoWindow.setContent(`
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:180px;">
<div style="background:${segBaseColor};color:#fff;padding:8px 12px;border-radius:6px 6px 0 0;">
<strong>${seg.name} - Start</strong>
</div>
<div style="padding:8px 12px;font-size:13px;">
${startLoc.address || 'Start location'}
<div style="color:#6b7280;margin-top:4px;font-size:11px;">${startLoc.source === 'clock_in' ? 'Clock-in location' : startLoc.source === 'start_address' ? 'Home address' : 'Company HQ'}</div>
</div>
</div>`);
this.infoWindow.open(this.map, startMarker);
});
this.routeLines.push(startMarker);
}
this._directionsService.route({
origin,
destination,
waypoints,
optimizeWaypoints: false,
travelMode: google.maps.TravelMode.DRIVING,
avoidTolls: true,
drivingOptions: {
departureTime: new Date(),
trafficModel: "bestguess",
},
}, (result, status) => {
if (status !== "OK" || !result.routes || !result.routes[0]) return;
const route = result.routes[0];
for (let li = 0; li < route.legs.length; li++) {
const leg = route.legs[li];
const legColor = LEG_COLORS[globalLegIdx % LEG_COLORS.length];
globalLegIdx++;
const legPath = [];
for (const step of leg.steps) {
for (const pt of step.path) legPath.push(pt);
}
if (legPath.length < 2) continue;
const baseLine = new google.maps.Polyline({
path: legPath, map: this.map,
strokeColor: legColor, strokeOpacity: 0.25, strokeWeight: 6,
zIndex: 1,
});
this.routeLines.push(baseLine);
const animLine = new google.maps.Polyline({
path: legPath, map: this.map,
strokeOpacity: 0, strokeWeight: 0, zIndex: 2,
icons: [{
icon: {
path: "M 0,-0.5 0,0.5",
strokeOpacity: 0.8, strokeColor: legColor,
strokeWeight: 3, scale: 4,
},
offset: "0%", repeat: "16px",
}],
});
this.routeLines.push(animLine);
allAnimLines.push(animLine);
const arrowLine = new google.maps.Polyline({
path: legPath, map: this.map,
strokeOpacity: 0, strokeWeight: 0, zIndex: 3,
icons: [{
icon: {
path: google.maps.SymbolPath.FORWARD_OPEN_ARROW,
scale: 3, strokeColor: legColor,
strokeOpacity: 0.9, strokeWeight: 2.5,
},
offset: "0%", repeat: "80px",
}],
});
this.routeLines.push(arrowLine);
allAnimLines.push(arrowLine);
const dur = leg.duration_in_traffic || leg.duration;
const dist = leg.distance;
if (dur) {
const totalMins = Math.round(dur.value / 60);
const totalKm = dist ? (dist.value / 1000).toFixed(1) : null;
const destIdx = hasStartLeg ? li : li + 1;
const destTask = destIdx < tasks.length ? tasks[destIdx] : tasks[tasks.length - 1];
const etaFloat = destTask.time_start || 0;
const etaStr = etaFloat ? floatToTime12(etaFloat) : "";
const techName = seg.name;
this.routeLabels.push(this._createTravelLabel(
legPath, totalMins, totalKm, legColor, techName, etaStr,
));
}
}
if (!this.routeAnimFrameId) {
this._startRouteAnimation(allAnimLines);
}
});
}
}
_pointAlongLeg(leg, fraction) {
const points = [];
for (const step of leg.steps) {
for (const pt of step.path) {
points.push(pt);
}
}
if (points.length < 2) return leg.start_location;
const segDists = [];
let totalDist = 0;
for (let i = 1; i < points.length; i++) {
const d = google.maps.geometry
? google.maps.geometry.spherical.computeDistanceBetween(points[i - 1], points[i])
: this._haversine(points[i - 1], points[i]);
segDists.push(d);
totalDist += d;
}
const target = totalDist * fraction;
let acc = 0;
for (let i = 0; i < segDists.length; i++) {
if (acc + segDists[i] >= target) {
const remain = target - acc;
const ratio = segDists[i] > 0 ? remain / segDists[i] : 0;
return new google.maps.LatLng(
points[i].lat() + (points[i + 1].lat() - points[i].lat()) * ratio,
points[i].lng() + (points[i + 1].lng() - points[i].lng()) * ratio,
);
}
acc += segDists[i];
}
return points[points.length - 1];
}
_haversine(a, b) {
const R = 6371000;
const dLat = (b.lat() - a.lat()) * Math.PI / 180;
const dLng = (b.lng() - a.lng()) * Math.PI / 180;
const s = Math.sin(dLat / 2) ** 2 +
Math.cos(a.lat() * Math.PI / 180) * Math.cos(b.lat() * Math.PI / 180) *
Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
}
_createTravelLabel(legPath, mins, km, color, techName, eta) {
if (!this._TravelLabel) {
this._TravelLabel = class extends google.maps.OverlayView {
constructor(path, html) {
super();
this._path = path;
this._html = html;
this._div = null;
}
onAdd() {
this._div = document.createElement("div");
this._div.style.position = "absolute";
this._div.style.whiteSpace = "nowrap";
this._div.style.pointerEvents = "none";
this._div.style.zIndex = "50";
this._div.style.transition = "left .3s ease, top .3s ease";
this._div.innerHTML = this._html;
this.getPanes().floatPane.appendChild(this._div);
}
draw() {
const proj = this.getProjection();
if (!proj || !this._div) return;
const map = this.getMap();
if (!map) return;
const bounds = map.getBounds();
if (!bounds) return;
const visible = this._path.filter(p => bounds.contains(p));
if (visible.length === 0) {
this._div.style.display = "none";
return;
}
this._div.style.display = "";
const anchor = visible[Math.floor(visible.length / 2)];
const px = proj.fromLatLngToDivPixel(anchor);
if (px) {
this._div.style.left = (px.x - this._div.offsetWidth / 2) + "px";
this._div.style.top = (px.y - this._div.offsetHeight - 8) + "px";
}
}
onRemove() {
if (this._div && this._div.parentNode) {
this._div.parentNode.removeChild(this._div);
}
this._div = null;
}
};
}
const timeStr = mins < 60
? `${mins} min`
: `${Math.floor(mins / 60)}h ${mins % 60}m`;
const distStr = km ? `${km} km` : "";
const firstName = techName ? techName.split(" ")[0] : "";
const html = `<div style="
display:inline-flex;align-items:center;gap:5px;
background:#fff;border:2px solid ${color};
border-radius:16px;padding:3px 10px;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
font-size:11px;font-weight:700;color:#1f2937;
box-shadow:0 2px 8px rgba(0,0,0,.18);
">${firstName ? `<span style="color:${color};font-weight:600;">${firstName}</span><span style="color:#d1d5db;">|</span>` : ""}<span style="color:${color};">&#x1F697;</span><span>${timeStr}</span>${distStr ? `<span style="color:#9ca3af;font-weight:500;">&#183; ${distStr}</span>` : ""}${eta ? `<span style="color:#d1d5db;">|</span><span style="color:#059669;font-weight:700;">ETA ${eta}</span>` : ""}</div>`;
const label = new this._TravelLabel(legPath, html);
label.setMap(this.map);
return label;
}
_startRouteAnimation(animLines) {
let off = 0;
let last = 0;
const animate = (ts) => {
this.routeAnimFrameId = requestAnimationFrame(animate);
if (ts - last < 50) return;
last = ts;
off = (off + 0.08) % 100;
const pct = off + "%";
for (const line of animLines) {
const icons = line.get("icons");
if (icons && icons.length > 0) {
icons[0].offset = pct;
line.set("icons", icons);
}
}
};
this.routeAnimFrameId = requestAnimationFrame(animate);
}
_openTaskPopup(task, marker) {
const c = task._dayColor;
const sc = task._statusColor;
const navDest = task.address_lat && task.address_lng
? `${task.address_lat},${task.address_lng}`
: encodeURIComponent(task.address_display || "");
const html = `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:270px;max-width:360px;color:#1f2937;position:relative;">
<div style="background:${c};color:#fff;padding:10px 14px;display:flex;justify-content:space-between;align-items:center;">
<strong style="font-size:14px;">#${task._scheduleNum} &nbsp;${task.name}</strong>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:11px;background:rgba(255,255,255,.2);padding:2px 8px;border-radius:10px;">${task._statusLabel}</span>
<button onclick="document.querySelector('.gm-ui-hover-effect')?.click()" title="Close"
style="background:rgba(255,255,255,.2);border:none;color:#fff;width:24px;height:24px;border-radius:50%;cursor:pointer;font-size:16px;line-height:1;display:flex;align-items:center;justify-content:center;">
&times;
</button>
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:290px;max-width:360px;color:#1f2937;">
<div style="background:${c};padding:14px 16px 12px;border-radius:0;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px;">
<span style="color:rgba(255,255,255,.75);font-size:11px;font-weight:600;letter-spacing:.3px;">#${task._scheduleNum} ${task.name}</span>
<span style="font-size:10px;font-weight:600;background:${sc};color:#fff;padding:2px 10px;border-radius:10px;">${task._statusLabel}</span>
</div>
<div style="color:#fff;font-size:16px;font-weight:700;line-height:1.25;">${task._clientName}</div>
</div>
<div style="padding:12px 14px;font-size:13px;line-height:1.9;color:#1f2937;">
<div><strong style="color:#374151;">Client:</strong> <span style="color:#111827;">${task._clientName}</span></div>
<div><strong style="color:#374151;">Type:</strong> <span style="color:#111827;">${task._typeLbl}</span></div>
<div><strong style="color:#374151;">Technician:</strong> <span style="color:#111827;">${task._techName}</span></div>
<div><strong style="color:#374151;">Date:</strong> <span style="color:#111827;">${task.scheduled_date || ""}</span></div>
<div><strong style="color:#374151;">Time:</strong> <span style="color:#111827;">${task._timeRange}</span></div>
${task.address_display ? `<div><strong style="color:#374151;">Address:</strong> <span style="color:#111827;">${task.address_display}</span></div>` : ""}
${task.travel_time_minutes ? `<div><strong style="color:#374151;">Travel:</strong> <span style="color:#111827;">${task.travel_time_minutes} min</span></div>` : ""}
<div style="padding:10px 16px 6px;display:flex;gap:6px;flex-wrap:wrap;">
<span style="display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;background:#f1f5f9;color:#334155;padding:3px 10px;border-radius:4px;">
<span style="opacity:.5;">&#xf02b;</span>${task._typeLbl}
</span>
<span style="display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;background:#f1f5f9;color:#334155;padding:3px 10px;border-radius:4px;">
<span style="opacity:.5;">&#xf017;</span>${task._timeRange}
</span>
${task.travel_time_minutes ? `<span style="display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;background:#f1f5f9;color:#334155;padding:3px 10px;border-radius:4px;"><span style="opacity:.5;">&#xf1b9;</span>${task.travel_time_minutes} min</span>` : ""}
</div>
<div style="padding:8px 14px 12px;border-top:1px solid #e5e7eb;display:flex;gap:10px;">
<div style="padding:8px 16px 12px;font-size:12px;line-height:1.7;color:#374151;">
<div style="display:flex;align-items:center;gap:6px;"><span style="color:#9ca3af;width:14px;text-align:center;">&#x1F464;</span><span>${task._techName}</span></div>
<div style="display:flex;align-items:center;gap:6px;"><span style="color:#9ca3af;width:14px;text-align:center;">&#x1F4C5;</span><span>${task.scheduled_date || "No date"}</span></div>
${task.address_display ? `<div style="display:flex;align-items:flex-start;gap:6px;"><span style="color:#9ca3af;width:14px;text-align:center;flex-shrink:0;">&#x1F4CD;</span><span>${task.address_display}</span></div>` : ""}
</div>
<div style="padding:6px 16px 14px;display:flex;gap:8px;align-items:center;">
<button onclick="window.__fusionMapOpenTask(${task.id})"
style="background:${c};color:#fff;border:none;padding:6px 16px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600;">
style="background:${c};color:#fff;border:none;padding:7px 20px;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;transition:opacity .15s;">
Open Task
</button>
<a href="https://www.google.com/maps/dir/?api=1&destination=${task.address_lat && task.address_lng ? task.address_lat + ',' + task.address_lng : encodeURIComponent(task.address_display || "")}"
target="_blank" style="color:${c};text-decoration:none;font-size:13px;font-weight:600;line-height:32px;">
Navigate &rarr;
<a href="https://www.google.com/maps/dir/?api=1&destination=${navDest}"
target="_blank" style="color:${c};text-decoration:none;font-size:12px;font-weight:600;padding:7px 4px;">
Navigate &#x2192;
</a>
</div>
</div>`;
@@ -605,26 +1016,69 @@ export class FusionTaskMapController extends Component {
this.state.showTechnicians = !this.state.showTechnicians;
this._renderMarkers();
}
toggleRoute() {
this.state.showRoute = !this.state.showRoute;
if (this.state.showRoute) {
this._renderRoute();
} else {
this._clearRoute();
}
}
onRefresh() {
this.state.loading = true;
this._loadAndRender();
}
openTask(taskId) {
this.actionService.switchView("form", { resId: taskId });
async openTask(taskId) {
if (!taskId) return;
try {
await this.actionService.doAction(
{
type: "ir.actions.act_window",
res_model: "fusion.technician.task",
res_id: taskId,
view_mode: "form",
views: [[false, "form"]],
target: "new",
context: { dialog_size: "extra-large" },
},
{ onClose: () => this._softRefresh() },
);
} catch (e) {
console.error("[FusionMap] openTask failed:", e);
this.actionService.doAction({
type: "ir.actions.act_window",
res_model: "fusion.technician.task",
res_id: taskId,
view_mode: "form",
views: [[false, "form"]],
target: "current",
});
}
}
createNewTask() {
this.actionService.doAction({
type: "ir.actions.act_window",
res_model: "fusion.technician.task",
views: [[false, "form"]],
target: "new",
context: { default_task_type: "delivery", dialog_size: "extra-large" },
}, {
onClose: () => {
// Refresh map data after dialog closes (task may have been created)
this.onRefresh();
},
});
async createNewTask() {
try {
await this.actionService.doAction(
{
type: "ir.actions.act_window",
res_model: "fusion.technician.task",
view_mode: "form",
views: [[false, "form"]],
target: "new",
context: { default_task_type: "delivery", dialog_size: "extra-large" },
},
{ onClose: () => this._softRefresh() },
);
} catch (e) {
console.error("[FusionMap] createNewTask failed:", e);
this.actionService.doAction({
type: "ir.actions.act_window",
res_model: "fusion.technician.task",
view_mode: "form",
views: [[false, "form"]],
target: "current",
context: { default_task_type: "delivery" },
});
}
}
}

View File

@@ -113,6 +113,11 @@
<i class="fa fa-building-o me-1"/>
<t t-esc="task._sourceLabel"/>
</span>
<span class="fc_task_edit_btn"
t-on-click.stop="() => this.openTask(task.id)"
title="Edit task">
<i class="fa fa-pencil me-1"/>Edit
</span>
</div>
</div>
</t>
@@ -170,6 +175,11 @@
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#a855f7;"/>Upcoming</span>
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#9ca3af;"/>Yesterday</span>
<span class="flex-grow-1"/>
<button class="btn btn-sm d-flex align-items-center gap-1"
t-att-class="state.showRoute ? 'btn-info' : 'btn-outline-secondary'"
t-on-click="toggleRoute" title="Toggle route animation">
<i class="fa fa-road"/>Route
</button>
<button class="btn btn-sm d-flex align-items-center gap-1"
t-att-class="state.showTraffic ? 'btn-warning' : 'btn-outline-secondary'"
t-on-click="toggleTraffic" title="Toggle traffic layer">

View File

@@ -41,15 +41,26 @@
<field name="x_fc_client_type" string="Client Type"
invisible="x_fc_sale_type not in ('adp', 'adp_odsp')"/>
<!-- Delivery Status -->
<field name="x_fc_show_delivery_datetime" invisible="1"/>
<field name="x_fc_delivery_status" string="Delivery Status"/>
<field name="x_fc_delivery_datetime" string="Delivery Date/Time"
invisible="not x_fc_show_delivery_datetime"/>
</xpath>
</field>
</record>
<!-- ===================================================================== -->
<!-- SALE ORDER FORM: Move Salesperson to header (after Quotation Template) -->
<!-- ===================================================================== -->
<record id="view_order_form_fusion_claims_salesperson" model="ir.ui.view">
<field name="name">sale.order.form.fusion.central.salesperson</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="priority">51</field>
<field name="arch" type="xml">
<field name="sale_order_template_id" position="after">
<field name="user_id" widget="many2one_avatar_user"/>
</field>
<xpath expr="//page[@name='other_information']//field[@name='user_id']" position="replace"/>
</field>
</record>
<!-- ===================================================================== -->
<!-- SALE ORDER FORM: March of Dimes Case Details -->
<!-- ===================================================================== -->
@@ -1183,12 +1194,12 @@
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status != 'quotation'"
help="Move to Assessment Scheduled status"/>
<!-- Assessment Scheduled -> Complete Assessment -->
<!-- Assessment Scheduled (or Quotation override) -> Complete Assessment -->
<button name="action_complete_assessment" type="object"
string="Complete Assessment" class="btn-info"
icon="fa-check-square-o"
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status != 'assessment_scheduled'"
help="Mark assessment as completed"/>
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status not in ('quotation', 'assessment_scheduled')"
help="Mark assessment as completed (override available from Quotation stage)"/>
<!-- Waiting for Application -> Application Received -->
<button name="action_application_received" type="object"
@@ -1260,13 +1271,13 @@
<button name="%(fusion_claims.action_set_status_on_hold)d"
type="action" string="Put On Hold" class="btn-warning"
icon="fa-pause"
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status not in ('approved', 'approved_deduction', 'ready_delivery', 'ready_bill')"
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status not in ('submitted', 'resubmitted', 'needs_correction', 'accepted', 'approved', 'approved_deduction', 'ready_delivery', 'ready_bill')"
help="Put this application on hold"/>
<button name="%(fusion_claims.action_set_status_withdrawn)d"
type="action" string="Withdraw" class="btn-secondary"
icon="fa-undo"
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status not in ('approved', 'approved_deduction', 'ready_bill')"
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status not in ('submitted', 'resubmitted', 'needs_correction', 'accepted', 'approved', 'approved_deduction', 'ready_bill')"
help="Withdraw this application"/>
<!-- ============================================================ -->
@@ -1277,14 +1288,14 @@
<button name="action_open_submission_verification_wizard" type="object"
string="Review Submission" class="fc-btn-status-good"
icon="fa-check-circle"
invisible="not x_fc_is_adp_sale or not x_fc_submission_verified or x_fc_adp_application_status in ('quotation', 'assessment_scheduled', 'assessment_completed', 'waiting_for_application', 'application_received', 'ready_submission', 'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed', 'denied', 'withdrawn', 'cancelled', 'expired')"
invisible="not x_fc_is_adp_sale or not x_fc_submission_verified or x_fc_adp_application_status in ('quotation', 'assessment_scheduled', 'assessment_completed', 'waiting_for_application', 'application_received', 'ready_submission', 'needs_correction', 'approved', 'approved_deduction', 'ready_delivery', 'ready_bill', 'billed', 'case_closed', 'on_hold', 'denied', 'withdrawn', 'cancelled', 'expired')"
help="Submission verified - click to review"/>
<!-- Review Submission: LIGHT RED when not yet verified -->
<button name="action_open_submission_verification_wizard" type="object"
string="Review Submission" class="fc-btn-status-bad"
icon="fa-exclamation-triangle"
invisible="not x_fc_is_adp_sale or x_fc_submission_verified or x_fc_adp_application_status in ('quotation', 'assessment_scheduled', 'assessment_completed', 'waiting_for_application', 'application_received', 'ready_submission', 'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed', 'denied', 'withdrawn', 'cancelled', 'expired')"
invisible="not x_fc_is_adp_sale or x_fc_submission_verified or x_fc_adp_application_status in ('quotation', 'assessment_scheduled', 'assessment_completed', 'waiting_for_application', 'application_received', 'ready_submission', 'needs_correction', 'approved', 'approved_deduction', 'ready_delivery', 'ready_bill', 'billed', 'case_closed', 'on_hold', 'denied', 'withdrawn', 'cancelled', 'expired')"
help="Submission not yet verified - click to review"/>
<!-- Review Approval: GREEN when all devices approved -->
@@ -1549,7 +1560,7 @@
<!-- Application Details - Show after Ready for Submission stage -->
<group string="Application Details" invisible="not x_fc_stage_after_ready_submission">
<group>
<field name="x_fc_client_ref_1" placeholder="e.g., DOJO"
<field name="x_fc_client_ref_1" placeholder="e.g., JODO"
required="x_fc_stage_after_ready_submission"
readonly="x_fc_case_locked"/>
<field name="x_fc_client_ref_2" placeholder="e.g., 1234"

View File

@@ -90,15 +90,6 @@
</field>
</record>
<!-- ================================================================== -->
<!-- MAP VIEW (QWeb HTML with Google Maps) -->
<!-- ================================================================== -->
<record id="action_technician_location_map" model="ir.actions.act_url">
<field name="name">Technician Map</field>
<field name="url">/my/technician/admin/map</field>
<field name="target">self</field>
</record>
<!-- ================================================================== -->
<!-- MENU ITEMS (under Technician Management) -->
<!-- ================================================================== -->
@@ -106,13 +97,7 @@
name="Location History"
parent="menu_technician_management"
action="action_technician_locations"
sequence="40"/>
<menuitem id="menu_technician_map"
name="Live Map"
parent="menu_technician_management"
action="action_technician_location_map"
sequence="45"/>
sequence="50"/>
<!-- CRON: Cleanup old location records (runs daily) -->
<record id="ir_cron_cleanup_technician_locations" model="ir.cron">

View File

@@ -194,10 +194,11 @@
<field name="partner_phone" widget="phone"/>
</group>
<group string="Location">
<field name="address_partner_id"/>
<field name="address_street"/>
<field name="address_street2" string="Unit/Suite #"/>
<field name="address_buzz_code"/>
<field name="is_in_store"/>
<field name="address_partner_id" invisible="is_in_store"/>
<field name="address_street" readonly="is_in_store"/>
<field name="address_street2" string="Unit/Suite #" invisible="is_in_store"/>
<field name="address_buzz_code" invisible="is_in_store"/>
<field name="address_city" invisible="1"/>
<field name="address_state_id" invisible="1"/>
<field name="address_zip" invisible="1"/>
@@ -505,35 +506,35 @@
sequence="5"
groups="fusion_claims.group_fusion_claims_user,fusion_claims.group_field_technician"/>
<menuitem id="menu_technician_tasks_today"
name="Today's Tasks"
parent="menu_technician_management"
action="action_technician_tasks_today"
sequence="10"/>
<menuitem id="menu_technician_schedule"
name="Schedule"
parent="menu_technician_management"
action="action_technician_schedule"
sequence="10"/>
<menuitem id="menu_technician_tasks"
name="Tasks"
parent="menu_technician_management"
action="action_technician_tasks"
sequence="20"/>
sequence="15"/>
<menuitem id="menu_technician_tasks_pending"
name="Pending Tasks"
parent="menu_technician_management"
action="action_technician_tasks_pending"
sequence="13"/>
sequence="20"/>
<menuitem id="menu_technician_tasks_today"
name="Today's Tasks"
<menuitem id="menu_technician_tasks"
name="All Tasks"
parent="menu_technician_management"
action="action_technician_tasks_today"
sequence="15"/>
action="action_technician_tasks"
sequence="30"/>
<menuitem id="menu_technician_my_tasks"
name="My Tasks"
parent="menu_technician_management"
action="action_technician_my_tasks"
sequence="25"
sequence="35"
groups="fusion_claims.group_field_technician"/>

View File

@@ -11,7 +11,6 @@ _logger = logging.getLogger(__name__)
class AssessmentCompletedWizard(models.TransientModel):
"""Wizard to record assessment completion date."""
_name = 'fusion_claims.assessment.completed.wizard'
_description = 'Assessment Completed Wizard'
@@ -21,18 +20,49 @@ class AssessmentCompletedWizard(models.TransientModel):
required=True,
readonly=True,
)
is_override = fields.Boolean(
string='Scheduling Override',
compute='_compute_is_override',
store=False,
)
assessment_start_date = fields.Date(
string='Assessment Start Date',
required=True,
help='Date the assessment was conducted',
)
completion_date = fields.Date(
string='Assessment Completion Date',
required=True,
default=fields.Date.context_today,
)
notes = fields.Text(
string='Assessment Notes',
help='Any notes from the assessment',
string='Notes',
help='Notes from the assessment',
)
override_reason = fields.Text(
string='Override Reason',
help='Mandatory when skipping the scheduling step. Explain why the assessment was completed without scheduling through the system.',
)
notify_authorizer = fields.Boolean(
string='Notify Authorizer',
default=True,
help='Send email to the authorizer about assessment completion',
)
@api.depends('sale_order_id')
def _compute_is_override(self):
for rec in self:
rec.is_override = (
rec.sale_order_id
and rec.sale_order_id.x_fc_adp_application_status == 'quotation'
)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
@@ -40,43 +70,174 @@ class AssessmentCompletedWizard(models.TransientModel):
if active_id:
order = self.env['sale.order'].browse(active_id)
res['sale_order_id'] = order.id
if order.x_fc_assessment_start_date:
res['assessment_start_date'] = order.x_fc_assessment_start_date
else:
res['assessment_start_date'] = fields.Date.context_today(self)
return res
def action_complete(self):
"""Mark assessment as completed."""
self.ensure_one()
order = self.sale_order_id
if order.x_fc_adp_application_status != 'assessment_scheduled':
raise UserError("Can only complete assessment from 'Assessment Scheduled' status.")
# Validate completion date is not before start date
if order.x_fc_assessment_start_date and self.completion_date < order.x_fc_assessment_start_date:
current_status = order.x_fc_adp_application_status
is_override = current_status == 'quotation'
if current_status not in ('quotation', 'assessment_scheduled'):
raise UserError(
f"Completion date ({self.completion_date}) cannot be before "
f"assessment start date ({order.x_fc_assessment_start_date})."
_("Can only complete assessment from 'Quotation' or 'Assessment Scheduled' status.")
)
# Update sale order
order.with_context(skip_status_validation=True).write({
if is_override and not (self.override_reason or '').strip():
raise UserError(
_("Override Reason is mandatory when skipping the assessment scheduling step. "
"Please explain why this assessment was completed without being scheduled through the system.")
)
if self.completion_date < self.assessment_start_date:
raise UserError(
_("Completion date (%s) cannot be before assessment start date (%s).")
% (self.completion_date, self.assessment_start_date)
)
write_vals = {
'x_fc_adp_application_status': 'assessment_completed',
'x_fc_assessment_end_date': self.completion_date,
})
# Post to chatter
notes_html = f'<p style="margin: 4px 0 0 0;"><strong>Notes:</strong> {self.notes}</p>' if self.notes else ''
order.message_post(
body=Markup(
'<div style="background: #d4edda; border-left: 4px solid #28a745; padding: 12px; margin: 8px 0; border-radius: 4px;">'
'<h4 style="color: #28a745; margin: 0 0 8px 0;"><i class="fa fa-check-square-o"/> Assessment Completed</h4>'
f'<p style="margin: 0;"><strong>Completion Date:</strong> {self.completion_date.strftime("%B %d, %Y")}</p>'
f'{notes_html}'
}
if is_override or not order.x_fc_assessment_start_date:
write_vals['x_fc_assessment_start_date'] = self.assessment_start_date
order.with_context(skip_status_validation=True).write(write_vals)
if is_override:
override_html = Markup(
'<div style="background:#fff3cd;border-left:4px solid #ffc107;padding:12px;margin:8px 0;border-radius:4px;">'
'<h4 style="color:#856404;margin:0 0 8px 0;">'
'<i class="fa fa-exclamation-triangle"/> Assessment Scheduling Override</h4>'
'<p style="margin:0;"><strong>Override by:</strong> %s</p>'
'<p style="margin:4px 0 0 0;"><strong>Reason:</strong> %s</p>'
'<p style="margin:4px 0 0 0;"><strong>Assessment Date:</strong> %s to %s</p>'
'%s'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
) % (
self.env.user.name,
self.override_reason.strip(),
self.assessment_start_date.strftime("%B %d, %Y"),
self.completion_date.strftime("%B %d, %Y"),
Markup('<p style="margin:4px 0 0 0;"><strong>Notes:</strong> %s</p>') % self.notes if self.notes else Markup(''),
)
order.message_post(
body=override_html,
message_type='notification',
subtype_xmlid='mail.mt_note',
)
else:
notes_html = (
Markup('<p style="margin:4px 0 0 0;"><strong>Notes:</strong> %s</p>') % self.notes
) if self.notes else Markup('')
order.message_post(
body=Markup(
'<div style="background:#d4edda;border-left:4px solid #28a745;padding:12px;margin:8px 0;border-radius:4px;">'
'<h4 style="color:#28a745;margin:0 0 8px 0;">'
'<i class="fa fa-check-square-o"/> Assessment Completed</h4>'
'<p style="margin:0;"><strong>Completion Date:</strong> %s</p>'
'%s'
'</div>'
) % (self.completion_date.strftime("%B %d, %Y"), notes_html),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
if self.notify_authorizer:
self._send_backend_completion_email(order, is_override)
return {'type': 'ir.actions.act_window_close'}
def _send_backend_completion_email(self, order, is_override):
"""Send assessment completion email when done from backend."""
self.ensure_one()
if not order._email_is_enabled():
return
authorizer = order.x_fc_authorizer_id
if not authorizer or not authorizer.email:
_logger.info("No authorizer email for %s, skipping notification", order.name)
return
to_email = authorizer.email
cc_emails = []
if order.user_id and order.user_id.email:
cc_emails.append(order.user_id.email)
company = self.env.company
office_partners = company.sudo().x_fc_office_notification_ids
cc_emails.extend([p.email for p in office_partners if p.email])
client_name = order.partner_id.name or 'Client'
override_note = ''
if is_override:
override_note = (
'<div style="background:#fff3cd;border-left:3px solid #ffc107;padding:8px 12px;'
'margin:12px 0;border-radius:4px;">'
'<strong>Note:</strong> This assessment was completed without being scheduled '
'through the system. '
f'<strong>Reason:</strong> {self.override_reason.strip()}'
'</div>'
)
sections = [
('Assessment Details', [
('Client', client_name),
('Case', order.name),
('Assessment Date', f"{self.assessment_start_date.strftime('%B %d, %Y')} to {self.completion_date.strftime('%B %d, %Y')}"),
('Completed by', self.env.user.name),
]),
]
if self.notes:
sections.append(('Notes', [('', self.notes)]))
summary = (
f'The assessment for <strong>{client_name}</strong> ({order.name}) '
f'has been completed on {self.completion_date.strftime("%B %d, %Y")}.'
)
if is_override:
summary += f' {override_note}'
email_body = order._email_build(
title='Assessment Completed',
summary=summary,
email_type='success',
sections=sections,
note='<strong>Next step:</strong> Please submit the ADP application '
'(including pages 11-12 signed by the client) so we can proceed.',
button_url=f'{order.get_base_url()}/web#id={order.id}&model=sale.order&view_type=form',
button_text='View Case',
sender_name=order.user_id.name if order.user_id else 'The Team',
)
try:
self.env['mail.mail'].sudo().create({
'subject': f'Assessment Completed - {client_name} - {order.name}',
'body_html': email_body,
'email_to': to_email,
'email_cc': ', '.join(cc_emails) if cc_emails else False,
'model': 'sale.order',
'res_id': order.id,
'auto_delete': True,
}).send()
order.message_post(
body=Markup(
'<div class="alert alert-info" role="alert">'
'<strong>Assessment Completed email sent</strong>'
'<ul class="mb-0 mt-1"><li>To: %s</li>'
'<li>CC: %s</li></ul></div>'
) % (to_email, ', '.join(cc_emails) or 'None'),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
_logger.info("Sent backend assessment completed email for %s", order.name)
except Exception as e:
_logger.error("Failed to send assessment completed email for %s: %s", order.name, e)

View File

@@ -1,18 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Assessment Completed Wizard Form View -->
<record id="view_assessment_completed_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.assessment.completed.wizard.form</field>
<field name="model">fusion_claims.assessment.completed.wizard</field>
<field name="arch" type="xml">
<form string="Assessment Completed">
<field name="sale_order_id" invisible="1"/>
<field name="is_override" invisible="1"/>
<div invisible="not is_override"
class="alert alert-warning mb-3" role="alert">
<strong>Scheduling Override:</strong>
This assessment was not scheduled through the system.
A reason is required to proceed.
</div>
<group>
<field name="sale_order_id" invisible="1"/>
<field name="completion_date"/>
<field name="notes" placeholder="Enter any notes from the assessment..."/>
<group string="Assessment Dates">
<field name="assessment_start_date"/>
<field name="completion_date"/>
</group>
<group string="Details">
<field name="notes"
placeholder="Enter any notes from the assessment..."/>
<field name="notify_authorizer"/>
</group>
</group>
<group invisible="not is_override">
<field name="override_reason"
required="is_override"
placeholder="e.g., Authorizer completed the assessment independently and sent us the application directly..."
widget="text"/>
</group>
<footer>
<button name="action_complete" type="object"
<button name="action_complete" type="object"
string="Mark Complete" class="btn-primary"
icon="fa-check"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
@@ -21,7 +44,6 @@
</field>
</record>
<!-- Action for the wizard -->
<record id="action_assessment_completed_wizard" model="ir.actions.act_window">
<field name="name">Assessment Completed</field>
<field name="res_model">fusion_claims.assessment.completed.wizard</field>

View File

@@ -34,11 +34,11 @@ class ReadyForSubmissionWizard(models.TransientModel):
# Client References (may already be filled)
client_ref_1 = fields.Char(
string='Client Reference 1',
help='First client reference number (e.g., PO number)',
help='First two letters of the client\'s first name and last two letters of their last name. Example: John Doe = JODO',
)
client_ref_2 = fields.Char(
string='Client Reference 2',
help='Second client reference number',
help='Last four digits of the client\'s health card number. Example: 1234',
)
# Reason for Application

View File

@@ -59,7 +59,7 @@
<field name="claim_authorization_date"/>
</group>
<group string="Client References">
<field name="client_ref_1" placeholder="e.g., DOJO"/>
<field name="client_ref_1" placeholder="e.g., JODO"/>
<field name="client_ref_2" placeholder="e.g., 1234"/>
</group>
</group>