Compare commits

..

21 Commits

Author SHA1 Message Date
f3766c2898 feat: add x_fc_authorizer_number, x_fc_account_number, x_marked_for fields; auto-link authorizer from XML
- fusion_claims: added x_fc_authorizer_number to res.partner for ADP authorizer registration numbers
- fusion_claims: XML parser auto-links authorizer contact to sale order by ADP number
- fusion_claims: removed size=9 constraint from x_fc_odsp_member_id
- fusion_claims: authorizer number shown on OT/PT contact form
- fusion_so_to_po: added x_marked_for (Many2one) field definition on purchase.order
- fusion_so_to_po: added x_fc_account_number on res.partner for vendor account numbers
2026-03-11 17:22:02 +00:00
431052920e feat: separate fusion field service and LTC into standalone modules, update core modules
- fusion_claims: separated field service logic, updated controllers/views
- fusion_tasks: updated task views and map integration
- fusion_authorizer_portal: added page 11 signing, schedule booking, migrations
- fusion_shipping: new standalone shipping module (Canada Post, FedEx, DHL, Purolator)
- fusion_ltc_management: new standalone LTC management module
2026-03-11 16:19:52 +00:00
1f79cdcaaf fix: improve AI chat table rendering with CSS styling and narrow-panel formatting
- Add SCSS for AI chat tables: borders, padding, zebra striping, hover, dark mode
- Style headings, code, bold text, and lists in chat messages
- Update system prompt: enforce 3-column max tables for narrow chat panel
- Use key-value (2-column) tables for summaries, split wide data into sections
- Provide explicit correct/wrong format examples in prompt
2026-03-10 02:54:04 +00:00
8761d0e7c7 feat: add Demographics & Analytics tool to Fusion Claims Intelligence
- Add Tool 6 for demographic analysis using direct SQL queries
- Age group breakdowns: clients, applications, avg apps/client, avg funding
- Device popularity by age bracket (under 45, 45-60, 61-75, 75+)
- City demographics with average age and funding per city
- Benefit type analysis (ODSP, OWP, ACSD, Regular)
- Top devices with average client age
- Overall funding summary (totals, averages, age range)
- Update AI topic and system prompt with Tool 6 routing examples
2026-03-10 02:45:51 +00:00
0053576cc2 fix: enable rich text markdown formatting for AI agent responses
- Install markdown2 dependency for Odoo AI module
- Update system prompt with explicit markdown formatting instructions
- Add example templates for client status and billing period responses
- Use tables, bold, headings, and code formatting for clean output
2026-03-10 02:39:02 +00:00
7bd7b8f7c4 fix: enhance Fusion Claims Intelligence AI with client status and billing period tools
- Fix _read_group override crash (dict_values not subscriptable) in sale_order.py
- Migrate _fc_tool_claims_stats from deprecated read_group() to _read_group() API
- Enrich client details tool with funding history, invoice status, prev-funded devices
- Add Client Status Lookup tool (search by name, returns orders/invoices/next steps)
- Add ADP Billing Period tool (invoiced amounts, paid/unpaid, submission deadlines)
- Update AI agent system prompt with all 5 tools and usage examples
2026-03-10 02:30:42 +00:00
3342b57469 feat: reorder search views - Customer first, add delivery/tags/status fields for ADP, ODSP, MOD 2026-03-10 01:46:15 +00:00
1bfa50aa5f feat: View Details uses ADP landscape report for ADP orders, add route decorators 2026-03-09 22:55:53 +00:00
85367747a6 fix: remove _get_display_grouped_section() call causing 500 error on portal 2026-03-09 22:49:55 +00:00
d7657bb356 feat: add borders, ADP Device Code, ADP/Client Portion columns and subtotals to portal view 2026-03-09 22:46:16 +00:00
9dac39853f fix: revert POD signature to original layout - only quotation reports need organized signature 2026-03-09 22:32:27 +00:00
c1a3b02ac5 fix: improve ADP report signature section with legal terms, date/time, printed name; switch portal to landscape report 2026-03-09 22:03:46 +00:00
1f750a6db4 fix: improve ADP report signature section with legal terms, date/time, printed name; switch portal to landscape report 2026-03-09 22:03:28 +00:00
ffcc83d7bd fix: improve ADP report signature section with legal terms, date/time, printed name; switch portal to landscape report 2026-03-09 22:03:12 +00:00
6c3c565440 fix: improve ADP report signature section with legal terms, date/time, printed name; switch portal to landscape report 2026-03-09 22:02:53 +00:00
1c191a54e1 fix: ADP portal sign/pay modal text - show client portion, not full total 2026-03-09 21:34:55 +00:00
512aedce69 fix: ADP portal - sidebar amount, claim details, signature report 2026-03-09 21:25:37 +00:00
f362fbd915 fix: ADP portal - sidebar amount, claim details, signature report 2026-03-09 21:25:05 +00:00
Nexa Agent
35399170b3 fix: ADP portal payment uses client portion instead of full order total
When customers pay for ADP quotations through the portal, the system
was charging the full order amount (ADP + client portions combined).
Now correctly charges only the client portion (25% for REG clients).

Changes:
- Override _get_prepayment_required_amount() to return client portion
- Override _has_to_be_paid() to skip payment for 100% ADP-funded orders
- Add portal controller to cap payment amount at client portion
- Add portal template showing ADP funding breakdown to customer
2026-03-09 21:11:19 +00:00
gsinghpal
3b3c57205a feat: add fusion_tasks module for field service management
Standalone module extracted from fusion_claims providing technician
scheduling, route simulation with Google Maps, GPS tracking, and
cross-instance task sync between odoo-westin and odoo-mobility.

Includes fix for route simulation: added Directions API error logging
to diagnose silent failures from conflicting Google Maps API keys.

Made-with: Cursor
2026-03-09 16:56:53 -04:00
gsinghpal
b649246e81 fix: auto-set rental sale type via onchange and context
is_rental_order is a computed field, not passed in vals during create.
Use in_rental_app context flag in create() and add onchange handler
so sale type sets to 'Rentals' immediately when toggled in the form.

Made-with: Cursor
2026-02-26 08:04:06 -05:00
296 changed files with 61197 additions and 4989 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
**/__pycache__/
*.pyc

View File

@@ -2,3 +2,29 @@
from . import models from . import models
from . import controllers from . import controllers
def _reactivate_views(env):
"""Ensure all module views are active after install/update.
Odoo silently deactivates inherited views when an xpath fails
validation (e.g. parent view structure changed between versions).
Once deactivated, subsequent -u runs never reactivate them.
This hook prevents that from silently breaking the portal.
"""
views = env['ir.ui.view'].sudo().search([
('key', 'like', 'fusion_authorizer_portal.%'),
('active', '=', False),
])
if views:
views.write({'active': True})
env.cr.execute("""
SELECT key FROM ir_ui_view
WHERE key LIKE 'fusion_authorizer_portal.%%'
AND id = ANY(%s)
""", [views.ids])
keys = [r[0] for r in env.cr.fetchall()]
import logging
logging.getLogger(__name__).warning(
"Reactivated %d deactivated views: %s", len(keys), keys
)

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
{ {
'name': 'Fusion Authorizer & Sales Portal', 'name': 'Fusion Authorizer & Sales Portal',
'version': '19.0.2.2.0', 'version': '19.0.2.5.0',
'category': 'Sales/Portal', 'category': 'Sales/Portal',
'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms', 'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms',
'description': """ 'description': """
@@ -50,8 +50,10 @@ This module provides external portal access for:
'website', 'website',
'mail', 'mail',
'calendar', 'calendar',
'appointment',
'knowledge', 'knowledge',
'fusion_claims', 'fusion_claims',
'fusion_tasks',
], ],
'data': [ 'data': [
# Security # Security
@@ -62,6 +64,7 @@ This module provides external portal access for:
'data/portal_menu_data.xml', 'data/portal_menu_data.xml',
'data/ir_actions_server_data.xml', 'data/ir_actions_server_data.xml',
'data/welcome_articles.xml', 'data/welcome_articles.xml',
'data/appointment_invite_data.xml',
# Views # Views
'views/res_partner_views.xml', 'views/res_partner_views.xml',
'views/sale_order_views.xml', 'views/sale_order_views.xml',
@@ -76,7 +79,8 @@ This module provides external portal access for:
'views/portal_accessibility_forms.xml', 'views/portal_accessibility_forms.xml',
'views/portal_technician_templates.xml', 'views/portal_technician_templates.xml',
'views/portal_book_assessment.xml', 'views/portal_book_assessment.xml',
'views/portal_repair_form.xml', 'views/portal_schedule.xml',
'views/portal_page11_sign_templates.xml',
], ],
'assets': { 'assets': {
'web.assets_backend': [ 'web.assets_backend': [
@@ -93,9 +97,11 @@ This module provides external portal access for:
'fusion_authorizer_portal/static/src/js/pdf_field_editor.js', 'fusion_authorizer_portal/static/src/js/pdf_field_editor.js',
'fusion_authorizer_portal/static/src/js/technician_push.js', 'fusion_authorizer_portal/static/src/js/technician_push.js',
'fusion_authorizer_portal/static/src/js/technician_location.js', 'fusion_authorizer_portal/static/src/js/technician_location.js',
'fusion_authorizer_portal/static/src/js/portal_schedule_booking.js',
], ],
}, },
'images': ['static/description/icon.png'], 'images': ['static/description/icon.png'],
'post_init_hook': '_reactivate_views',
'installable': True, 'installable': True,
'application': False, 'application': False,
'auto_install': False, 'auto_install': False,

View File

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

View File

@@ -26,6 +26,7 @@ class AuthorizerPortal(CustomerPortal):
if hasattr(response, 'qcontext') and (partner.is_authorizer or partner.is_sales_rep_portal or partner.is_client_portal or partner.is_technician_portal): if hasattr(response, 'qcontext') and (partner.is_authorizer or partner.is_sales_rep_portal or partner.is_client_portal or partner.is_technician_portal):
posting_info = self._get_adp_posting_info() posting_info = self._get_adp_posting_info()
response.qcontext.update(posting_info) response.qcontext.update(posting_info)
response.qcontext.update(self._get_clock_status_data())
# Add signature count (documents to sign) - only if Sign module is installed # Add signature count (documents to sign) - only if Sign module is installed
sign_count = 0 sign_count = 0
@@ -724,7 +725,7 @@ class AuthorizerPortal(CustomerPortal):
'sale_type_filter': sale_type, 'sale_type_filter': sale_type,
'status_filter': status, 'status_filter': status,
} }
values.update(self._get_clock_status_data())
return request.render('fusion_authorizer_portal.portal_sales_dashboard', values) return request.render('fusion_authorizer_portal.portal_sales_dashboard', values)
@http.route(['/my/sales/cases', '/my/sales/cases/page/<int:page>'], type='http', auth='user', website=True) @http.route(['/my/sales/cases', '/my/sales/cases/page/<int:page>'], type='http', auth='user', website=True)
@@ -1090,14 +1091,60 @@ class AuthorizerPortal(CustomerPortal):
_logger.error(f"Error downloading proof of delivery: {e}") _logger.error(f"Error downloading proof of delivery: {e}")
return request.redirect('/my/funding-claims') return request.redirect('/my/funding-claims')
# ==================== CLOCK STATUS HELPER ====================
def _get_clock_status_data(self):
"""Get clock in/out status for the current portal user."""
try:
user = request.env.user
Employee = request.env['hr.employee'].sudo()
employee = Employee.search([('user_id', '=', user.id)], limit=1)
if not employee:
employee = Employee.search([
('name', '=', user.partner_id.name),
('user_id', '=', False),
], limit=1)
if not employee or not getattr(employee, 'x_fclk_enable_clock', False):
return {'clock_enabled': False}
is_checked_in = employee.attendance_state == 'checked_in'
check_in_time = ''
location_name = ''
if is_checked_in:
att = request.env['hr.attendance'].sudo().search([
('employee_id', '=', employee.id),
('check_out', '=', False),
], limit=1)
if att:
check_in_time = att.check_in.isoformat() if att.check_in else ''
location_name = att.x_fclk_location_id.name if att.x_fclk_location_id else ''
return {
'clock_enabled': True,
'clock_checked_in': is_checked_in,
'clock_check_in_time': check_in_time,
'clock_location_name': location_name,
}
except Exception as e:
_logger.warning("Clock status check failed: %s", e)
return {'clock_enabled': False}
# ==================== TECHNICIAN PORTAL ==================== # ==================== TECHNICIAN PORTAL ====================
def _check_technician_access(self): def _check_technician_access(self):
"""Check if current user is a technician portal user.""" """Check if current user is a technician portal user."""
partner = request.env.user.partner_id partner = request.env.user.partner_id
if not partner.is_technician_portal: if partner.is_technician_portal:
return False return True
return True has_tasks = request.env['fusion.technician.task'].sudo().search_count([
'|',
('technician_id', '=', request.env.user.id),
('additional_technician_ids', 'in', [request.env.user.id]),
], limit=1)
if has_tasks:
partner.sudo().write({'is_technician_portal': True})
return True
return False
@http.route(['/my/technician', '/my/technician/dashboard'], type='http', auth='user', website=True) @http.route(['/my/technician', '/my/technician/dashboard'], type='http', auth='user', website=True)
def technician_dashboard(self, **kw): def technician_dashboard(self, **kw):
@@ -1159,6 +1206,8 @@ class AuthorizerPortal(CustomerPortal):
ICP = request.env['ir.config_parameter'].sudo() ICP = request.env['ir.config_parameter'].sudo()
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '') google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
clock_data = self._get_clock_status_data()
values = { values = {
'today_tasks': today_tasks, 'today_tasks': today_tasks,
'current_task': current_task, 'current_task': current_task,
@@ -1174,6 +1223,7 @@ class AuthorizerPortal(CustomerPortal):
'google_maps_api_key': google_maps_api_key, 'google_maps_api_key': google_maps_api_key,
'page_name': 'technician_dashboard', 'page_name': 'technician_dashboard',
} }
values.update(clock_data)
return request.render('fusion_authorizer_portal.portal_technician_dashboard', values) return request.render('fusion_authorizer_portal.portal_technician_dashboard', values)
@http.route(['/my/technician/tasks', '/my/technician/tasks/page/<int:page>'], type='http', auth='user', website=True) @http.route(['/my/technician/tasks', '/my/technician/tasks/page/<int:page>'], type='http', auth='user', website=True)
@@ -1423,11 +1473,17 @@ class AuthorizerPortal(CustomerPortal):
return {'success': False, 'error': str(e)} return {'success': False, 'error': str(e)}
@http.route('/my/technician/task/<int:task_id>/action', type='json', auth='user', website=True) @http.route('/my/technician/task/<int:task_id>/action', type='json', auth='user', website=True)
def technician_task_action(self, task_id, action, **kw): def technician_task_action(self, task_id, action, latitude=None, longitude=None, accuracy=None, **kw):
"""Handle task status changes (start, complete, en_route, cancel).""" """Handle task status changes (start, complete, en_route, cancel).
Location is mandatory -- the client must send GPS coordinates."""
if not self._check_technician_access(): if not self._check_technician_access():
return {'success': False, 'error': 'Access denied'} return {'success': False, 'error': 'Access denied'}
if not latitude or not longitude:
return {'success': False, 'error': 'Location is required. Please enable GPS and try again.'}
if not (-90 <= latitude <= 90 and -180 <= longitude <= 180):
return {'success': False, 'error': 'Invalid GPS coordinates.'}
user = request.env.user user = request.env.user
Task = request.env['fusion.technician.task'].sudo() Task = request.env['fusion.technician.task'].sudo()
@@ -1439,21 +1495,39 @@ class AuthorizerPortal(CustomerPortal):
): ):
return {'success': False, 'error': 'Task not found or not assigned to you'} return {'success': False, 'error': 'Task not found or not assigned to you'}
request.env['fusion.technician.location'].sudo().log_location(
latitude=latitude,
longitude=longitude,
accuracy=accuracy,
)
# Push location to remote instances for cross-instance visibility
try:
request.env['fusion.task.sync.config'].sudo()._push_technician_location(
user.id, latitude, longitude, accuracy or 0)
except Exception:
pass # Non-blocking: sync failure should not block task action
location_ctx = {
'action_latitude': latitude,
'action_longitude': longitude,
'action_accuracy': accuracy or 0,
}
if action == 'en_route': if action == 'en_route':
task.action_start_en_route() task.with_context(**location_ctx).action_start_en_route()
elif action == 'start': elif action == 'start':
task.action_start_task() task.with_context(**location_ctx).action_start_task()
elif action == 'complete': elif action == 'complete':
completion_notes = kw.get('completion_notes', '') completion_notes = kw.get('completion_notes', '')
if completion_notes: if completion_notes:
task.completion_notes = completion_notes task.completion_notes = completion_notes
task.action_complete_task() task.with_context(**location_ctx).action_complete_task()
elif action == 'cancel': elif action == 'cancel':
task.action_cancel_task() task.with_context(**location_ctx).action_cancel_task()
else: else:
return {'success': False, 'error': f'Unknown action: {action}'} return {'success': False, 'error': f'Unknown action: {action}'}
# For completion, also return next task info
result = { result = {
'success': True, 'success': True,
'status': task.status, 'status': task.status,
@@ -1600,10 +1674,14 @@ class AuthorizerPortal(CustomerPortal):
return {'success': False, 'error': str(e)} return {'success': False, 'error': str(e)}
@http.route('/my/technician/task/<int:task_id>/voice-complete', type='json', auth='user', website=True) @http.route('/my/technician/task/<int:task_id>/voice-complete', type='json', auth='user', website=True)
def technician_voice_complete(self, task_id, transcription, **kw): def technician_voice_complete(self, task_id, transcription, latitude=None, longitude=None, accuracy=None, **kw):
"""Format transcription with GPT and complete the task.""" """Format transcription with GPT and complete the task."""
if not self._check_technician_access(): if not self._check_technician_access():
return {'success': False, 'error': 'Access denied'} return {'success': False, 'error': 'Access denied'}
if not latitude or not longitude:
return {'success': False, 'error': 'Location is required. Please enable GPS and try again.'}
if not (-90 <= latitude <= 90 and -180 <= longitude <= 180):
return {'success': False, 'error': 'Invalid GPS coordinates.'}
user = request.env.user user = request.env.user
Task = request.env['fusion.technician.task'].sudo() Task = request.env['fusion.technician.task'].sudo()
@@ -1675,7 +1753,18 @@ class AuthorizerPortal(CustomerPortal):
'completion_notes': completion_html, 'completion_notes': completion_html,
'voice_note_transcription': transcription, 'voice_note_transcription': transcription,
}) })
task.action_complete_task()
request.env['fusion.technician.location'].sudo().log_location(
latitude=latitude,
longitude=longitude,
accuracy=accuracy,
)
location_ctx = {
'action_latitude': latitude,
'action_longitude': longitude,
'action_accuracy': accuracy or 0,
}
task.with_context(**location_ctx).action_complete_task()
return { return {
'success': True, 'success': True,
@@ -1788,6 +1877,25 @@ class AuthorizerPortal(CustomerPortal):
_logger.warning(f"Location log error: {e}") _logger.warning(f"Location log error: {e}")
return {'success': False} return {'success': False}
@http.route('/my/technician/clock-status', type='json', auth='user', website=True)
def technician_clock_status(self, **kw):
"""Check if the current technician is clocked in.
Returns {clocked_in: bool} so the JS background logger can decide
whether to track location. Replaces the fixed 9-6 hour window.
"""
if not self._check_technician_access():
return {'clocked_in': False}
try:
emp = request.env['hr.employee'].sudo().search([
('user_id', '=', request.env.user.id),
], limit=1)
if emp and emp.attendance_state == 'checked_in':
return {'clocked_in': True}
except Exception:
pass
return {'clocked_in': False}
@http.route('/my/technician/settings/start-location', type='json', auth='user', website=True) @http.route('/my/technician/settings/start-location', type='json', auth='user', website=True)
def technician_save_start_location(self, address='', **kw): def technician_save_start_location(self, address='', **kw):
"""Save the technician's personal start location.""" """Save the technician's personal start location."""
@@ -2055,6 +2163,94 @@ class AuthorizerPortal(CustomerPortal):
_logger.error(f"Error saving POD signature: {e}") _logger.error(f"Error saving POD signature: {e}")
return {'success': False, 'error': str(e)} return {'success': False, 'error': str(e)}
# ==================== TASK-LEVEL POD SIGNATURE ====================
@http.route('/my/technician/task/<int:task_id>/pod', type='http', auth='user', website=True)
def task_pod_signature_page(self, task_id, **kw):
"""Task-level POD signature capture page (works for all tasks including shadow)."""
if not self._check_technician_access():
return request.redirect('/my')
user = request.env.user
Task = request.env['fusion.technician.task'].sudo()
try:
task = Task.browse(task_id)
if not task.exists() or (
task.technician_id.id != user.id
and user.id not in task.additional_technician_ids.ids
):
raise AccessError(_('You do not have access to this task.'))
except (AccessError, MissingError):
return request.redirect('/my/technician/tasks')
values = {
'task': task,
'has_existing_signature': bool(task.pod_signature),
'page_name': 'task_pod_signature',
}
return request.render('fusion_authorizer_portal.portal_task_pod_signature', values)
@http.route('/my/technician/task/<int:task_id>/pod/sign', type='json', auth='user', methods=['POST'])
def task_pod_save_signature(self, task_id, client_name, signature_data, signature_date=None, **kw):
"""Save POD signature directly on a task."""
if not self._check_technician_access():
return {'success': False, 'error': 'Access denied'}
user = request.env.user
Task = request.env['fusion.technician.task'].sudo()
try:
task = Task.browse(task_id)
if not task.exists() or (
task.technician_id.id != user.id
and user.id not in task.additional_technician_ids.ids
):
return {'success': False, 'error': 'Task not found'}
if not client_name or not client_name.strip():
return {'success': False, 'error': 'Client name is required'}
if not signature_data:
return {'success': False, 'error': 'Signature is required'}
if ',' in signature_data:
signature_data = signature_data.split(',')[1]
from datetime import datetime as dt_datetime
sig_date = None
if signature_date:
try:
sig_date = dt_datetime.strptime(signature_date, '%Y-%m-%d').date()
except ValueError:
pass
task.write({
'pod_signature': signature_data,
'pod_client_name': client_name.strip(),
'pod_signature_date': sig_date,
'pod_signed_by_user_id': user.id,
'pod_signed_datetime': fields.Datetime.now(),
})
if task.sale_order_id:
task.sale_order_id.write({
'x_fc_pod_signature': signature_data,
'x_fc_pod_client_name': client_name.strip(),
'x_fc_pod_signature_date': sig_date,
'x_fc_pod_signed_by_user_id': user.id,
'x_fc_pod_signed_datetime': fields.Datetime.now(),
})
return {
'success': True,
'message': 'Signature saved successfully',
'redirect_url': f'/my/technician/task/{task_id}',
}
except Exception as e:
_logger.error(f"Error saving task POD signature: {e}")
return {'success': False, 'error': str(e)}
def _generate_signed_pod_pdf(self, order, save_to_field=True): def _generate_signed_pod_pdf(self, order, save_to_field=True):
"""Generate a signed POD PDF with the signature embedded. """Generate a signed POD PDF with the signature embedded.

View File

@@ -0,0 +1,206 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import base64
import json
import logging
from odoo import http, fields, _
from odoo.http import request
_logger = logging.getLogger(__name__)
class Page11PublicSignController(http.Controller):
def _get_sign_request(self, token):
"""Look up and validate a signing request by token."""
req = request.env['fusion.page11.sign.request'].sudo().search([
('access_token', '=', token),
], limit=1)
if not req:
return None, 'not_found'
if req.state == 'signed':
return req, 'already_signed'
if req.state == 'cancelled':
return req, 'cancelled'
if req.state == 'expired' or (
req.expiry_date and req.expiry_date < fields.Datetime.now()
):
if req.state != 'expired':
req.state = 'expired'
return req, 'expired'
return req, 'ok'
@http.route('/page11/sign/<string:token>', type='http', auth='public',
website=True, sitemap=False)
def page11_sign_form(self, token, **kw):
"""Display the Page 11 signing form."""
sign_req, status = self._get_sign_request(token)
if status == 'not_found':
return request.render(
'fusion_authorizer_portal.portal_page11_sign_invalid', {}
)
if status in ('expired', 'cancelled'):
return request.render(
'fusion_authorizer_portal.portal_page11_sign_expired',
{'sign_request': sign_req},
)
if status == 'already_signed':
return request.render(
'fusion_authorizer_portal.portal_page11_sign_success',
{'sign_request': sign_req, 'token': token},
)
order = sign_req.sale_order_id
partner = order.partner_id
assessment = request.env['fusion.assessment'].sudo().search([
('sale_order_id', '=', order.id),
], limit=1, order='create_date desc')
ICP = request.env['ir.config_parameter'].sudo()
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
client_first_name = ''
client_last_name = ''
client_middle_name = ''
client_health_card = ''
client_health_card_version = ''
if assessment:
client_first_name = assessment.client_first_name or ''
client_last_name = assessment.client_last_name or ''
client_middle_name = assessment.client_middle_name or ''
client_health_card = assessment.client_health_card or ''
client_health_card_version = assessment.client_health_card_version or ''
else:
first, last = order._get_client_name_parts()
client_first_name = first
client_last_name = last
values = {
'sign_request': sign_req,
'order': order,
'partner': partner,
'assessment': assessment,
'company': order.company_id,
'token': token,
'signer_type': sign_req.signer_type,
'is_agent': sign_req.signer_type != 'client',
'google_maps_api_key': google_maps_api_key,
'client_first_name': client_first_name,
'client_last_name': client_last_name,
'client_middle_name': client_middle_name,
'client_health_card': client_health_card,
'client_health_card_version': client_health_card_version,
}
return request.render(
'fusion_authorizer_portal.portal_page11_public_sign', values,
)
@http.route('/page11/sign/<string:token>/submit', type='http',
auth='public', methods=['POST'], website=True,
csrf=True, sitemap=False)
def page11_sign_submit(self, token, **post):
"""Process the submitted Page 11 signature."""
sign_req, status = self._get_sign_request(token)
if status != 'ok':
return request.redirect(f'/page11/sign/{token}')
signature_data = post.get('signature_data', '')
if not signature_data:
return request.redirect(f'/page11/sign/{token}?error=no_signature')
if signature_data.startswith('data:image'):
signature_data = signature_data.split(',', 1)[1]
consent_accepted = post.get('consent_declaration', '') == 'on'
if not consent_accepted:
return request.redirect(f'/page11/sign/{token}?error=no_consent')
signer_name = post.get('signer_name', sign_req.signer_name or '')
chosen_signer_type = post.get('signer_type', sign_req.signer_type or 'client')
consent_signed_by = 'applicant' if chosen_signer_type == 'client' else 'agent'
signer_type_labels = {
'spouse': 'Spouse', 'parent': 'Parent',
'legal_guardian': 'Legal Guardian',
'poa': 'Power of Attorney',
'public_trustee': 'Public Trustee',
}
vals = {
'signature_data': signature_data,
'signer_name': signer_name,
'signer_type': chosen_signer_type,
'consent_declaration_accepted': True,
'consent_signed_by': consent_signed_by,
'signed_date': fields.Datetime.now(),
'state': 'signed',
'client_first_name': post.get('client_first_name', ''),
'client_last_name': post.get('client_last_name', ''),
'client_health_card': post.get('client_health_card', ''),
'client_health_card_version': post.get('client_health_card_version', ''),
}
if consent_signed_by == 'agent':
vals.update({
'agent_first_name': post.get('agent_first_name', ''),
'agent_last_name': post.get('agent_last_name', ''),
'agent_middle_initial': post.get('agent_middle_initial', ''),
'agent_phone': post.get('agent_phone', ''),
'agent_unit': post.get('agent_unit', ''),
'agent_street_number': post.get('agent_street_number', ''),
'agent_street': post.get('agent_street', ''),
'agent_city': post.get('agent_city', ''),
'agent_province': post.get('agent_province', 'Ontario'),
'agent_postal_code': post.get('agent_postal_code', ''),
'signer_relationship': signer_type_labels.get(chosen_signer_type, chosen_signer_type),
})
sign_req.sudo().write(vals)
try:
sign_req.sudo()._generate_signed_pdf()
except Exception as e:
_logger.error("PDF generation failed for sign request %s: %s", sign_req.id, e)
try:
sign_req.sudo()._update_sale_order()
except Exception as e:
_logger.error("Sale order update failed for sign request %s: %s", sign_req.id, e)
return request.render(
'fusion_authorizer_portal.portal_page11_sign_success',
{'sign_request': sign_req, 'token': token},
)
@http.route('/page11/sign/<string:token>/download', type='http',
auth='public', website=True, sitemap=False)
def page11_download_pdf(self, token, **kw):
"""Download the signed Page 11 PDF."""
sign_req = request.env['fusion.page11.sign.request'].sudo().search([
('access_token', '=', token),
('state', '=', 'signed'),
], limit=1)
if not sign_req or not sign_req.signed_pdf:
return request.redirect(f'/page11/sign/{token}')
pdf_content = base64.b64decode(sign_req.signed_pdf)
filename = sign_req.signed_pdf_filename or 'Page11_Signed.pdf'
return request.make_response(
pdf_content,
headers=[
('Content-Type', 'application/pdf'),
('Content-Disposition', f'attachment; filename="{filename}"'),
('Content-Length', str(len(pdf_content))),
],
)

View File

@@ -0,0 +1,327 @@
# -*- coding: utf-8 -*-
from odoo import http, _, fields
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal
from odoo.exceptions import AccessError, ValidationError
from datetime import datetime, timedelta
import json
import logging
import pytz
_logger = logging.getLogger(__name__)
class PortalSchedule(CustomerPortal):
"""Portal controller for appointment scheduling and calendar management."""
def _get_schedule_values(self):
"""Common values for schedule pages."""
ICP = request.env['ir.config_parameter'].sudo()
g_start = ICP.get_param('fusion_claims.portal_gradient_start', '#5ba848')
g_mid = ICP.get_param('fusion_claims.portal_gradient_mid', '#3a8fb7')
g_end = ICP.get_param('fusion_claims.portal_gradient_end', '#2e7aad')
gradient = 'linear-gradient(135deg, %s 0%%, %s 60%%, %s 100%%)' % (g_start, g_mid, g_end)
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
return {
'portal_gradient': gradient,
'google_maps_api_key': google_maps_api_key,
}
def _get_user_timezone(self):
tz_name = request.env.user.tz or 'America/Toronto'
try:
return pytz.timezone(tz_name)
except pytz.exceptions.UnknownTimeZoneError:
return pytz.timezone('America/Toronto')
def _get_appointment_types(self):
"""Get appointment types available to the current user."""
return request.env['appointment.type'].sudo().search([
('staff_user_ids', 'in', [request.env.user.id]),
])
@http.route(['/my/schedule'], type='http', auth='user', website=True)
def schedule_page(self, **kw):
"""Schedule overview: upcoming appointments and shareable link."""
partner = request.env.user.partner_id
user = request.env.user
now = fields.Datetime.now()
upcoming_events = request.env['calendar.event'].sudo().search([
('partner_ids', 'in', [partner.id]),
('start', '>=', now),
], order='start asc', limit=20)
today_events = request.env['calendar.event'].sudo().search([
('partner_ids', 'in', [partner.id]),
('start', '>=', now.replace(hour=0, minute=0, second=0)),
('start', '<', (now + timedelta(days=1)).replace(hour=0, minute=0, second=0)),
], order='start asc')
invite = request.env['appointment.invite'].sudo().search([
('staff_user_ids', 'in', [user.id]),
], limit=1)
share_url = invite.book_url if invite else ''
appointment_types = self._get_appointment_types()
tz = self._get_user_timezone()
values = self._get_schedule_values()
values.update({
'page_name': 'schedule',
'upcoming_events': upcoming_events,
'today_events': today_events,
'share_url': share_url,
'appointment_types': appointment_types,
'user_tz': tz,
'now': now,
})
return request.render('fusion_authorizer_portal.portal_schedule_page', values)
@http.route(['/my/schedule/book'], type='http', auth='user', website=True)
def schedule_book(self, appointment_type_id=None, **kw):
"""Booking form for a new appointment."""
appointment_types = self._get_appointment_types()
if not appointment_types:
return request.redirect('/my/schedule')
if appointment_type_id:
selected_type = request.env['appointment.type'].sudo().browse(int(appointment_type_id))
if not selected_type.exists():
selected_type = appointment_types[0]
else:
selected_type = appointment_types[0]
values = self._get_schedule_values()
values.update({
'page_name': 'schedule_book',
'appointment_types': appointment_types,
'selected_type': selected_type,
'now': fields.Datetime.now(),
'error': kw.get('error'),
'success': kw.get('success'),
})
return request.render('fusion_authorizer_portal.portal_schedule_book', values)
@http.route('/my/schedule/available-slots', type='json', auth='user', website=True)
def schedule_available_slots(self, appointment_type_id, selected_date=None, **kw):
"""JSON-RPC endpoint: return available time slots for a date."""
appointment_type = request.env['appointment.type'].sudo().browse(int(appointment_type_id))
if not appointment_type.exists():
return {'error': 'Appointment type not found', 'slots': []}
user = request.env.user
tz_name = user.tz or 'America/Toronto'
tz = self._get_user_timezone()
ref_date = fields.Datetime.now()
slot_data = appointment_type._get_appointment_slots(
timezone=tz_name,
filter_users=request.env['res.users'].sudo().browse(user.id),
asked_capacity=1,
reference_date=ref_date,
)
filtered_slots = []
target_date = None
if selected_date:
try:
target_date = datetime.strptime(selected_date, '%Y-%m-%d').date()
except ValueError:
return {'error': 'Invalid date format', 'slots': []}
for month_data in slot_data:
for week in month_data.get('weeks', []):
for day_info in week:
if not day_info:
continue
day = day_info.get('day')
if target_date and day != target_date:
continue
for slot in day_info.get('slots', []):
slot_dt_str = slot.get('datetime')
if not slot_dt_str:
continue
filtered_slots.append({
'datetime': slot_dt_str,
'start_hour': slot.get('start_hour', ''),
'end_hour': slot.get('end_hour', ''),
'duration': slot.get('slot_duration', str(appointment_type.appointment_duration)),
'staff_user_id': slot.get('staff_user_id', user.id),
})
available_dates = []
if not target_date:
seen = set()
for month_data in slot_data:
for week in month_data.get('weeks', []):
for day_info in week:
if not day_info:
continue
day = day_info.get('day')
if day and day_info.get('slots') and str(day) not in seen:
seen.add(str(day))
available_dates.append(str(day))
return {
'slots': filtered_slots,
'available_dates': sorted(available_dates),
'duration': appointment_type.appointment_duration,
'timezone': tz_name,
}
@http.route('/my/schedule/week-events', type='json', auth='user', website=True)
def schedule_week_events(self, selected_date, **kw):
"""Return the user's calendar events for the Mon-Sun week containing selected_date."""
try:
target = datetime.strptime(selected_date, '%Y-%m-%d').date()
except (ValueError, TypeError):
return {'error': 'Invalid date format', 'events': [], 'week_days': []}
monday = target - timedelta(days=target.weekday())
sunday = monday + timedelta(days=6)
partner = request.env.user.partner_id
tz = self._get_user_timezone()
monday_start_local = tz.localize(datetime.combine(monday, datetime.min.time()))
sunday_end_local = tz.localize(datetime.combine(sunday, datetime.max.time()))
monday_start_utc = monday_start_local.astimezone(pytz.utc).replace(tzinfo=None)
sunday_end_utc = sunday_end_local.astimezone(pytz.utc).replace(tzinfo=None)
events = request.env['calendar.event'].sudo().search([
('partner_ids', 'in', [partner.id]),
('start', '>=', monday_start_utc),
('start', '<=', sunday_end_utc),
], order='start asc')
event_list = []
for ev in events:
start_utc = ev.start
stop_utc = ev.stop
start_local = pytz.utc.localize(start_utc).astimezone(tz)
stop_local = pytz.utc.localize(stop_utc).astimezone(tz)
event_list.append({
'name': ev.name or '',
'start': start_local.strftime('%Y-%m-%d %H:%M'),
'end': stop_local.strftime('%Y-%m-%d %H:%M'),
'start_time': start_local.strftime('%I:%M %p'),
'end_time': stop_local.strftime('%I:%M %p'),
'day_of_week': start_local.weekday(),
'date': start_local.strftime('%Y-%m-%d'),
'location': ev.location or '',
'duration': ev.duration,
})
day_labels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
week_days = []
for i in range(7):
day = monday + timedelta(days=i)
week_days.append({
'label': day_labels[i],
'date': day.strftime('%Y-%m-%d'),
'day_num': day.day,
'is_selected': day == target,
})
return {
'events': event_list,
'week_days': week_days,
'selected_date': selected_date,
}
@http.route('/my/schedule/book/submit', type='http', auth='user', website=True, methods=['POST'])
def schedule_book_submit(self, **post):
"""Process the booking form submission."""
appointment_type_id = int(post.get('appointment_type_id', 0))
appointment_type = request.env['appointment.type'].sudo().browse(appointment_type_id)
if not appointment_type.exists():
return request.redirect('/my/schedule/book?error=Invalid+appointment+type')
client_name = (post.get('client_name') or '').strip()
client_street = (post.get('client_street') or '').strip()
client_city = (post.get('client_city') or '').strip()
client_province = (post.get('client_province') or '').strip()
client_postal = (post.get('client_postal') or '').strip()
notes = (post.get('notes') or '').strip()
slot_datetime = (post.get('slot_datetime') or '').strip()
slot_duration = post.get('slot_duration', str(appointment_type.appointment_duration))
if not client_name or not slot_datetime:
return request.redirect('/my/schedule/book?error=Client+name+and+time+slot+are+required')
user = request.env.user
tz = self._get_user_timezone()
try:
start_dt_naive = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S')
start_dt_local = tz.localize(start_dt_naive)
start_dt_utc = start_dt_local.astimezone(pytz.utc).replace(tzinfo=None)
except (ValueError, Exception) as e:
_logger.error("Failed to parse slot datetime %s: %s", slot_datetime, e)
return request.redirect('/my/schedule/book?error=Invalid+time+slot')
duration = float(slot_duration)
stop_dt_utc = start_dt_utc + timedelta(hours=duration)
is_valid = appointment_type._check_appointment_is_valid_slot(
staff_user=user,
resources=request.env['appointment.resource'],
asked_capacity=1,
timezone=str(tz),
start_dt=start_dt_utc,
duration=duration,
allday=False,
)
if not is_valid:
return request.redirect('/my/schedule/book?error=This+slot+is+no+longer+available.+Please+choose+another+time.')
address_parts = [p for p in [client_street, client_city, client_province, client_postal] if p]
location = ', '.join(address_parts)
description_lines = []
if client_name:
description_lines.append(f"Client: {client_name}")
if location:
description_lines.append(f"Address: {location}")
if notes:
description_lines.append(f"Notes: {notes}")
description = '\n'.join(description_lines)
event_name = f"{client_name} - {appointment_type.name}"
booking_line_values = [{
'appointment_user_id': user.id,
'capacity_reserved': 1,
'capacity_used': 1,
}]
try:
event_vals = appointment_type._prepare_calendar_event_values(
asked_capacity=1,
booking_line_values=booking_line_values,
description=description,
duration=duration,
allday=False,
appointment_invite=request.env['appointment.invite'],
guests=request.env['res.partner'],
name=event_name,
customer=user.partner_id,
staff_user=user,
start=start_dt_utc,
stop=stop_dt_utc,
)
event_vals['location'] = location
event = request.env['calendar.event'].sudo().create(event_vals)
_logger.info(
"Appointment booked: %s at %s (event ID: %s)",
event_name, start_dt_utc, event.id,
)
except Exception as e:
_logger.error("Failed to create appointment: %s", e)
return request.redirect('/my/schedule/book?error=Failed+to+create+appointment.+Please+try+again.')
return request.redirect('/my/schedule?success=Appointment+booked+successfully')

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- Auto-create a shareable booking link for staff members.
URL: /book/book-appointment
Filtered to appointment type "Assessment" and staff users configured on that type. -->
<record id="default_appointment_invite" model="appointment.invite">
<field name="short_code">book-appointment</field>
<field name="appointment_type_ids" eval="[(6, 0, [])]"/>
</record>
</odoo>

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""Reactivate any views that Odoo silently deactivated.
Odoo deactivates inherited views when xpath validation fails (e.g. parent
view structure changed between versions). Once deactivated, subsequent
``-u`` runs never reactivate them. This end-migration script catches
that scenario on every version bump.
"""
import logging
_logger = logging.getLogger(__name__)
MODULE = 'fusion_authorizer_portal'
def migrate(cr, version):
if not version:
return
cr.execute("""
UPDATE ir_ui_view v
SET active = true
FROM ir_model_data d
WHERE d.res_id = v.id
AND d.model = 'ir.ui.view'
AND d.module = %s
AND v.active = false
RETURNING v.id, v.name, v.key
""", [MODULE])
rows = cr.fetchall()
if rows:
_logger.warning(
"Reactivated %d deactivated views for %s: %s",
len(rows), MODULE, [r[2] or r[1] for r in rows],
)

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""Reactivate any views that Odoo silently deactivated.
Odoo deactivates inherited views when xpath validation fails (e.g. parent
view structure changed between versions). Once deactivated, subsequent
``-u`` runs never reactivate them. This end-migration script catches
that scenario on every version bump.
"""
import logging
_logger = logging.getLogger(__name__)
MODULE = 'fusion_authorizer_portal'
def migrate(cr, version):
if not version:
return
cr.execute("""
UPDATE ir_ui_view v
SET active = true
FROM ir_model_data d
WHERE d.res_id = v.id
AND d.model = 'ir.ui.view'
AND d.module = %s
AND v.active = false
RETURNING v.id, v.name, v.key
""", [MODULE])
rows = cr.fetchall()
if rows:
_logger.warning(
"Reactivated %d deactivated views for %s: %s",
len(rows), MODULE, [r[2] or r[1] for r in rows],
)

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""Reactivate any views that Odoo silently deactivated.
Odoo deactivates inherited views when xpath validation fails (e.g. parent
view structure changed between versions). Once deactivated, subsequent
``-u`` runs never reactivate them. This end-migration script catches
that scenario on every version bump.
"""
import logging
_logger = logging.getLogger(__name__)
MODULE = 'fusion_authorizer_portal'
def migrate(cr, version):
if not version:
return
cr.execute("""
UPDATE ir_ui_view v
SET active = true
FROM ir_model_data d
WHERE d.res_id = v.id
AND d.model = 'ir.ui.view'
AND d.module = %s
AND v.active = false
RETURNING v.id, v.name, v.key
""", [MODULE])
rows = cr.fetchall()
if rows:
_logger.warning(
"Reactivated %d deactivated views for %s: %s",
len(rows), MODULE, [r[2] or r[1] for r in rows],
)

View File

@@ -499,6 +499,7 @@ class FusionAssessment(models.Model):
'res_model': 'sale.order', 'res_model': 'sale.order',
'res_id': sale_order.id, 'res_id': sale_order.id,
'view_mode': 'form', 'view_mode': 'form',
'views': [(False, 'form')],
'target': 'current', 'target': 'current',
} }
@@ -1482,6 +1483,7 @@ class FusionAssessment(models.Model):
'name': _('Documents'), 'name': _('Documents'),
'res_model': 'fusion.adp.document', 'res_model': 'fusion.adp.document',
'view_mode': 'list,form', 'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': [('assessment_id', '=', self.id)], 'domain': [('assessment_id', '=', self.id)],
'context': {'default_assessment_id': self.id}, 'context': {'default_assessment_id': self.id},
} }
@@ -1497,6 +1499,7 @@ class FusionAssessment(models.Model):
'res_model': 'sale.order', 'res_model': 'sale.order',
'res_id': self.sale_order_id.id, 'res_id': self.sale_order_id.id,
'view_mode': 'form', 'view_mode': 'form',
'views': [(False, 'form')],
'target': 'current', 'target': 'current',
} }

View File

@@ -23,5 +23,6 @@ class FusionLoanerCheckoutAssessment(models.Model):
'type': 'ir.actions.act_window', 'type': 'ir.actions.act_window',
'res_model': 'fusion.assessment', 'res_model': 'fusion.assessment',
'view_mode': 'form', 'view_mode': 'form',
'views': [(False, 'form')],
'res_id': self.assessment_id.id, 'res_id': self.assessment_id.id,
} }

View File

@@ -160,7 +160,7 @@ class ResPartner(models.Model):
if self.is_technician_portal: if self.is_technician_portal:
# Add Field Technician group # Add Field Technician group
g = self.env.ref('fusion_claims.group_field_technician', raise_if_not_found=False) g = self.env.ref('fusion_tasks.group_field_technician', raise_if_not_found=False)
if g and g not in internal_user.group_ids: if g and g not in internal_user.group_ids:
internal_user.sudo().write({'group_ids': [(4, g.id)]}) internal_user.sudo().write({'group_ids': [(4, g.id)]})
added.append('Field Technician') added.append('Field Technician')
@@ -596,6 +596,7 @@ class ResPartner(models.Model):
'name': _('Assigned Cases'), 'name': _('Assigned Cases'),
'res_model': 'sale.order', 'res_model': 'sale.order',
'view_mode': 'list,form', 'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': [('x_fc_authorizer_id', '=', self.id)], 'domain': [('x_fc_authorizer_id', '=', self.id)],
'context': {'default_x_fc_authorizer_id': self.id}, 'context': {'default_x_fc_authorizer_id': self.id},
} }
@@ -614,6 +615,7 @@ class ResPartner(models.Model):
'name': _('Assessments'), 'name': _('Assessments'),
'res_model': 'fusion.assessment', 'res_model': 'fusion.assessment',
'view_mode': 'list,form', 'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': domain, 'domain': domain,
} }
@@ -697,6 +699,7 @@ class ResPartner(models.Model):
'name': _('Assigned Deliveries'), 'name': _('Assigned Deliveries'),
'res_model': 'sale.order', 'res_model': 'sale.order',
'view_mode': 'list,form', 'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': [('x_fc_delivery_technician_ids', 'in', [self.authorizer_portal_user_id.id])], 'domain': [('x_fc_delivery_technician_ids', 'in', [self.authorizer_portal_user_id.id])],
} }

View File

@@ -101,6 +101,7 @@ class SaleOrder(models.Model):
'name': 'Message Authorizer', 'name': 'Message Authorizer',
'res_model': 'mail.compose.message', 'res_model': 'mail.compose.message',
'view_mode': 'form', 'view_mode': 'form',
'views': [(False, 'form')],
'target': 'new', 'target': 'new',
'context': { 'context': {
'default_model': 'sale.order', 'default_model': 'sale.order',
@@ -137,6 +138,7 @@ class SaleOrder(models.Model):
'name': _('Portal Comments'), 'name': _('Portal Comments'),
'res_model': 'fusion.authorizer.comment', 'res_model': 'fusion.authorizer.comment',
'view_mode': 'list,form', 'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': [('sale_order_id', '=', self.id)], 'domain': [('sale_order_id', '=', self.id)],
'context': {'default_sale_order_id': self.id}, 'context': {'default_sale_order_id': self.id},
} }
@@ -149,6 +151,7 @@ class SaleOrder(models.Model):
'name': _('Portal Documents'), 'name': _('Portal Documents'),
'res_model': 'fusion.adp.document', 'res_model': 'fusion.adp.document',
'view_mode': 'list,form', 'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': [('sale_order_id', '=', self.id)], 'domain': [('sale_order_id', '=', self.id)],
'context': {'default_sale_order_id': self.id}, 'context': {'default_sale_order_id': self.id},
} }

View File

@@ -14,16 +14,12 @@
.tech-stats-bar { .tech-stats-bar {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.5rem;
scrollbar-width: none;
} }
.tech-stats-bar::-webkit-scrollbar { display: none; }
.tech-stat-card { .tech-stat-card {
flex: 0 0 auto; flex: 1 1 0;
min-width: 100px; min-width: 0;
padding: 0.75rem 1rem; padding: 0.75rem 0.5rem;
border-radius: 12px; border-radius: 12px;
text-align: center; text-align: center;
color: #fff; color: #fff;
@@ -42,7 +38,145 @@
.tech-stat-total { background: linear-gradient(135deg, #5ba848, #3a8fb7); } .tech-stat-total { background: linear-gradient(135deg, #5ba848, #3a8fb7); }
.tech-stat-remaining { background: linear-gradient(135deg, #3498db, #2980b9); } .tech-stat-remaining { background: linear-gradient(135deg, #3498db, #2980b9); }
.tech-stat-completed { background: linear-gradient(135deg, #27ae60, #219a52); } .tech-stat-completed { background: linear-gradient(135deg, #27ae60, #219a52); }
.tech-stat-travel { background: linear-gradient(135deg, #8e44ad, #7d3c98); }
/* ---- Clock In/Out Card ---- */
.tech-clock-card {
background: var(--o-main-card-bg, #fff);
border: 1px solid var(--o-main-border-color, #e9ecef);
border-radius: 14px;
padding: 0.875rem 1rem;
}
.tech-clock-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #adb5bd;
flex-shrink: 0;
}
.tech-clock-dot--active {
background: #10b981;
box-shadow: 0 0 6px rgba(16, 185, 129, 0.5);
animation: tech-clock-pulse 2s ease-in-out infinite;
}
@keyframes tech-clock-pulse {
0%, 100% { box-shadow: 0 0 6px rgba(16, 185, 129, 0.5); }
50% { box-shadow: 0 0 12px rgba(16, 185, 129, 0.8); }
}
.tech-clock-status {
font-size: 0.85rem;
font-weight: 600;
color: var(--o-main-text-color, #212529);
line-height: 1.2;
}
.tech-clock-timer {
font-size: 0.75rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: #6c757d;
}
.tech-clock-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
border-radius: 10px;
border: none;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.tech-clock-btn:active { transform: scale(0.96); }
.tech-clock-btn--in {
background: #10b981;
color: #fff;
}
.tech-clock-btn--in:hover { background: #059669; }
.tech-clock-btn--out {
background: #ef4444;
color: #fff;
}
.tech-clock-btn--out:hover { background: #dc2626; }
.tech-clock-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.tech-clock-error {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.5rem;
padding: 0.4rem 0.75rem;
border-radius: 8px;
background: #fef2f2;
color: #dc2626;
font-size: 0.8rem;
font-weight: 500;
}
/* ---- Quick Links (All Tasks / Tomorrow / Repair Form) ---- */
.tech-quick-links {
display: flex;
gap: 0.5rem;
}
.tech-quick-link {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.875rem 0.5rem;
border-radius: 12px;
border: 1.5px solid;
text-decoration: none !important;
font-weight: 600;
font-size: 0.8rem;
transition: all 0.15s;
position: relative;
}
.tech-quick-link:active { transform: scale(0.97); }
.tech-quick-link i { font-size: 1.1rem; }
.tech-quick-link-primary {
border-color: #3498db;
color: #3498db !important;
background: rgba(52, 152, 219, 0.04);
}
.tech-quick-link-primary:hover { background: rgba(52, 152, 219, 0.1); }
.tech-quick-link-secondary {
border-color: #6c757d;
color: #6c757d !important;
background: rgba(108, 117, 125, 0.04);
}
.tech-quick-link-secondary:hover { background: rgba(108, 117, 125, 0.1); }
.tech-quick-link-warning {
border-color: #e67e22;
color: #e67e22 !important;
background: rgba(230, 126, 34, 0.04);
}
.tech-quick-link-warning:hover { background: rgba(230, 126, 34, 0.1); }
.tech-quick-link-badge {
position: absolute;
top: -6px;
right: -6px;
background: #3498db;
color: #fff;
font-size: 0.65rem;
font-weight: 700;
min-width: 18px;
height: 18px;
line-height: 18px;
text-align: center;
border-radius: 9px;
padding: 0 4px;
}
/* ---- Hero Card (Dashboard Current Task) ---- */ /* ---- Hero Card (Dashboard Current Task) ---- */
.tech-hero-card { .tech-hero-card {
@@ -475,12 +609,18 @@
gap: 1rem; gap: 1rem;
} }
.tech-stat-card { .tech-stat-card {
min-width: 130px;
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
} }
.tech-stat-card .stat-number { .tech-stat-card .stat-number {
font-size: 2rem; font-size: 2rem;
} }
.tech-quick-links {
gap: 1rem;
}
.tech-quick-link {
padding: 1rem 0.75rem;
font-size: 0.85rem;
}
.tech-bottom-bar { .tech-bottom-bar {
position: static; position: static;
box-shadow: none; box-shadow: none;

View File

@@ -28,6 +28,9 @@ patch(Chatter.prototype, {
[thread.id], [thread.id],
); );
if (result && result.type === "ir.actions.act_window") { if (result && result.type === "ir.actions.act_window") {
if (!result.views && result.view_mode) {
result.views = result.view_mode.split(",").map(v => [false, v.trim()]);
}
this._fapActionService.doAction(result); this._fapActionService.doAction(result);
} }
} catch (e) { } catch (e) {

View File

@@ -0,0 +1,343 @@
(function () {
'use strict';
var dateInput = document.getElementById('bookingDate');
var slotsContainer = document.getElementById('slotsContainer');
var slotsGrid = document.getElementById('slotsGrid');
var slotsLoading = document.getElementById('slotsLoading');
var noSlots = document.getElementById('noSlots');
var slotDatetimeInput = document.getElementById('slotDatetime');
var slotDurationInput = document.getElementById('slotDuration');
var submitBtn = document.getElementById('btnSubmitBooking');
var typeSelect = document.getElementById('appointmentTypeSelect');
var selectedSlotBtn = null;
var weekContainer = document.getElementById('weekCalendarContainer');
var weekLoading = document.getElementById('weekCalendarLoading');
var weekGrid = document.getElementById('weekCalendarGrid');
var weekHeader = document.getElementById('weekCalendarHeader');
var weekBody = document.getElementById('weekCalendarBody');
var weekEmpty = document.getElementById('weekCalendarEmpty');
function getAppointmentTypeId() {
if (typeSelect) return typeSelect.value;
var hidden = document.querySelector('input[name="appointment_type_id"]');
return hidden ? hidden.value : null;
}
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function truncate(str, max) {
if (!str) return '';
return str.length > max ? str.substring(0, max) + '...' : str;
}
function fetchWeekEvents(date) {
if (!weekContainer || !date) return;
weekContainer.style.display = 'block';
weekLoading.style.display = 'block';
weekGrid.style.display = 'none';
weekEmpty.style.display = 'none';
fetch('/my/schedule/week-events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: { selected_date: date },
}),
})
.then(function (resp) { return resp.json(); })
.then(function (data) {
weekLoading.style.display = 'none';
var result = data.result || {};
var events = result.events || [];
var weekDays = result.week_days || [];
if (result.error || !weekDays.length) {
weekEmpty.style.display = 'block';
return;
}
renderWeekCalendar(weekDays, events, date);
})
.catch(function () {
weekLoading.style.display = 'none';
weekEmpty.textContent = 'Failed to load calendar. Please try again.';
weekEmpty.style.display = 'block';
});
}
function renderWeekCalendar(weekDays, events, selectedDate) {
weekHeader.innerHTML = '';
weekBody.innerHTML = '';
var eventsByDate = {};
events.forEach(function (ev) {
if (!eventsByDate[ev.date]) eventsByDate[ev.date] = [];
eventsByDate[ev.date].push(ev);
});
var hasAnyEvents = events.length > 0;
weekDays.forEach(function (day) {
var isSelected = day.date === selectedDate;
var isWeekend = day.label === 'Sat' || day.label === 'Sun';
var dayEvents = eventsByDate[day.date] || [];
var headerCell = document.createElement('div');
headerCell.className = 'text-center py-2 flex-fill';
headerCell.style.cssText = 'min-width: 0; font-size: 12px; border-right: 1px solid #dee2e6;';
if (isSelected) {
headerCell.style.backgroundColor = '#e8f4fd';
}
if (isWeekend) {
headerCell.style.opacity = '0.6';
}
var labelEl = document.createElement('div');
labelEl.className = 'fw-semibold text-muted';
labelEl.textContent = day.label;
var numEl = document.createElement('div');
numEl.className = isSelected ? 'fw-bold text-primary' : 'fw-semibold';
numEl.style.fontSize = '14px';
numEl.textContent = day.day_num;
headerCell.appendChild(labelEl);
headerCell.appendChild(numEl);
weekHeader.appendChild(headerCell);
var bodyCell = document.createElement('div');
bodyCell.className = 'flex-fill p-1';
bodyCell.style.cssText = 'min-width: 0; min-height: 70px; border-right: 1px solid #dee2e6; overflow: hidden;';
if (isSelected) {
bodyCell.style.backgroundColor = '#f0f8ff';
}
if (dayEvents.length) {
dayEvents.forEach(function (ev) {
var card = document.createElement('div');
card.className = 'mb-1 px-1 py-1 rounded';
card.style.cssText = 'font-size: 11px; background: #eef6ff; border-left: 3px solid #3a8fb7; overflow: hidden; cursor: default;';
card.title = ev.start_time + ' - ' + ev.end_time + '\n' + ev.name + (ev.location ? '\n' + ev.location : '');
var timeEl = document.createElement('div');
timeEl.className = 'fw-semibold text-primary';
timeEl.style.fontSize = '10px';
timeEl.textContent = ev.start_time;
var nameEl = document.createElement('div');
nameEl.className = 'text-truncate';
nameEl.style.fontSize = '10px';
nameEl.textContent = truncate(ev.name, 18);
card.appendChild(timeEl);
card.appendChild(nameEl);
bodyCell.appendChild(card);
});
}
weekBody.appendChild(bodyCell);
});
if (hasAnyEvents) {
weekGrid.style.display = 'block';
weekEmpty.style.display = 'none';
} else {
weekGrid.style.display = 'none';
weekEmpty.style.display = 'block';
}
}
function fetchSlots(date) {
var typeId = getAppointmentTypeId();
if (!typeId || !date) return;
slotsContainer.style.display = 'block';
slotsLoading.style.display = 'block';
slotsGrid.innerHTML = '';
noSlots.style.display = 'none';
slotDatetimeInput.value = '';
if (submitBtn) submitBtn.disabled = true;
selectedSlotBtn = null;
fetch('/my/schedule/available-slots', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: {
appointment_type_id: parseInt(typeId),
selected_date: date,
},
}),
})
.then(function (resp) { return resp.json(); })
.then(function (data) {
slotsLoading.style.display = 'none';
var result = data.result || {};
var slots = result.slots || [];
if (result.error) {
noSlots.textContent = result.error;
noSlots.style.display = 'block';
return;
}
if (!slots.length) {
noSlots.style.display = 'block';
return;
}
var morningSlots = [];
var afternoonSlots = [];
slots.forEach(function (slot) {
var hour = parseInt(slot.start_hour);
if (isNaN(hour)) {
var match = slot.start_hour.match(/(\d+)/);
hour = match ? parseInt(match[1]) : 0;
if (slot.start_hour.toLowerCase().indexOf('pm') > -1 && hour !== 12) hour += 12;
if (slot.start_hour.toLowerCase().indexOf('am') > -1 && hour === 12) hour = 0;
}
if (hour < 12) {
morningSlots.push(slot);
} else {
afternoonSlots.push(slot);
}
});
function renderGroup(label, icon, groupSlots) {
if (!groupSlots.length) return;
var header = document.createElement('div');
header.className = 'w-100 mt-2 mb-1';
header.innerHTML = '<small class="text-muted fw-semibold"><i class="fa ' + icon + ' me-1"></i>' + label + '</small>';
slotsGrid.appendChild(header);
groupSlots.forEach(function (slot) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-outline-primary btn-sm slot-btn';
btn.style.cssText = 'min-width: 100px; border-radius: 8px; padding: 8px 14px;';
btn.textContent = slot.start_hour;
btn.dataset.datetime = slot.datetime;
btn.dataset.duration = slot.duration;
btn.addEventListener('click', function () {
if (selectedSlotBtn) {
selectedSlotBtn.classList.remove('btn-primary');
selectedSlotBtn.classList.add('btn-outline-primary');
}
btn.classList.remove('btn-outline-primary');
btn.classList.add('btn-primary');
selectedSlotBtn = btn;
slotDatetimeInput.value = slot.datetime;
slotDurationInput.value = slot.duration;
if (submitBtn) submitBtn.disabled = false;
});
slotsGrid.appendChild(btn);
});
}
renderGroup('Morning', 'fa-sun-o', morningSlots);
renderGroup('Afternoon', 'fa-cloud', afternoonSlots);
})
.catch(function (err) {
slotsLoading.style.display = 'none';
noSlots.textContent = 'Failed to load slots. Please try again.';
noSlots.style.display = 'block';
});
}
if (dateInput) {
dateInput.addEventListener('change', function () {
var val = this.value;
fetchWeekEvents(val);
fetchSlots(val);
});
}
if (typeSelect) {
typeSelect.addEventListener('change', function () {
if (dateInput && dateInput.value) {
fetchSlots(dateInput.value);
}
});
}
var bookingForm = document.getElementById('bookingForm');
if (bookingForm) {
bookingForm.addEventListener('submit', function (e) {
if (!slotDatetimeInput || !slotDatetimeInput.value) {
e.preventDefault();
alert('Please select a time slot before booking.');
return false;
}
var clientName = bookingForm.querySelector('input[name="client_name"]');
if (!clientName || !clientName.value.trim()) {
e.preventDefault();
alert('Please enter the client name.');
return false;
}
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Booking...';
}
});
}
window.initScheduleAddressAutocomplete = function () {
var streetInput = document.getElementById('clientStreet');
if (!streetInput) return;
var autocomplete = new google.maps.places.Autocomplete(streetInput, {
componentRestrictions: { country: 'ca' },
types: ['address'],
});
autocomplete.addListener('place_changed', function () {
var place = autocomplete.getPlace();
if (!place.address_components) return;
var streetNumber = '';
var streetName = '';
var city = '';
var province = '';
var postalCode = '';
for (var i = 0; i < place.address_components.length; i++) {
var component = place.address_components[i];
var types = component.types;
if (types.indexOf('street_number') > -1) {
streetNumber = component.long_name;
} else if (types.indexOf('route') > -1) {
streetName = component.long_name;
} else if (types.indexOf('locality') > -1) {
city = component.long_name;
} else if (types.indexOf('administrative_area_level_1') > -1) {
province = component.long_name;
} else if (types.indexOf('postal_code') > -1) {
postalCode = component.long_name;
}
}
streetInput.value = (streetNumber + ' ' + streetName).trim();
var cityInput = document.getElementById('clientCity');
if (cityInput) cityInput.value = city;
var provInput = document.getElementById('clientProvince');
if (provInput) provInput.value = province;
var postalInput = document.getElementById('clientPostal');
if (postalInput) postalInput.value = postalCode;
});
};
})();

View File

@@ -1,94 +1,234 @@
/** /**
* Technician Location Logger * Technician Location Services
* Logs GPS location every 5 minutes during working hours (9 AM - 6 PM) *
* Only logs while the browser tab is visible. * 1. Background logger -- logs GPS every 5 minutes while the tech is clocked in.
* 2. getLocation() -- returns a Promise that resolves to {latitude, longitude, accuracy}.
* If the user denies permission or the request times out a blocking modal is shown
* and the promise is rejected.
* 3. Blocking modal -- cannot be dismissed; forces the technician to grant permission.
*/ */
(function () { (function () {
'use strict'; 'use strict';
var INTERVAL_MS = 5 * 60 * 1000; // 5 minutes var INTERVAL_MS = 5 * 60 * 1000;
var STORE_OPEN_HOUR = 9; var CLOCK_CHECK_MS = 60 * 1000; // check clock status every 60s
var STORE_CLOSE_HOUR = 18;
var locationTimer = null; var locationTimer = null;
var clockCheckTimer = null;
var isClockedIn = false;
var permissionDenied = false;
function isWorkingHours() { // =====================================================================
var now = new Date(); // BLOCKING MODAL
var hour = now.getHours(); // =====================================================================
return hour >= STORE_OPEN_HOUR && hour < STORE_CLOSE_HOUR;
var modalEl = null;
function ensureModal() {
if (modalEl) return;
var div = document.createElement('div');
div.id = 'fusionLocationModal';
div.innerHTML =
'<div style="position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:99999;display:flex;align-items:center;justify-content:center;">' +
'<div style="background:#fff;border-radius:16px;max-width:400px;width:90%;padding:2rem;text-align:center;box-shadow:0 8px 32px rgba(0,0,0,.3);">' +
'<div style="font-size:3rem;color:#dc3545;margin-bottom:1rem;"><i class="fa fa-map-marker"></i></div>' +
'<h4 style="margin-bottom:0.5rem;">Location Required</h4>' +
'<p style="color:#666;font-size:0.95rem;">Your GPS location is mandatory to perform this action. ' +
'Please allow location access in your browser settings and try again.</p>' +
'<p style="color:#999;font-size:0.85rem;">If you previously denied access, open your browser settings ' +
'and reset the location permission for this site.</p>' +
'<button id="fusionLocationRetryBtn" style="background:#0d6efd;color:#fff;border:none;border-radius:12px;padding:0.75rem 2rem;font-size:1rem;cursor:pointer;margin-top:0.5rem;width:100%;">' +
'<i class="fa fa-refresh" style="margin-right:6px;"></i>Try Again' +
'</button>' +
'</div>' +
'</div>';
document.body.appendChild(div);
modalEl = div;
document.getElementById('fusionLocationRetryBtn').addEventListener('click', function () {
hideModal();
window.fusionGetLocation().catch(function () {
showModal();
});
});
} }
function isTechnicianPortal() { function showModal() {
// Check if we're on a technician portal page ensureModal();
return window.location.pathname.indexOf('/my/technician') !== -1; modalEl.style.display = '';
} }
function logLocation() { function hideModal() {
if (!isWorkingHours()) { if (modalEl) modalEl.style.display = 'none';
return; }
}
if (document.hidden) {
return;
}
if (!navigator.geolocation) {
return;
}
navigator.geolocation.getCurrentPosition( // =====================================================================
function (position) { // PERMISSION-DENIED BANNER (persistent warning for background logger)
var data = { // =====================================================================
jsonrpc: '2.0',
method: 'call', var bannerEl = null;
params: {
function showDeniedBanner() {
if (bannerEl) return;
bannerEl = document.createElement('div');
bannerEl.id = 'fusionLocationBanner';
bannerEl.style.cssText =
'position:fixed;top:0;left:0;right:0;z-index:9999;background:#dc3545;color:#fff;' +
'padding:10px 16px;text-align:center;font-size:0.9rem;font-weight:600;box-shadow:0 2px 8px rgba(0,0,0,.2);';
bannerEl.innerHTML =
'<i class="fa fa-exclamation-triangle" style="margin-right:6px;"></i>' +
'Location access is denied. Your location is not being tracked. ' +
'Please enable location in browser settings.';
document.body.appendChild(bannerEl);
}
// =====================================================================
// getLocation() -- public API
// =====================================================================
function getLocation() {
return new Promise(function (resolve, reject) {
if (!navigator.geolocation) {
reject(new Error('Geolocation is not supported by this browser.'));
return;
}
navigator.geolocation.getCurrentPosition(
function (position) {
permissionDenied = false;
if (bannerEl) { bannerEl.remove(); bannerEl = null; }
resolve({
latitude: position.coords.latitude, latitude: position.coords.latitude,
longitude: position.coords.longitude, longitude: position.coords.longitude,
accuracy: position.coords.accuracy || 0, accuracy: position.coords.accuracy || 0,
});
},
function (error) {
permissionDenied = true;
showDeniedBanner();
console.error('Fusion Location: GPS error', error.code, error.message);
reject(error);
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 30000 }
);
});
}
window.fusionGetLocation = getLocation;
// =====================================================================
// NAVIGATE -- opens Google Maps app on iOS/Android, browser fallback
// =====================================================================
function openGoogleMapsNav(el) {
var addr = (el.dataset.navAddr || '').trim();
var fallbackUrl = el.dataset.navUrl || '';
if (!addr && !fallbackUrl) return;
var dest = encodeURIComponent(addr) || fallbackUrl.split('destination=')[1];
var isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
var isAndroid = /Android/i.test(navigator.userAgent);
if (isIOS) {
window.location.href = 'comgooglemaps://?daddr=' + dest + '&directionsmode=driving';
} else if (isAndroid) {
window.location.href = 'google.navigation:q=' + dest;
} else {
window.open(fallbackUrl, '_blank');
}
}
window.openGoogleMapsNav = openGoogleMapsNav;
// =====================================================================
// BACKGROUND LOGGER (tied to clock-in / clock-out status)
// =====================================================================
function isTechnicianPortal() {
return window.location.pathname.indexOf('/my/technician') !== -1;
}
function checkClockStatus() {
fetch('/my/technician/clock-status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: {} }),
})
.then(function (r) { return r.json(); })
.then(function (data) {
var wasClocked = isClockedIn;
isClockedIn = !!(data.result && data.result.clocked_in);
if (isClockedIn && !wasClocked) {
// Just clocked in — start tracking immediately
startLocationTimer();
} else if (!isClockedIn && wasClocked) {
// Just clocked out — stop tracking
stopLocationTimer();
}
})
.catch(function () {
/* network error: keep current state */
});
}
function logLocation() {
if (!isClockedIn || document.hidden || !navigator.geolocation) return;
getLocation().then(function (coords) {
fetch('/my/technician/location/log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: {
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy,
} }
}; }),
fetch('/my/technician/location/log', { })
method: 'POST', .then(function (r) { return r.json(); })
headers: { 'Content-Type': 'application/json' }, .then(function (data) {
body: JSON.stringify(data), if (data.result && !data.result.success) {
}).catch(function () { console.warn('Fusion Location: server rejected log', data.result);
// Silently fail - location logging is best-effort }
}); })
}, .catch(function (err) {
function () { console.warn('Fusion Location: network error', err);
// Geolocation permission denied or error - silently ignore });
}, }).catch(function () {
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 } /* permission denied -- banner already shown */
); });
}
function startLocationTimer() {
if (locationTimer) return; // already running
logLocation(); // immediate first log
locationTimer = setInterval(logLocation, INTERVAL_MS);
}
function stopLocationTimer() {
if (locationTimer) {
clearInterval(locationTimer);
locationTimer = null;
}
} }
function startLocationLogging() { function startLocationLogging() {
if (!isTechnicianPortal()) { if (!isTechnicianPortal()) return;
return;
}
// Log immediately on page load // Check clock status immediately, then every 60s
logLocation(); checkClockStatus();
clockCheckTimer = setInterval(checkClockStatus, CLOCK_CHECK_MS);
// Set interval for periodic logging // Pause/resume on tab visibility
locationTimer = setInterval(logLocation, INTERVAL_MS);
// Pause/resume on tab visibility change
document.addEventListener('visibilitychange', function () { document.addEventListener('visibilitychange', function () {
if (document.hidden) { if (document.hidden) {
// Tab hidden - clear interval to save battery stopLocationTimer();
if (locationTimer) { } else if (isClockedIn) {
clearInterval(locationTimer); startLocationTimer();
locationTimer = null;
}
} else {
// Tab visible again - log immediately and restart interval
logLocation();
if (!locationTimer) {
locationTimer = setInterval(logLocation, INTERVAL_MS);
}
} }
}); });
} }
// Start when DOM is ready
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startLocationLogging); document.addEventListener('DOMContentLoaded', startLocationLogging);
} else { } else {

View File

@@ -51,19 +51,25 @@ class PDFTemplateFiller:
for page_idx in range(num_pages): for page_idx in range(num_pages):
page = original.getPage(page_idx) page = original.getPage(page_idx)
page_num = page_idx + 1 # 1-based page number page_num = page_idx + 1 # 1-based page number
page_w = float(page.mediaBox.getWidth()) mb = page.mediaBox
page_h = float(page.mediaBox.getHeight()) page_w = float(mb.getWidth())
page_h = float(mb.getHeight())
origin_x = float(mb.getLowerLeft_x())
origin_y = float(mb.getLowerLeft_y())
fields = fields_by_page.get(page_num, []) fields = fields_by_page.get(page_num, [])
if fields: if fields:
# Create a transparent overlay for this page
overlay_buf = BytesIO() overlay_buf = BytesIO()
c = canvas.Canvas(overlay_buf, pagesize=(page_w, page_h)) c = canvas.Canvas(
overlay_buf,
pagesize=(origin_x + page_w, origin_y + page_h),
)
for field in fields: for field in fields:
PDFTemplateFiller._draw_field( PDFTemplateFiller._draw_field(
c, field, context, signatures, page_w, page_h c, field, context, signatures,
page_w, page_h, origin_x, origin_y,
) )
c.save() c.save()
@@ -80,7 +86,8 @@ class PDFTemplateFiller:
return result.getvalue() return result.getvalue()
@staticmethod @staticmethod
def _draw_field(c, field, context, signatures, page_w, page_h): def _draw_field(c, field, context, signatures,
page_w, page_h, origin_x=0, origin_y=0):
"""Draw a single field onto the reportlab canvas. """Draw a single field onto the reportlab canvas.
Args: Args:
@@ -90,6 +97,8 @@ class PDFTemplateFiller:
signatures: dict of {field_key: binary} for signature fields signatures: dict of {field_key: binary} for signature fields
page_w: page width in PDF points page_w: page width in PDF points
page_h: page height in PDF points page_h: page height in PDF points
origin_x: mediaBox lower-left X (accounts for non-zero origin)
origin_y: mediaBox lower-left Y (accounts for non-zero origin)
""" """
field_key = field.get('field_key') or field.get('field_name', '') field_key = field.get('field_key') or field.get('field_name', '')
field_type = field.get('field_type', 'text') field_type = field.get('field_type', 'text')
@@ -98,11 +107,12 @@ class PDFTemplateFiller:
if not value and field_type != 'signature': if not value and field_type != 'signature':
return return
# Convert percentage positions to absolute PDF coordinates # Convert percentage positions to absolute PDF coordinates.
# pos_x/pos_y are 0.0-1.0 ratios from top-left # pos_x/pos_y are 0.0-1.0 ratios from top-left of the visible page.
# PDF coordinate system: origin at bottom-left, Y goes up # PDF coordinate system: origin at bottom-left, Y goes up.
abs_x = field['pos_x'] * page_w # origin_x/origin_y account for PDFs whose mediaBox doesn't start at (0,0).
abs_y = page_h - (field['pos_y'] * page_h) # flip Y axis abs_x = field['pos_x'] * page_w + origin_x
abs_y = (origin_y + page_h) - (field['pos_y'] * page_h)
font_name = field.get('font_name', 'Helvetica') font_name = field.get('font_name', 'Helvetica')
font_size = field.get('font_size', 10.0) font_size = field.get('font_size', 10.0)
@@ -124,10 +134,22 @@ class PDFTemplateFiller:
elif field_type == 'checkbox': elif field_type == 'checkbox':
if value: if value:
c.setFont('ZapfDingbats', font_size) # Draw a cross mark (✗) that fills the checkbox box
cb_w = field.get('width', 0.015) * page_w
cb_h = field.get('height', 0.018) * page_h cb_h = field.get('height', 0.018) * page_h
cb_y = abs_y - cb_h + (cb_h - font_size) / 2 # Inset slightly so the cross doesn't touch the box edges
c.drawString(abs_x, cb_y, '4') pad = min(cb_w, cb_h) * 0.15
x1 = abs_x + pad
y1 = abs_y - cb_h + pad
x2 = abs_x + cb_w - pad
y2 = abs_y - pad
c.saveState()
c.setStrokeColorRGB(0, 0, 0)
c.setLineWidth(1.5)
# Draw X (two diagonal lines)
c.line(x1, y1, x2, y2)
c.line(x1, y2, x2, y1)
c.restoreState()
elif field_type == 'signature': elif field_type == 'signature':
sig_data = signatures.get(field_key) sig_data = signatures.get(field_key)

View File

@@ -0,0 +1,413 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ============================================================ -->
<!-- Page 11 Public Signing Form -->
<!-- ============================================================ -->
<template id="portal_page11_public_sign" name="Page 11 - Sign">
<t t-call="portal.frontend_layout">
<div class="container py-4" style="max-width:720px;">
<div class="text-center mb-4">
<t t-if="company.logo">
<img t-att-src="'/web/image/res.company/%s/logo/200x60' % company.id"
alt="Company Logo" style="max-height:60px;" class="mb-2"/>
</t>
<h3 class="mb-1">ADP Consent and Declaration</h3>
<p class="text-muted">Page 11 - Assistive Devices Program</p>
</div>
<t t-if="request.params.get('error') == 'no_signature'">
<div class="alert alert-danger">Please draw your signature before submitting.</div>
</t>
<t t-if="request.params.get('error') == 'no_consent'">
<div class="alert alert-danger">You must accept the consent declaration before signing.</div>
</t>
<!-- Consent Declaration -->
<form method="POST" t-att-action="'/page11/sign/%s/submit' % token" id="page11Form">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<!-- Applicant Information -->
<div class="card mb-3">
<div class="card-header"><strong>Applicant Information</strong></div>
<div class="card-body">
<div class="row mb-2">
<div class="col-sm-4">
<label class="form-label">Last Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="client_last_name"
t-att-value="client_last_name or ''" required="required"/>
</div>
<div class="col-sm-4">
<label class="form-label">First Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="client_first_name"
t-att-value="client_first_name or ''" required="required"/>
</div>
<div class="col-sm-4">
<label class="form-label">Middle Name</label>
<input type="text" class="form-control" name="client_middle_name"
t-att-value="client_middle_name or ''"/>
</div>
</div>
<div class="row mb-2">
<div class="col-sm-6">
<label class="form-label">Health Card Number (10 digits) <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="client_health_card"
t-att-value="client_health_card or ''" required="required"
maxlength="10" pattern="[0-9]{10}" title="10-digit health card number"
placeholder="e.g. 1234567890"/>
</div>
<div class="col-sm-3">
<label class="form-label">Version <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="client_health_card_version"
t-att-value="client_health_card_version or ''" required="required"
maxlength="2" placeholder="e.g. AB"/>
</div>
<div class="col-sm-3">
<label class="form-label">Case Ref</label>
<input type="text" class="form-control" readonly="readonly"
t-att-value="order.name"/>
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><strong>Consent and Declaration</strong></div>
<div class="card-body">
<p class="small">
I consent to information being collected and used by the Ministry of Health and Long-Term Care,
and agents authorized by the Ministry, for the administration and enforcement of the
Assistive Devices Program. I understand this consent is voluntary and I may withdraw it
at any time. I declare that the information in this application is true and complete.
</p>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="consent_declaration"
name="consent_declaration" required="required"/>
<label class="form-check-label" for="consent_declaration">
<strong>I have read and accept the above declaration.</strong>
</label>
</div>
<div class="mb-3">
<label for="signer_type" class="form-label"><strong>I am signing as:</strong></label>
<select class="form-select" id="signer_type" name="signer_type" required="required"
onchange="toggleAgentFields()">
<option value="client" t-att-selected="signer_type == 'client' and 'selected'">Applicant (Client - Self)</option>
<option value="spouse" t-att-selected="signer_type == 'spouse' and 'selected'">Spouse</option>
<option value="parent" t-att-selected="signer_type == 'parent' and 'selected'">Parent</option>
<option value="legal_guardian" t-att-selected="signer_type == 'legal_guardian' and 'selected'">Legal Guardian</option>
<option value="poa" t-att-selected="signer_type == 'poa' and 'selected'">Power of Attorney</option>
<option value="public_trustee" t-att-selected="signer_type == 'public_trustee' and 'selected'">Public Trustee</option>
</select>
</div>
<div class="mb-3">
<label for="signer_name" class="form-label">Full Name</label>
<input type="text" class="form-control" id="signer_name" name="signer_name"
t-att-value="sign_request.signer_name or ''" required="required"/>
</div>
</div>
</div>
<!-- Agent Details (shown/hidden via JS based on signer type selection) -->
<div class="card mb-3" id="agent_details_card" t-att-style="'' if is_agent else 'display:none;'">
<div class="card-header"><strong>Agent Details</strong></div>
<div class="card-body">
<div class="row mb-2">
<div class="col-sm-5">
<label class="form-label">Last Name</label>
<input type="text" class="form-control agent-field" name="agent_last_name"/>
</div>
<div class="col-sm-5">
<label class="form-label">First Name</label>
<input type="text" class="form-control agent-field" name="agent_first_name"/>
</div>
<div class="col-sm-2">
<label class="form-label">M.I.</label>
<input type="text" class="form-control" name="agent_middle_initial"
maxlength="2" placeholder="M"/>
</div>
</div>
<div class="row mb-2">
<div class="col-sm-6">
<label class="form-label">Home Phone</label>
<input type="tel" class="form-control" name="agent_phone"/>
</div>
<div class="col-sm-6">
<label class="form-label">Business Phone</label>
<input type="tel" class="form-control" name="agent_business_phone"/>
</div>
</div>
<div class="mb-2">
<label class="form-label">Search Address</label>
<input type="text" class="form-control" id="agent_street_search"
placeholder="Start typing an address..." autocomplete="off"/>
</div>
<div class="row mb-2">
<div class="col-sm-3">
<label class="form-label">Unit #</label>
<input type="text" class="form-control" name="agent_unit" id="agent_unit"/>
</div>
<div class="col-sm-3">
<label class="form-label">Street #</label>
<input type="text" class="form-control" name="agent_street_number" id="agent_street_number"/>
</div>
<div class="col-sm-6">
<label class="form-label">Street Name</label>
<input type="text" class="form-control" name="agent_street" id="agent_street"/>
</div>
</div>
<div class="row mb-2">
<div class="col-sm-5">
<label class="form-label">City/Town</label>
<input type="text" class="form-control" name="agent_city" id="agent_city"/>
</div>
<div class="col-sm-4">
<label class="form-label">Province</label>
<input type="text" class="form-control" name="agent_province" id="agent_province" value="Ontario"/>
</div>
<div class="col-sm-3">
<label class="form-label">Postal Code</label>
<input type="text" class="form-control" name="agent_postal_code" id="agent_postal_code"/>
</div>
</div>
</div>
</div>
<!-- Signature Pad -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Signature</strong>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="clearSignature()">Clear</button>
</div>
<div class="card-body p-2">
<canvas id="signature-canvas" width="660" height="200"
style="border:1px dashed rgba(128,128,128,0.35);border-radius:6px;width:100%;touch-action:none;cursor:crosshair;">
</canvas>
<input type="hidden" name="signature_data" id="signature_data"/>
</div>
</div>
<div class="text-center mb-4">
<button type="submit" class="btn btn-primary btn-lg px-5" onclick="return prepareSubmit()">
Submit Signature
</button>
</div>
</form>
<p class="text-center text-muted small">
<t t-out="company.name"/> &amp;middot;
<t t-if="company.phone"><t t-out="company.phone"/> &amp;middot; </t>
<t t-if="company.email"><t t-out="company.email"/></t>
</p>
</div>
<script type="text/javascript">
function toggleAgentFields() {
var sel = document.getElementById('signer_type');
var card = document.getElementById('agent_details_card');
var agentFields = card ? card.querySelectorAll('.agent-field') : [];
var isAgent = sel.value !== 'client';
if (card) card.style.display = isAgent ? '' : 'none';
agentFields.forEach(function(f) {
if (isAgent) { f.setAttribute('required', 'required'); }
else { f.removeAttribute('required'); f.value = ''; }
});
}
document.addEventListener('DOMContentLoaded', toggleAgentFields);
</script>
<script type="text/javascript">
(function() {
var canvas = document.getElementById('signature-canvas');
if (!canvas) return;
var ctx = canvas.getContext('2d');
var drawing = false;
var lastX = 0, lastY = 0;
var hasDrawn = false;
function resizeCanvas() {
var rect = canvas.getBoundingClientRect();
var dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}
resizeCanvas();
function getPos(e) {
var rect = canvas.getBoundingClientRect();
var touch = e.touches ? e.touches[0] : e;
return {
x: touch.clientX - rect.left,
y: touch.clientY - rect.top
};
}
function startDraw(e) {
e.preventDefault();
drawing = true;
var pos = getPos(e);
lastX = pos.x;
lastY = pos.y;
}
function draw(e) {
if (!drawing) return;
e.preventDefault();
var pos = getPos(e);
ctx.beginPath();
ctx.moveTo(lastX, lastY);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
lastX = pos.x;
lastY = pos.y;
hasDrawn = true;
}
function stopDraw(e) {
if (e) e.preventDefault();
drawing = false;
}
canvas.addEventListener('mousedown', startDraw);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', stopDraw);
canvas.addEventListener('mouseleave', stopDraw);
canvas.addEventListener('touchstart', startDraw, {passive: false});
canvas.addEventListener('touchmove', draw, {passive: false});
canvas.addEventListener('touchend', stopDraw, {passive: false});
window.clearSignature = function() {
var dpr = window.devicePixelRatio || 1;
ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
hasDrawn = false;
document.getElementById('signature_data').value = '';
};
window.prepareSubmit = function() {
if (!hasDrawn) {
alert('Please draw your signature before submitting.');
return false;
}
var dataUrl = canvas.toDataURL('image/png');
document.getElementById('signature_data').value = dataUrl;
return true;
};
})();
</script>
<t t-if="google_maps_api_key">
<script t-attf-src="https://maps.googleapis.com/maps/api/js?key=#{google_maps_api_key}&amp;libraries=places&amp;callback=initPage11AddressAutocomplete" async="async" defer="defer"></script>
<script type="text/javascript">
function initPage11AddressAutocomplete() {
var searchInput = document.getElementById('agent_street_search');
if (!searchInput) return;
var autocomplete = new google.maps.places.Autocomplete(searchInput, {
types: ['address'],
componentRestrictions: { country: 'ca' }
});
autocomplete.setFields(['address_components', 'formatted_address']);
autocomplete.addListener('place_changed', function() {
var place = autocomplete.getPlace();
if (!place || !place.address_components) return;
var street_number = '', route = '', city = '', province = '', postal = '', unit = '';
place.address_components.forEach(function(c) {
var t = c.types;
if (t.indexOf('street_number') >= 0) street_number = c.long_name;
else if (t.indexOf('route') >= 0) route = c.long_name;
else if (t.indexOf('locality') >= 0) city = c.long_name;
else if (t.indexOf('sublocality') >= 0 &amp;&amp; !city) city = c.long_name;
else if (t.indexOf('administrative_area_level_1') >= 0) province = c.long_name;
else if (t.indexOf('postal_code') >= 0) postal = c.long_name;
else if (t.indexOf('subpremise') >= 0) unit = c.long_name;
});
document.getElementById('agent_street_number').value = street_number;
document.getElementById('agent_street').value = route;
document.getElementById('agent_city').value = city;
document.getElementById('agent_province').value = province;
document.getElementById('agent_postal_code').value = postal;
if (unit) document.getElementById('agent_unit').value = unit;
});
}
</script>
</t>
</t>
</template>
<!-- ============================================================ -->
<!-- Success Page -->
<!-- ============================================================ -->
<template id="portal_page11_sign_success" name="Page 11 - Signed Successfully">
<t t-call="portal.frontend_layout">
<div class="container py-5" style="max-width:600px;">
<div class="text-center">
<div class="mb-4">
<i class="fa fa-check-circle text-success" style="font-size:64px;"/>
</div>
<h3>Signature Submitted Successfully</h3>
<p class="text-muted mt-3">
Thank you for signing the ADP Consent and Declaration form.
Your signature has been recorded and the document has been updated.
</p>
<t t-if="sign_request and sign_request.sale_order_id">
<p class="text-muted">
Case Reference: <strong><t t-out="sign_request.sale_order_id.name"/></strong>
</p>
</t>
<t t-if="sign_request and sign_request.signed_pdf and token">
<a t-attf-href="/page11/sign/#{token}/download"
class="btn btn-outline-primary mt-3">
<i class="fa fa-download"/> Download Signed PDF
</a>
</t>
<p class="text-muted mt-4 small">You may close this window.</p>
</div>
</div>
</t>
</template>
<!-- ============================================================ -->
<!-- Expired / Cancelled Page -->
<!-- ============================================================ -->
<template id="portal_page11_sign_expired" name="Page 11 - Link Expired">
<t t-call="portal.frontend_layout">
<div class="container py-5" style="max-width:600px;">
<div class="text-center">
<div class="mb-4">
<i class="fa fa-clock-o text-warning" style="font-size:64px;"/>
</div>
<h3>Signing Link Expired</h3>
<p class="text-muted mt-3">
This signing link is no longer valid. It may have expired or been cancelled.
</p>
<p class="text-muted">
Please contact the office to request a new signing link.
</p>
</div>
</div>
</t>
</template>
<!-- ============================================================ -->
<!-- Invalid / Not Found Page -->
<!-- ============================================================ -->
<template id="portal_page11_sign_invalid" name="Page 11 - Invalid Link">
<t t-call="portal.frontend_layout">
<div class="container py-5" style="max-width:600px;">
<div class="text-center">
<div class="mb-4">
<i class="fa fa-exclamation-triangle text-danger" style="font-size:64px;"/>
</div>
<h3>Invalid Link</h3>
<p class="text-muted mt-3">
This signing link is not valid. Please check that you are using the correct link
from the email you received.
</p>
</div>
</div>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,348 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ==================== SCHEDULE OVERVIEW PAGE ==================== -->
<template id="portal_schedule_page" name="My Schedule">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<div class="container py-4">
<!-- Success/Error Messages -->
<t t-if="request.params.get('success')">
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="fa fa-check-circle me-2"/><t t-out="request.params.get('success')"/>
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
</div>
</t>
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h3 class="mb-1"><i class="fa fa-calendar-check-o me-2"/>My Schedule</h3>
<p class="text-muted mb-0">View your appointments and book new ones</p>
</div>
<div class="d-flex gap-2 flex-wrap">
<t t-if="share_url">
<div class="input-group" style="max-width: 350px;">
<input type="text" class="form-control form-control-sm" t-att-value="share_url"
id="shareBookingUrl" readonly="readonly" style="font-size: 13px;"/>
<button class="btn btn-outline-secondary btn-sm" type="button"
id="btnCopyShareUrl">
<i class="fa fa-copy" id="copyIcon"/> <span id="copyText">Copy</span>
</button>
<script type="text/javascript">
(function() {
var btn = document.getElementById('btnCopyShareUrl');
if (!btn) return;
btn.addEventListener('click', function() {
var url = document.getElementById('shareBookingUrl').value;
navigator.clipboard.writeText(url);
var icon = document.getElementById('copyIcon');
var text = document.getElementById('copyText');
icon.className = 'fa fa-check';
text.textContent = 'Copied';
setTimeout(function() {
icon.className = 'fa fa-copy';
text.textContent = 'Copy';
}, 2000);
});
})();
</script>
</div>
</t>
<a href="/my/schedule/book" class="btn btn-primary">
<i class="fa fa-plus me-1"/> Book Appointment
</a>
</div>
</div>
<!-- Today's Appointments -->
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
<div class="card-header bg-white border-bottom-0 pt-3 pb-2 px-4"
style="border-radius: 12px 12px 0 0;">
<h5 class="mb-0"><i class="fa fa-sun-o me-2 text-warning"/>Today's Appointments</h5>
</div>
<div class="card-body px-4 pb-4 pt-2">
<t t-if="today_events">
<div class="list-group list-group-flush">
<t t-foreach="today_events" t-as="event">
<div class="list-group-item px-0 py-3 border-start-0 border-end-0">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<div class="rounded-3 text-center px-3 py-2 me-3"
t-attf-style="background: #{portal_gradient}; min-width: 70px;">
<div class="text-white fw-bold" style="font-size: 14px;">
<t t-out="event.start.astimezone(user_tz).strftime('%I:%M')"/>
</div>
<div class="text-white" style="font-size: 10px;">
<t t-out="event.start.astimezone(user_tz).strftime('%p')"/>
</div>
</div>
<div>
<h6 class="mb-0"><t t-out="event.name"/></h6>
<small class="text-muted">
<t t-if="event.location">
<i class="fa fa-map-marker me-1"/><t t-out="event.location"/>
</t>
</small>
</div>
</div>
<div class="text-end">
<span class="badge bg-light text-dark">
<t t-out="'%.0f' % (event.duration * 60)"/> min
</span>
</div>
</div>
</div>
</t>
</div>
</t>
<t t-else="">
<p class="text-muted mb-0 py-3 text-center">
<i class="fa fa-calendar-o me-1"/> No appointments scheduled for today.
</p>
</t>
</div>
</div>
<!-- Upcoming Appointments -->
<div class="card border-0 shadow-sm" style="border-radius: 12px;">
<div class="card-header bg-white border-bottom-0 pt-3 pb-2 px-4"
style="border-radius: 12px 12px 0 0;">
<h5 class="mb-0"><i class="fa fa-calendar me-2 text-primary"/>Upcoming Appointments</h5>
</div>
<div class="card-body px-4 pb-4 pt-2">
<t t-if="upcoming_events">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th style="border-top:none;">Date</th>
<th style="border-top:none;">Time</th>
<th style="border-top:none;">Appointment</th>
<th style="border-top:none;">Location</th>
<th style="border-top:none;">Duration</th>
</tr>
</thead>
<tbody>
<t t-foreach="upcoming_events" t-as="event">
<tr>
<td>
<strong><t t-out="event.start.astimezone(user_tz).strftime('%b %d')"/></strong>
<br/>
<small class="text-muted">
<t t-out="event.start.astimezone(user_tz).strftime('%A')"/>
</small>
</td>
<td>
<t t-out="event.start.astimezone(user_tz).strftime('%I:%M %p')"/>
</td>
<td><t t-out="event.name"/></td>
<td>
<t t-if="event.location">
<small><t t-out="event.location"/></small>
</t>
<t t-else="">
<small class="text-muted">-</small>
</t>
</td>
<td>
<span class="badge bg-light text-dark">
<t t-out="'%.0f' % (event.duration * 60)"/> min
</span>
</td>
</tr>
</t>
</tbody>
</table>
</div>
</t>
<t t-else="">
<p class="text-muted mb-0 py-3 text-center">
<i class="fa fa-calendar-o me-1"/> No upcoming appointments.
<a href="/my/schedule/book">Book one now</a>
</p>
</t>
</div>
</div>
</div>
</t>
</template>
<!-- ==================== BOOKING FORM ==================== -->
<template id="portal_schedule_book" name="Book Appointment">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<div class="container py-4" style="max-width: 800px;">
<!-- Error Messages -->
<t t-if="error">
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="fa fa-exclamation-circle me-2"/><t t-out="error"/>
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
</div>
</t>
<!-- Header -->
<div class="mb-4">
<a href="/my/schedule" class="text-muted text-decoration-none mb-2 d-inline-block">
<i class="fa fa-arrow-left me-1"/> Back to Schedule
</a>
<h3 class="mb-1"><i class="fa fa-plus-circle me-2"/>Book Appointment</h3>
<p class="text-muted mb-0">Select a time slot and enter client details</p>
</div>
<form action="/my/schedule/book/submit" method="post" id="bookingForm">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<!-- Step 1: Appointment Type + Date/Time -->
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
<div class="card-header bg-white border-bottom pt-3 pb-2 px-4"
style="border-radius: 12px 12px 0 0;">
<h5 class="mb-0">
<span class="badge rounded-pill me-2"
t-attf-style="background: #{portal_gradient};">1</span>
Date &amp; Time
</h5>
</div>
<div class="card-body px-4 pb-4">
<!-- Appointment Type (if multiple) -->
<t t-if="len(appointment_types) > 1">
<div class="mb-3">
<label class="form-label fw-semibold">Appointment Type</label>
<select name="appointment_type_id" class="form-select"
id="appointmentTypeSelect">
<t t-foreach="appointment_types" t-as="atype">
<option t-att-value="atype.id"
t-att-selected="atype.id == selected_type.id"
t-att-data-duration="atype.appointment_duration">
<t t-out="atype.name"/>
(<t t-out="'%.0f' % (atype.appointment_duration * 60)"/> min)
</option>
</t>
</select>
</div>
</t>
<t t-else="">
<input type="hidden" name="appointment_type_id"
t-att-value="selected_type.id"/>
</t>
<!-- Date Picker -->
<div class="mb-3">
<label class="form-label fw-semibold">Select Date</label>
<input type="date" class="form-control" id="bookingDate"
required="required"
t-att-min="now.strftime('%Y-%m-%d')"/>
</div>
<!-- Week Calendar Preview -->
<div id="weekCalendarContainer" class="mb-3" style="display: none;">
<label class="form-label fw-semibold">
<i class="fa fa-calendar me-1"/>Your Week
</label>
<div id="weekCalendarLoading" class="text-center py-3" style="display: none;">
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"/>
Loading calendar...
</div>
<div id="weekCalendarGrid" class="border rounded-3 overflow-hidden" style="display: none;">
<div id="weekCalendarHeader" class="d-flex bg-light border-bottom" style="min-height: 40px;"></div>
<div id="weekCalendarBody" class="d-flex" style="min-height: 80px;"></div>
</div>
<div id="weekCalendarEmpty" class="text-muted py-2 text-center" style="display: none;">
<i class="fa fa-calendar-o me-1"/> No events this week -- your schedule is open.
</div>
</div>
<!-- Available Slots -->
<div id="slotsContainer" style="display: none;">
<label class="form-label fw-semibold">Available Time Slots</label>
<div id="slotsLoading" class="text-center py-3" style="display: none;">
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"/>
Loading available slots...
</div>
<div id="slotsGrid" class="d-flex flex-wrap gap-2 mb-2"></div>
<div id="noSlots" class="text-muted py-2" style="display: none;">
<i class="fa fa-info-circle me-1"/> No available slots for this date.
Try another date.
</div>
<input type="hidden" name="slot_datetime" id="slotDatetime"/>
<input type="hidden" name="slot_duration" id="slotDuration"
t-att-value="selected_type.appointment_duration"/>
</div>
</div>
</div>
<!-- Step 2: Client Details -->
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
<div class="card-header bg-white border-bottom pt-3 pb-2 px-4"
style="border-radius: 12px 12px 0 0;">
<h5 class="mb-0">
<span class="badge rounded-pill me-2"
t-attf-style="background: #{portal_gradient};">2</span>
Client Details
</h5>
</div>
<div class="card-body px-4 pb-4">
<div class="mb-3">
<label class="form-label fw-semibold">Client Name <span class="text-danger">*</span></label>
<input type="text" name="client_name" class="form-control"
placeholder="Enter client's full name" required="required"/>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Address</label>
<input type="text" name="client_street" class="form-control mb-2"
id="clientStreet"
placeholder="Start typing address..."/>
</div>
<div class="row g-2 mb-3">
<div class="col-md-4">
<input type="text" name="client_city" class="form-control"
id="clientCity" placeholder="City"/>
</div>
<div class="col-md-4">
<input type="text" name="client_province" class="form-control"
id="clientProvince" placeholder="Province"/>
</div>
<div class="col-md-4">
<input type="text" name="client_postal" class="form-control"
id="clientPostal" placeholder="Postal Code"/>
</div>
</div>
<div class="mb-0">
<label class="form-label fw-semibold">Notes</label>
<textarea name="notes" class="form-control" rows="3"
placeholder="e.g. Equipment to bring, special instructions, reason for visit..."></textarea>
</div>
</div>
</div>
<!-- Submit -->
<div class="d-flex justify-content-between">
<a href="/my/schedule" class="btn btn-outline-secondary">
<i class="fa fa-arrow-left me-1"/> Cancel
</a>
<button type="submit" class="btn btn-primary btn-lg px-4" id="btnSubmitBooking"
disabled="disabled">
<i class="fa fa-calendar-check-o me-1"/> Book Appointment
</button>
</div>
</form>
</div>
<!-- Google Maps Places API -->
<t t-if="google_maps_api_key">
<script t-attf-src="https://maps.googleapis.com/maps/api/js?key=#{google_maps_api_key}&amp;libraries=places&amp;callback=initScheduleAddressAutocomplete"
async="async" defer="defer"></script>
</t>
<script t-attf-src="/fusion_authorizer_portal/static/src/js/portal_schedule_booking.js"></script>
</t>
</template>
</odoo>

View File

@@ -18,6 +18,41 @@
</ol> </ol>
</nav> </nav>
<!-- Clock In/Out -->
<t t-if="clock_enabled">
<div class="tech-clock-card mb-3"
id="techClockCard"
t-att-data-checked-in="'true' if clock_checked_in else 'false'"
t-att-data-check-in-time="clock_check_in_time or ''">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<div class="tech-clock-dot" t-att-class="'tech-clock-dot--active' if clock_checked_in else ''"/>
<div>
<div class="tech-clock-status" id="clockStatusText">
<t t-if="clock_checked_in">Clocked In</t>
<t t-else="">Not Clocked In</t>
</div>
<div class="tech-clock-timer" id="clockTimer">00:00:00</div>
</div>
</div>
<button class="tech-clock-btn" id="clockActionBtn"
t-att-class="'tech-clock-btn--out' if clock_checked_in else 'tech-clock-btn--in'"
onclick="handleClockAction()">
<t t-if="clock_checked_in">
<i class="fa fa-stop-circle-o"/> Clock Out
</t>
<t t-else="">
<i class="fa fa-play-circle-o"/> Clock In
</t>
</button>
</div>
<div class="tech-clock-error" id="clockError" style="display:none;">
<i class="fa fa-exclamation-triangle"/>
<span id="clockErrorText"/>
</div>
</div>
</t>
<!-- Quick Stats Bar --> <!-- Quick Stats Bar -->
<div class="tech-stats-bar mb-4"> <div class="tech-stats-bar mb-4">
<div class="tech-stat-card tech-stat-total"> <div class="tech-stat-card tech-stat-total">
@@ -32,10 +67,6 @@
<div class="stat-number"><t t-out="completed_today"/></div> <div class="stat-number"><t t-out="completed_today"/></div>
<div class="stat-label">Done</div> <div class="stat-label">Done</div>
</div> </div>
<div class="tech-stat-card tech-stat-travel">
<div class="stat-number"><t t-out="total_travel"/></div>
<div class="stat-label">Travel min</div>
</div>
</div> </div>
<!-- Current / Next Task Hero Card --> <!-- Current / Next Task Hero Card -->
@@ -55,21 +86,24 @@
</div> </div>
<span class="text-muted"><t t-out="current_task.time_start_display"/> - <t t-out="current_task.time_end_display"/></span> <span class="text-muted"><t t-out="current_task.time_start_display"/> - <t t-out="current_task.time_end_display"/></span>
</div> </div>
<p class="mb-1"><i class="fa fa-user me-1 text-muted"/><t t-out="current_task.partner_id.name or 'N/A'"/></p> <p class="mb-1"><i class="fa fa-user me-1 text-muted"/><t t-out="current_task.client_display_name or 'N/A'"/></p>
<p class="mb-2 text-muted"><i class="fa fa-map-marker me-1"/><t t-out="current_task.address_display or 'No address'"/></p> <p class="mb-2 text-muted"><i class="fa fa-map-marker me-1"/><t t-out="current_task.address_display or 'No address'"/></p>
<t t-if="current_task.description"> <t t-if="current_task.description">
<p class="mb-2 small"><t t-out="current_task.description"/></p> <p class="mb-2 small"><t t-out="current_task.description"/></p>
</t> </t>
<div class="d-flex gap-2 flex-wrap mt-3"> <div class="d-flex gap-2 flex-wrap mt-3">
<a t-if="current_task.get_google_maps_url()" t-att-href="current_task.get_google_maps_url()" <a t-if="current_task.get_google_maps_url()"
class="tech-action-btn tech-btn-navigate" target="_blank"> href="#" class="tech-action-btn tech-btn-navigate"
t-att-data-nav-url="current_task.get_google_maps_url()"
t-att-data-nav-addr="current_task.address_display or ''"
onclick="openGoogleMapsNav(this); return false;">
<i class="fa fa-location-arrow"/>Navigate <i class="fa fa-location-arrow"/>Navigate
</a> </a>
<a t-attf-href="/my/technician/task/#{current_task.id}" <a t-attf-href="/my/technician/task/#{current_task.id}"
class="tech-action-btn tech-btn-complete"> class="tech-action-btn tech-btn-complete">
<i class="fa fa-check"/>Complete <i class="fa fa-check"/>Complete
</a> </a>
<a t-if="current_task.partner_phone" t-attf-href="tel:#{current_task.partner_phone}" <a t-if="current_task.client_display_phone" t-attf-href="tel:#{current_task.client_display_phone}"
class="tech-action-btn tech-btn-call"> class="tech-action-btn tech-btn-call">
<i class="fa fa-phone"/>Call <i class="fa fa-phone"/>Call
</a> </a>
@@ -94,7 +128,7 @@
<span class="ms-2 fw-bold"><t t-out="next_task.name"/></span> <span class="ms-2 fw-bold"><t t-out="next_task.name"/></span>
</div> </div>
</div> </div>
<p class="mb-1"><i class="fa fa-user me-1 text-muted"/><t t-out="next_task.partner_id.name or 'N/A'"/></p> <p class="mb-1"><i class="fa fa-user me-1 text-muted"/><t t-out="next_task.client_display_name or 'N/A'"/></p>
<p class="mb-1 text-muted"><i class="fa fa-map-marker me-1"/><t t-out="next_task.address_display or 'No address'"/></p> <p class="mb-1 text-muted"><i class="fa fa-map-marker me-1"/><t t-out="next_task.address_display or 'No address'"/></p>
<t t-if="next_task.travel_time_minutes"> <t t-if="next_task.travel_time_minutes">
<p class="mb-2 small text-purple"><i class="fa fa-car me-1"/><t t-out="next_task.travel_time_minutes"/> min drive <p class="mb-2 small text-purple"><i class="fa fa-car me-1"/><t t-out="next_task.travel_time_minutes"/> min drive
@@ -102,8 +136,11 @@
</p> </p>
</t> </t>
<div class="d-flex gap-2 flex-wrap mt-3"> <div class="d-flex gap-2 flex-wrap mt-3">
<a t-if="next_task.get_google_maps_url()" t-att-href="next_task.get_google_maps_url()" <a t-if="next_task.get_google_maps_url()"
class="tech-action-btn tech-btn-navigate" target="_blank"> href="#" class="tech-action-btn tech-btn-navigate"
t-att-data-nav-url="next_task.get_google_maps_url()"
t-att-data-nav-addr="next_task.address_display or ''"
onclick="openGoogleMapsNav(this); return false;">
<i class="fa fa-location-arrow"/>Navigate <i class="fa fa-location-arrow"/>Navigate
</a> </a>
<button class="tech-action-btn tech-btn-enroute" <button class="tech-action-btn tech-btn-enroute"
@@ -155,7 +192,7 @@
<span t-attf-class="tech-badge tech-badge-#{task.task_type} me-1"> <span t-attf-class="tech-badge tech-badge-#{task.task_type} me-1">
<t t-out="dict(task._fields['task_type'].selection).get(task.task_type, '')"/> <t t-out="dict(task._fields['task_type'].selection).get(task.task_type, '')"/>
</span> </span>
<t t-out="task.partner_id.name or task.name"/> <t t-out="task.client_display_name or task.name"/>
</div> </div>
<div class="tech-timeline-meta"> <div class="tech-timeline-meta">
<i class="fa fa-map-marker me-1"/><t t-out="task.address_city or 'No address'"/> <i class="fa fa-map-marker me-1"/><t t-out="task.address_city or 'No address'"/>
@@ -170,25 +207,22 @@
</t> </t>
<!-- Quick Links --> <!-- Quick Links -->
<div class="row g-2 mb-4"> <div class="tech-quick-links mb-4">
<div class="col-4"> <a href="/my/technician/tasks" class="tech-quick-link tech-quick-link-primary">
<a href="/my/technician/tasks" class="btn btn-outline-primary w-100 py-3"> <i class="fa fa-list"/>
<i class="fa fa-list me-1"/>All Tasks <span>All Tasks</span>
</a> </a>
</div> <a href="/my/technician/tomorrow" class="tech-quick-link tech-quick-link-secondary">
<div class="col-4"> <i class="fa fa-calendar"/>
<a href="/my/technician/tomorrow" class="btn btn-outline-secondary w-100 py-3"> <span>Tomorrow</span>
<i class="fa fa-calendar me-1"/>Tomorrow <t t-if="tomorrow_count">
<t t-if="tomorrow_count"> <span class="tech-quick-link-badge"><t t-out="tomorrow_count"/></span>
<span class="badge bg-primary ms-1"><t t-out="tomorrow_count"/></span> </t>
</t> </a>
</a> <a href="/repair-form" class="tech-quick-link tech-quick-link-warning">
</div> <i class="fa fa-wrench"/>
<div class="col-4"> <span>Repair Form</span>
<a href="/repair-form" class="btn btn-outline-warning w-100 py-3"> </a>
<i class="fa fa-wrench me-1"/>Repair Form
</a>
</div>
</div> </div>
<!-- My Start Location --> <!-- My Start Location -->
@@ -221,30 +255,146 @@
</div> </div>
</div> </div>
<!-- Clock In/Out JS -->
<script type="text/javascript">
(function() {
var card = document.getElementById('techClockCard');
if (!card) return;
var isCheckedIn = card.dataset.checkedIn === 'true';
var checkInTime = card.dataset.checkInTime ? new Date(card.dataset.checkInTime + 'Z') : null;
var timerInterval = null;
function updateTimer() {
if (!checkInTime) return;
var diff = Math.max(0, Math.floor((new Date() - checkInTime) / 1000));
var h = Math.floor(diff / 3600);
var m = Math.floor((diff % 3600) / 60);
var s = diff % 60;
var pad = function(n) { return n &lt; 10 ? '0' + n : '' + n; };
document.getElementById('clockTimer').textContent = pad(h) + ':' + pad(m) + ':' + pad(s);
}
function startTimer() {
stopTimer();
updateTimer();
timerInterval = setInterval(updateTimer, 1000);
}
function stopTimer() {
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
}
function applyState() {
var dot = card.querySelector('.tech-clock-dot');
var statusEl = document.getElementById('clockStatusText');
var btn = document.getElementById('clockActionBtn');
var timerEl = document.getElementById('clockTimer');
if (dot) dot.className = 'tech-clock-dot' + (isCheckedIn ? ' tech-clock-dot--active' : '');
if (statusEl) statusEl.textContent = isCheckedIn ? 'Clocked In' : 'Not Clocked In';
if (btn) {
btn.className = 'tech-clock-btn ' + (isCheckedIn ? 'tech-clock-btn--out' : 'tech-clock-btn--in');
btn.innerHTML = isCheckedIn
? '&lt;i class="fa fa-stop-circle-o">&lt;/i> Clock Out'
: '&lt;i class="fa fa-play-circle-o">&lt;/i> Clock In';
}
if (!isCheckedIn &amp;&amp; timerEl) timerEl.textContent = '00:00:00';
}
if (isCheckedIn &amp;&amp; checkInTime) startTimer();
window.handleClockAction = function() {
var btn = document.getElementById('clockActionBtn');
var errEl = document.getElementById('clockError');
var errText = document.getElementById('clockErrorText');
btn.disabled = true;
errEl.style.display = 'none';
window.fusionGetLocation().then(function(coords) {
fetch('/fusion_clock/clock_action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy,
source: 'portal'
}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
var result = data.result || {};
if (result.error) {
errText.textContent = result.error;
errEl.style.display = 'flex';
btn.disabled = false;
return;
}
if (result.action === 'clock_in') {
isCheckedIn = true;
checkInTime = new Date(result.check_in + 'Z');
startTimer();
} else {
isCheckedIn = false;
checkInTime = null;
stopTimer();
}
applyState();
btn.disabled = false;
})
.catch(function() {
errText.textContent = 'Network error. Please try again.';
errEl.style.display = 'flex';
btn.disabled = false;
});
}).catch(function() {
errText.textContent = 'Location access is required for clock in/out.';
errEl.style.display = 'flex';
btn.disabled = false;
});
};
})();
</script>
<!-- Inline JS for task actions --> <!-- Inline JS for task actions -->
<script type="text/javascript"> <script type="text/javascript">
function techTaskAction(btn, action) { function techTaskAction(btn, action) {
var taskId = btn.dataset.taskId; var taskId = btn.dataset.taskId;
var origHtml = btn.innerHTML;
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> ...'; btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Getting location...';
fetch('/my/technician/task/' + taskId + '/action', { window.fusionGetLocation().then(function(coords) {
method: 'POST', btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> ...';
headers: {'Content-Type': 'application/json'}, fetch('/my/technician/task/' + taskId + '/action', {
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {action: action}}) method: 'POST',
}) headers: {'Content-Type': 'application/json'},
.then(function(r) { return r.json(); }) body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
.then(function(data) { action: action,
if (data.result &amp;&amp; data.result.success) { latitude: coords.latitude,
window.location.reload(); longitude: coords.longitude,
} else { accuracy: coords.accuracy
alert(data.result ? data.result.error : 'Error'); }})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.result &amp;&amp; data.result.success) {
window.location.reload();
} else {
alert(data.result ? data.result.error : 'Error');
btn.disabled = false;
btn.innerHTML = origHtml;
}
})
.catch(function() {
alert('Network error. Please try again.');
btn.disabled = false; btn.disabled = false;
btn.innerHTML = '<i class="fa fa-road"></i> En Route'; btn.innerHTML = origHtml;
} });
}) }).catch(function() {
.catch(function() { alert('Location access is required. Please enable GPS and try again.');
btn.disabled = false; btn.disabled = false;
btn.innerHTML = '<i class="fa fa-road"></i> En Route'; btn.innerHTML = origHtml;
}); });
} }
@@ -375,7 +525,7 @@
<span class="ms-1"><t t-out="task.time_start_display"/></span> <span class="ms-1"><t t-out="task.time_start_display"/></span>
</span> </span>
<span> <span>
<i class="fa fa-user me-1"/><t t-out="task.partner_id.name or '-'"/> <i class="fa fa-user me-1"/><t t-out="task.client_display_name or '-'"/>
</span> </span>
</div> </div>
<div class="text-muted small mt-1"> <div class="text-muted small mt-1">
@@ -462,19 +612,22 @@
<!-- ===== QUICK ACTIONS ROW ===== --> <!-- ===== QUICK ACTIONS ROW ===== -->
<div class="tech-quick-actions mb-3"> <div class="tech-quick-actions mb-3">
<t t-if="task.get_google_maps_url()"> <t t-if="task.get_google_maps_url()">
<a t-att-href="task.get_google_maps_url()" class="tech-quick-btn" target="_blank"> <a href="#" class="tech-quick-btn"
t-att-data-nav-url="task.get_google_maps_url()"
t-att-data-nav-addr="task.address_display or ''"
onclick="openGoogleMapsNav(this); return false;">
<i class="fa fa-location-arrow"/> <i class="fa fa-location-arrow"/>
<span>Navigate</span> <span>Navigate</span>
</a> </a>
</t> </t>
<t t-if="task.partner_phone"> <t t-if="task.client_display_phone">
<a t-attf-href="tel:#{task.partner_phone}" class="tech-quick-btn"> <a t-attf-href="tel:#{task.client_display_phone}" class="tech-quick-btn">
<i class="fa fa-phone"/> <i class="fa fa-phone"/>
<span>Call</span> <span>Call</span>
</a> </a>
</t> </t>
<t t-if="task.partner_phone"> <t t-if="task.client_display_phone">
<a t-attf-href="sms:#{task.partner_phone}" class="tech-quick-btn"> <a t-attf-href="sms:#{task.client_display_phone}" class="tech-quick-btn">
<i class="fa fa-comment"/> <i class="fa fa-comment"/>
<span>Text</span> <span>Text</span>
</a> </a>
@@ -512,10 +665,10 @@
<i class="fa fa-user"/> <i class="fa fa-user"/>
</div> </div>
<div class="flex-grow-1"> <div class="flex-grow-1">
<div class="fw-semibold"><t t-out="task.partner_id.name or 'No client'"/></div> <div class="fw-semibold"><t t-out="task.client_display_name or 'No client'"/></div>
<t t-if="task.partner_phone"> <t t-if="task.client_display_phone">
<a t-attf-href="tel:#{task.partner_phone}" class="text-muted small text-decoration-none"> <a t-attf-href="tel:#{task.client_display_phone}" class="text-muted small text-decoration-none">
<i class="fa fa-phone me-1"/><t t-out="task.partner_phone"/> <i class="fa fa-phone me-1"/><t t-out="task.client_display_phone"/>
</a> </a>
</t> </t>
</div> </div>
@@ -565,8 +718,8 @@
</div> </div>
</t> </t>
<!-- ===== POD (if required and linked to a sale order) ===== --> <!-- ===== POD (if required) ===== -->
<t t-if="task.pod_required and task.sale_order_id"> <t t-if="task.pod_required">
<div class="tech-card mb-3"> <div class="tech-card mb-3">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="tech-card-icon bg-warning-subtle text-warning"> <div class="tech-card-icon bg-warning-subtle text-warning">
@@ -574,14 +727,16 @@
</div> </div>
<div class="flex-grow-1"> <div class="flex-grow-1">
<div class="fw-semibold">Proof of Delivery</div> <div class="fw-semibold">Proof of Delivery</div>
<t t-if="task.sale_order_id.x_fc_pod_signature"> <t t-set="has_task_pod" t-value="bool(task.pod_signature)"/>
<t t-set="has_order_pod" t-value="bool(task.sale_order_id and task.sale_order_id.x_fc_pod_signature)"/>
<t t-if="has_task_pod or has_order_pod">
<span class="text-success small d-block"><i class="fa fa-check me-1"/>Signature collected</span> <span class="text-success small d-block"><i class="fa fa-check me-1"/>Signature collected</span>
<a t-attf-href="/my/pod/#{task.sale_order_id.id}" class="btn btn-sm btn-outline-warning mt-1"> <a t-attf-href="/my/technician/task/#{task.id}/pod" class="btn btn-sm btn-outline-warning mt-1">
<i class="fa fa-refresh me-1"/>Re-collect Signature <i class="fa fa-refresh me-1"/>Re-collect Signature
</a> </a>
</t> </t>
<t t-else=""> <t t-else="">
<a t-attf-href="/my/pod/#{task.sale_order_id.id}" class="btn btn-sm btn-warning mt-1"> <a t-attf-href="/my/technician/task/#{task.id}/pod" class="btn btn-sm btn-warning mt-1">
<i class="fa fa-pencil me-1"/>Collect Signature <i class="fa fa-pencil me-1"/>Collect Signature
</a> </a>
</t> </t>
@@ -693,10 +848,18 @@
t-att-data-task-id="task.id"> t-att-data-task-id="task.id">
<i class="fa fa-play"/>Start <i class="fa fa-play"/>Start
</button> </button>
<button class="tech-action-btn tech-btn-complete"
onclick="techCompleteTask(this)"
t-att-data-task-id="task.id">
<i class="fa fa-check-circle"/>Complete
</button>
</t> </t>
<t t-if="task.status == 'en_route'"> <t t-if="task.status == 'en_route'">
<a t-if="task.get_google_maps_url()" t-att-href="task.get_google_maps_url()" <a t-if="task.get_google_maps_url()"
class="tech-action-btn tech-btn-navigate" target="_blank"> href="#" class="tech-action-btn tech-btn-navigate"
t-att-data-nav-url="task.get_google_maps_url()"
t-att-data-nav-addr="task.address_display or ''"
onclick="openGoogleMapsNav(this); return false;">
<i class="fa fa-location-arrow"/>Navigate <i class="fa fa-location-arrow"/>Navigate
</a> </a>
<button class="tech-action-btn tech-btn-start" <button class="tech-action-btn tech-btn-start"
@@ -704,6 +867,11 @@
t-att-data-task-id="task.id"> t-att-data-task-id="task.id">
<i class="fa fa-play"/>Start <i class="fa fa-play"/>Start
</button> </button>
<button class="tech-action-btn tech-btn-complete"
onclick="techCompleteTask(this)"
t-att-data-task-id="task.id">
<i class="fa fa-check-circle"/>Complete
</button>
</t> </t>
<t t-if="task.status == 'in_progress'"> <t t-if="task.status == 'in_progress'">
<button class="tech-action-btn tech-btn-complete" <button class="tech-action-btn tech-btn-complete"
@@ -750,46 +918,78 @@
var recordingSeconds = 0; var recordingSeconds = 0;
function techTaskAction(btn, action) { function techTaskAction(btn, action) {
var origHtml = btn.innerHTML;
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i>'; btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i> Getting location...';
fetch('/my/technician/task/' + taskId + '/action', { window.fusionGetLocation().then(function(coords) {
method: 'POST', btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i>';
headers: {'Content-Type': 'application/json'}, fetch('/my/technician/task/' + taskId + '/action', {
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {action: action}}) method: 'POST',
}) headers: {'Content-Type': 'application/json'},
.then(function(r) { return r.json(); }) body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
.then(function(data) { action: action,
if (data.result &amp;&amp; data.result.success) { latitude: coords.latitude,
window.location.reload(); longitude: coords.longitude,
} else { accuracy: coords.accuracy
alert(data.result ? data.result.error : 'Error'); }})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.result &amp;&amp; data.result.success) {
window.location.reload();
} else {
alert(data.result ? data.result.error : 'Error');
btn.disabled = false;
btn.innerHTML = origHtml;
}
})
.catch(function() {
alert('Network error. Please try again.');
btn.disabled = false; btn.disabled = false;
} btn.innerHTML = origHtml;
});
}).catch(function() {
alert('Location access is required. Please enable GPS and try again.');
btn.disabled = false;
btn.innerHTML = origHtml;
}); });
} }
function techCompleteTask(btn) { function techCompleteTask(btn) {
var origHtml = btn.innerHTML;
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i> Completing...'; btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i> Getting location...';
fetch('/my/technician/task/' + taskId + '/action', { window.fusionGetLocation().then(function(coords) {
method: 'POST', btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i> Completing...';
headers: {'Content-Type': 'application/json'}, fetch('/my/technician/task/' + taskId + '/action', {
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {action: 'complete'}}) method: 'POST',
}) headers: {'Content-Type': 'application/json'},
.then(function(r) { return r.json(); }) body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
.then(function(data) { action: 'complete',
if (data.result &amp;&amp; data.result.success) { latitude: coords.latitude,
showCompletionOverlay(data.result); longitude: coords.longitude,
} else { accuracy: coords.accuracy
alert(data.result ? data.result.error : 'Error completing task'); }})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.result &amp;&amp; data.result.success) {
showCompletionOverlay(data.result);
} else {
alert(data.result ? data.result.error : 'Error completing task');
btn.disabled = false;
btn.innerHTML = origHtml;
}
})
.catch(function(err) {
alert('Network error. Please try again.');
btn.disabled = false; btn.disabled = false;
btn.innerHTML = '&lt;i class="fa fa-check-circle">&lt;/i> Complete Task'; btn.innerHTML = origHtml;
} });
}) }).catch(function() {
.catch(function(err) { alert('Location access is required. Please enable GPS and try again.');
alert('Network error. Please try again.');
btn.disabled = false; btn.disabled = false;
btn.innerHTML = '&lt;i class="fa fa-check-circle">&lt;/i> Complete Task'; btn.innerHTML = origHtml;
}); });
} }
@@ -1184,10 +1384,16 @@
btns.forEach(function(b){b.disabled = true;}); btns.forEach(function(b){b.disabled = true;});
try { try {
var coords = await window.fusionGetLocation();
var resp = await fetch('/my/technician/task/' + taskId + '/voice-complete', { var resp = await fetch('/my/technician/task/' + taskId + '/voice-complete', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {transcription: text}}) body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
transcription: text,
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy
}})
}); });
var data = await resp.json(); var data = await resp.json();
if (data.result &amp;&amp; data.result.success) { if (data.result &amp;&amp; data.result.success) {
@@ -1197,7 +1403,11 @@
btns.forEach(function(b){b.disabled = false;}); btns.forEach(function(b){b.disabled = false;});
} }
} catch (err) { } catch (err) {
alert('Error: ' + err.message); if (err instanceof GeolocationPositionError || err.code) {
alert('Location access is required. Please enable GPS and try again.');
} else {
alert('Error: ' + err.message);
}
btns.forEach(function(b){b.disabled = false;}); btns.forEach(function(b){b.disabled = false;});
} }
} }
@@ -1271,15 +1481,15 @@
</t> </t>
</div> </div>
<div class="mt-2"> <div class="mt-2">
<strong><t t-out="task.partner_id.name or task.name"/></strong> <strong><t t-out="task.client_display_name or task.name"/></strong>
</div> </div>
<div class="text-muted small"> <div class="text-muted small">
<i class="fa fa-map-marker me-1"/><t t-out="task.address_display or 'No address'"/> <i class="fa fa-map-marker me-1"/><t t-out="task.address_display or 'No address'"/>
</div> </div>
<t t-if="task.partner_phone"> <t t-if="task.client_display_phone">
<div class="small mt-1"> <div class="small mt-1">
<a t-attf-href="tel:#{task.partner_phone}" class="text-decoration-none"> <a t-attf-href="tel:#{task.client_display_phone}" class="text-decoration-none">
<i class="fa fa-phone me-1"/><t t-out="task.partner_phone"/> <i class="fa fa-phone me-1"/><t t-out="task.client_display_phone"/>
</a> </a>
</div> </div>
</t> </t>
@@ -1353,7 +1563,7 @@
<span t-attf-class="tech-badge tech-badge-#{task.task_type} me-1"> <span t-attf-class="tech-badge tech-badge-#{task.task_type} me-1">
<t t-out="dict(task._fields['task_type'].selection).get(task.task_type, '')"/> <t t-out="dict(task._fields['task_type'].selection).get(task.task_type, '')"/>
</span> </span>
<t t-out="task.partner_id.name or task.name"/> <t t-out="task.client_display_name or task.name"/>
</div> </div>
<div class="tech-timeline-meta"> <div class="tech-timeline-meta">
<i class="fa fa-map-marker me-1"/><t t-out="task.address_city or 'No address'"/> <i class="fa fa-map-marker me-1"/><t t-out="task.address_city or 'No address'"/>
@@ -1384,7 +1594,7 @@
<div class="container-fluid py-3"> <div class="container-fluid py-3">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h3><i class="fa fa-map-marker"/> Technician Locations</h3> <h3><i class="fa fa-map-marker"/> Technician Locations</h3>
<a href="/web#action=fusion_claims.action_technician_locations" class="btn btn-secondary btn-sm"> <a href="/web#action=fusion_tasks.action_technician_locations" class="btn btn-secondary btn-sm">
<i class="fa fa-list"/> View History <i class="fa fa-list"/> View History
</a> </a>
</div> </div>

View File

@@ -189,6 +189,89 @@
</div> </div>
</t> </t>
<!-- My Schedule (All portal roles) -->
<div class="col-md-6">
<a href="/my/schedule" class="card h-100 border-0 shadow-sm text-decoration-none" style="border-radius: 12px; min-height: 100px;">
<div class="card-body d-flex align-items-center p-4">
<div class="me-3">
<div class="rounded-circle d-flex align-items-center justify-content-center" t-attf-style="width: 50px; height: 50px; background: {{fc_gradient}};">
<i class="fa fa-calendar-check-o fa-lg text-white"/>
</div>
</div>
<div>
<h5 class="mb-1 text-dark">My Schedule</h5>
<small class="text-muted">View and book appointments</small>
</div>
</div>
</a>
</div>
<!-- Clock In/Out -->
<t t-if="clock_enabled">
<div class="col-md-6">
<style>
@keyframes hcPulseGreen {
0% { box-shadow: 0 0 0 0 rgba(16,185,129,0.5); }
70% { box-shadow: 0 0 0 14px rgba(16,185,129,0); }
100% { box-shadow: 0 0 0 0 rgba(16,185,129,0); }
}
@keyframes hcPulseRed {
0% { box-shadow: 0 0 0 0 rgba(239,68,68,0.5); }
70% { box-shadow: 0 0 0 14px rgba(239,68,68,0); }
100% { box-shadow: 0 0 0 0 rgba(239,68,68,0); }
}
.hc-btn-ring {
width: 56px; height: 56px; border-radius: 50%; border: none;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; pointer-events: none;
}
.hc-btn-ring--in {
background: #10b981;
animation: hcPulseGreen 2s ease-in-out infinite;
}
.hc-btn-ring--out {
background: #ef4444;
animation: hcPulseRed 2s ease-in-out infinite;
}
.hc-btn-ring i { color: #fff; font-size: 1.4rem; }
.hc-btn-ring--in i { padding-left: 3px; }
.hc-timer-badge {
display: inline-block; font-family: monospace; font-size: 0.75rem; font-weight: 700;
color: #10b981; background: rgba(16,185,129,0.1); border-radius: 20px;
padding: 2px 10px; letter-spacing: 0.05em;
}
.hc-clock-link { text-decoration: none; }
.hc-clock-link:hover { text-decoration: none; }
.hc-clock-link:hover .card { box-shadow: 0 4px 16px rgba(0,0,0,0.12) !important; }
.hc-clock-link:active .hc-btn-ring { transform: scale(0.92); }
</style>
<a href="/my/clock" class="hc-clock-link"
id="homeClockCard"
t-att-data-checked-in="'true' if clock_checked_in else 'false'"
t-att-data-check-in-time="clock_check_in_time or ''">
<div class="card h-100 border-0 shadow-sm" style="border-radius: 12px; min-height: 100px;">
<div class="card-body d-flex align-items-center p-4">
<div class="me-3">
<div t-attf-class="hc-btn-ring #{clock_checked_in and 'hc-btn-ring--out' or 'hc-btn-ring--in'}">
<i t-attf-class="fa #{clock_checked_in and 'fa-stop' or 'fa-play'}"/>
</div>
</div>
<div style="min-width: 0;">
<h5 class="mb-0 text-dark" id="homeClockStatus">
<t t-if="clock_checked_in">Clocked In</t>
<t t-else="">Clock In</t>
</h5>
<div id="homeClockTimer">
<t t-if="clock_checked_in"><span class="hc-timer-badge">00:00:00</span></t>
<t t-else=""><small class="text-muted">Tap to start your shift</small></t>
</div>
</div>
</div>
</div>
</a>
</div>
</t>
<!-- Funding Claims (Clients/Authorizers) --> <!-- Funding Claims (Clients/Authorizers) -->
<t t-if="request.env.user.partner_id.is_client_portal or request.env.user.partner_id.is_authorizer"> <t t-if="request.env.user.partner_id.is_client_portal or request.env.user.partner_id.is_authorizer">
<div class="col-md-6"> <div class="col-md-6">
@@ -251,6 +334,28 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Home Clock Timer (display only, links to /my/clock) -->
<script type="text/javascript">
(function() {
var card = document.getElementById('homeClockCard');
if (!card) return;
var isCheckedIn = card.dataset.checkedIn === 'true';
var checkInTime = card.dataset.checkInTime ? new Date(card.dataset.checkInTime + 'Z') : null;
if (!isCheckedIn || !checkInTime) return;
function pad(n) { return n &lt; 10 ? '0' + n : '' + n; }
var badge = document.querySelector('#homeClockTimer .hc-timer-badge');
if (!badge) return;
function tick() {
var diff = Math.max(0, Math.floor((new Date() - checkInTime) / 1000));
badge.textContent = pad(Math.floor(diff / 3600)) + ':' + pad(Math.floor((diff % 3600) / 60)) + ':' + pad(diff % 60);
}
tick();
setInterval(tick, 1000);
})();
</script>
</t> </t>
</xpath> </xpath>
</template> </template>
@@ -1087,6 +1192,41 @@
</div> </div>
</div> </div>
<!-- Clock In/Out -->
<t t-if="clock_enabled">
<div class="tech-clock-card mb-3"
id="techClockCard"
t-att-data-checked-in="'true' if clock_checked_in else 'false'"
t-att-data-check-in-time="clock_check_in_time or ''">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<div class="tech-clock-dot" t-att-class="'tech-clock-dot--active' if clock_checked_in else ''"/>
<div>
<div class="tech-clock-status" id="clockStatusText">
<t t-if="clock_checked_in">Clocked In</t>
<t t-else="">Not Clocked In</t>
</div>
<div class="tech-clock-timer" id="clockTimer">00:00:00</div>
</div>
</div>
<button class="tech-clock-btn" id="clockActionBtn"
t-att-class="'tech-clock-btn--out' if clock_checked_in else 'tech-clock-btn--in'"
onclick="handleClockAction()">
<t t-if="clock_checked_in">
<i class="fa fa-stop-circle-o"/> Clock Out
</t>
<t t-else="">
<i class="fa fa-play-circle-o"/> Clock In
</t>
</button>
</div>
<div class="tech-clock-error" id="clockError" style="display:none;">
<i class="fa fa-exclamation-triangle"/>
<span id="clockErrorText"/>
</div>
</div>
</t>
<!-- Stats Cards - 2x2 on mobile, 4 columns on desktop --> <!-- Stats Cards - 2x2 on mobile, 4 columns on desktop -->
<div class="row mb-3 g-2"> <div class="row mb-3 g-2">
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
@@ -1377,6 +1517,113 @@
<!-- Include loaner modals --> <!-- Include loaner modals -->
<t t-call="fusion_authorizer_portal.loaner_checkout_modal"/> <t t-call="fusion_authorizer_portal.loaner_checkout_modal"/>
<t t-call="fusion_authorizer_portal.loaner_return_modal"/> <t t-call="fusion_authorizer_portal.loaner_return_modal"/>
<!-- Clock In/Out JS -->
<script type="text/javascript">
(function() {
var card = document.getElementById('techClockCard');
if (!card) return;
var isCheckedIn = card.dataset.checkedIn === 'true';
var checkInTime = card.dataset.checkInTime ? new Date(card.dataset.checkInTime + 'Z') : null;
var timerInterval = null;
function updateTimer() {
if (!checkInTime) return;
var diff = Math.max(0, Math.floor((new Date() - checkInTime) / 1000));
var h = Math.floor(diff / 3600);
var m = Math.floor((diff % 3600) / 60);
var s = diff % 60;
var pad = function(n) { return n &lt; 10 ? '0' + n : '' + n; };
document.getElementById('clockTimer').textContent = pad(h) + ':' + pad(m) + ':' + pad(s);
}
function startTimer() { stopTimer(); updateTimer(); timerInterval = setInterval(updateTimer, 1000); }
function stopTimer() { if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } }
function applyState() {
var dot = card.querySelector('.tech-clock-dot');
var statusEl = document.getElementById('clockStatusText');
var btn = document.getElementById('clockActionBtn');
var timerEl = document.getElementById('clockTimer');
if (dot) dot.className = 'tech-clock-dot' + (isCheckedIn ? ' tech-clock-dot--active' : '');
if (statusEl) statusEl.textContent = isCheckedIn ? 'Clocked In' : 'Not Clocked In';
if (btn) {
btn.className = 'tech-clock-btn ' + (isCheckedIn ? 'tech-clock-btn--out' : 'tech-clock-btn--in');
btn.innerHTML = isCheckedIn
? '&lt;i class="fa fa-stop-circle-o">&lt;/i> Clock Out'
: '&lt;i class="fa fa-play-circle-o">&lt;/i> Clock In';
}
if (!isCheckedIn &amp;&amp; timerEl) timerEl.textContent = '00:00:00';
}
if (isCheckedIn &amp;&amp; checkInTime) startTimer();
window.handleClockAction = function() {
var btn = document.getElementById('clockActionBtn');
var errEl = document.getElementById('clockError');
var errText = document.getElementById('clockErrorText');
btn.disabled = true;
errEl.style.display = 'none';
function doClockAction(lat, lng, acc) {
fetch('/fusion_clock/clock_action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
latitude: lat, longitude: lng, accuracy: acc, source: 'portal'
}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
var result = data.result || {};
if (result.error) {
errText.textContent = result.error;
errEl.style.display = 'flex';
btn.disabled = false;
return;
}
if (result.action === 'clock_in') {
isCheckedIn = true;
checkInTime = new Date(result.check_in + 'Z');
startTimer();
} else {
isCheckedIn = false;
checkInTime = null;
stopTimer();
}
applyState();
btn.disabled = false;
})
.catch(function() {
errText.textContent = 'Network error. Please try again.';
errEl.style.display = 'flex';
btn.disabled = false;
});
}
if (window.fusionGetLocation) {
window.fusionGetLocation().then(function(coords) {
doClockAction(coords.latitude, coords.longitude, coords.accuracy);
}).catch(function() {
errText.textContent = 'Location access is required for clock in/out.';
errEl.style.display = 'flex';
btn.disabled = false;
});
} else if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(function(pos) {
doClockAction(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy);
}, function() {
errText.textContent = 'Location access is required for clock in/out.';
errEl.style.display = 'flex';
btn.disabled = false;
}, {enableHighAccuracy: true, timeout: 15000});
} else {
doClockAction(0, 0, 0);
}
};
})();
</script>
</t> </t>
</template> </template>
@@ -3699,4 +3946,232 @@
</t> </t>
</template> </template>
<!-- ============================================================ -->
<!-- TASK-LEVEL POD SIGNATURE (works for shadow + regular tasks) -->
<!-- ============================================================ -->
<template id="portal_task_pod_signature" name="Task POD Signature Capture">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="False"/>
<t t-set="no_breadcrumbs" t-value="True"/>
<div class="container mt-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="/my/home">Home</a></li>
<li class="breadcrumb-item"><a href="/my/technician">Dashboard</a></li>
<li class="breadcrumb-item"><a href="/my/technician/tasks">Tasks</a></li>
<li class="breadcrumb-item"><a t-attf-href="/my/technician/task/#{task.id}"><t t-out="task.name"/></a></li>
<li class="breadcrumb-item active" aria-current="page">Collect POD Signature</li>
</ol>
</nav>
</div>
<div class="container py-4">
<div class="row mb-4">
<div class="col-12">
<h2>
<i class="fa fa-pencil-square-o me-2"/>
Proof of Delivery - <t t-out="task.name"/>
</h2>
</div>
</div>
<div class="row">
<div class="col-lg-7">
<div class="card mb-4">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="fa fa-truck me-2"/>Delivery Summary</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<p><strong>Client:</strong> <t t-out="task.client_display_name or 'N/A'"/></p>
<p><strong>Task:</strong> <t t-out="task.name"/></p>
<p><strong>Type:</strong>
<t t-out="dict(task._fields['task_type'].selection).get(task.task_type, '')"/>
</p>
</div>
<div class="col-md-6">
<p><strong>Delivery Address:</strong></p>
<p class="mb-0 text-muted">
<t t-out="task.address_display or 'No address'"/>
</p>
<t t-if="task.scheduled_date">
<p class="mt-2"><strong>Scheduled:</strong>
<t t-out="task.scheduled_date" t-options="{'widget': 'date'}"/>
<t t-out="task.time_start_display"/> - <t t-out="task.time_end_display"/>
</p>
</t>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="card" id="task-pod-signature-section">
<div class="card-header bg-success text-white">
<h5 class="mb-0"><i class="fa fa-pencil me-2"/>Client Signature</h5>
</div>
<div class="card-body">
<t t-if="has_existing_signature">
<div class="alert alert-warning">
<i class="fa fa-exclamation-triangle me-2"/>
A signature has already been collected. Submitting a new one will replace it.
</div>
</t>
<form id="taskPodSignatureForm">
<div class="mb-3">
<label for="task_client_name" class="form-label">
Client Name <span class="text-danger">*</span>
</label>
<input type="text" class="form-control" id="task_client_name"
name="client_name" required=""
t-att-value="task.pod_client_name or task.client_display_name or ''"
placeholder="Enter the client's full name"/>
</div>
<div class="mb-3">
<label for="task_signature_date" class="form-label">Signature Date</label>
<input type="date" class="form-control" id="task_signature_date" name="signature_date"/>
<script>document.getElementById('task_signature_date').value = new Date().toISOString().slice(0,10);</script>
</div>
<div class="mb-3">
<label class="form-label">Signature <span class="text-danger">*</span></label>
<div class="border rounded p-2 bg-white">
<canvas id="task-signature-canvas"
style="width:100%;height:200px;border:1px dashed #ccc;border-radius:4px;touch-action:none;">
</canvas>
</div>
<div class="d-flex justify-content-between mt-2">
<small class="text-muted">Draw signature above</small>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="clearTaskSignature()">
<i class="fa fa-eraser me-1"/>Clear
</button>
</div>
</div>
<div class="d-grid gap-2">
<button type="button" class="btn btn-success btn-lg"
onclick="submitTaskPODSignature()">
<i class="fa fa-check me-2"/>Submit Signature
</button>
<a t-attf-href="/my/technician/task/#{task.id}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<style>
.tpod-overlay { display:none; position:fixed; top:0; left:0; width:100%; height:100%;
background:rgba(0,0,0,0.65); backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px);
z-index:9999; align-items:center; justify-content:center; }
.tpod-overlay.show { display:flex; }
.tpod-overlay-card { background:#fff; border-radius:20px; padding:2.5rem 2rem;
max-width:420px; width:90%; text-align:center; animation:tpodSlideUp 0.3s ease;
box-shadow:0 8px 32px rgba(0,0,0,0.2); }
.tpod-overlay-icon { font-size:3.5rem; margin-bottom:1rem; }
@keyframes tpodSlideUp { from { opacity:0; transform:translateY(30px); } to { opacity:1; transform:translateY(0); } }
</style>
<div id="taskPodOverlay" class="tpod-overlay">
<div class="tpod-overlay-card">
<div class="tpod-overlay-icon" id="tpodIcon"></div>
<h4 id="tpodTitle" style="font-weight:700;"></h4>
<p id="tpodMsg" style="color:#6c757d;margin-bottom:1.5rem;"></p>
<div id="tpodActions"></div>
</div>
</div>
<script type="text/javascript">
var tCanvas, tCtx, tIsDrawing = false;
function showTaskPodOverlay(type, title, msg, url) {
var ov = document.getElementById('taskPodOverlay');
document.getElementById('tpodIcon').innerHTML = type === 'success'
? '&lt;i class="fa fa-check-circle text-success">&lt;/i>'
: '&lt;i class="fa fa-exclamation-circle text-danger">&lt;/i>';
document.getElementById('tpodTitle').textContent = title;
document.getElementById('tpodTitle').className = type === 'success' ? 'text-success' : 'text-danger';
document.getElementById('tpodMsg').textContent = msg;
var acts = document.getElementById('tpodActions');
if (type === 'success' &amp;&amp; url) {
acts.innerHTML = '&lt;a href="' + url + '" class="btn btn-success w-100 rounded-pill mb-2">Continue&lt;/a>' +
'&lt;p class="text-muted small mb-0">Redirecting in &lt;span id="tpodCD">3&lt;/span>s...&lt;/p>';
ov.classList.add('show');
var s = 3, t = setInterval(function() { s--; var c = document.getElementById('tpodCD');
if (c) c.textContent = s; if (s &lt;= 0) { clearInterval(t); window.location.href = url; } }, 1000);
} else {
acts.innerHTML = '&lt;button class="btn btn-outline-secondary w-100 rounded-pill" onclick="document.getElementById(\'taskPodOverlay\').classList.remove(\'show\')">OK&lt;/button>';
ov.classList.add('show');
}
}
document.addEventListener('DOMContentLoaded', function() {
tCanvas = document.getElementById('task-signature-canvas');
if (!tCanvas) return;
tCtx = tCanvas.getContext('2d');
var r = tCanvas.getBoundingClientRect();
tCanvas.width = r.width * 2; tCanvas.height = r.height * 2;
tCtx.scale(2, 2); tCtx.lineCap = 'round'; tCtx.lineJoin = 'round';
tCtx.lineWidth = 2; tCtx.strokeStyle = '#000';
tCanvas.addEventListener('mousedown', tStart);
tCanvas.addEventListener('mousemove', tDraw);
tCanvas.addEventListener('mouseup', tStop);
tCanvas.addEventListener('mouseout', tStop);
tCanvas.addEventListener('touchstart', function(e) { e.preventDefault(); tStart(e); }, {passive:false});
tCanvas.addEventListener('touchmove', function(e) { e.preventDefault(); tDraw(e); }, {passive:false});
tCanvas.addEventListener('touchend', tStop);
var sec = document.getElementById('task-pod-signature-section');
if (sec) setTimeout(function() { sec.scrollIntoView({behavior:'smooth', block:'start'}); }, 300);
});
function tPos(e) {
var r = tCanvas.getBoundingClientRect();
if (e.touches) return { x: e.touches[0].clientX - r.left, y: e.touches[0].clientY - r.top };
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function tStart(e) { tIsDrawing = true; var p = tPos(e); tCtx.beginPath(); tCtx.moveTo(p.x, p.y); }
function tDraw(e) { if (!tIsDrawing) return; var p = tPos(e); tCtx.lineTo(p.x, p.y); tCtx.stroke(); }
function tStop() { tIsDrawing = false; }
function clearTaskSignature() { if (tCtx) tCtx.clearRect(0, 0, tCanvas.width, tCanvas.height); }
function submitTaskPODSignature() {
var name = document.getElementById('task_client_name').value.trim();
var sigDate = document.getElementById('task_signature_date').value;
if (!name) { showTaskPodOverlay('error', 'Missing Information', 'Please enter the client name.'); return; }
var blank = document.createElement('canvas');
blank.width = tCanvas.width; blank.height = tCanvas.height;
if (tCanvas.toDataURL() === blank.toDataURL()) {
showTaskPodOverlay('error', 'Missing Signature', 'Please draw a signature before submitting.'); return;
}
var sigData = tCanvas.toDataURL('image/png');
var btn = document.querySelector('button[onclick="submitTaskPODSignature()"]');
var orig = btn.innerHTML; btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin me-2">&lt;/i>Saving...'; btn.disabled = true;
fetch('<t t-out="'/my/technician/task/' + str(task.id) + '/pod/sign'"/>', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ jsonrpc:'2.0', method:'call',
params: { client_name: name, signature_data: sigData, signature_date: sigDate || null },
id: Math.floor(Math.random()*1000000) })
}).then(function(r) { return r.json(); }).then(function(d) {
if (d.result &amp;&amp; d.result.success) {
showTaskPodOverlay('success', 'Signature Saved!', 'Proof of Delivery recorded.', d.result.redirect_url);
} else {
showTaskPodOverlay('error', 'Error', d.result?.error || 'Unknown error');
btn.innerHTML = orig; btn.disabled = false;
}
}).catch(function() {
showTaskPodOverlay('error', 'Connection Error', 'Please check your connection.');
btn.innerHTML = orig; btn.disabled = false;
});
}
</script>
</t>
</template>
</odoo> </odoo>

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Claims', 'name': 'Fusion Claims',
'version': '19.0.7.0.0', 'version': '19.0.7.2.0',
'category': 'Sales', 'category': 'Sales',
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.', 'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
'description': """ 'description': """
@@ -84,6 +84,7 @@
'calendar', 'calendar',
'ai', 'ai',
'fusion_ringcentral', 'fusion_ringcentral',
'fusion_tasks',
], ],
'external_dependencies': { 'external_dependencies': {
'python': ['pdf2image', 'PIL'], 'python': ['pdf2image', 'PIL'],
@@ -127,47 +128,38 @@
'wizard/odsp_submit_to_odsp_wizard_views.xml', 'wizard/odsp_submit_to_odsp_wizard_views.xml',
'wizard/odsp_pre_approved_wizard_views.xml', 'wizard/odsp_pre_approved_wizard_views.xml',
'wizard/odsp_ready_delivery_wizard_views.xml', 'wizard/odsp_ready_delivery_wizard_views.xml',
'wizard/ltc_repair_create_so_wizard_views.xml', 'wizard/send_page11_wizard_views.xml',
'views/res_partner_views.xml', 'views/res_partner_views.xml',
'views/pdf_template_inherit_views.xml', 'views/pdf_template_inherit_views.xml',
'views/dashboard_views.xml', 'views/dashboard_views.xml',
'views/client_profile_views.xml', 'views/client_profile_views.xml',
'wizard/xml_import_wizard_views.xml', 'wizard/xml_import_wizard_views.xml',
'views/ltc_facility_views.xml',
'views/ltc_repair_views.xml',
'views/ltc_cleanup_views.xml',
'views/ltc_form_submission_views.xml',
'views/adp_claims_views.xml', 'views/adp_claims_views.xml',
'views/submission_history_views.xml', 'views/submission_history_views.xml',
'views/fusion_loaner_views.xml', 'views/fusion_loaner_views.xml',
'views/page11_sign_request_views.xml',
'views/technician_task_views.xml', 'views/technician_task_views.xml',
'views/task_sync_views.xml',
'views/technician_location_views.xml',
'report/report_actions.xml', 'report/report_actions.xml',
'report/report_templates.xml', 'report/report_templates.xml',
'report/sale_report_portrait.xml', 'report/sale_report_portrait.xml',
'report/sale_report_landscape.xml', 'report/sale_report_landscape.xml',
'report/sale_report_ltc_repair.xml',
'report/invoice_report_portrait.xml', 'report/invoice_report_portrait.xml',
'report/invoice_report_landscape.xml', 'report/invoice_report_landscape.xml',
'report/report_proof_of_delivery.xml', 'report/report_proof_of_delivery.xml',
'report/report_proof_of_delivery_standard.xml', 'report/report_proof_of_delivery_standard.xml',
'report/report_proof_of_pickup.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_grab_bar_waiver.xml',
'report/report_accessibility_contract.xml', 'report/report_accessibility_contract.xml',
'report/report_mod_quotation.xml', 'report/report_mod_quotation.xml',
'report/report_mod_invoice.xml', 'report/report_mod_invoice.xml',
'data/ltc_data.xml',
'report/report_ltc_nursing_station.xml',
'data/ltc_report_data.xml',
'data/mail_template_data.xml', 'data/mail_template_data.xml',
'data/ai_agent_data.xml', 'data/ai_agent_data.xml',
], ],
'assets': { 'assets': {
'web.assets_backend': [ 'web.assets_backend': [
'fusion_claims/static/src/scss/fusion_claims.scss', 'fusion_claims/static/src/scss/fusion_claims.scss',
'fusion_claims/static/src/css/fusion_task_map_view.scss',
'fusion_claims/static/src/js/chatter_resize.js', 'fusion_claims/static/src/js/chatter_resize.js',
'fusion_claims/static/src/js/document_preview.js', 'fusion_claims/static/src/js/document_preview.js',
'fusion_claims/static/src/js/preview_button_widget.js', 'fusion_claims/static/src/js/preview_button_widget.js',
@@ -176,10 +168,9 @@
'fusion_claims/static/src/js/tax_totals_patch.js', 'fusion_claims/static/src/js/tax_totals_patch.js',
'fusion_claims/static/src/js/google_address_autocomplete.js', 'fusion_claims/static/src/js/google_address_autocomplete.js',
'fusion_claims/static/src/js/calendar_store_hours.js', 'fusion_claims/static/src/js/calendar_store_hours.js',
'fusion_claims/static/src/js/fusion_task_map_view.js',
'fusion_claims/static/src/js/attachment_image_compress.js', 'fusion_claims/static/src/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/document_preview.xml',
'fusion_claims/static/src/xml/fusion_task_map_view.xml',
], ],
}, },
'images': ['static/description/icon.png'], 'images': ['static/description/icon.png'],

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -45,26 +45,6 @@
<field name="value">True</field> <field name="value">True</field>
</record> </record>
<!-- Technician / Field Service -->
<record id="config_store_open_hour" model="ir.config_parameter">
<field name="key">fusion_claims.store_open_hour</field>
<field name="value">9.0</field>
</record>
<record id="config_store_close_hour" model="ir.config_parameter">
<field name="key">fusion_claims.store_close_hour</field>
<field name="value">18.0</field>
</record>
<!-- Push Notifications -->
<record id="config_push_enabled" model="ir.config_parameter">
<field name="key">fusion_claims.push_enabled</field>
<field name="value">False</field>
</record>
<record id="config_push_advance_minutes" model="ir.config_parameter">
<field name="key">fusion_claims.push_advance_minutes</field>
<field name="value">30</field>
</record>
<!-- Field Mappings (defaults for fresh installs) --> <!-- Field Mappings (defaults for fresh installs) -->
<record id="config_field_sale_type" model="ir.config_parameter"> <record id="config_field_sale_type" model="ir.config_parameter">
<field name="key">fusion_claims.field_sale_type</field> <field name="key">fusion_claims.field_sale_type</field>
@@ -147,17 +127,5 @@
<field name="value">1-888-222-5099</field> <field name="value">1-888-222-5099</field>
</record> </record>
<!-- Cross-instance task sync: unique ID for this Odoo instance -->
<record id="config_sync_instance_id" model="ir.config_parameter">
<field name="key">fusion_claims.sync_instance_id</field>
<field name="value"></field>
</record>
<!-- LTC Portal Form Password -->
<record id="config_ltc_form_password" model="ir.config_parameter">
<field name="key">fusion_claims.ltc_form_password</field>
<field name="value"></field>
</record>
</data> </data>
</odoo> </odoo>

View File

@@ -6,16 +6,6 @@
--> -->
<odoo> <odoo>
<data> <data>
<!-- Cron Job: Sync ADP Fields from Sale Orders to Invoices -->
<record id="ir_cron_sync_adp_fields" model="ir.cron">
<field name="name">Fusion Claims: Sync ADP Fields</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="state">code</field>
<field name="code">model._cron_sync_adp_fields()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
</record>
<!-- Cron Job: Renew ADP Delivery Reminders --> <!-- Cron Job: Renew ADP Delivery Reminders -->
<record id="ir_cron_renew_delivery_reminders" model="ir.cron"> <record id="ir_cron_renew_delivery_reminders" model="ir.cron">
<field name="name">Fusion Claims: Renew Delivery Reminders</field> <field name="name">Fusion Claims: Renew Delivery Reminders</field>
@@ -134,50 +124,17 @@
<field name="nextcall" eval="DateTime.now().replace(hour=10, minute=0, second=0)"/> <field name="nextcall" eval="DateTime.now().replace(hour=10, minute=0, second=0)"/>
</record> </record>
<!-- Cron Job: Calculate Travel Times for Technician Tasks --> <!-- Cron Job: Expire Old Page 11 Signing Requests -->
<record id="ir_cron_technician_travel_times" model="ir.cron"> <record id="ir_cron_expire_page11_requests" model="ir.cron">
<field name="name">Fusion Claims: Calculate Technician Travel Times</field> <field name="name">Fusion Claims: Expire Page 11 Signing Requests</field>
<field name="model_id" ref="model_fusion_technician_task"/> <field name="model_id" ref="model_fusion_page11_sign_request"/>
<field name="state">code</field> <field name="state">code</field>
<field name="code">model._cron_calculate_travel_times()</field> <field name="code">model._cron_expire_requests()</field>
<field name="interval_number">1</field> <field name="interval_number">1</field>
<field name="interval_type">days</field> <field name="interval_type">days</field>
<field name="active">True</field> <field name="active">True</field>
<field name="nextcall" eval="DateTime.now().replace(hour=5, minute=0, second=0)"/> <field name="nextcall" eval="DateTime.now().replace(hour=2, minute=0, second=0)"/>
</record> </record>
<!-- Cron Job: Send Push Notifications for Upcoming Tasks -->
<record id="ir_cron_technician_push_notifications" model="ir.cron">
<field name="name">Fusion Claims: Technician Push Notifications</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<field name="state">code</field>
<field name="code">model._cron_send_push_notifications()</field>
<field name="interval_number">15</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<!-- Cron Job: Pull Remote Technician Tasks (cross-instance sync) -->
<record id="ir_cron_task_sync_pull" model="ir.cron">
<field name="name">Fusion Claims: Sync Remote Tasks (Pull)</field>
<field name="model_id" ref="model_fusion_task_sync_config"/>
<field name="state">code</field>
<field name="code">model._cron_pull_remote_tasks()</field>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<!-- Cron Job: Cleanup Old Shadow Tasks (30+ days) -->
<record id="ir_cron_task_sync_cleanup" model="ir.cron">
<field name="name">Fusion Claims: Cleanup Old Shadow Tasks</field>
<field name="model_id" ref="model_fusion_task_sync_config"/>
<field name="state">code</field>
<field name="code">model._cron_cleanup_old_shadows()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
<field name="nextcall" eval="DateTime.now().replace(hour=3, minute=0, second=0)"/>
</record>
</data> </data>
</odoo> </odoo>

View File

@@ -20,34 +20,34 @@
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field> <field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field> <field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html"> <field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;"> <div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="height:4px;background-color:#2B6CB0;"></div> <div style="height:4px;background-color:#2B6CB0;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;"> <div style="padding:32px 28px;">
<p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;"> <p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<t t-out="object.company_id.name"/> <t t-out="object.company_id.name"/>
</p> </p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">ADP Quotation</h2> <h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">ADP Quotation</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;"> <p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Please find attached your quotation <strong style="color:#2d3748;"><t t-out="object.name"/></strong>. Please find attached your quotation <strong><t t-out="object.name"/></strong>.
</p> </p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;"> <table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Quotation Details</td></tr> <tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid rgba(128,128,128,0.25);">Quotation Details</td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Reference</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.name"/></td></tr> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Reference</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.name"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Date</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.date_order" t-options="{'widget': 'date'}"/></td></tr> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Date</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.date_order" t-options="{'widget': 'date'}"/></td></tr>
<t t-if="object.x_fc_authorizer_id"> <t t-if="object.x_fc_authorizer_id">
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Authorizer</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.x_fc_authorizer_id.name"/></td></tr> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Authorizer</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.x_fc_authorizer_id.name"/></td></tr>
</t> </t>
<t t-if="object.x_fc_client_type == 'REG'"> <t t-if="object.x_fc_client_type == 'REG'">
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Client Portion (25%)</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.x_fc_client_portion_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Client Portion (25%)</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.x_fc_client_portion_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">ADP Portion (75%)</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.x_fc_adp_portion_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">ADP Portion (75%)</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.x_fc_adp_portion_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
</t> </t>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;font-weight:600;border-top:2px solid #e2e8f0;">Total</td><td style="padding:10px 14px;color:#2B6CB0;font-size:14px;font-weight:700;border-top:2px solid #e2e8f0;"><t t-out="object.amount_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;font-weight:600;border-top:2px solid rgba(128,128,128,0.25);">Total</td><td style="padding:10px 14px;color:#2B6CB0;font-size:14px;font-weight:700;border-top:2px solid rgba(128,128,128,0.25);"><t t-out="object.amount_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
</table> </table>
<div style="padding:10px 14px;border:1px dashed #e2e8f0;border-radius:6px;margin:0 0 24px 0;"> <div style="padding:10px 14px;border:1px dashed rgba(128,128,128,0.35);border-radius:6px;margin:0 0 24px 0;">
<p style="margin:0;font-size:13px;color:#718096;"><strong style="color:#2d3748;">Attached:</strong> ADP Quotation (PDF)</p> <p style="margin:0;font-size:13px;opacity:0.65;"><strong style="opacity:1;">Attached:</strong> ADP Quotation (PDF)</p>
</div> </div>
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;"> <div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">Please review the attached quotation. If you have any questions or need assistance, do not hesitate to contact us.</p> <p style="margin:0;font-size:14px;line-height:1.5;">Please review the attached quotation. If you have any questions or need assistance, do not hesitate to contact us.</p>
</div> </div>
<t t-if="not is_html_empty(object.user_id.signature)" data-o-mail-quote-container="1"> <t t-if="not is_html_empty(object.user_id.signature)" data-o-mail-quote-container="1">
<div data-o-mail-quote="1">--<br data-o-mail-quote="1"/><t t-out="object.user_id.signature or ''" data-o-mail-quote="1"/></div> <div data-o-mail-quote="1">--<br data-o-mail-quote="1"/><t t-out="object.user_id.signature or ''" data-o-mail-quote="1"/></div>
@@ -70,34 +70,34 @@
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field> <field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field> <field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html"> <field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;"> <div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="height:4px;background-color:#38a169;"></div> <div style="height:4px;background-color:#38a169;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;"> <div style="padding:32px 28px;">
<p style="color:#38a169;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;"> <p style="color:#38a169;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<t t-out="object.company_id.name"/> <t t-out="object.company_id.name"/>
</p> </p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Order Confirmed</h2> <h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Order Confirmed</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;"> <p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Your ADP sales order <strong style="color:#2d3748;"><t t-out="object.name"/></strong> has been confirmed. Your ADP sales order <strong><t t-out="object.name"/></strong> has been confirmed.
</p> </p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;"> <table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Order Details</td></tr> <tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid rgba(128,128,128,0.25);">Order Details</td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Reference</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.name"/></td></tr> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Reference</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.name"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Date</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.date_order" t-options="{'widget': 'date'}"/></td></tr> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Date</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.date_order" t-options="{'widget': 'date'}"/></td></tr>
<t t-if="object.x_fc_authorizer_id"> <t t-if="object.x_fc_authorizer_id">
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Authorizer</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.x_fc_authorizer_id.name"/></td></tr> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Authorizer</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.x_fc_authorizer_id.name"/></td></tr>
</t> </t>
<t t-if="object.x_fc_client_type == 'REG'"> <t t-if="object.x_fc_client_type == 'REG'">
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Client Portion (25%)</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.x_fc_client_portion_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Client Portion (25%)</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.x_fc_client_portion_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">ADP Portion (75%)</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.x_fc_adp_portion_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">ADP Portion (75%)</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.x_fc_adp_portion_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
</t> </t>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;font-weight:600;border-top:2px solid #e2e8f0;">Total</td><td style="padding:10px 14px;color:#38a169;font-size:14px;font-weight:700;border-top:2px solid #e2e8f0;"><t t-out="object.amount_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;font-weight:600;border-top:2px solid rgba(128,128,128,0.25);">Total</td><td style="padding:10px 14px;color:#38a169;font-size:14px;font-weight:700;border-top:2px solid rgba(128,128,128,0.25);"><t t-out="object.amount_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
</table> </table>
<div style="padding:10px 14px;border:1px dashed #e2e8f0;border-radius:6px;margin:0 0 24px 0;"> <div style="padding:10px 14px;border:1px dashed rgba(128,128,128,0.35);border-radius:6px;margin:0 0 24px 0;">
<p style="margin:0;font-size:13px;color:#718096;"><strong style="color:#2d3748;">Attached:</strong> Sales Order Confirmation (PDF)</p> <p style="margin:0;font-size:13px;opacity:0.65;"><strong style="opacity:1;">Attached:</strong> Sales Order Confirmation (PDF)</p>
</div> </div>
<div style="border-left:3px solid #38a169;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;"> <div style="border-left:3px solid #38a169;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">Your order is being processed. We will keep you updated on the delivery status and any updates from the Assistive Devices Program.</p> <p style="margin:0;font-size:14px;line-height:1.5;">Your order is being processed. We will keep you updated on the delivery status and any updates from the Assistive Devices Program.</p>
</div> </div>
<t t-if="not is_html_empty(object.user_id.signature)" data-o-mail-quote-container="1"> <t t-if="not is_html_empty(object.user_id.signature)" data-o-mail-quote-container="1">
<div data-o-mail-quote="1">--<br data-o-mail-quote="1"/><t t-out="object.user_id.signature or ''" data-o-mail-quote="1"/></div> <div data-o-mail-quote="1">--<br data-o-mail-quote="1"/><t t-out="object.user_id.signature or ''" data-o-mail-quote="1"/></div>
@@ -120,42 +120,42 @@
<field name="email_from">{{ (object.invoice_user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field> <field name="email_from">{{ (object.invoice_user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field> <field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html"> <field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;"> <div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="height:4px;background-color:#2B6CB0;"></div> <div style="height:4px;background-color:#2B6CB0;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;"> <div style="padding:32px 28px;">
<p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;"> <p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<t t-out="object.company_id.name"/> <t t-out="object.company_id.name"/>
</p> </p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Invoice</h2> <h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Invoice</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;"> <p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Please find attached your invoice <strong style="color:#2d3748;"><t t-out="object.name or 'Draft'"/></strong>. Please find attached your invoice <strong><t t-out="object.name or 'Draft'"/></strong>.
</p> </p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;"> <table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Invoice Details</td></tr> <tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid rgba(128,128,128,0.25);">Invoice Details</td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Invoice</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.name or 'Draft'"/></td></tr> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Invoice</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.name or 'Draft'"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Date</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.invoice_date" t-options="{'widget': 'date'}"/></td></tr> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Date</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.invoice_date" t-options="{'widget': 'date'}"/></td></tr>
<t t-if="object.invoice_date_due"> <t t-if="object.invoice_date_due">
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Due Date</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.invoice_date_due" t-options="{'widget': 'date'}"/></td></tr> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Due Date</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.invoice_date_due" t-options="{'widget': 'date'}"/></td></tr>
</t> </t>
<t t-if="object.x_fc_adp_invoice_portion"> <t t-if="object.x_fc_adp_invoice_portion">
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Type</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Type</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">
<t t-if="object.x_fc_adp_invoice_portion == 'client'">Client Portion</t> <t t-if="object.x_fc_adp_invoice_portion == 'client'">Client Portion</t>
<t t-if="object.x_fc_adp_invoice_portion == 'adp'">ADP Portion</t> <t t-if="object.x_fc_adp_invoice_portion == 'adp'">ADP Portion</t>
</td></tr> </td></tr>
</t> </t>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;font-weight:600;border-top:2px solid #e2e8f0;">Amount Due</td><td style="padding:10px 14px;color:#2B6CB0;font-size:14px;font-weight:700;border-top:2px solid #e2e8f0;"><t t-out="object.amount_residual" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;font-weight:600;border-top:2px solid rgba(128,128,128,0.25);">Amount Due</td><td style="padding:10px 14px;color:#2B6CB0;font-size:14px;font-weight:700;border-top:2px solid rgba(128,128,128,0.25);"><t t-out="object.amount_residual" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
</table> </table>
<div style="padding:10px 14px;border:1px dashed #e2e8f0;border-radius:6px;margin:0 0 24px 0;"> <div style="padding:10px 14px;border:1px dashed rgba(128,128,128,0.35);border-radius:6px;margin:0 0 24px 0;">
<p style="margin:0;font-size:13px;color:#718096;"><strong style="color:#2d3748;">Attached:</strong> Invoice (PDF)</p> <p style="margin:0;font-size:13px;opacity:0.65;"><strong style="opacity:1;">Attached:</strong> Invoice (PDF)</p>
</div> </div>
<t t-if="object.x_fc_adp_invoice_portion == 'client'"> <t t-if="object.x_fc_adp_invoice_portion == 'client'">
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;"> <div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">This invoice represents your client portion for the ADP-funded equipment. The remaining amount will be billed directly to the Assistive Devices Program.</p> <p style="margin:0;font-size:14px;line-height:1.5;">This invoice represents your client portion for the ADP-funded equipment. The remaining amount will be billed directly to the Assistive Devices Program.</p>
</div> </div>
</t> </t>
<t t-else=""> <t t-else="">
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;"> <div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">Please review the attached invoice and process payment at your earliest convenience. Contact us if you have any questions.</p> <p style="margin:0;font-size:14px;line-height:1.5;">Please review the attached invoice and process payment at your earliest convenience. Contact us if you have any questions.</p>
</div> </div>
</t> </t>
<t t-set="sig" t-value="object.invoice_user_id.signature or object.user_id.signature"/> <t t-set="sig" t-value="object.invoice_user_id.signature or object.user_id.signature"/>

View File

@@ -9,7 +9,7 @@
<field name="uom_id" ref="uom.product_uom_hour"/> <field name="uom_id" ref="uom.product_uom_hour"/>
<field name="sale_ok" eval="True"/> <field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/> <field name="purchase_ok" eval="False"/>
<field name="taxes_id" eval="[(6, 0, [ref('account.1_hst_sale_tax_13')])]"/> <!-- taxes_id set via post_init_hook or manually per database -->
</record> </record>
</data> </data>
</odoo> </odoo>

View File

@@ -3,7 +3,6 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family. # Part of the Fusion Claim Assistant product family.
from . import email_builder_mixin
from . import adp_posting_schedule from . import adp_posting_schedule
from . import res_company from . import res_company
from . import res_config_settings from . import res_config_settings
@@ -27,12 +26,5 @@ from . import client_chat
from . import ai_agent_ext from . import ai_agent_ext
from . import dashboard from . import dashboard
from . import res_partner from . import res_partner
from . import res_users
from . import technician_task from . import technician_task
from . import task_sync from . import page11_sign_request
from . import technician_location
from . import push_subscription
from . import ltc_facility
from . import ltc_repair
from . import ltc_cleanup
from . import ltc_form_submission

View File

@@ -105,9 +105,11 @@ class AccountMove(models.Model):
try: try:
report = self.env.ref('fusion_claims.action_report_mod_invoice') report = self.env.ref('fusion_claims.action_report_mod_invoice')
pdf_content, _ = report._render_qweb_pdf(report.id, [self.id]) 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({ att = Attachment.create({
'name': f'Invoice - {client_name} - {self.name}.pdf', 'name': f'{first}_{last}_MOD_Invoice_{self.name}.pdf',
'type': 'binary', 'type': 'binary',
'datas': base64.b64encode(pdf_content), 'datas': base64.b64encode(pdf_content),
'res_model': 'account.move', 'res_model': 'account.move',

View File

@@ -57,6 +57,12 @@ class FusionADPDeviceCode(models.Model):
index=True, index=True,
help='Device manufacturer', help='Device manufacturer',
) )
build_type = fields.Selection(
[('modular', 'Modular'), ('custom_fabricated', 'Custom Fabricated')],
string='Build Type',
index=True,
help='Build type for positioning/seating devices: Modular or Custom Fabricated',
)
device_description = fields.Char( device_description = fields.Char(
string='Device Description', string='Device Description',
help='Detailed device description from mobility manual', help='Detailed device description from mobility manual',
@@ -243,6 +249,16 @@ class FusionADPDeviceCode(models.Model):
manufacturer = self._clean_text(item.get('Manufacturer', '') or item.get('manufacturer', '')) manufacturer = self._clean_text(item.get('Manufacturer', '') or item.get('manufacturer', ''))
device_description = self._clean_text(item.get('Device Description', '') or item.get('device_description', '')) device_description = self._clean_text(item.get('Device Description', '') or item.get('device_description', ''))
# Parse build type (Modular / Custom Fabricated)
build_type_raw = self._clean_text(item.get('Build Type', '') or item.get('build_type', ''))
build_type = False
if build_type_raw:
bt_lower = build_type_raw.lower().strip()
if bt_lower in ('modular', 'mod'):
build_type = 'modular'
elif bt_lower in ('custom fabricated', 'custom_fabricated', 'custom'):
build_type = 'custom_fabricated'
# Parse quantity # Parse quantity
qty_val = item.get('Quantity', 1) or item.get('Qty', 1) or item.get('quantity', 1) qty_val = item.get('Quantity', 1) or item.get('Qty', 1) or item.get('quantity', 1)
max_qty = int(qty_val) if qty_val else 1 max_qty = int(qty_val) if qty_val else 1
@@ -277,6 +293,8 @@ class FusionADPDeviceCode(models.Model):
'last_updated': fields.Datetime.now(), 'last_updated': fields.Datetime.now(),
'active': True, 'active': True,
} }
if build_type:
vals['build_type'] = build_type
if existing: if existing:
existing.write(vals) existing.write(vals)

View File

@@ -0,0 +1,389 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import base64
import logging
import uuid
from datetime import timedelta
from markupsafe import Markup
from odoo import models, fields, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
SIGNER_TYPE_SELECTION = [
('client', 'Client (Self)'),
('spouse', 'Spouse'),
('parent', 'Parent'),
('legal_guardian', 'Legal Guardian'),
('poa', 'Power of Attorney'),
('public_trustee', 'Public Trustee'),
]
SIGNER_TYPE_TO_RELATIONSHIP = {
'spouse': 'Spouse',
'parent': 'Parent',
'legal_guardian': 'Legal Guardian',
'poa': 'Power of Attorney',
'public_trustee': 'Public Trustee',
}
class Page11SignRequest(models.Model):
_name = 'fusion.page11.sign.request'
_description = 'ADP Page 11 Remote Signing Request'
_inherit = ['fusion.email.builder.mixin']
_order = 'create_date desc'
sale_order_id = fields.Many2one(
'sale.order', string='Sale Order',
required=True, ondelete='cascade', index=True,
)
access_token = fields.Char(
string='Access Token', required=True, copy=False,
default=lambda self: str(uuid.uuid4()), index=True,
)
state = fields.Selection([
('draft', 'Draft'),
('sent', 'Sent'),
('signed', 'Signed'),
('expired', 'Expired'),
('cancelled', 'Cancelled'),
], string='Status', default='draft', required=True, tracking=True)
signer_email = fields.Char(string='Recipient Email', required=True)
signer_type = fields.Selection(
SIGNER_TYPE_SELECTION, string='Signer Type',
default='client', required=True,
)
signer_name = fields.Char(string='Signer Name')
signer_relationship = fields.Char(string='Relationship to Client')
signature_data = fields.Binary(string='Signature', attachment=True)
signed_pdf = fields.Binary(string='Signed PDF', attachment=True)
signed_pdf_filename = fields.Char(string='Signed PDF Filename')
signed_date = fields.Datetime(string='Signed Date')
sent_date = fields.Datetime(string='Sent Date')
expiry_date = fields.Datetime(string='Expiry Date')
consent_declaration_accepted = fields.Boolean(string='Declaration Accepted')
consent_signed_by = fields.Selection([
('applicant', 'Applicant'),
('agent', 'Agent'),
], string='Signed By')
client_first_name = fields.Char(string='Client First Name')
client_last_name = fields.Char(string='Client Last Name')
client_health_card = fields.Char(string='Health Card Number')
client_health_card_version = fields.Char(string='Health Card Version')
agent_first_name = fields.Char(string='Agent First Name')
agent_last_name = fields.Char(string='Agent Last Name')
agent_middle_initial = fields.Char(string='Agent Middle Initial')
agent_phone = fields.Char(string='Agent Phone')
agent_unit = fields.Char(string='Agent Unit Number')
agent_street_number = fields.Char(string='Agent Street Number')
agent_street = fields.Char(string='Agent Street Name')
agent_city = fields.Char(string='Agent City')
agent_province = fields.Char(string='Agent Province', default='Ontario')
agent_postal_code = fields.Char(string='Agent Postal Code')
custom_message = fields.Text(string='Custom Message')
company_id = fields.Many2one(
'res.company', string='Company',
related='sale_order_id.company_id', store=True,
)
def name_get(self):
return [
(r.id, f"Page 11 - {r.sale_order_id.name} ({r.state})")
for r in self
]
def _send_signing_email(self):
"""Build and send the signing request email."""
self.ensure_one()
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
sign_url = f'{base_url}/page11/sign/{self.access_token}'
order = self.sale_order_id
client_name = order.partner_id.name or 'N/A'
sections = [
('Case Details', [
('Client', client_name),
('Case Reference', order.name),
]),
]
if order.x_fc_authorizer_id:
sections[0][1].append(('Authorizer', order.x_fc_authorizer_id.name))
if order.x_fc_assessment_start_date:
sections[0][1].append((
'Assessment Date',
order.x_fc_assessment_start_date.strftime('%B %d, %Y'),
))
note_parts = []
if self.custom_message:
note_parts.append(self.custom_message)
days_left = 7
if self.expiry_date:
delta = self.expiry_date - fields.Datetime.now()
days_left = max(1, delta.days)
note_parts.append(
f'This link will expire in {days_left} days. '
'Please complete the signing at your earliest convenience.'
)
note_text = '<br/><br/>'.join(note_parts)
body_html = self._email_build(
title='Page 11 Signature Required',
summary=(
f'{order.company_id.name} requires your signature on the '
f'ADP Consent and Declaration form for <strong>{client_name}</strong>.'
),
sections=sections,
note=note_text,
email_type='info',
button_url=sign_url,
button_text='Sign Now',
sender_name=self.env.user.name,
)
mail_values = {
'subject': f'{order.company_id.name} - Page 11 Signature Required ({order.name})',
'body_html': body_html,
'email_to': self.signer_email,
'email_from': (
self.env.user.email_formatted
or order.company_id.email_formatted
),
'auto_delete': True,
}
mail = self.env['mail.mail'].sudo().create(mail_values)
mail.send()
self.write({
'state': 'sent',
'sent_date': fields.Datetime.now(),
})
signer_display = self.signer_name or self.signer_email
order.message_post(
body=Markup(
'Page 11 signing request sent to <strong>%s</strong> (%s).'
) % (signer_display, self.signer_email),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
def _generate_signed_pdf(self):
"""Generate the signed Page 11 PDF using the PDF template engine."""
self.ensure_one()
order = self.sale_order_id
assessment = self.env['fusion.assessment'].search([
('sale_order_id', '=', order.id),
], limit=1, order='create_date desc')
if assessment:
ctx = assessment._get_pdf_context()
else:
ctx = self._build_pdf_context_from_order()
if self.client_first_name:
ctx['client_first_name'] = self.client_first_name
if self.client_last_name:
ctx['client_last_name'] = self.client_last_name
if self.client_health_card:
ctx['client_health_card'] = self.client_health_card
if self.client_health_card_version:
ctx['client_health_card_version'] = self.client_health_card_version
ctx.update({
'consent_signed_by': self.consent_signed_by or '',
'consent_applicant': self.consent_signed_by == 'applicant',
'consent_agent': self.consent_signed_by == 'agent',
'consent_declaration_accepted': self.consent_declaration_accepted,
'consent_date': str(fields.Date.today()),
})
if self.consent_signed_by == 'agent':
ctx.update({
'agent_first_name': self.agent_first_name or '',
'agent_last_name': self.agent_last_name or '',
'agent_middle_initial': self.agent_middle_initial or '',
'agent_unit': self.agent_unit or '',
'agent_street_number': self.agent_street_number or '',
'agent_street_name': self.agent_street or '',
'agent_city': self.agent_city or '',
'agent_province': self.agent_province or '',
'agent_postal_code': self.agent_postal_code or '',
'agent_home_phone': self.agent_phone or '',
'agent_relationship': self.signer_relationship or '',
'agent_rel_spouse': self.signer_type == 'spouse',
'agent_rel_parent': self.signer_type == 'parent',
'agent_rel_poa': self.signer_type == 'poa',
'agent_rel_guardian': self.signer_type in ('legal_guardian', 'public_trustee'),
})
signatures = {}
if self.signature_data:
signatures['signature_page_11'] = base64.b64decode(self.signature_data)
template = self.env['fusion.pdf.template'].search([
('state', '=', 'active'),
('name', 'ilike', 'adp_page_11'),
], limit=1)
if not template:
template = self.env['fusion.pdf.template'].search([
('state', '=', 'active'),
('name', 'ilike', 'page 11'),
], limit=1)
if not template:
_logger.warning("No active PDF template found for Page 11")
return None
try:
pdf_bytes = template.generate_filled_pdf(ctx, signatures)
if pdf_bytes:
first, last = order._get_client_name_parts()
filename = f'{first}_{last}_Page11_Signed.pdf'
self.write({
'signed_pdf': base64.b64encode(pdf_bytes),
'signed_pdf_filename': filename,
})
return pdf_bytes
except Exception as e:
_logger.error("Failed to generate Page 11 PDF: %s", e)
return None
def _build_pdf_context_from_order(self):
"""Build a PDF context dict from the sale order when no assessment exists."""
order = self.sale_order_id
partner = order.partner_id
first, last = order._get_client_name_parts()
return {
'client_first_name': first,
'client_last_name': last,
'client_name': partner.name or '',
'client_street': partner.street or '',
'client_city': partner.city or '',
'client_state': partner.state_id.name if partner.state_id else 'Ontario',
'client_postal_code': partner.zip or '',
'client_phone': partner.phone or partner.mobile or '',
'client_email': partner.email or '',
'client_type': order.x_fc_client_type or '',
'client_type_reg': order.x_fc_client_type == 'REG',
'client_type_ods': order.x_fc_client_type == 'ODS',
'client_type_acs': order.x_fc_client_type == 'ACS',
'client_type_owp': order.x_fc_client_type == 'OWP',
'reference': order.name or '',
'authorizer_name': order.x_fc_authorizer_id.name if order.x_fc_authorizer_id else '',
'authorizer_phone': order.x_fc_authorizer_id.phone if order.x_fc_authorizer_id else '',
'authorizer_email': order.x_fc_authorizer_id.email if order.x_fc_authorizer_id else '',
'claim_authorization_date': str(order.x_fc_claim_authorization_date) if order.x_fc_claim_authorization_date else '',
'assessment_start_date': str(order.x_fc_assessment_start_date) if order.x_fc_assessment_start_date else '',
'assessment_end_date': str(order.x_fc_assessment_end_date) if order.x_fc_assessment_end_date else '',
}
def _update_sale_order(self):
"""Copy signing data from this request to the sale order."""
self.ensure_one()
order = self.sale_order_id
vals = {
'x_fc_page11_signer_type': self.signer_type,
'x_fc_page11_signer_name': self.signer_name,
'x_fc_page11_signed_date': fields.Date.today(),
}
if self.signer_type != 'client':
vals['x_fc_page11_signer_relationship'] = (
self.signer_relationship
or SIGNER_TYPE_TO_RELATIONSHIP.get(self.signer_type, '')
)
if self.signed_pdf:
vals['x_fc_signed_pages_11_12'] = self.signed_pdf
vals['x_fc_signed_pages_filename'] = self.signed_pdf_filename
order.with_context(
skip_page11_check=True,
skip_document_chatter=True,
).write(vals)
signer_display = self.signer_name or 'N/A'
if self.signed_pdf:
att = self.env['ir.attachment'].sudo().create({
'name': self.signed_pdf_filename or 'Page11_Signed.pdf',
'datas': self.signed_pdf,
'res_model': 'sale.order',
'res_id': order.id,
'mimetype': 'application/pdf',
})
order.message_post(
body=Markup(
'Page 11 has been signed by <strong>%s</strong> (%s).'
) % (signer_display, self.signer_email),
attachment_ids=[att.id],
message_type='notification',
subtype_xmlid='mail.mt_note',
)
else:
order.message_post(
body=Markup(
'Page 11 has been signed by <strong>%s</strong> (%s). '
'PDF generation was not available.'
) % (signer_display, self.signer_email),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
def action_cancel(self):
"""Cancel a pending signing request."""
for rec in self:
if rec.state in ('draft', 'sent'):
rec.state = 'cancelled'
def action_resend(self):
"""Resend the signing email."""
for rec in self:
if rec.state in ('sent', 'expired'):
rec.expiry_date = fields.Datetime.now() + timedelta(days=7)
rec.access_token = str(uuid.uuid4())
rec._send_signing_email()
def action_request_new_signature(self):
"""Create a new signing request (e.g. to re-sign after corrections)."""
self.ensure_one()
if self.state == 'signed':
self.state = 'cancelled'
return {
'type': 'ir.actions.act_window',
'name': 'Request Page 11 Signature',
'res_model': 'fusion_claims.send.page11.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_sale_order_id': self.sale_order_id.id,
'default_signer_email': self.signer_email,
'default_signer_name': self.signer_name,
'default_signer_type': self.signer_type,
},
}
@api.model
def _cron_expire_requests(self):
"""Mark expired unsigned requests."""
expired = self.search([
('state', '=', 'sent'),
('expiry_date', '<', fields.Datetime.now()),
])
if expired:
expired.write({'state': 'expired'})
_logger.info("Expired %d Page 11 signing requests", len(expired))

View File

@@ -317,16 +317,6 @@ class ResConfigSettings(models.TransientModel):
help='The user who signs Page 12 on behalf of the company', help='The user who signs Page 12 on behalf of the company',
) )
# =========================================================================
# GOOGLE MAPS API SETTINGS
# =========================================================================
fc_google_maps_api_key = fields.Char(
string='Google Maps API Key',
config_parameter='fusion_claims.google_maps_api_key',
help='API key for Google Maps Places autocomplete in address fields',
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# AI CLIENT INTELLIGENCE # AI CLIENT INTELLIGENCE
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -349,62 +339,6 @@ class ResConfigSettings(models.TransientModel):
help='Automatically parse ADP XML files when uploaded and create/update client profiles', help='Automatically parse ADP XML files when uploaded and create/update client profiles',
) )
# ------------------------------------------------------------------
# TECHNICIAN MANAGEMENT
# ------------------------------------------------------------------
fc_store_open_hour = fields.Float(
string='Store Open Time',
config_parameter='fusion_claims.store_open_hour',
help='Store opening time for technician scheduling (e.g. 9.0 = 9:00 AM)',
)
fc_store_close_hour = fields.Float(
string='Store Close Time',
config_parameter='fusion_claims.store_close_hour',
help='Store closing time for technician scheduling (e.g. 18.0 = 6:00 PM)',
)
fc_google_distance_matrix_enabled = fields.Boolean(
string='Enable Distance Matrix',
config_parameter='fusion_claims.google_distance_matrix_enabled',
help='Enable Google Distance Matrix API for travel time calculations between technician tasks',
)
fc_technician_start_address = fields.Char(
string='Technician Start Address',
config_parameter='fusion_claims.technician_start_address',
help='Default start location for technician travel calculations (e.g. warehouse/office address)',
)
fc_location_retention_days = fields.Char(
string='Location History Retention (Days)',
config_parameter='fusion_claims.location_retention_days',
help='How many days to keep technician location history. '
'Leave empty = 30 days (1 month). '
'0 = delete at end of each day. '
'1+ = keep for that many days.',
)
# ------------------------------------------------------------------
# WEB PUSH NOTIFICATIONS
# ------------------------------------------------------------------
fc_push_enabled = fields.Boolean(
string='Enable Push Notifications',
config_parameter='fusion_claims.push_enabled',
help='Enable web push notifications for technician tasks',
)
fc_vapid_public_key = fields.Char(
string='VAPID Public Key',
config_parameter='fusion_claims.vapid_public_key',
help='Public key for Web Push VAPID authentication (auto-generated)',
)
fc_vapid_private_key = fields.Char(
string='VAPID Private Key',
config_parameter='fusion_claims.vapid_private_key',
help='Private key for Web Push VAPID authentication (auto-generated)',
)
fc_push_advance_minutes = fields.Integer(
string='Notification Advance (min)',
config_parameter='fusion_claims.push_advance_minutes',
help='Send push notifications this many minutes before a scheduled task',
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# TWILIO SMS SETTINGS # TWILIO SMS SETTINGS
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -477,16 +411,6 @@ class ResConfigSettings(models.TransientModel):
help='Default ODSP office contact for new ODSP cases', help='Default ODSP office contact for new ODSP cases',
) )
# =========================================================================
# PORTAL FORMS
# =========================================================================
fc_ltc_form_password = fields.Char(
string='LTC Form Access Password',
config_parameter='fusion_claims.ltc_form_password',
help='Minimum 4 characters. Share with facility staff to access the repair form.',
)
# ========================================================================= # =========================================================================
# PORTAL BRANDING # PORTAL BRANDING
# ========================================================================= # =========================================================================
@@ -609,15 +533,11 @@ class ResConfigSettings(models.TransientModel):
# an existing non-empty value (e.g. API keys, user-customized settings). # an existing non-empty value (e.g. API keys, user-customized settings).
_protected_keys = [ _protected_keys = [
'fusion_claims.ai_api_key', 'fusion_claims.ai_api_key',
'fusion_claims.google_maps_api_key',
'fusion_claims.vendor_code', 'fusion_claims.vendor_code',
'fusion_claims.ai_model', 'fusion_claims.ai_model',
'fusion_claims.adp_posting_base_date', 'fusion_claims.adp_posting_base_date',
'fusion_claims.application_reminder_days', 'fusion_claims.application_reminder_days',
'fusion_claims.application_reminder_2_days', 'fusion_claims.application_reminder_2_days',
'fusion_claims.store_open_hour',
'fusion_claims.store_close_hour',
'fusion_claims.technician_start_address',
] ]
# Snapshot existing values BEFORE super().set_values() runs # Snapshot existing values BEFORE super().set_values() runs
_existing = {} _existing = {}
@@ -656,13 +576,6 @@ class ResConfigSettings(models.TransientModel):
# Office notification recipients are stored via related field on res.company # Office notification recipients are stored via related field on res.company
# No need to store in ir.config_parameter # No need to store in ir.config_parameter
# Validate LTC form password length
form_pw = self.fc_ltc_form_password or ''
if form_pw and len(form_pw.strip()) < 4:
raise ValidationError(
'LTC Form Access Password must be at least 4 characters.'
)
# Store designated vendor signer (Many2one - manual handling) # Store designated vendor signer (Many2one - manual handling)
if self.fc_designated_vendor_signer: if self.fc_designated_vendor_signer:
ICP.set_param('fusion_claims.designated_vendor_signer', ICP.set_param('fusion_claims.designated_vendor_signer',

View File

@@ -8,13 +8,6 @@ from odoo import models, fields, api
class ResPartner(models.Model): class ResPartner(models.Model):
_inherit = 'res.partner' _inherit = 'res.partner'
x_fc_start_address = fields.Char(
string='Start Location',
help='Technician daily start location (home, warehouse, etc.). '
'Used as origin for first travel time calculation. '
'If empty, the company default HQ address is used.',
)
# ========================================================================== # ==========================================================================
# CONTACT TYPE # CONTACT TYPE
# ========================================================================== # ==========================================================================
@@ -52,9 +45,8 @@ class ResPartner(models.Model):
# ========================================================================== # ==========================================================================
x_fc_odsp_member_id = fields.Char( x_fc_odsp_member_id = fields.Char(
string='ODSP Member ID', string='ODSP Member ID',
size=9,
tracking=True, tracking=True,
help='9-digit Ontario Disability Support Program Member ID', help='Ontario Disability Support Program Member ID',
) )
x_fc_case_worker_id = fields.Many2one( x_fc_case_worker_id = fields.Many2one(
'res.partner', 'res.partner',
@@ -77,22 +69,14 @@ class ResPartner(models.Model):
) )
# ========================================================================== # ==========================================================================
# LTC FIELDS # AUTHORIZER FIELDS
# ========================================================================== # ==========================================================================
x_fc_ltc_facility_id = fields.Many2one( x_fc_authorizer_number = fields.Char(
'fusion.ltc.facility', string='ADP Authorizer Number',
string='LTC Home',
tracking=True, tracking=True,
help='Long-Term Care Home this resident belongs to', index=True,
) help='ADP Registration Number for this authorizer (e.g. OT). '
x_fc_ltc_room_number = fields.Char( 'Used to auto-link authorizers when processing ADP XML files.',
string='Room Number',
tracking=True,
)
x_fc_ltc_family_contact_ids = fields.One2many(
'fusion.ltc.family.contact',
'partner_id',
string='Family Contacts',
) )
@api.depends('x_fc_contact_type') @api.depends('x_fc_contact_type')

File diff suppressed because it is too large Load Diff

View File

@@ -29,10 +29,11 @@ class SaleOrderLine(models.Model):
@api.depends('product_id', 'product_id.default_code') @api.depends('product_id', 'product_id.default_code')
def _compute_adp_device_type(self): def _compute_adp_device_type(self):
"""Compute ADP device type from the product's device code.""" """Compute ADP device type and build type from the product's device code."""
ADPDevice = self.env['fusion.adp.device.code'].sudo() ADPDevice = self.env['fusion.adp.device.code'].sudo()
for line in self: for line in self:
device_type = '' device_type = ''
build_type = False
if line.product_id: if line.product_id:
# Get the device code from product (default_code or custom field) # Get the device code from product (default_code or custom field)
device_code = line._get_adp_device_code() device_code = line._get_adp_device_code()
@@ -44,7 +45,9 @@ class SaleOrderLine(models.Model):
], limit=1) ], limit=1)
if adp_device: if adp_device:
device_type = adp_device.device_type or '' device_type = adp_device.device_type or ''
build_type = adp_device.build_type or False
line.x_fc_adp_device_type = device_type line.x_fc_adp_device_type = device_type
line.x_fc_adp_build_type = build_type
# ========================================================================== # ==========================================================================
# SERIAL NUMBER AND DEVICE PLACEMENT # SERIAL NUMBER AND DEVICE PLACEMENT
@@ -110,6 +113,16 @@ class SaleOrderLine(models.Model):
store=True, store=True,
help='Device type from ADP mobility manual (for approval matching)', help='Device type from ADP mobility manual (for approval matching)',
) )
x_fc_adp_build_type = fields.Selection(
selection=[
('modular', 'Modular'),
('custom_fabricated', 'Custom Fabricated'),
],
string='Build Type',
compute='_compute_adp_device_type',
store=True,
help='Build type from ADP mobility manual (Modular or Custom Fabricated)',
)
# ========================================================================== # ==========================================================================
# COMPUTED ADP PORTIONS # COMPUTED ADP PORTIONS
@@ -306,6 +319,49 @@ class SaleOrderLine(models.Model):
# 5. Final fallback - return default_code even if not in ADP database # 5. Final fallback - return default_code even if not in ADP database
return self.product_id.default_code or '' 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): def _get_serial_number(self):
"""Get serial number from mapped field or native field.""" """Get serial number from mapped field or native field."""
self.ensure_one() self.ensure_one()

File diff suppressed because it is too large Load Diff

View File

@@ -66,6 +66,10 @@ class FusionXmlParser(models.AbstractModel):
# Step 3: Create/update profile # Step 3: Create/update profile
profile = self._find_or_create_profile(model_vals, sale_order) profile = self._find_or_create_profile(model_vals, sale_order)
# Step 3b: Auto-link authorizer on sale order by ADP number
if sale_order:
self._link_authorizer_by_adp_number(model_vals, sale_order)
# Step 4: Create application data record # Step 4: Create application data record
model_vals['profile_id'] = profile.id model_vals['profile_id'] = profile.id
model_vals['sale_order_id'] = sale_order.id if sale_order else False model_vals['sale_order_id'] = sale_order.id if sale_order else False
@@ -637,6 +641,39 @@ class FusionXmlParser(models.AbstractModel):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# PROFILE MANAGEMENT # PROFILE MANAGEMENT
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _link_authorizer_by_adp_number(self, vals, sale_order):
"""Auto-link the authorizer contact on the sale order using the ADP number from XML."""
adp_number = (vals.get('authorizer_adp_number') or '').strip()
if not adp_number or adp_number.upper() in ('NA', 'N/A', ''):
return
if sale_order.x_fc_authorizer_id:
return
Partner = self.env['res.partner']
authorizer = Partner.search([
('x_fc_authorizer_number', '=', adp_number),
], limit=1)
if not authorizer:
first = (vals.get('authorizer_first_name') or '').strip()
last = (vals.get('authorizer_last_name') or '').strip()
if first and last:
authorizer = Partner.search([
'|',
('name', 'ilike', f'{first} {last}'),
('name', 'ilike', f'{last}, {first}'),
], limit=1)
if authorizer and not authorizer.x_fc_authorizer_number:
authorizer.write({'x_fc_authorizer_number': adp_number})
if authorizer:
sale_order.write({'x_fc_authorizer_id': authorizer.id})
_logger.info(
'Auto-linked authorizer %s (ADP# %s) to SO %s',
authorizer.name, adp_number, sale_order.name,
)
def _find_or_create_profile(self, vals, sale_order=None): def _find_or_create_profile(self, vals, sale_order=None):
"""Find or create a client profile from parsed application data.""" """Find or create a client profile from parsed application data."""
Profile = self.env['fusion.client.profile'] Profile = self.env['fusion.client.profile']

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