Compare commits
21 Commits
14fe9ab716
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f3766c2898 | |||
| 431052920e | |||
| 1f79cdcaaf | |||
| 8761d0e7c7 | |||
| 0053576cc2 | |||
| 7bd7b8f7c4 | |||
| 3342b57469 | |||
| 1bfa50aa5f | |||
| 85367747a6 | |||
| d7657bb356 | |||
| 9dac39853f | |||
| c1a3b02ac5 | |||
| 1f750a6db4 | |||
| ffcc83d7bd | |||
| 6c3c565440 | |||
| 1c191a54e1 | |||
| 512aedce69 | |||
| f362fbd915 | |||
|
|
35399170b3 | ||
|
|
3b3c57205a | ||
|
|
b649246e81 |
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
**/__pycache__/
|
||||||
|
*.pyc
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
206
fusion_authorizer_portal/controllers/portal_page11_sign.py
Normal file
206
fusion_authorizer_portal/controllers/portal_page11_sign.py
Normal 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))),
|
||||||
|
],
|
||||||
|
)
|
||||||
327
fusion_authorizer_portal/controllers/portal_schedule.py
Normal file
327
fusion_authorizer_portal/controllers/portal_schedule.py
Normal 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')
|
||||||
13
fusion_authorizer_portal/data/appointment_invite_data.xml
Normal file
13
fusion_authorizer_portal/data/appointment_invite_data.xml
Normal 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>
|
||||||
@@ -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],
|
||||||
|
)
|
||||||
@@ -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],
|
||||||
|
)
|
||||||
@@ -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],
|
||||||
|
)
|
||||||
@@ -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',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -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;
|
// =====================================================================
|
||||||
|
// PERMISSION-DENIED BANNER (persistent warning for background logger)
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
var bannerEl = null;
|
||||||
|
|
||||||
|
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) {
|
if (!navigator.geolocation) {
|
||||||
|
reject(new Error('Geolocation is not supported by this browser.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
function (position) {
|
function (position) {
|
||||||
var data = {
|
permissionDenied = false;
|
||||||
jsonrpc: '2.0',
|
if (bannerEl) { bannerEl.remove(); bannerEl = null; }
|
||||||
method: 'call',
|
resolve({
|
||||||
params: {
|
|
||||||
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', {
|
fetch('/my/technician/location/log', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify({
|
||||||
}).catch(function () {
|
jsonrpc: '2.0',
|
||||||
// Silently fail - location logging is best-effort
|
method: 'call',
|
||||||
|
params: {
|
||||||
|
latitude: coords.latitude,
|
||||||
|
longitude: coords.longitude,
|
||||||
|
accuracy: coords.accuracy,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (data.result && !data.result.success) {
|
||||||
|
console.warn('Fusion Location: server rejected log', data.result);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
console.warn('Fusion Location: network error', err);
|
||||||
|
});
|
||||||
|
}).catch(function () {
|
||||||
|
/* permission denied -- banner already shown */
|
||||||
});
|
});
|
||||||
},
|
|
||||||
function () {
|
|
||||||
// Geolocation permission denied or error - silently ignore
|
|
||||||
},
|
|
||||||
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startLocationLogging() {
|
function startLocationTimer() {
|
||||||
if (!isTechnicianPortal()) {
|
if (locationTimer) return; // already running
|
||||||
return;
|
logLocation(); // immediate first log
|
||||||
}
|
|
||||||
|
|
||||||
// Log immediately on page load
|
|
||||||
logLocation();
|
|
||||||
|
|
||||||
// Set interval for periodic logging
|
|
||||||
locationTimer = setInterval(logLocation, INTERVAL_MS);
|
locationTimer = setInterval(logLocation, INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
// Pause/resume on tab visibility change
|
function stopLocationTimer() {
|
||||||
document.addEventListener('visibilitychange', function () {
|
|
||||||
if (document.hidden) {
|
|
||||||
// Tab hidden - clear interval to save battery
|
|
||||||
if (locationTimer) {
|
if (locationTimer) {
|
||||||
clearInterval(locationTimer);
|
clearInterval(locationTimer);
|
||||||
locationTimer = null;
|
locationTimer = null;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Tab visible again - log immediately and restart interval
|
|
||||||
logLocation();
|
|
||||||
if (!locationTimer) {
|
|
||||||
locationTimer = setInterval(logLocation, INTERVAL_MS);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startLocationLogging() {
|
||||||
|
if (!isTechnicianPortal()) return;
|
||||||
|
|
||||||
|
// Check clock status immediately, then every 60s
|
||||||
|
checkClockStatus();
|
||||||
|
clockCheckTimer = setInterval(checkClockStatus, CLOCK_CHECK_MS);
|
||||||
|
|
||||||
|
// Pause/resume on tab visibility
|
||||||
|
document.addEventListener('visibilitychange', function () {
|
||||||
|
if (document.hidden) {
|
||||||
|
stopLocationTimer();
|
||||||
|
} else if (isClockedIn) {
|
||||||
|
startLocationTimer();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start when DOM is ready
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', startLocationLogging);
|
document.addEventListener('DOMContentLoaded', startLocationLogging);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
413
fusion_authorizer_portal/views/portal_page11_sign_templates.xml
Normal file
413
fusion_authorizer_portal/views/portal_page11_sign_templates.xml
Normal 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"/> &middot;
|
||||||
|
<t t-if="company.phone"><t t-out="company.phone"/> &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}&libraries=places&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 && !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>
|
||||||
348
fusion_authorizer_portal/views/portal_schedule.xml
Normal file
348
fusion_authorizer_portal/views/portal_schedule.xml
Normal 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 & 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}&libraries=places&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>
|
||||||
@@ -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,26 +207,23 @@
|
|||||||
</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="badge bg-primary ms-1"><t t-out="tomorrow_count"/></span>
|
<span class="tech-quick-link-badge"><t t-out="tomorrow_count"/></span>
|
||||||
</t>
|
</t>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
<a href="/repair-form" class="tech-quick-link tech-quick-link-warning">
|
||||||
<div class="col-4">
|
<i class="fa fa-wrench"/>
|
||||||
<a href="/repair-form" class="btn btn-outline-warning w-100 py-3">
|
<span>Repair Form</span>
|
||||||
<i class="fa fa-wrench me-1"/>Repair Form
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- My Start Location -->
|
<!-- My Start Location -->
|
||||||
<div class="tech-card mb-4">
|
<div class="tech-card mb-4">
|
||||||
@@ -221,16 +255,126 @@
|
|||||||
</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 < 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
|
||||||
|
? '<i class="fa fa-stop-circle-o"></i> Clock Out'
|
||||||
|
: '<i class="fa fa-play-circle-o"></i> Clock In';
|
||||||
|
}
|
||||||
|
if (!isCheckedIn && timerEl) timerEl.textContent = '00:00:00';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCheckedIn && 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> Getting location...';
|
||||||
|
window.fusionGetLocation().then(function(coords) {
|
||||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> ...';
|
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> ...';
|
||||||
fetch('/my/technician/task/' + taskId + '/action', {
|
fetch('/my/technician/task/' + taskId + '/action', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {action: action}})
|
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
|
||||||
|
action: action,
|
||||||
|
latitude: coords.latitude,
|
||||||
|
longitude: coords.longitude,
|
||||||
|
accuracy: coords.accuracy
|
||||||
|
}})
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
@@ -239,12 +383,18 @@
|
|||||||
} else {
|
} else {
|
||||||
alert(data.result ? data.result.error : 'Error');
|
alert(data.result ? data.result.error : 'Error');
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = '<i class="fa fa-road"></i> En Route';
|
btn.innerHTML = origHtml;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function() {
|
.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() {
|
||||||
|
alert('Location access is required. Please enable GPS and try again.');
|
||||||
|
btn.disabled = false;
|
||||||
|
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,12 +918,20 @@
|
|||||||
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 = '<i class="fa fa-spinner fa-spin"></i> Getting location...';
|
||||||
|
window.fusionGetLocation().then(function(coords) {
|
||||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i>';
|
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i>';
|
||||||
fetch('/my/technician/task/' + taskId + '/action', {
|
fetch('/my/technician/task/' + taskId + '/action', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {action: action}})
|
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
|
||||||
|
action: action,
|
||||||
|
latitude: coords.latitude,
|
||||||
|
longitude: coords.longitude,
|
||||||
|
accuracy: coords.accuracy
|
||||||
|
}})
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
@@ -764,17 +940,36 @@
|
|||||||
} else {
|
} else {
|
||||||
alert(data.result ? data.result.error : 'Error');
|
alert(data.result ? data.result.error : 'Error');
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = origHtml;
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
alert('Network error. Please try again.');
|
||||||
|
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 = '<i class="fa fa-spinner fa-spin"></i> Getting location...';
|
||||||
|
window.fusionGetLocation().then(function(coords) {
|
||||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Completing...';
|
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Completing...';
|
||||||
fetch('/my/technician/task/' + taskId + '/action', {
|
fetch('/my/technician/task/' + taskId + '/action', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {action: 'complete'}})
|
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
|
||||||
|
action: 'complete',
|
||||||
|
latitude: coords.latitude,
|
||||||
|
longitude: coords.longitude,
|
||||||
|
accuracy: coords.accuracy
|
||||||
|
}})
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
@@ -783,13 +978,18 @@
|
|||||||
} else {
|
} else {
|
||||||
alert(data.result ? data.result.error : 'Error completing task');
|
alert(data.result ? data.result.error : 'Error completing task');
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = '<i class="fa fa-check-circle"></i> Complete Task';
|
btn.innerHTML = origHtml;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
alert('Network error. Please try again.');
|
alert('Network error. Please try again.');
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = '<i class="fa fa-check-circle"></i> Complete Task';
|
btn.innerHTML = origHtml;
|
||||||
|
});
|
||||||
|
}).catch(function() {
|
||||||
|
alert('Location access is required. Please enable GPS and try again.');
|
||||||
|
btn.disabled = false;
|
||||||
|
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 && data.result.success) {
|
if (data.result && 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) {
|
||||||
|
if (err instanceof GeolocationPositionError || err.code) {
|
||||||
|
alert('Location access is required. Please enable GPS and try again.');
|
||||||
|
} else {
|
||||||
alert('Error: ' + err.message);
|
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>
|
||||||
|
|||||||
@@ -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 < 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 < 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
|
||||||
|
? '<i class="fa fa-stop-circle-o"></i> Clock Out'
|
||||||
|
: '<i class="fa fa-play-circle-o"></i> Clock In';
|
||||||
|
}
|
||||||
|
if (!isCheckedIn && timerEl) timerEl.textContent = '00:00:00';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCheckedIn && 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'
|
||||||
|
? '<i class="fa fa-check-circle text-success"></i>'
|
||||||
|
: '<i class="fa fa-exclamation-circle text-danger"></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' && url) {
|
||||||
|
acts.innerHTML = '<a href="' + url + '" class="btn btn-success w-100 rounded-pill mb-2">Continue</a>' +
|
||||||
|
'<p class="text-muted small mb-0">Redirecting in <span id="tpodCD">3</span>s...</p>';
|
||||||
|
ov.classList.add('show');
|
||||||
|
var s = 3, t = setInterval(function() { s--; var c = document.getElementById('tpodCD');
|
||||||
|
if (c) c.textContent = s; if (s <= 0) { clearInterval(t); window.location.href = url; } }, 1000);
|
||||||
|
} else {
|
||||||
|
acts.innerHTML = '<button class="btn btn-outline-secondary w-100 rounded-pill" onclick="document.getElementById(\'taskPodOverlay\').classList.remove(\'show\')">OK</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 = '<i class="fa fa-spinner fa-spin me-2"></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 && 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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
389
fusion_claims/models/page11_sign_request.py
Normal file
389
fusion_claims/models/page11_sign_request.py
Normal 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))
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user