changes
This commit is contained in:
@@ -53,6 +53,7 @@ This module provides external portal access for:
|
||||
'appointment',
|
||||
'knowledge',
|
||||
'fusion_claims',
|
||||
'fusion_tasks',
|
||||
],
|
||||
'data': [
|
||||
# Security
|
||||
@@ -80,6 +81,7 @@ This module provides external portal access for:
|
||||
'views/portal_book_assessment.xml',
|
||||
'views/portal_repair_form.xml',
|
||||
'views/portal_schedule.xml',
|
||||
'views/portal_page11_sign_templates.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
|
||||
@@ -4,4 +4,5 @@ from . import portal_main
|
||||
from . import portal_assessment
|
||||
from . import pdf_editor
|
||||
from . import portal_repair
|
||||
from . import portal_schedule
|
||||
from . import portal_schedule
|
||||
from . import portal_page11_sign
|
||||
@@ -1501,6 +1501,13 @@ class AuthorizerPortal(CustomerPortal):
|
||||
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,
|
||||
@@ -1870,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."""
|
||||
|
||||
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))),
|
||||
],
|
||||
)
|
||||
@@ -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')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Technician Location Services
|
||||
*
|
||||
* 1. Background logger -- logs GPS every 5 minutes during working hours.
|
||||
* 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.
|
||||
@@ -11,9 +11,10 @@
|
||||
'use strict';
|
||||
|
||||
var INTERVAL_MS = 5 * 60 * 1000;
|
||||
var STORE_OPEN_HOUR = 9;
|
||||
var STORE_CLOSE_HOUR = 18;
|
||||
var CLOCK_CHECK_MS = 60 * 1000; // check clock status every 60s
|
||||
var locationTimer = null;
|
||||
var clockCheckTimer = null;
|
||||
var isClockedIn = false;
|
||||
var permissionDenied = false;
|
||||
|
||||
// =====================================================================
|
||||
@@ -137,21 +138,38 @@
|
||||
window.openGoogleMapsNav = openGoogleMapsNav;
|
||||
|
||||
// =====================================================================
|
||||
// BACKGROUND LOGGER
|
||||
// BACKGROUND LOGGER (tied to clock-in / clock-out status)
|
||||
// =====================================================================
|
||||
|
||||
function isWorkingHours() {
|
||||
var now = new Date();
|
||||
var hour = now.getHours();
|
||||
return hour >= STORE_OPEN_HOUR && hour < STORE_CLOSE_HOUR;
|
||||
}
|
||||
|
||||
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 (!isWorkingHours() || document.hidden || !navigator.geolocation) return;
|
||||
if (!isClockedIn || document.hidden || !navigator.geolocation) return;
|
||||
|
||||
getLocation().then(function (coords) {
|
||||
fetch('/my/technician/location/log', {
|
||||
@@ -181,16 +199,32 @@
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
logLocation();
|
||||
locationTimer = setInterval(logLocation, INTERVAL_MS);
|
||||
|
||||
// Check clock status immediately, then every 60s
|
||||
checkClockStatus();
|
||||
clockCheckTimer = setInterval(checkClockStatus, CLOCK_CHECK_MS);
|
||||
|
||||
// Pause/resume on tab visibility
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.hidden) {
|
||||
if (locationTimer) { clearInterval(locationTimer); locationTimer = null; }
|
||||
} else {
|
||||
logLocation();
|
||||
if (!locationTimer) { locationTimer = setInterval(logLocation, INTERVAL_MS); }
|
||||
stopLocationTimer();
|
||||
} else if (isClockedIn) {
|
||||
startLocationTimer();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -1594,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>
|
||||
|
||||
Reference in New Issue
Block a user