feat: separate fusion field service and LTC into standalone modules, update core modules
- fusion_claims: separated field service logic, updated controllers/views - fusion_tasks: updated task views and map integration - fusion_authorizer_portal: added page 11 signing, schedule booking, migrations - fusion_shipping: new standalone shipping module (Canada Post, FedEx, DHL, Purolator) - fusion_ltc_management: new standalone LTC management module
This commit is contained in:
@@ -2,3 +2,29 @@
|
||||
|
||||
from . import models
|
||||
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 -*-
|
||||
{
|
||||
'name': 'Fusion Authorizer & Sales Portal',
|
||||
'version': '19.0.2.2.0',
|
||||
'version': '19.0.2.5.0',
|
||||
'category': 'Sales/Portal',
|
||||
'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms',
|
||||
'description': """
|
||||
@@ -50,8 +50,10 @@ This module provides external portal access for:
|
||||
'website',
|
||||
'mail',
|
||||
'calendar',
|
||||
'appointment',
|
||||
'knowledge',
|
||||
'fusion_claims',
|
||||
'fusion_tasks',
|
||||
],
|
||||
'data': [
|
||||
# Security
|
||||
@@ -62,6 +64,7 @@ This module provides external portal access for:
|
||||
'data/portal_menu_data.xml',
|
||||
'data/ir_actions_server_data.xml',
|
||||
'data/welcome_articles.xml',
|
||||
'data/appointment_invite_data.xml',
|
||||
# Views
|
||||
'views/res_partner_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_technician_templates.xml',
|
||||
'views/portal_book_assessment.xml',
|
||||
'views/portal_repair_form.xml',
|
||||
'views/portal_schedule.xml',
|
||||
'views/portal_page11_sign_templates.xml',
|
||||
],
|
||||
'assets': {
|
||||
'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/technician_push.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'],
|
||||
'post_init_hook': '_reactivate_views',
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
|
||||
@@ -3,4 +3,5 @@
|
||||
from . import portal_main
|
||||
from . import portal_assessment
|
||||
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):
|
||||
posting_info = self._get_adp_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
|
||||
sign_count = 0
|
||||
@@ -724,7 +725,7 @@ class AuthorizerPortal(CustomerPortal):
|
||||
'sale_type_filter': sale_type,
|
||||
'status_filter': status,
|
||||
}
|
||||
|
||||
values.update(self._get_clock_status_data())
|
||||
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)
|
||||
@@ -1090,14 +1091,60 @@ class AuthorizerPortal(CustomerPortal):
|
||||
_logger.error(f"Error downloading proof of delivery: {e}")
|
||||
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 ====================
|
||||
|
||||
def _check_technician_access(self):
|
||||
"""Check if current user is a technician portal user."""
|
||||
partner = request.env.user.partner_id
|
||||
if not partner.is_technician_portal:
|
||||
return False
|
||||
return True
|
||||
if partner.is_technician_portal:
|
||||
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)
|
||||
def technician_dashboard(self, **kw):
|
||||
@@ -1159,6 +1206,8 @@ class AuthorizerPortal(CustomerPortal):
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
|
||||
|
||||
clock_data = self._get_clock_status_data()
|
||||
|
||||
values = {
|
||||
'today_tasks': today_tasks,
|
||||
'current_task': current_task,
|
||||
@@ -1174,6 +1223,7 @@ class AuthorizerPortal(CustomerPortal):
|
||||
'google_maps_api_key': google_maps_api_key,
|
||||
'page_name': 'technician_dashboard',
|
||||
}
|
||||
values.update(clock_data)
|
||||
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)
|
||||
@@ -1423,11 +1473,17 @@ class AuthorizerPortal(CustomerPortal):
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
@http.route('/my/technician/task/<int:task_id>/action', type='json', auth='user', website=True)
|
||||
def technician_task_action(self, task_id, action, **kw):
|
||||
"""Handle task status changes (start, complete, en_route, cancel)."""
|
||||
def technician_task_action(self, task_id, action, latitude=None, longitude=None, accuracy=None, **kw):
|
||||
"""Handle task status changes (start, complete, en_route, cancel).
|
||||
Location is mandatory -- the client must send GPS coordinates."""
|
||||
if not self._check_technician_access():
|
||||
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
|
||||
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'}
|
||||
|
||||
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':
|
||||
task.action_start_en_route()
|
||||
task.with_context(**location_ctx).action_start_en_route()
|
||||
elif action == 'start':
|
||||
task.action_start_task()
|
||||
task.with_context(**location_ctx).action_start_task()
|
||||
elif action == 'complete':
|
||||
completion_notes = kw.get('completion_notes', '')
|
||||
if completion_notes:
|
||||
task.completion_notes = completion_notes
|
||||
task.action_complete_task()
|
||||
task.with_context(**location_ctx).action_complete_task()
|
||||
elif action == 'cancel':
|
||||
task.action_cancel_task()
|
||||
task.with_context(**location_ctx).action_cancel_task()
|
||||
else:
|
||||
return {'success': False, 'error': f'Unknown action: {action}'}
|
||||
|
||||
# For completion, also return next task info
|
||||
result = {
|
||||
'success': True,
|
||||
'status': task.status,
|
||||
@@ -1600,10 +1674,14 @@ class AuthorizerPortal(CustomerPortal):
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
@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."""
|
||||
if not self._check_technician_access():
|
||||
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
|
||||
Task = request.env['fusion.technician.task'].sudo()
|
||||
@@ -1675,7 +1753,18 @@ class AuthorizerPortal(CustomerPortal):
|
||||
'completion_notes': completion_html,
|
||||
'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 {
|
||||
'success': True,
|
||||
@@ -1788,6 +1877,25 @@ class AuthorizerPortal(CustomerPortal):
|
||||
_logger.warning(f"Location log error: {e}")
|
||||
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)
|
||||
def technician_save_start_location(self, address='', **kw):
|
||||
"""Save the technician's personal start location."""
|
||||
@@ -2055,6 +2163,94 @@ class AuthorizerPortal(CustomerPortal):
|
||||
_logger.error(f"Error saving POD signature: {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):
|
||||
"""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))),
|
||||
],
|
||||
)
|
||||
@@ -1,182 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import http, _, fields
|
||||
from odoo.http import request
|
||||
import base64
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LTCRepairPortal(http.Controller):
|
||||
|
||||
def _is_password_required(self):
|
||||
password = request.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_claims.ltc_form_password', ''
|
||||
)
|
||||
return bool(password and password.strip())
|
||||
|
||||
def _process_photos(self, file_list, repair):
|
||||
attachment_ids = []
|
||||
for photo in file_list:
|
||||
if photo and photo.filename:
|
||||
data = photo.read()
|
||||
if data:
|
||||
attachment = request.env['ir.attachment'].sudo().create({
|
||||
'name': photo.filename,
|
||||
'datas': base64.b64encode(data),
|
||||
'res_model': 'fusion.ltc.repair',
|
||||
'res_id': repair.id,
|
||||
})
|
||||
attachment_ids.append(attachment.id)
|
||||
return attachment_ids
|
||||
|
||||
def _is_authenticated(self):
|
||||
if not request.env.user._is_public():
|
||||
return True
|
||||
if not self._is_password_required():
|
||||
return True
|
||||
return request.session.get('ltc_form_authenticated', False)
|
||||
|
||||
@http.route('/repair-form', type='http', auth='public', website=True,
|
||||
sitemap=False)
|
||||
def repair_form(self, **kw):
|
||||
if not self._is_authenticated():
|
||||
return request.render(
|
||||
'fusion_authorizer_portal.portal_ltc_repair_password',
|
||||
{'error': kw.get('auth_error', False)}
|
||||
)
|
||||
|
||||
facilities = request.env['fusion.ltc.facility'].sudo().search(
|
||||
[('active', '=', True)], order='name'
|
||||
)
|
||||
is_technician = not request.env.user._is_public() and request.env.user.has_group(
|
||||
'base.group_user'
|
||||
)
|
||||
return request.render(
|
||||
'fusion_authorizer_portal.portal_ltc_repair_form',
|
||||
{
|
||||
'facilities': facilities,
|
||||
'today': fields.Date.today(),
|
||||
'is_technician': is_technician,
|
||||
}
|
||||
)
|
||||
|
||||
@http.route('/repair-form/auth', type='http', auth='public',
|
||||
website=True, methods=['POST'], csrf=True)
|
||||
def repair_form_auth(self, **kw):
|
||||
stored_password = request.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_claims.ltc_form_password', ''
|
||||
).strip()
|
||||
|
||||
entered_password = (kw.get('password', '') or '').strip()
|
||||
|
||||
if stored_password and entered_password == stored_password:
|
||||
request.session['ltc_form_authenticated'] = True
|
||||
return request.redirect('/repair-form')
|
||||
|
||||
return request.render(
|
||||
'fusion_authorizer_portal.portal_ltc_repair_password',
|
||||
{'error': True}
|
||||
)
|
||||
|
||||
@http.route('/repair-form/submit', type='http', auth='public',
|
||||
website=True, methods=['POST'], csrf=True)
|
||||
def repair_form_submit(self, **kw):
|
||||
if not self._is_authenticated():
|
||||
return request.redirect('/repair-form')
|
||||
|
||||
try:
|
||||
facility_id = int(kw.get('facility_id', 0))
|
||||
if not facility_id:
|
||||
return request.redirect('/repair-form?error=facility')
|
||||
|
||||
vals = {
|
||||
'facility_id': facility_id,
|
||||
'client_name': kw.get('client_name', '').strip(),
|
||||
'room_number': kw.get('room_number', '').strip(),
|
||||
'product_serial': kw.get('product_serial', '').strip(),
|
||||
'issue_description': kw.get('issue_description', '').strip(),
|
||||
'issue_reported_date': kw.get('issue_reported_date') or fields.Date.today(),
|
||||
'is_emergency': kw.get('is_emergency') == 'on',
|
||||
'poa_name': kw.get('poa_name', '').strip() or False,
|
||||
'poa_phone': kw.get('poa_phone', '').strip() or False,
|
||||
'source': 'portal_form',
|
||||
}
|
||||
|
||||
if not vals['client_name']:
|
||||
return request.redirect('/repair-form?error=name')
|
||||
if not vals['issue_description']:
|
||||
return request.redirect('/repair-form?error=description')
|
||||
|
||||
before_files = request.httprequest.files.getlist('before_photos')
|
||||
has_before = any(f and f.filename for f in before_files)
|
||||
if not has_before:
|
||||
return request.redirect('/repair-form?error=photos')
|
||||
|
||||
repair = request.env['fusion.ltc.repair'].sudo().create(vals)
|
||||
|
||||
before_ids = self._process_photos(before_files, repair)
|
||||
if before_ids:
|
||||
repair.sudo().write({
|
||||
'before_photo_ids': [(6, 0, before_ids)],
|
||||
})
|
||||
|
||||
after_files = request.httprequest.files.getlist('after_photos')
|
||||
after_ids = self._process_photos(after_files, repair)
|
||||
if after_ids:
|
||||
repair.sudo().write({
|
||||
'after_photo_ids': [(6, 0, after_ids)],
|
||||
})
|
||||
|
||||
resolved = kw.get('resolved') == 'yes'
|
||||
if resolved:
|
||||
resolution = kw.get('resolution_description', '').strip()
|
||||
if resolution:
|
||||
repair.sudo().write({
|
||||
'resolution_description': resolution,
|
||||
'issue_fixed_date': fields.Date.today(),
|
||||
})
|
||||
|
||||
repair.sudo().activity_schedule(
|
||||
'mail.mail_activity_data_todo',
|
||||
summary=_('New repair request from portal: %s', repair.display_client_name),
|
||||
note=_(
|
||||
'Repair request submitted via portal form for %s at %s (Room %s).',
|
||||
repair.display_client_name,
|
||||
repair.facility_id.name,
|
||||
repair.room_number or 'N/A',
|
||||
),
|
||||
)
|
||||
|
||||
ip_address = request.httprequest.headers.get(
|
||||
'X-Forwarded-For', request.httprequest.remote_addr
|
||||
)
|
||||
if ip_address and ',' in ip_address:
|
||||
ip_address = ip_address.split(',')[0].strip()
|
||||
|
||||
try:
|
||||
request.env['fusion.ltc.form.submission'].sudo().create({
|
||||
'form_type': 'repair',
|
||||
'repair_id': repair.id,
|
||||
'facility_id': facility_id,
|
||||
'client_name': vals['client_name'],
|
||||
'room_number': vals['room_number'],
|
||||
'product_serial': vals['product_serial'],
|
||||
'is_emergency': vals['is_emergency'],
|
||||
'ip_address': ip_address or '',
|
||||
'status': 'processed',
|
||||
})
|
||||
except Exception:
|
||||
_logger.warning('Failed to log form submission', exc_info=True)
|
||||
|
||||
return request.render(
|
||||
'fusion_authorizer_portal.portal_ltc_repair_thank_you',
|
||||
{'repair': repair}
|
||||
)
|
||||
|
||||
except Exception:
|
||||
_logger.exception('Error submitting LTC repair form')
|
||||
return request.redirect('/repair-form?error=server')
|
||||
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_id': sale_order.id,
|
||||
'view_mode': 'form',
|
||||
'views': [(False, 'form')],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
@@ -1482,6 +1483,7 @@ class FusionAssessment(models.Model):
|
||||
'name': _('Documents'),
|
||||
'res_model': 'fusion.adp.document',
|
||||
'view_mode': 'list,form',
|
||||
'views': [(False, 'list'), (False, 'form')],
|
||||
'domain': [('assessment_id', '=', self.id)],
|
||||
'context': {'default_assessment_id': self.id},
|
||||
}
|
||||
@@ -1497,6 +1499,7 @@ class FusionAssessment(models.Model):
|
||||
'res_model': 'sale.order',
|
||||
'res_id': self.sale_order_id.id,
|
||||
'view_mode': 'form',
|
||||
'views': [(False, 'form')],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
|
||||
@@ -23,5 +23,6 @@ class FusionLoanerCheckoutAssessment(models.Model):
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fusion.assessment',
|
||||
'view_mode': 'form',
|
||||
'views': [(False, 'form')],
|
||||
'res_id': self.assessment_id.id,
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ class ResPartner(models.Model):
|
||||
|
||||
if self.is_technician_portal:
|
||||
# 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:
|
||||
internal_user.sudo().write({'group_ids': [(4, g.id)]})
|
||||
added.append('Field Technician')
|
||||
@@ -596,6 +596,7 @@ class ResPartner(models.Model):
|
||||
'name': _('Assigned Cases'),
|
||||
'res_model': 'sale.order',
|
||||
'view_mode': 'list,form',
|
||||
'views': [(False, 'list'), (False, 'form')],
|
||||
'domain': [('x_fc_authorizer_id', '=', self.id)],
|
||||
'context': {'default_x_fc_authorizer_id': self.id},
|
||||
}
|
||||
@@ -614,6 +615,7 @@ class ResPartner(models.Model):
|
||||
'name': _('Assessments'),
|
||||
'res_model': 'fusion.assessment',
|
||||
'view_mode': 'list,form',
|
||||
'views': [(False, 'list'), (False, 'form')],
|
||||
'domain': domain,
|
||||
}
|
||||
|
||||
@@ -697,6 +699,7 @@ class ResPartner(models.Model):
|
||||
'name': _('Assigned Deliveries'),
|
||||
'res_model': 'sale.order',
|
||||
'view_mode': 'list,form',
|
||||
'views': [(False, 'list'), (False, 'form')],
|
||||
'domain': [('x_fc_delivery_technician_ids', 'in', [self.authorizer_portal_user_id.id])],
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ class SaleOrder(models.Model):
|
||||
'name': 'Message Authorizer',
|
||||
'res_model': 'mail.compose.message',
|
||||
'view_mode': 'form',
|
||||
'views': [(False, 'form')],
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_model': 'sale.order',
|
||||
@@ -137,6 +138,7 @@ class SaleOrder(models.Model):
|
||||
'name': _('Portal Comments'),
|
||||
'res_model': 'fusion.authorizer.comment',
|
||||
'view_mode': 'list,form',
|
||||
'views': [(False, 'list'), (False, 'form')],
|
||||
'domain': [('sale_order_id', '=', self.id)],
|
||||
'context': {'default_sale_order_id': self.id},
|
||||
}
|
||||
@@ -149,6 +151,7 @@ class SaleOrder(models.Model):
|
||||
'name': _('Portal Documents'),
|
||||
'res_model': 'fusion.adp.document',
|
||||
'view_mode': 'list,form',
|
||||
'views': [(False, 'list'), (False, 'form')],
|
||||
'domain': [('sale_order_id', '=', self.id)],
|
||||
'context': {'default_sale_order_id': self.id},
|
||||
}
|
||||
|
||||
@@ -14,16 +14,12 @@
|
||||
.tech-stats-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.tech-stats-bar::-webkit-scrollbar { display: none; }
|
||||
|
||||
.tech-stat-card {
|
||||
flex: 0 0 auto;
|
||||
min-width: 100px;
|
||||
padding: 0.75rem 1rem;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding: 0.75rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
@@ -42,7 +38,145 @@
|
||||
.tech-stat-total { background: linear-gradient(135deg, #5ba848, #3a8fb7); }
|
||||
.tech-stat-remaining { background: linear-gradient(135deg, #3498db, #2980b9); }
|
||||
.tech-stat-completed { background: linear-gradient(135deg, #27ae60, #219a52); }
|
||||
.tech-stat-travel { background: linear-gradient(135deg, #8e44ad, #7d3c98); }
|
||||
|
||||
/* ---- 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) ---- */
|
||||
.tech-hero-card {
|
||||
@@ -475,12 +609,18 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
.tech-stat-card {
|
||||
min-width: 130px;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
.tech-stat-card .stat-number {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.tech-quick-links {
|
||||
gap: 1rem;
|
||||
}
|
||||
.tech-quick-link {
|
||||
padding: 1rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.tech-bottom-bar {
|
||||
position: static;
|
||||
box-shadow: none;
|
||||
|
||||
@@ -28,6 +28,9 @@ patch(Chatter.prototype, {
|
||||
[thread.id],
|
||||
);
|
||||
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);
|
||||
}
|
||||
} 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
|
||||
* Logs GPS location every 5 minutes during working hours (9 AM - 6 PM)
|
||||
* Only logs while the browser tab is visible.
|
||||
* Technician Location Services
|
||||
*
|
||||
* 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 () {
|
||||
'use strict';
|
||||
|
||||
var INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
var STORE_OPEN_HOUR = 9;
|
||||
var STORE_CLOSE_HOUR = 18;
|
||||
var INTERVAL_MS = 5 * 60 * 1000;
|
||||
var CLOCK_CHECK_MS = 60 * 1000; // check clock status every 60s
|
||||
var locationTimer = null;
|
||||
var clockCheckTimer = null;
|
||||
var isClockedIn = false;
|
||||
var permissionDenied = false;
|
||||
|
||||
function isWorkingHours() {
|
||||
var now = new Date();
|
||||
var hour = now.getHours();
|
||||
return hour >= STORE_OPEN_HOUR && hour < STORE_CLOSE_HOUR;
|
||||
// =====================================================================
|
||||
// BLOCKING MODAL
|
||||
// =====================================================================
|
||||
|
||||
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() {
|
||||
// Check if we're on a technician portal page
|
||||
return window.location.pathname.indexOf('/my/technician') !== -1;
|
||||
function showModal() {
|
||||
ensureModal();
|
||||
modalEl.style.display = '';
|
||||
}
|
||||
|
||||
function logLocation() {
|
||||
if (!isWorkingHours()) {
|
||||
return;
|
||||
}
|
||||
if (document.hidden) {
|
||||
return;
|
||||
}
|
||||
if (!navigator.geolocation) {
|
||||
return;
|
||||
}
|
||||
function hideModal() {
|
||||
if (modalEl) modalEl.style.display = 'none';
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
function (position) {
|
||||
var data = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'call',
|
||||
params: {
|
||||
// =====================================================================
|
||||
// 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) {
|
||||
reject(new Error('Geolocation is not supported by this browser.'));
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
function (position) {
|
||||
permissionDenied = false;
|
||||
if (bannerEl) { bannerEl.remove(); bannerEl = null; }
|
||||
resolve({
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
accuracy: position.coords.accuracy || 0,
|
||||
});
|
||||
},
|
||||
function (error) {
|
||||
permissionDenied = true;
|
||||
showDeniedBanner();
|
||||
console.error('Fusion Location: GPS error', error.code, error.message);
|
||||
reject(error);
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 30000 }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
window.fusionGetLocation = getLocation;
|
||||
|
||||
// =====================================================================
|
||||
// NAVIGATE -- opens Google Maps app on iOS/Android, browser fallback
|
||||
// =====================================================================
|
||||
|
||||
function openGoogleMapsNav(el) {
|
||||
var addr = (el.dataset.navAddr || '').trim();
|
||||
var fallbackUrl = el.dataset.navUrl || '';
|
||||
if (!addr && !fallbackUrl) return;
|
||||
|
||||
var dest = encodeURIComponent(addr) || fallbackUrl.split('destination=')[1];
|
||||
var isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
var isAndroid = /Android/i.test(navigator.userAgent);
|
||||
|
||||
if (isIOS) {
|
||||
window.location.href = 'comgooglemaps://?daddr=' + dest + '&directionsmode=driving';
|
||||
} else if (isAndroid) {
|
||||
window.location.href = 'google.navigation:q=' + dest;
|
||||
} else {
|
||||
window.open(fallbackUrl, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
window.openGoogleMapsNav = openGoogleMapsNav;
|
||||
|
||||
// =====================================================================
|
||||
// BACKGROUND LOGGER (tied to clock-in / clock-out status)
|
||||
// =====================================================================
|
||||
|
||||
function isTechnicianPortal() {
|
||||
return window.location.pathname.indexOf('/my/technician') !== -1;
|
||||
}
|
||||
|
||||
function checkClockStatus() {
|
||||
fetch('/my/technician/clock-status', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: {} }),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var wasClocked = isClockedIn;
|
||||
isClockedIn = !!(data.result && data.result.clocked_in);
|
||||
if (isClockedIn && !wasClocked) {
|
||||
// Just clocked in — start tracking immediately
|
||||
startLocationTimer();
|
||||
} else if (!isClockedIn && wasClocked) {
|
||||
// Just clocked out — stop tracking
|
||||
stopLocationTimer();
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
/* network error: keep current state */
|
||||
});
|
||||
}
|
||||
|
||||
function logLocation() {
|
||||
if (!isClockedIn || document.hidden || !navigator.geolocation) return;
|
||||
|
||||
getLocation().then(function (coords) {
|
||||
fetch('/my/technician/location/log', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'call',
|
||||
params: {
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
accuracy: coords.accuracy,
|
||||
}
|
||||
};
|
||||
fetch('/my/technician/location/log', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
}).catch(function () {
|
||||
// Silently fail - location logging is best-effort
|
||||
});
|
||||
},
|
||||
function () {
|
||||
// Geolocation permission denied or error - silently ignore
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
|
||||
);
|
||||
}),
|
||||
})
|
||||
.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 startLocationTimer() {
|
||||
if (locationTimer) return; // already running
|
||||
logLocation(); // immediate first log
|
||||
locationTimer = setInterval(logLocation, INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stopLocationTimer() {
|
||||
if (locationTimer) {
|
||||
clearInterval(locationTimer);
|
||||
locationTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startLocationLogging() {
|
||||
if (!isTechnicianPortal()) {
|
||||
return;
|
||||
}
|
||||
if (!isTechnicianPortal()) return;
|
||||
|
||||
// Log immediately on page load
|
||||
logLocation();
|
||||
// Check clock status immediately, then every 60s
|
||||
checkClockStatus();
|
||||
clockCheckTimer = setInterval(checkClockStatus, CLOCK_CHECK_MS);
|
||||
|
||||
// Set interval for periodic logging
|
||||
locationTimer = setInterval(logLocation, INTERVAL_MS);
|
||||
|
||||
// Pause/resume on tab visibility change
|
||||
// Pause/resume on tab visibility
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.hidden) {
|
||||
// Tab hidden - clear interval to save battery
|
||||
if (locationTimer) {
|
||||
clearInterval(locationTimer);
|
||||
locationTimer = null;
|
||||
}
|
||||
} else {
|
||||
// Tab visible again - log immediately and restart interval
|
||||
logLocation();
|
||||
if (!locationTimer) {
|
||||
locationTimer = setInterval(logLocation, INTERVAL_MS);
|
||||
}
|
||||
stopLocationTimer();
|
||||
} else if (isClockedIn) {
|
||||
startLocationTimer();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Start when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', startLocationLogging);
|
||||
} else {
|
||||
|
||||
@@ -51,19 +51,25 @@ class PDFTemplateFiller:
|
||||
for page_idx in range(num_pages):
|
||||
page = original.getPage(page_idx)
|
||||
page_num = page_idx + 1 # 1-based page number
|
||||
page_w = float(page.mediaBox.getWidth())
|
||||
page_h = float(page.mediaBox.getHeight())
|
||||
mb = page.mediaBox
|
||||
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, [])
|
||||
|
||||
if fields:
|
||||
# Create a transparent overlay for this page
|
||||
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:
|
||||
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()
|
||||
@@ -80,7 +86,8 @@ class PDFTemplateFiller:
|
||||
return result.getvalue()
|
||||
|
||||
@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.
|
||||
|
||||
Args:
|
||||
@@ -90,6 +97,8 @@ class PDFTemplateFiller:
|
||||
signatures: dict of {field_key: binary} for signature fields
|
||||
page_w: page width 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_type = field.get('field_type', 'text')
|
||||
@@ -98,11 +107,12 @@ class PDFTemplateFiller:
|
||||
if not value and field_type != 'signature':
|
||||
return
|
||||
|
||||
# Convert percentage positions to absolute PDF coordinates
|
||||
# pos_x/pos_y are 0.0-1.0 ratios from top-left
|
||||
# PDF coordinate system: origin at bottom-left, Y goes up
|
||||
abs_x = field['pos_x'] * page_w
|
||||
abs_y = page_h - (field['pos_y'] * page_h) # flip Y axis
|
||||
# Convert percentage positions to absolute PDF coordinates.
|
||||
# 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.
|
||||
# origin_x/origin_y account for PDFs whose mediaBox doesn't start at (0,0).
|
||||
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_size = field.get('font_size', 10.0)
|
||||
@@ -124,10 +134,22 @@ class PDFTemplateFiller:
|
||||
|
||||
elif field_type == 'checkbox':
|
||||
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_y = abs_y - cb_h + (cb_h - font_size) / 2
|
||||
c.drawString(abs_x, cb_y, '4')
|
||||
# Inset slightly so the cross doesn't touch the box edges
|
||||
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':
|
||||
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>
|
||||
@@ -1,322 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<template id="portal_ltc_repair_form"
|
||||
name="LTC Repair Form">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="oe_structure">
|
||||
<section class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="text-center mb-4">
|
||||
<h1>LTC Repairs Request</h1>
|
||||
<p class="lead text-muted">
|
||||
Submit a repair request for medical equipment at your facility.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<t t-if="request.params.get('error') == 'facility'">
|
||||
<div class="alert alert-danger">Please select a facility.</div>
|
||||
</t>
|
||||
<t t-if="request.params.get('error') == 'name'">
|
||||
<div class="alert alert-danger">Patient name is required.</div>
|
||||
</t>
|
||||
<t t-if="request.params.get('error') == 'description'">
|
||||
<div class="alert alert-danger">Issue description is required.</div>
|
||||
</t>
|
||||
<t t-if="request.params.get('error') == 'photos'">
|
||||
<div class="alert alert-danger">At least one before photo is required.</div>
|
||||
</t>
|
||||
<t t-if="request.params.get('error') == 'server'">
|
||||
<div class="alert alert-danger">
|
||||
An error occurred. Please try again or contact us.
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<form action="/repair-form/submit" method="POST"
|
||||
enctype="multipart/form-data"
|
||||
class="card shadow-sm">
|
||||
<input type="hidden" name="csrf_token"
|
||||
t-att-value="request.csrf_token()"/>
|
||||
<div class="card-body p-4">
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input"
|
||||
id="is_emergency" name="is_emergency"/>
|
||||
<label class="form-check-label fw-bold text-danger"
|
||||
for="is_emergency">
|
||||
Is this an Emergency Repair Request?
|
||||
</label>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
Emergency visits may be chargeable at an extra rate.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="facility_id" class="form-label fw-bold">
|
||||
Facility Location *
|
||||
</label>
|
||||
<select name="facility_id" id="facility_id"
|
||||
class="form-select" required="required">
|
||||
<option value="">-- Select Facility --</option>
|
||||
<t t-foreach="facilities" t-as="fac">
|
||||
<option t-att-value="fac.id">
|
||||
<t t-esc="fac.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="client_name" class="form-label fw-bold">
|
||||
Patient Name *
|
||||
</label>
|
||||
<input type="text" name="client_name" id="client_name"
|
||||
class="form-control" required="required"
|
||||
placeholder="Enter patient name"/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="room_number" class="form-label fw-bold">
|
||||
Room Number *
|
||||
</label>
|
||||
<input type="text" name="room_number" id="room_number"
|
||||
class="form-control" required="required"
|
||||
placeholder="e.g. 305"/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="issue_description" class="form-label fw-bold">
|
||||
Describe the Issue *
|
||||
</label>
|
||||
<textarea name="issue_description" id="issue_description"
|
||||
class="form-control" rows="4"
|
||||
required="required"
|
||||
placeholder="Please provide as much detail as possible about the issue."/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="issue_reported_date" class="form-label fw-bold">
|
||||
Issue Reported Date *
|
||||
</label>
|
||||
<input type="date" name="issue_reported_date"
|
||||
id="issue_reported_date"
|
||||
class="form-control" required="required"
|
||||
t-att-value="today"/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="product_serial" class="form-label fw-bold">
|
||||
Product Serial # *
|
||||
</label>
|
||||
<input type="text" name="product_serial"
|
||||
id="product_serial"
|
||||
class="form-control" required="required"
|
||||
placeholder="Serial number is required for repairs"/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="before_photos" class="form-label fw-bold">
|
||||
Before Photos (Reported Condition) *
|
||||
</label>
|
||||
<input type="file" name="before_photos" id="before_photos"
|
||||
class="form-control" multiple="multiple"
|
||||
accept="image/*" required="required"/>
|
||||
<small class="text-muted">
|
||||
At least 1 photo required. Up to 4 photos (max 10MB each).
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<h5>Family / POA Contact</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="poa_name" class="form-label">
|
||||
Relative/POA Name
|
||||
</label>
|
||||
<input type="text" name="poa_name" id="poa_name"
|
||||
class="form-control"
|
||||
placeholder="Contact name"/>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="poa_phone" class="form-label">
|
||||
Relative/POA Phone
|
||||
</label>
|
||||
<input type="tel" name="poa_phone" id="poa_phone"
|
||||
class="form-control"
|
||||
placeholder="Phone number"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<t t-if="is_technician">
|
||||
<hr/>
|
||||
|
||||
<div class="bg-light p-3 rounded mb-3">
|
||||
<p class="fw-bold text-muted mb-2">
|
||||
FOR TECHNICIAN USE ONLY
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
Has the issue been resolved?
|
||||
</label>
|
||||
<div class="form-check form-check-inline">
|
||||
<input type="radio" name="resolved" value="yes"
|
||||
class="form-check-input" id="resolved_yes"/>
|
||||
<label class="form-check-label"
|
||||
for="resolved_yes">Yes</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input type="radio" name="resolved" value="no"
|
||||
class="form-check-input" id="resolved_no"
|
||||
checked="checked"/>
|
||||
<label class="form-check-label"
|
||||
for="resolved_no">No</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="resolution_section"
|
||||
style="display: none;">
|
||||
<div class="mb-3">
|
||||
<label for="resolution_description"
|
||||
class="form-label">
|
||||
Describe the Solution
|
||||
</label>
|
||||
<textarea name="resolution_description"
|
||||
id="resolution_description"
|
||||
class="form-control" rows="3"
|
||||
placeholder="How was the issue resolved?"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="after_photos" class="form-label fw-bold">
|
||||
After Photos (Completed Repair)
|
||||
</label>
|
||||
<input type="file" name="after_photos" id="after_photos"
|
||||
class="form-control" multiple="multiple"
|
||||
accept="image/*"/>
|
||||
<small class="text-muted">
|
||||
Attach after repair is completed. Up to 4 photos (max 10MB each).
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<button type="submit" class="btn btn-primary btn-lg px-5">
|
||||
Submit Repair Request
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var section = document.getElementById('resolution_section');
|
||||
if (!section) return;
|
||||
var radios = document.querySelectorAll('input[name="resolved"]');
|
||||
radios.forEach(function(r) {
|
||||
r.addEventListener('change', function() {
|
||||
section.style.display = this.value === 'yes' ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="portal_ltc_repair_thank_you"
|
||||
name="Repair Request Submitted">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="oe_structure">
|
||||
<section class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6 text-center">
|
||||
<div class="mb-4">
|
||||
<i class="fa fa-check-circle text-success"
|
||||
style="font-size: 4rem;"/>
|
||||
</div>
|
||||
<h2>Thank You!</h2>
|
||||
<p class="lead text-muted">
|
||||
Your repair request has been submitted successfully.
|
||||
</p>
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<p><strong>Reference:</strong>
|
||||
<t t-esc="repair.name"/></p>
|
||||
<p><strong>Facility:</strong>
|
||||
<t t-esc="repair.facility_id.name"/></p>
|
||||
<p><strong>Patient:</strong>
|
||||
<t t-esc="repair.display_client_name"/></p>
|
||||
<p><strong>Room:</strong>
|
||||
<t t-esc="repair.room_number"/></p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/repair-form" class="btn btn-outline-primary mt-4">
|
||||
Submit Another Request
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="portal_ltc_repair_password"
|
||||
name="LTC Repair Form - Password">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="oe_structure">
|
||||
<section class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-5">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4 text-center">
|
||||
<div class="mb-3">
|
||||
<i class="fa fa-lock text-primary"
|
||||
style="font-size: 3rem;"/>
|
||||
</div>
|
||||
<h3>LTC Repairs Request</h3>
|
||||
<p class="text-muted">
|
||||
Please enter the access password to continue.
|
||||
</p>
|
||||
|
||||
<t t-if="error">
|
||||
<div class="alert alert-danger">
|
||||
Incorrect password. Please try again.
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<form action="/repair-form/auth" method="POST"
|
||||
class="mt-3">
|
||||
<input type="hidden" name="csrf_token"
|
||||
t-att-value="request.csrf_token()"/>
|
||||
<div class="mb-3">
|
||||
<input type="password" name="password"
|
||||
class="form-control form-control-lg text-center"
|
||||
placeholder="Enter password"
|
||||
minlength="4" required="required"
|
||||
autofocus="autofocus"/>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="btn btn-primary btn-lg w-100">
|
||||
Access Form
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
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>
|
||||
</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 -->
|
||||
<div class="tech-stats-bar mb-4">
|
||||
<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-label">Done</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>
|
||||
|
||||
<!-- Current / Next Task Hero Card -->
|
||||
@@ -55,21 +86,24 @@
|
||||
</div>
|
||||
<span class="text-muted"><t t-out="current_task.time_start_display"/> - <t t-out="current_task.time_end_display"/></span>
|
||||
</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>
|
||||
<t t-if="current_task.description">
|
||||
<p class="mb-2 small"><t t-out="current_task.description"/></p>
|
||||
</t>
|
||||
<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()"
|
||||
class="tech-action-btn tech-btn-navigate" target="_blank">
|
||||
<a t-if="current_task.get_google_maps_url()"
|
||||
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
|
||||
</a>
|
||||
<a t-attf-href="/my/technician/task/#{current_task.id}"
|
||||
class="tech-action-btn tech-btn-complete">
|
||||
<i class="fa fa-check"/>Complete
|
||||
</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">
|
||||
<i class="fa fa-phone"/>Call
|
||||
</a>
|
||||
@@ -94,7 +128,7 @@
|
||||
<span class="ms-2 fw-bold"><t t-out="next_task.name"/></span>
|
||||
</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>
|
||||
<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
|
||||
@@ -102,8 +136,11 @@
|
||||
</p>
|
||||
</t>
|
||||
<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()"
|
||||
class="tech-action-btn tech-btn-navigate" target="_blank">
|
||||
<a t-if="next_task.get_google_maps_url()"
|
||||
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
|
||||
</a>
|
||||
<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">
|
||||
<t t-out="dict(task._fields['task_type'].selection).get(task.task_type, '')"/>
|
||||
</span>
|
||||
<t t-out="task.partner_id.name or task.name"/>
|
||||
<t t-out="task.client_display_name or task.name"/>
|
||||
</div>
|
||||
<div class="tech-timeline-meta">
|
||||
<i class="fa fa-map-marker me-1"/><t t-out="task.address_city or 'No address'"/>
|
||||
@@ -170,25 +207,22 @@
|
||||
</t>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div class="row g-2 mb-4">
|
||||
<div class="col-4">
|
||||
<a href="/my/technician/tasks" class="btn btn-outline-primary w-100 py-3">
|
||||
<i class="fa fa-list me-1"/>All Tasks
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<a href="/my/technician/tomorrow" class="btn btn-outline-secondary w-100 py-3">
|
||||
<i class="fa fa-calendar me-1"/>Tomorrow
|
||||
<t t-if="tomorrow_count">
|
||||
<span class="badge bg-primary ms-1"><t t-out="tomorrow_count"/></span>
|
||||
</t>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<a href="/repair-form" class="btn btn-outline-warning w-100 py-3">
|
||||
<i class="fa fa-wrench me-1"/>Repair Form
|
||||
</a>
|
||||
</div>
|
||||
<div class="tech-quick-links mb-4">
|
||||
<a href="/my/technician/tasks" class="tech-quick-link tech-quick-link-primary">
|
||||
<i class="fa fa-list"/>
|
||||
<span>All Tasks</span>
|
||||
</a>
|
||||
<a href="/my/technician/tomorrow" class="tech-quick-link tech-quick-link-secondary">
|
||||
<i class="fa fa-calendar"/>
|
||||
<span>Tomorrow</span>
|
||||
<t t-if="tomorrow_count">
|
||||
<span class="tech-quick-link-badge"><t t-out="tomorrow_count"/></span>
|
||||
</t>
|
||||
</a>
|
||||
<a href="/repair-form" class="tech-quick-link tech-quick-link-warning">
|
||||
<i class="fa fa-wrench"/>
|
||||
<span>Repair Form</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- My Start Location -->
|
||||
@@ -221,30 +255,146 @@
|
||||
</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 -->
|
||||
<script type="text/javascript">
|
||||
function techTaskAction(btn, action) {
|
||||
var taskId = btn.dataset.taskId;
|
||||
var origHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> ...';
|
||||
fetch('/my/technician/task/' + taskId + '/action', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {action: action}})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.result && data.result.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(data.result ? data.result.error : 'Error');
|
||||
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> ...';
|
||||
fetch('/my/technician/task/' + taskId + '/action', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
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(data) {
|
||||
if (data.result && data.result.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(data.result ? data.result.error : 'Error');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
alert('Network error. Please try again.');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fa fa-road"></i> En Route';
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
btn.innerHTML = origHtml;
|
||||
});
|
||||
}).catch(function() {
|
||||
alert('Location access is required. Please enable GPS and try again.');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fa fa-road"></i> En Route';
|
||||
btn.innerHTML = origHtml;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -375,7 +525,7 @@
|
||||
<span class="ms-1"><t t-out="task.time_start_display"/></span>
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
<div class="text-muted small mt-1">
|
||||
@@ -462,19 +612,22 @@
|
||||
<!-- ===== QUICK ACTIONS ROW ===== -->
|
||||
<div class="tech-quick-actions mb-3">
|
||||
<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"/>
|
||||
<span>Navigate</span>
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="task.partner_phone">
|
||||
<a t-attf-href="tel:#{task.partner_phone}" class="tech-quick-btn">
|
||||
<t t-if="task.client_display_phone">
|
||||
<a t-attf-href="tel:#{task.client_display_phone}" class="tech-quick-btn">
|
||||
<i class="fa fa-phone"/>
|
||||
<span>Call</span>
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="task.partner_phone">
|
||||
<a t-attf-href="sms:#{task.partner_phone}" class="tech-quick-btn">
|
||||
<t t-if="task.client_display_phone">
|
||||
<a t-attf-href="sms:#{task.client_display_phone}" class="tech-quick-btn">
|
||||
<i class="fa fa-comment"/>
|
||||
<span>Text</span>
|
||||
</a>
|
||||
@@ -512,10 +665,10 @@
|
||||
<i class="fa fa-user"/>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-semibold"><t t-out="task.partner_id.name or 'No client'"/></div>
|
||||
<t t-if="task.partner_phone">
|
||||
<a t-attf-href="tel:#{task.partner_phone}" class="text-muted small text-decoration-none">
|
||||
<i class="fa fa-phone me-1"/><t t-out="task.partner_phone"/>
|
||||
<div class="fw-semibold"><t t-out="task.client_display_name or 'No client'"/></div>
|
||||
<t t-if="task.client_display_phone">
|
||||
<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.client_display_phone"/>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
@@ -565,8 +718,8 @@
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- ===== POD (if required and linked to a sale order) ===== -->
|
||||
<t t-if="task.pod_required and task.sale_order_id">
|
||||
<!-- ===== POD (if required) ===== -->
|
||||
<t t-if="task.pod_required">
|
||||
<div class="tech-card mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="tech-card-icon bg-warning-subtle text-warning">
|
||||
@@ -574,14 +727,16 @@
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<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>
|
||||
<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
|
||||
</a>
|
||||
</t>
|
||||
<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
|
||||
</a>
|
||||
</t>
|
||||
@@ -693,10 +848,18 @@
|
||||
t-att-data-task-id="task.id">
|
||||
<i class="fa fa-play"/>Start
|
||||
</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-if="task.status == 'en_route'">
|
||||
<a t-if="task.get_google_maps_url()" t-att-href="task.get_google_maps_url()"
|
||||
class="tech-action-btn tech-btn-navigate" target="_blank">
|
||||
<a t-if="task.get_google_maps_url()"
|
||||
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
|
||||
</a>
|
||||
<button class="tech-action-btn tech-btn-start"
|
||||
@@ -704,6 +867,11 @@
|
||||
t-att-data-task-id="task.id">
|
||||
<i class="fa fa-play"/>Start
|
||||
</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-if="task.status == 'in_progress'">
|
||||
<button class="tech-action-btn tech-btn-complete"
|
||||
@@ -750,46 +918,78 @@
|
||||
var recordingSeconds = 0;
|
||||
|
||||
function techTaskAction(btn, action) {
|
||||
var origHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i>';
|
||||
fetch('/my/technician/task/' + taskId + '/action', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {action: action}})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.result && data.result.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(data.result ? data.result.error : 'Error');
|
||||
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>';
|
||||
fetch('/my/technician/task/' + taskId + '/action', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
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(data) {
|
||||
if (data.result && data.result.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(data.result ? data.result.error : 'Error');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
alert('Network error. Please try again.');
|
||||
btn.disabled = false;
|
||||
}
|
||||
btn.innerHTML = origHtml;
|
||||
});
|
||||
}).catch(function() {
|
||||
alert('Location access is required. Please enable GPS and try again.');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
});
|
||||
}
|
||||
|
||||
function techCompleteTask(btn) {
|
||||
var origHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Completing...';
|
||||
fetch('/my/technician/task/' + taskId + '/action', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {action: 'complete'}})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.result && data.result.success) {
|
||||
showCompletionOverlay(data.result);
|
||||
} else {
|
||||
alert(data.result ? data.result.error : 'Error completing task');
|
||||
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...';
|
||||
fetch('/my/technician/task/' + taskId + '/action', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
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(data) {
|
||||
if (data.result && data.result.success) {
|
||||
showCompletionOverlay(data.result);
|
||||
} else {
|
||||
alert(data.result ? data.result.error : 'Error completing task');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
alert('Network error. Please try again.');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fa fa-check-circle"></i> Complete Task';
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
alert('Network error. Please try again.');
|
||||
btn.innerHTML = origHtml;
|
||||
});
|
||||
}).catch(function() {
|
||||
alert('Location access is required. Please enable GPS and try again.');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fa fa-check-circle"></i> Complete Task';
|
||||
btn.innerHTML = origHtml;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1184,10 +1384,16 @@
|
||||
btns.forEach(function(b){b.disabled = true;});
|
||||
|
||||
try {
|
||||
var coords = await window.fusionGetLocation();
|
||||
var resp = await fetch('/my/technician/task/' + taskId + '/voice-complete', {
|
||||
method: 'POST',
|
||||
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();
|
||||
if (data.result && data.result.success) {
|
||||
@@ -1197,7 +1403,11 @@
|
||||
btns.forEach(function(b){b.disabled = false;});
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Error: ' + err.message);
|
||||
if (err instanceof GeolocationPositionError || err.code) {
|
||||
alert('Location access is required. Please enable GPS and try again.');
|
||||
} else {
|
||||
alert('Error: ' + err.message);
|
||||
}
|
||||
btns.forEach(function(b){b.disabled = false;});
|
||||
}
|
||||
}
|
||||
@@ -1271,15 +1481,15 @@
|
||||
</t>
|
||||
</div>
|
||||
<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 class="text-muted small">
|
||||
<i class="fa fa-map-marker me-1"/><t t-out="task.address_display or 'No address'"/>
|
||||
</div>
|
||||
<t t-if="task.partner_phone">
|
||||
<t t-if="task.client_display_phone">
|
||||
<div class="small mt-1">
|
||||
<a t-attf-href="tel:#{task.partner_phone}" class="text-decoration-none">
|
||||
<i class="fa fa-phone me-1"/><t t-out="task.partner_phone"/>
|
||||
<a t-attf-href="tel:#{task.client_display_phone}" class="text-decoration-none">
|
||||
<i class="fa fa-phone me-1"/><t t-out="task.client_display_phone"/>
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
@@ -1353,7 +1563,7 @@
|
||||
<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, '')"/>
|
||||
</span>
|
||||
<t t-out="task.partner_id.name or task.name"/>
|
||||
<t t-out="task.client_display_name or task.name"/>
|
||||
</div>
|
||||
<div class="tech-timeline-meta">
|
||||
<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="d-flex justify-content-between align-items-center mb-3">
|
||||
<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
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -188,7 +188,90 @@
|
||||
</a>
|
||||
</div>
|
||||
</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) -->
|
||||
<t t-if="request.env.user.partner_id.is_client_portal or request.env.user.partner_id.is_authorizer">
|
||||
<div class="col-md-6">
|
||||
@@ -251,6 +334,28 @@
|
||||
</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>
|
||||
</xpath>
|
||||
</template>
|
||||
@@ -1086,7 +1191,42 @@
|
||||
<p class="text-muted">Welcome back, <t t-out="partner.name"/>!</p>
|
||||
</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 -->
|
||||
<div class="row mb-3 g-2">
|
||||
<div class="col-6 col-md-3">
|
||||
@@ -1377,6 +1517,113 @@
|
||||
<!-- Include loaner modals -->
|
||||
<t t-call="fusion_authorizer_portal.loaner_checkout_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>
|
||||
</template>
|
||||
|
||||
@@ -3699,4 +3946,232 @@
|
||||
</t>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user