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:
2026-03-11 16:19:52 +00:00
parent 1f79cdcaaf
commit 431052920e
274 changed files with 52782 additions and 7302 deletions

View File

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

View File

@@ -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>

View File

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

View File

@@ -18,6 +18,41 @@
</ol>
</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 &lt; 10 ? '0' + n : '' + n; };
document.getElementById('clockTimer').textContent = pad(h) + ':' + pad(m) + ':' + pad(s);
}
function startTimer() {
stopTimer();
updateTimer();
timerInterval = setInterval(updateTimer, 1000);
}
function stopTimer() {
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
}
function applyState() {
var dot = card.querySelector('.tech-clock-dot');
var statusEl = document.getElementById('clockStatusText');
var btn = document.getElementById('clockActionBtn');
var timerEl = document.getElementById('clockTimer');
if (dot) dot.className = 'tech-clock-dot' + (isCheckedIn ? ' tech-clock-dot--active' : '');
if (statusEl) statusEl.textContent = isCheckedIn ? 'Clocked In' : 'Not Clocked In';
if (btn) {
btn.className = 'tech-clock-btn ' + (isCheckedIn ? 'tech-clock-btn--out' : 'tech-clock-btn--in');
btn.innerHTML = isCheckedIn
? '&lt;i class="fa fa-stop-circle-o">&lt;/i> Clock Out'
: '&lt;i class="fa fa-play-circle-o">&lt;/i> Clock In';
}
if (!isCheckedIn &amp;&amp; timerEl) timerEl.textContent = '00:00:00';
}
if (isCheckedIn &amp;&amp; checkInTime) startTimer();
window.handleClockAction = function() {
var btn = document.getElementById('clockActionBtn');
var errEl = document.getElementById('clockError');
var errText = document.getElementById('clockErrorText');
btn.disabled = true;
errEl.style.display = 'none';
window.fusionGetLocation().then(function(coords) {
fetch('/fusion_clock/clock_action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy,
source: 'portal'
}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
var result = data.result || {};
if (result.error) {
errText.textContent = result.error;
errEl.style.display = 'flex';
btn.disabled = false;
return;
}
if (result.action === 'clock_in') {
isCheckedIn = true;
checkInTime = new Date(result.check_in + 'Z');
startTimer();
} else {
isCheckedIn = false;
checkInTime = null;
stopTimer();
}
applyState();
btn.disabled = false;
})
.catch(function() {
errText.textContent = 'Network error. Please try again.';
errEl.style.display = 'flex';
btn.disabled = false;
});
}).catch(function() {
errText.textContent = 'Location access is required for clock in/out.';
errEl.style.display = 'flex';
btn.disabled = false;
});
};
})();
</script>
<!-- Inline JS for task actions -->
<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 &amp;&amp; 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 &amp;&amp; data.result.success) {
window.location.reload();
} else {
alert(data.result ? data.result.error : 'Error');
btn.disabled = false;
btn.innerHTML = origHtml;
}
})
.catch(function() {
alert('Network error. Please try again.');
btn.disabled = false;
btn.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 = '&lt;i class="fa fa-spinner fa-spin">&lt;/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 &amp;&amp; data.result.success) {
window.location.reload();
} else {
alert(data.result ? data.result.error : 'Error');
btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i> Getting location...';
window.fusionGetLocation().then(function(coords) {
btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/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 &amp;&amp; data.result.success) {
window.location.reload();
} else {
alert(data.result ? data.result.error : 'Error');
btn.disabled = false;
btn.innerHTML = origHtml;
}
})
.catch(function() {
alert('Network error. Please try again.');
btn.disabled = false;
}
btn.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 = '&lt;i class="fa fa-spinner fa-spin">&lt;/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 &amp;&amp; data.result.success) {
showCompletionOverlay(data.result);
} else {
alert(data.result ? data.result.error : 'Error completing task');
btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i> Getting location...';
window.fusionGetLocation().then(function(coords) {
btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/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 &amp;&amp; data.result.success) {
showCompletionOverlay(data.result);
} else {
alert(data.result ? data.result.error : 'Error completing task');
btn.disabled = false;
btn.innerHTML = origHtml;
}
})
.catch(function(err) {
alert('Network error. Please try again.');
btn.disabled = false;
btn.innerHTML = '&lt;i class="fa fa-check-circle">&lt;/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 = '&lt;i class="fa fa-check-circle">&lt;/i> Complete Task';
btn.innerHTML = origHtml;
});
}
@@ -1184,10 +1384,16 @@
btns.forEach(function(b){b.disabled = true;});
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 &amp;&amp; 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>

View File

@@ -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 &lt; 10 ? '0' + n : '' + n; }
var badge = document.querySelector('#homeClockTimer .hc-timer-badge');
if (!badge) return;
function tick() {
var diff = Math.max(0, Math.floor((new Date() - checkInTime) / 1000));
badge.textContent = pad(Math.floor(diff / 3600)) + ':' + pad(Math.floor((diff % 3600) / 60)) + ':' + pad(diff % 60);
}
tick();
setInterval(tick, 1000);
})();
</script>
</t>
</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 &lt; 10 ? '0' + n : '' + n; };
document.getElementById('clockTimer').textContent = pad(h) + ':' + pad(m) + ':' + pad(s);
}
function startTimer() { stopTimer(); updateTimer(); timerInterval = setInterval(updateTimer, 1000); }
function stopTimer() { if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } }
function applyState() {
var dot = card.querySelector('.tech-clock-dot');
var statusEl = document.getElementById('clockStatusText');
var btn = document.getElementById('clockActionBtn');
var timerEl = document.getElementById('clockTimer');
if (dot) dot.className = 'tech-clock-dot' + (isCheckedIn ? ' tech-clock-dot--active' : '');
if (statusEl) statusEl.textContent = isCheckedIn ? 'Clocked In' : 'Not Clocked In';
if (btn) {
btn.className = 'tech-clock-btn ' + (isCheckedIn ? 'tech-clock-btn--out' : 'tech-clock-btn--in');
btn.innerHTML = isCheckedIn
? '&lt;i class="fa fa-stop-circle-o">&lt;/i> Clock Out'
: '&lt;i class="fa fa-play-circle-o">&lt;/i> Clock In';
}
if (!isCheckedIn &amp;&amp; timerEl) timerEl.textContent = '00:00:00';
}
if (isCheckedIn &amp;&amp; checkInTime) startTimer();
window.handleClockAction = function() {
var btn = document.getElementById('clockActionBtn');
var errEl = document.getElementById('clockError');
var errText = document.getElementById('clockErrorText');
btn.disabled = true;
errEl.style.display = 'none';
function doClockAction(lat, lng, acc) {
fetch('/fusion_clock/clock_action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
latitude: lat, longitude: lng, accuracy: acc, source: 'portal'
}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
var result = data.result || {};
if (result.error) {
errText.textContent = result.error;
errEl.style.display = 'flex';
btn.disabled = false;
return;
}
if (result.action === 'clock_in') {
isCheckedIn = true;
checkInTime = new Date(result.check_in + 'Z');
startTimer();
} else {
isCheckedIn = false;
checkInTime = null;
stopTimer();
}
applyState();
btn.disabled = false;
})
.catch(function() {
errText.textContent = 'Network error. Please try again.';
errEl.style.display = 'flex';
btn.disabled = false;
});
}
if (window.fusionGetLocation) {
window.fusionGetLocation().then(function(coords) {
doClockAction(coords.latitude, coords.longitude, coords.accuracy);
}).catch(function() {
errText.textContent = 'Location access is required for clock in/out.';
errEl.style.display = 'flex';
btn.disabled = false;
});
} else if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(function(pos) {
doClockAction(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy);
}, function() {
errText.textContent = 'Location access is required for clock in/out.';
errEl.style.display = 'flex';
btn.disabled = false;
}, {enableHighAccuracy: true, timeout: 15000});
} else {
doClockAction(0, 0, 0);
}
};
})();
</script>
</t>
</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'
? '&lt;i class="fa fa-check-circle text-success">&lt;/i>'
: '&lt;i class="fa fa-exclamation-circle text-danger">&lt;/i>';
document.getElementById('tpodTitle').textContent = title;
document.getElementById('tpodTitle').className = type === 'success' ? 'text-success' : 'text-danger';
document.getElementById('tpodMsg').textContent = msg;
var acts = document.getElementById('tpodActions');
if (type === 'success' &amp;&amp; url) {
acts.innerHTML = '&lt;a href="' + url + '" class="btn btn-success w-100 rounded-pill mb-2">Continue&lt;/a>' +
'&lt;p class="text-muted small mb-0">Redirecting in &lt;span id="tpodCD">3&lt;/span>s...&lt;/p>';
ov.classList.add('show');
var s = 3, t = setInterval(function() { s--; var c = document.getElementById('tpodCD');
if (c) c.textContent = s; if (s &lt;= 0) { clearInterval(t); window.location.href = url; } }, 1000);
} else {
acts.innerHTML = '&lt;button class="btn btn-outline-secondary w-100 rounded-pill" onclick="document.getElementById(\'taskPodOverlay\').classList.remove(\'show\')">OK&lt;/button>';
ov.classList.add('show');
}
}
document.addEventListener('DOMContentLoaded', function() {
tCanvas = document.getElementById('task-signature-canvas');
if (!tCanvas) return;
tCtx = tCanvas.getContext('2d');
var r = tCanvas.getBoundingClientRect();
tCanvas.width = r.width * 2; tCanvas.height = r.height * 2;
tCtx.scale(2, 2); tCtx.lineCap = 'round'; tCtx.lineJoin = 'round';
tCtx.lineWidth = 2; tCtx.strokeStyle = '#000';
tCanvas.addEventListener('mousedown', tStart);
tCanvas.addEventListener('mousemove', tDraw);
tCanvas.addEventListener('mouseup', tStop);
tCanvas.addEventListener('mouseout', tStop);
tCanvas.addEventListener('touchstart', function(e) { e.preventDefault(); tStart(e); }, {passive:false});
tCanvas.addEventListener('touchmove', function(e) { e.preventDefault(); tDraw(e); }, {passive:false});
tCanvas.addEventListener('touchend', tStop);
var sec = document.getElementById('task-pod-signature-section');
if (sec) setTimeout(function() { sec.scrollIntoView({behavior:'smooth', block:'start'}); }, 300);
});
function tPos(e) {
var r = tCanvas.getBoundingClientRect();
if (e.touches) return { x: e.touches[0].clientX - r.left, y: e.touches[0].clientY - r.top };
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function tStart(e) { tIsDrawing = true; var p = tPos(e); tCtx.beginPath(); tCtx.moveTo(p.x, p.y); }
function tDraw(e) { if (!tIsDrawing) return; var p = tPos(e); tCtx.lineTo(p.x, p.y); tCtx.stroke(); }
function tStop() { tIsDrawing = false; }
function clearTaskSignature() { if (tCtx) tCtx.clearRect(0, 0, tCanvas.width, tCanvas.height); }
function submitTaskPODSignature() {
var name = document.getElementById('task_client_name').value.trim();
var sigDate = document.getElementById('task_signature_date').value;
if (!name) { showTaskPodOverlay('error', 'Missing Information', 'Please enter the client name.'); return; }
var blank = document.createElement('canvas');
blank.width = tCanvas.width; blank.height = tCanvas.height;
if (tCanvas.toDataURL() === blank.toDataURL()) {
showTaskPodOverlay('error', 'Missing Signature', 'Please draw a signature before submitting.'); return;
}
var sigData = tCanvas.toDataURL('image/png');
var btn = document.querySelector('button[onclick="submitTaskPODSignature()"]');
var orig = btn.innerHTML; btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin me-2">&lt;/i>Saving...'; btn.disabled = true;
fetch('<t t-out="'/my/technician/task/' + str(task.id) + '/pod/sign'"/>', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ jsonrpc:'2.0', method:'call',
params: { client_name: name, signature_data: sigData, signature_date: sigDate || null },
id: Math.floor(Math.random()*1000000) })
}).then(function(r) { return r.json(); }).then(function(d) {
if (d.result &amp;&amp; d.result.success) {
showTaskPodOverlay('success', 'Signature Saved!', 'Proof of Delivery recorded.', d.result.redirect_url);
} else {
showTaskPodOverlay('error', 'Error', d.result?.error || 'Unknown error');
btn.innerHTML = orig; btn.disabled = false;
}
}).catch(function() {
showTaskPodOverlay('error', 'Connection Error', 'Please check your connection.');
btn.innerHTML = orig; btn.disabled = false;
});
}
</script>
</t>
</template>
</odoo>