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:
413
fusion_authorizer_portal/views/portal_page11_sign_templates.xml
Normal file
413
fusion_authorizer_portal/views/portal_page11_sign_templates.xml
Normal file
@@ -0,0 +1,413 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<!-- ============================================================ -->
|
||||
<!-- Page 11 Public Signing Form -->
|
||||
<!-- ============================================================ -->
|
||||
<template id="portal_page11_public_sign" name="Page 11 - Sign">
|
||||
<t t-call="portal.frontend_layout">
|
||||
<div class="container py-4" style="max-width:720px;">
|
||||
<div class="text-center mb-4">
|
||||
<t t-if="company.logo">
|
||||
<img t-att-src="'/web/image/res.company/%s/logo/200x60' % company.id"
|
||||
alt="Company Logo" style="max-height:60px;" class="mb-2"/>
|
||||
</t>
|
||||
<h3 class="mb-1">ADP Consent and Declaration</h3>
|
||||
<p class="text-muted">Page 11 - Assistive Devices Program</p>
|
||||
</div>
|
||||
|
||||
<t t-if="request.params.get('error') == 'no_signature'">
|
||||
<div class="alert alert-danger">Please draw your signature before submitting.</div>
|
||||
</t>
|
||||
<t t-if="request.params.get('error') == 'no_consent'">
|
||||
<div class="alert alert-danger">You must accept the consent declaration before signing.</div>
|
||||
</t>
|
||||
|
||||
<!-- Consent Declaration -->
|
||||
<form method="POST" t-att-action="'/page11/sign/%s/submit' % token" id="page11Form">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
|
||||
<!-- Applicant Information -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Applicant Information</strong></div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-2">
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label">Last Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" name="client_last_name"
|
||||
t-att-value="client_last_name or ''" required="required"/>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label">First Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" name="client_first_name"
|
||||
t-att-value="client_first_name or ''" required="required"/>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label">Middle Name</label>
|
||||
<input type="text" class="form-control" name="client_middle_name"
|
||||
t-att-value="client_middle_name or ''"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label">Health Card Number (10 digits) <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" name="client_health_card"
|
||||
t-att-value="client_health_card or ''" required="required"
|
||||
maxlength="10" pattern="[0-9]{10}" title="10-digit health card number"
|
||||
placeholder="e.g. 1234567890"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Version <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" name="client_health_card_version"
|
||||
t-att-value="client_health_card_version or ''" required="required"
|
||||
maxlength="2" placeholder="e.g. AB"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Case Ref</label>
|
||||
<input type="text" class="form-control" readonly="readonly"
|
||||
t-att-value="order.name"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Consent and Declaration</strong></div>
|
||||
<div class="card-body">
|
||||
<p class="small">
|
||||
I consent to information being collected and used by the Ministry of Health and Long-Term Care,
|
||||
and agents authorized by the Ministry, for the administration and enforcement of the
|
||||
Assistive Devices Program. I understand this consent is voluntary and I may withdraw it
|
||||
at any time. I declare that the information in this application is true and complete.
|
||||
</p>
|
||||
<div class="form-check mb-3">
|
||||
<input type="checkbox" class="form-check-input" id="consent_declaration"
|
||||
name="consent_declaration" required="required"/>
|
||||
<label class="form-check-label" for="consent_declaration">
|
||||
<strong>I have read and accept the above declaration.</strong>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="signer_type" class="form-label"><strong>I am signing as:</strong></label>
|
||||
<select class="form-select" id="signer_type" name="signer_type" required="required"
|
||||
onchange="toggleAgentFields()">
|
||||
<option value="client" t-att-selected="signer_type == 'client' and 'selected'">Applicant (Client - Self)</option>
|
||||
<option value="spouse" t-att-selected="signer_type == 'spouse' and 'selected'">Spouse</option>
|
||||
<option value="parent" t-att-selected="signer_type == 'parent' and 'selected'">Parent</option>
|
||||
<option value="legal_guardian" t-att-selected="signer_type == 'legal_guardian' and 'selected'">Legal Guardian</option>
|
||||
<option value="poa" t-att-selected="signer_type == 'poa' and 'selected'">Power of Attorney</option>
|
||||
<option value="public_trustee" t-att-selected="signer_type == 'public_trustee' and 'selected'">Public Trustee</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="signer_name" class="form-label">Full Name</label>
|
||||
<input type="text" class="form-control" id="signer_name" name="signer_name"
|
||||
t-att-value="sign_request.signer_name or ''" required="required"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agent Details (shown/hidden via JS based on signer type selection) -->
|
||||
<div class="card mb-3" id="agent_details_card" t-att-style="'' if is_agent else 'display:none;'">
|
||||
<div class="card-header"><strong>Agent Details</strong></div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-2">
|
||||
<div class="col-sm-5">
|
||||
<label class="form-label">Last Name</label>
|
||||
<input type="text" class="form-control agent-field" name="agent_last_name"/>
|
||||
</div>
|
||||
<div class="col-sm-5">
|
||||
<label class="form-label">First Name</label>
|
||||
<input type="text" class="form-control agent-field" name="agent_first_name"/>
|
||||
</div>
|
||||
<div class="col-sm-2">
|
||||
<label class="form-label">M.I.</label>
|
||||
<input type="text" class="form-control" name="agent_middle_initial"
|
||||
maxlength="2" placeholder="M"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label">Home Phone</label>
|
||||
<input type="tel" class="form-control" name="agent_phone"/>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label">Business Phone</label>
|
||||
<input type="tel" class="form-control" name="agent_business_phone"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Search Address</label>
|
||||
<input type="text" class="form-control" id="agent_street_search"
|
||||
placeholder="Start typing an address..." autocomplete="off"/>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Unit #</label>
|
||||
<input type="text" class="form-control" name="agent_unit" id="agent_unit"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Street #</label>
|
||||
<input type="text" class="form-control" name="agent_street_number" id="agent_street_number"/>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label">Street Name</label>
|
||||
<input type="text" class="form-control" name="agent_street" id="agent_street"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-sm-5">
|
||||
<label class="form-label">City/Town</label>
|
||||
<input type="text" class="form-control" name="agent_city" id="agent_city"/>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label">Province</label>
|
||||
<input type="text" class="form-control" name="agent_province" id="agent_province" value="Ontario"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Postal Code</label>
|
||||
<input type="text" class="form-control" name="agent_postal_code" id="agent_postal_code"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Signature Pad -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Signature</strong>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="clearSignature()">Clear</button>
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
<canvas id="signature-canvas" width="660" height="200"
|
||||
style="border:1px dashed rgba(128,128,128,0.35);border-radius:6px;width:100%;touch-action:none;cursor:crosshair;">
|
||||
</canvas>
|
||||
<input type="hidden" name="signature_data" id="signature_data"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mb-4">
|
||||
<button type="submit" class="btn btn-primary btn-lg px-5" onclick="return prepareSubmit()">
|
||||
Submit Signature
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-muted small">
|
||||
<t t-out="company.name"/> &middot;
|
||||
<t t-if="company.phone"><t t-out="company.phone"/> &middot; </t>
|
||||
<t t-if="company.email"><t t-out="company.email"/></t>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
function toggleAgentFields() {
|
||||
var sel = document.getElementById('signer_type');
|
||||
var card = document.getElementById('agent_details_card');
|
||||
var agentFields = card ? card.querySelectorAll('.agent-field') : [];
|
||||
var isAgent = sel.value !== 'client';
|
||||
if (card) card.style.display = isAgent ? '' : 'none';
|
||||
agentFields.forEach(function(f) {
|
||||
if (isAgent) { f.setAttribute('required', 'required'); }
|
||||
else { f.removeAttribute('required'); f.value = ''; }
|
||||
});
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', toggleAgentFields);
|
||||
</script>
|
||||
<script type="text/javascript">
|
||||
(function() {
|
||||
var canvas = document.getElementById('signature-canvas');
|
||||
if (!canvas) return;
|
||||
var ctx = canvas.getContext('2d');
|
||||
var drawing = false;
|
||||
var lastX = 0, lastY = 0;
|
||||
var hasDrawn = false;
|
||||
|
||||
function resizeCanvas() {
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
}
|
||||
|
||||
resizeCanvas();
|
||||
|
||||
function getPos(e) {
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
var touch = e.touches ? e.touches[0] : e;
|
||||
return {
|
||||
x: touch.clientX - rect.left,
|
||||
y: touch.clientY - rect.top
|
||||
};
|
||||
}
|
||||
|
||||
function startDraw(e) {
|
||||
e.preventDefault();
|
||||
drawing = true;
|
||||
var pos = getPos(e);
|
||||
lastX = pos.x;
|
||||
lastY = pos.y;
|
||||
}
|
||||
|
||||
function draw(e) {
|
||||
if (!drawing) return;
|
||||
e.preventDefault();
|
||||
var pos = getPos(e);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lastX, lastY);
|
||||
ctx.lineTo(pos.x, pos.y);
|
||||
ctx.stroke();
|
||||
lastX = pos.x;
|
||||
lastY = pos.y;
|
||||
hasDrawn = true;
|
||||
}
|
||||
|
||||
function stopDraw(e) {
|
||||
if (e) e.preventDefault();
|
||||
drawing = false;
|
||||
}
|
||||
|
||||
canvas.addEventListener('mousedown', startDraw);
|
||||
canvas.addEventListener('mousemove', draw);
|
||||
canvas.addEventListener('mouseup', stopDraw);
|
||||
canvas.addEventListener('mouseleave', stopDraw);
|
||||
canvas.addEventListener('touchstart', startDraw, {passive: false});
|
||||
canvas.addEventListener('touchmove', draw, {passive: false});
|
||||
canvas.addEventListener('touchend', stopDraw, {passive: false});
|
||||
|
||||
window.clearSignature = function() {
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
|
||||
hasDrawn = false;
|
||||
document.getElementById('signature_data').value = '';
|
||||
};
|
||||
|
||||
window.prepareSubmit = function() {
|
||||
if (!hasDrawn) {
|
||||
alert('Please draw your signature before submitting.');
|
||||
return false;
|
||||
}
|
||||
var dataUrl = canvas.toDataURL('image/png');
|
||||
document.getElementById('signature_data').value = dataUrl;
|
||||
return true;
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
<t t-if="google_maps_api_key">
|
||||
<script t-attf-src="https://maps.googleapis.com/maps/api/js?key=#{google_maps_api_key}&libraries=places&callback=initPage11AddressAutocomplete" async="async" defer="defer"></script>
|
||||
<script type="text/javascript">
|
||||
function initPage11AddressAutocomplete() {
|
||||
var searchInput = document.getElementById('agent_street_search');
|
||||
if (!searchInput) return;
|
||||
var autocomplete = new google.maps.places.Autocomplete(searchInput, {
|
||||
types: ['address'],
|
||||
componentRestrictions: { country: 'ca' }
|
||||
});
|
||||
autocomplete.setFields(['address_components', 'formatted_address']);
|
||||
autocomplete.addListener('place_changed', function() {
|
||||
var place = autocomplete.getPlace();
|
||||
if (!place || !place.address_components) return;
|
||||
var street_number = '', route = '', city = '', province = '', postal = '', unit = '';
|
||||
place.address_components.forEach(function(c) {
|
||||
var t = c.types;
|
||||
if (t.indexOf('street_number') >= 0) street_number = c.long_name;
|
||||
else if (t.indexOf('route') >= 0) route = c.long_name;
|
||||
else if (t.indexOf('locality') >= 0) city = c.long_name;
|
||||
else if (t.indexOf('sublocality') >= 0 && !city) city = c.long_name;
|
||||
else if (t.indexOf('administrative_area_level_1') >= 0) province = c.long_name;
|
||||
else if (t.indexOf('postal_code') >= 0) postal = c.long_name;
|
||||
else if (t.indexOf('subpremise') >= 0) unit = c.long_name;
|
||||
});
|
||||
document.getElementById('agent_street_number').value = street_number;
|
||||
document.getElementById('agent_street').value = route;
|
||||
document.getElementById('agent_city').value = city;
|
||||
document.getElementById('agent_province').value = province;
|
||||
document.getElementById('agent_postal_code').value = postal;
|
||||
if (unit) document.getElementById('agent_unit').value = unit;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Success Page -->
|
||||
<!-- ============================================================ -->
|
||||
<template id="portal_page11_sign_success" name="Page 11 - Signed Successfully">
|
||||
<t t-call="portal.frontend_layout">
|
||||
<div class="container py-5" style="max-width:600px;">
|
||||
<div class="text-center">
|
||||
<div class="mb-4">
|
||||
<i class="fa fa-check-circle text-success" style="font-size:64px;"/>
|
||||
</div>
|
||||
<h3>Signature Submitted Successfully</h3>
|
||||
<p class="text-muted mt-3">
|
||||
Thank you for signing the ADP Consent and Declaration form.
|
||||
Your signature has been recorded and the document has been updated.
|
||||
</p>
|
||||
<t t-if="sign_request and sign_request.sale_order_id">
|
||||
<p class="text-muted">
|
||||
Case Reference: <strong><t t-out="sign_request.sale_order_id.name"/></strong>
|
||||
</p>
|
||||
</t>
|
||||
<t t-if="sign_request and sign_request.signed_pdf and token">
|
||||
<a t-attf-href="/page11/sign/#{token}/download"
|
||||
class="btn btn-outline-primary mt-3">
|
||||
<i class="fa fa-download"/> Download Signed PDF
|
||||
</a>
|
||||
</t>
|
||||
<p class="text-muted mt-4 small">You may close this window.</p>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Expired / Cancelled Page -->
|
||||
<!-- ============================================================ -->
|
||||
<template id="portal_page11_sign_expired" name="Page 11 - Link Expired">
|
||||
<t t-call="portal.frontend_layout">
|
||||
<div class="container py-5" style="max-width:600px;">
|
||||
<div class="text-center">
|
||||
<div class="mb-4">
|
||||
<i class="fa fa-clock-o text-warning" style="font-size:64px;"/>
|
||||
</div>
|
||||
<h3>Signing Link Expired</h3>
|
||||
<p class="text-muted mt-3">
|
||||
This signing link is no longer valid. It may have expired or been cancelled.
|
||||
</p>
|
||||
<p class="text-muted">
|
||||
Please contact the office to request a new signing link.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Invalid / Not Found Page -->
|
||||
<!-- ============================================================ -->
|
||||
<template id="portal_page11_sign_invalid" name="Page 11 - Invalid Link">
|
||||
<t t-call="portal.frontend_layout">
|
||||
<div class="container py-5" style="max-width:600px;">
|
||||
<div class="text-center">
|
||||
<div class="mb-4">
|
||||
<i class="fa fa-exclamation-triangle text-danger" style="font-size:64px;"/>
|
||||
</div>
|
||||
<h3>Invalid Link</h3>
|
||||
<p class="text-muted mt-3">
|
||||
This signing link is not valid. Please check that you are using the correct link
|
||||
from the email you received.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -1,322 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<template id="portal_ltc_repair_form"
|
||||
name="LTC Repair Form">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="oe_structure">
|
||||
<section class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="text-center mb-4">
|
||||
<h1>LTC Repairs Request</h1>
|
||||
<p class="lead text-muted">
|
||||
Submit a repair request for medical equipment at your facility.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<t t-if="request.params.get('error') == 'facility'">
|
||||
<div class="alert alert-danger">Please select a facility.</div>
|
||||
</t>
|
||||
<t t-if="request.params.get('error') == 'name'">
|
||||
<div class="alert alert-danger">Patient name is required.</div>
|
||||
</t>
|
||||
<t t-if="request.params.get('error') == 'description'">
|
||||
<div class="alert alert-danger">Issue description is required.</div>
|
||||
</t>
|
||||
<t t-if="request.params.get('error') == 'photos'">
|
||||
<div class="alert alert-danger">At least one before photo is required.</div>
|
||||
</t>
|
||||
<t t-if="request.params.get('error') == 'server'">
|
||||
<div class="alert alert-danger">
|
||||
An error occurred. Please try again or contact us.
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<form action="/repair-form/submit" method="POST"
|
||||
enctype="multipart/form-data"
|
||||
class="card shadow-sm">
|
||||
<input type="hidden" name="csrf_token"
|
||||
t-att-value="request.csrf_token()"/>
|
||||
<div class="card-body p-4">
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input"
|
||||
id="is_emergency" name="is_emergency"/>
|
||||
<label class="form-check-label fw-bold text-danger"
|
||||
for="is_emergency">
|
||||
Is this an Emergency Repair Request?
|
||||
</label>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
Emergency visits may be chargeable at an extra rate.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="facility_id" class="form-label fw-bold">
|
||||
Facility Location *
|
||||
</label>
|
||||
<select name="facility_id" id="facility_id"
|
||||
class="form-select" required="required">
|
||||
<option value="">-- Select Facility --</option>
|
||||
<t t-foreach="facilities" t-as="fac">
|
||||
<option t-att-value="fac.id">
|
||||
<t t-esc="fac.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="client_name" class="form-label fw-bold">
|
||||
Patient Name *
|
||||
</label>
|
||||
<input type="text" name="client_name" id="client_name"
|
||||
class="form-control" required="required"
|
||||
placeholder="Enter patient name"/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="room_number" class="form-label fw-bold">
|
||||
Room Number *
|
||||
</label>
|
||||
<input type="text" name="room_number" id="room_number"
|
||||
class="form-control" required="required"
|
||||
placeholder="e.g. 305"/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="issue_description" class="form-label fw-bold">
|
||||
Describe the Issue *
|
||||
</label>
|
||||
<textarea name="issue_description" id="issue_description"
|
||||
class="form-control" rows="4"
|
||||
required="required"
|
||||
placeholder="Please provide as much detail as possible about the issue."/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="issue_reported_date" class="form-label fw-bold">
|
||||
Issue Reported Date *
|
||||
</label>
|
||||
<input type="date" name="issue_reported_date"
|
||||
id="issue_reported_date"
|
||||
class="form-control" required="required"
|
||||
t-att-value="today"/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="product_serial" class="form-label fw-bold">
|
||||
Product Serial # *
|
||||
</label>
|
||||
<input type="text" name="product_serial"
|
||||
id="product_serial"
|
||||
class="form-control" required="required"
|
||||
placeholder="Serial number is required for repairs"/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="before_photos" class="form-label fw-bold">
|
||||
Before Photos (Reported Condition) *
|
||||
</label>
|
||||
<input type="file" name="before_photos" id="before_photos"
|
||||
class="form-control" multiple="multiple"
|
||||
accept="image/*" required="required"/>
|
||||
<small class="text-muted">
|
||||
At least 1 photo required. Up to 4 photos (max 10MB each).
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<h5>Family / POA Contact</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="poa_name" class="form-label">
|
||||
Relative/POA Name
|
||||
</label>
|
||||
<input type="text" name="poa_name" id="poa_name"
|
||||
class="form-control"
|
||||
placeholder="Contact name"/>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="poa_phone" class="form-label">
|
||||
Relative/POA Phone
|
||||
</label>
|
||||
<input type="tel" name="poa_phone" id="poa_phone"
|
||||
class="form-control"
|
||||
placeholder="Phone number"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<t t-if="is_technician">
|
||||
<hr/>
|
||||
|
||||
<div class="bg-light p-3 rounded mb-3">
|
||||
<p class="fw-bold text-muted mb-2">
|
||||
FOR TECHNICIAN USE ONLY
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
Has the issue been resolved?
|
||||
</label>
|
||||
<div class="form-check form-check-inline">
|
||||
<input type="radio" name="resolved" value="yes"
|
||||
class="form-check-input" id="resolved_yes"/>
|
||||
<label class="form-check-label"
|
||||
for="resolved_yes">Yes</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input type="radio" name="resolved" value="no"
|
||||
class="form-check-input" id="resolved_no"
|
||||
checked="checked"/>
|
||||
<label class="form-check-label"
|
||||
for="resolved_no">No</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="resolution_section"
|
||||
style="display: none;">
|
||||
<div class="mb-3">
|
||||
<label for="resolution_description"
|
||||
class="form-label">
|
||||
Describe the Solution
|
||||
</label>
|
||||
<textarea name="resolution_description"
|
||||
id="resolution_description"
|
||||
class="form-control" rows="3"
|
||||
placeholder="How was the issue resolved?"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="after_photos" class="form-label fw-bold">
|
||||
After Photos (Completed Repair)
|
||||
</label>
|
||||
<input type="file" name="after_photos" id="after_photos"
|
||||
class="form-control" multiple="multiple"
|
||||
accept="image/*"/>
|
||||
<small class="text-muted">
|
||||
Attach after repair is completed. Up to 4 photos (max 10MB each).
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<button type="submit" class="btn btn-primary btn-lg px-5">
|
||||
Submit Repair Request
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var section = document.getElementById('resolution_section');
|
||||
if (!section) return;
|
||||
var radios = document.querySelectorAll('input[name="resolved"]');
|
||||
radios.forEach(function(r) {
|
||||
r.addEventListener('change', function() {
|
||||
section.style.display = this.value === 'yes' ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="portal_ltc_repair_thank_you"
|
||||
name="Repair Request Submitted">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="oe_structure">
|
||||
<section class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6 text-center">
|
||||
<div class="mb-4">
|
||||
<i class="fa fa-check-circle text-success"
|
||||
style="font-size: 4rem;"/>
|
||||
</div>
|
||||
<h2>Thank You!</h2>
|
||||
<p class="lead text-muted">
|
||||
Your repair request has been submitted successfully.
|
||||
</p>
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<p><strong>Reference:</strong>
|
||||
<t t-esc="repair.name"/></p>
|
||||
<p><strong>Facility:</strong>
|
||||
<t t-esc="repair.facility_id.name"/></p>
|
||||
<p><strong>Patient:</strong>
|
||||
<t t-esc="repair.display_client_name"/></p>
|
||||
<p><strong>Room:</strong>
|
||||
<t t-esc="repair.room_number"/></p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/repair-form" class="btn btn-outline-primary mt-4">
|
||||
Submit Another Request
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="portal_ltc_repair_password"
|
||||
name="LTC Repair Form - Password">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="oe_structure">
|
||||
<section class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-5">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4 text-center">
|
||||
<div class="mb-3">
|
||||
<i class="fa fa-lock text-primary"
|
||||
style="font-size: 3rem;"/>
|
||||
</div>
|
||||
<h3>LTC Repairs Request</h3>
|
||||
<p class="text-muted">
|
||||
Please enter the access password to continue.
|
||||
</p>
|
||||
|
||||
<t t-if="error">
|
||||
<div class="alert alert-danger">
|
||||
Incorrect password. Please try again.
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<form action="/repair-form/auth" method="POST"
|
||||
class="mt-3">
|
||||
<input type="hidden" name="csrf_token"
|
||||
t-att-value="request.csrf_token()"/>
|
||||
<div class="mb-3">
|
||||
<input type="password" name="password"
|
||||
class="form-control form-control-lg text-center"
|
||||
placeholder="Enter password"
|
||||
minlength="4" required="required"
|
||||
autofocus="autofocus"/>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="btn btn-primary btn-lg w-100">
|
||||
Access Form
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
348
fusion_authorizer_portal/views/portal_schedule.xml
Normal file
348
fusion_authorizer_portal/views/portal_schedule.xml
Normal file
@@ -0,0 +1,348 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ==================== SCHEDULE OVERVIEW PAGE ==================== -->
|
||||
|
||||
<template id="portal_schedule_page" name="My Schedule">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
|
||||
<div class="container py-4">
|
||||
<!-- Success/Error Messages -->
|
||||
<t t-if="request.params.get('success')">
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-check-circle me-2"/><t t-out="request.params.get('success')"/>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<h3 class="mb-1"><i class="fa fa-calendar-check-o me-2"/>My Schedule</h3>
|
||||
<p class="text-muted mb-0">View your appointments and book new ones</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<t t-if="share_url">
|
||||
<div class="input-group" style="max-width: 350px;">
|
||||
<input type="text" class="form-control form-control-sm" t-att-value="share_url"
|
||||
id="shareBookingUrl" readonly="readonly" style="font-size: 13px;"/>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button"
|
||||
id="btnCopyShareUrl">
|
||||
<i class="fa fa-copy" id="copyIcon"/> <span id="copyText">Copy</span>
|
||||
</button>
|
||||
<script type="text/javascript">
|
||||
(function() {
|
||||
var btn = document.getElementById('btnCopyShareUrl');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function() {
|
||||
var url = document.getElementById('shareBookingUrl').value;
|
||||
navigator.clipboard.writeText(url);
|
||||
var icon = document.getElementById('copyIcon');
|
||||
var text = document.getElementById('copyText');
|
||||
icon.className = 'fa fa-check';
|
||||
text.textContent = 'Copied';
|
||||
setTimeout(function() {
|
||||
icon.className = 'fa fa-copy';
|
||||
text.textContent = 'Copy';
|
||||
}, 2000);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</t>
|
||||
<a href="/my/schedule/book" class="btn btn-primary">
|
||||
<i class="fa fa-plus me-1"/> Book Appointment
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today's Appointments -->
|
||||
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
|
||||
<div class="card-header bg-white border-bottom-0 pt-3 pb-2 px-4"
|
||||
style="border-radius: 12px 12px 0 0;">
|
||||
<h5 class="mb-0"><i class="fa fa-sun-o me-2 text-warning"/>Today's Appointments</h5>
|
||||
</div>
|
||||
<div class="card-body px-4 pb-4 pt-2">
|
||||
<t t-if="today_events">
|
||||
<div class="list-group list-group-flush">
|
||||
<t t-foreach="today_events" t-as="event">
|
||||
<div class="list-group-item px-0 py-3 border-start-0 border-end-0">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="rounded-3 text-center px-3 py-2 me-3"
|
||||
t-attf-style="background: #{portal_gradient}; min-width: 70px;">
|
||||
<div class="text-white fw-bold" style="font-size: 14px;">
|
||||
<t t-out="event.start.astimezone(user_tz).strftime('%I:%M')"/>
|
||||
</div>
|
||||
<div class="text-white" style="font-size: 10px;">
|
||||
<t t-out="event.start.astimezone(user_tz).strftime('%p')"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="mb-0"><t t-out="event.name"/></h6>
|
||||
<small class="text-muted">
|
||||
<t t-if="event.location">
|
||||
<i class="fa fa-map-marker me-1"/><t t-out="event.location"/>
|
||||
</t>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<span class="badge bg-light text-dark">
|
||||
<t t-out="'%.0f' % (event.duration * 60)"/> min
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<p class="text-muted mb-0 py-3 text-center">
|
||||
<i class="fa fa-calendar-o me-1"/> No appointments scheduled for today.
|
||||
</p>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upcoming Appointments -->
|
||||
<div class="card border-0 shadow-sm" style="border-radius: 12px;">
|
||||
<div class="card-header bg-white border-bottom-0 pt-3 pb-2 px-4"
|
||||
style="border-radius: 12px 12px 0 0;">
|
||||
<h5 class="mb-0"><i class="fa fa-calendar me-2 text-primary"/>Upcoming Appointments</h5>
|
||||
</div>
|
||||
<div class="card-body px-4 pb-4 pt-2">
|
||||
<t t-if="upcoming_events">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="border-top:none;">Date</th>
|
||||
<th style="border-top:none;">Time</th>
|
||||
<th style="border-top:none;">Appointment</th>
|
||||
<th style="border-top:none;">Location</th>
|
||||
<th style="border-top:none;">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="upcoming_events" t-as="event">
|
||||
<tr>
|
||||
<td>
|
||||
<strong><t t-out="event.start.astimezone(user_tz).strftime('%b %d')"/></strong>
|
||||
<br/>
|
||||
<small class="text-muted">
|
||||
<t t-out="event.start.astimezone(user_tz).strftime('%A')"/>
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<t t-out="event.start.astimezone(user_tz).strftime('%I:%M %p')"/>
|
||||
</td>
|
||||
<td><t t-out="event.name"/></td>
|
||||
<td>
|
||||
<t t-if="event.location">
|
||||
<small><t t-out="event.location"/></small>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<small class="text-muted">-</small>
|
||||
</t>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark">
|
||||
<t t-out="'%.0f' % (event.duration * 60)"/> min
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<p class="text-muted mb-0 py-3 text-center">
|
||||
<i class="fa fa-calendar-o me-1"/> No upcoming appointments.
|
||||
<a href="/my/schedule/book">Book one now</a>
|
||||
</p>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ==================== BOOKING FORM ==================== -->
|
||||
|
||||
<template id="portal_schedule_book" name="Book Appointment">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
|
||||
<div class="container py-4" style="max-width: 800px;">
|
||||
<!-- Error Messages -->
|
||||
<t t-if="error">
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-exclamation-circle me-2"/><t t-out="error"/>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-4">
|
||||
<a href="/my/schedule" class="text-muted text-decoration-none mb-2 d-inline-block">
|
||||
<i class="fa fa-arrow-left me-1"/> Back to Schedule
|
||||
</a>
|
||||
<h3 class="mb-1"><i class="fa fa-plus-circle me-2"/>Book Appointment</h3>
|
||||
<p class="text-muted mb-0">Select a time slot and enter client details</p>
|
||||
</div>
|
||||
|
||||
<form action="/my/schedule/book/submit" method="post" id="bookingForm">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
|
||||
<!-- Step 1: Appointment Type + Date/Time -->
|
||||
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
|
||||
<div class="card-header bg-white border-bottom pt-3 pb-2 px-4"
|
||||
style="border-radius: 12px 12px 0 0;">
|
||||
<h5 class="mb-0">
|
||||
<span class="badge rounded-pill me-2"
|
||||
t-attf-style="background: #{portal_gradient};">1</span>
|
||||
Date & Time
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body px-4 pb-4">
|
||||
<!-- Appointment Type (if multiple) -->
|
||||
<t t-if="len(appointment_types) > 1">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Appointment Type</label>
|
||||
<select name="appointment_type_id" class="form-select"
|
||||
id="appointmentTypeSelect">
|
||||
<t t-foreach="appointment_types" t-as="atype">
|
||||
<option t-att-value="atype.id"
|
||||
t-att-selected="atype.id == selected_type.id"
|
||||
t-att-data-duration="atype.appointment_duration">
|
||||
<t t-out="atype.name"/>
|
||||
(<t t-out="'%.0f' % (atype.appointment_duration * 60)"/> min)
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<input type="hidden" name="appointment_type_id"
|
||||
t-att-value="selected_type.id"/>
|
||||
</t>
|
||||
|
||||
<!-- Date Picker -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Select Date</label>
|
||||
<input type="date" class="form-control" id="bookingDate"
|
||||
required="required"
|
||||
t-att-min="now.strftime('%Y-%m-%d')"/>
|
||||
</div>
|
||||
|
||||
<!-- Week Calendar Preview -->
|
||||
<div id="weekCalendarContainer" class="mb-3" style="display: none;">
|
||||
<label class="form-label fw-semibold">
|
||||
<i class="fa fa-calendar me-1"/>Your Week
|
||||
</label>
|
||||
<div id="weekCalendarLoading" class="text-center py-3" style="display: none;">
|
||||
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"/>
|
||||
Loading calendar...
|
||||
</div>
|
||||
<div id="weekCalendarGrid" class="border rounded-3 overflow-hidden" style="display: none;">
|
||||
<div id="weekCalendarHeader" class="d-flex bg-light border-bottom" style="min-height: 40px;"></div>
|
||||
<div id="weekCalendarBody" class="d-flex" style="min-height: 80px;"></div>
|
||||
</div>
|
||||
<div id="weekCalendarEmpty" class="text-muted py-2 text-center" style="display: none;">
|
||||
<i class="fa fa-calendar-o me-1"/> No events this week -- your schedule is open.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Available Slots -->
|
||||
<div id="slotsContainer" style="display: none;">
|
||||
<label class="form-label fw-semibold">Available Time Slots</label>
|
||||
<div id="slotsLoading" class="text-center py-3" style="display: none;">
|
||||
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"/>
|
||||
Loading available slots...
|
||||
</div>
|
||||
<div id="slotsGrid" class="d-flex flex-wrap gap-2 mb-2"></div>
|
||||
<div id="noSlots" class="text-muted py-2" style="display: none;">
|
||||
<i class="fa fa-info-circle me-1"/> No available slots for this date.
|
||||
Try another date.
|
||||
</div>
|
||||
<input type="hidden" name="slot_datetime" id="slotDatetime"/>
|
||||
<input type="hidden" name="slot_duration" id="slotDuration"
|
||||
t-att-value="selected_type.appointment_duration"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Client Details -->
|
||||
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
|
||||
<div class="card-header bg-white border-bottom pt-3 pb-2 px-4"
|
||||
style="border-radius: 12px 12px 0 0;">
|
||||
<h5 class="mb-0">
|
||||
<span class="badge rounded-pill me-2"
|
||||
t-attf-style="background: #{portal_gradient};">2</span>
|
||||
Client Details
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body px-4 pb-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Client Name <span class="text-danger">*</span></label>
|
||||
<input type="text" name="client_name" class="form-control"
|
||||
placeholder="Enter client's full name" required="required"/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Address</label>
|
||||
<input type="text" name="client_street" class="form-control mb-2"
|
||||
id="clientStreet"
|
||||
placeholder="Start typing address..."/>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-4">
|
||||
<input type="text" name="client_city" class="form-control"
|
||||
id="clientCity" placeholder="City"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="text" name="client_province" class="form-control"
|
||||
id="clientProvince" placeholder="Province"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="text" name="client_postal" class="form-control"
|
||||
id="clientPostal" placeholder="Postal Code"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-0">
|
||||
<label class="form-label fw-semibold">Notes</label>
|
||||
<textarea name="notes" class="form-control" rows="3"
|
||||
placeholder="e.g. Equipment to bring, special instructions, reason for visit..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="/my/schedule" class="btn btn-outline-secondary">
|
||||
<i class="fa fa-arrow-left me-1"/> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary btn-lg px-4" id="btnSubmitBooking"
|
||||
disabled="disabled">
|
||||
<i class="fa fa-calendar-check-o me-1"/> Book Appointment
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Google Maps Places API -->
|
||||
<t t-if="google_maps_api_key">
|
||||
<script t-attf-src="https://maps.googleapis.com/maps/api/js?key=#{google_maps_api_key}&libraries=places&callback=initScheduleAddressAutocomplete"
|
||||
async="async" defer="defer"></script>
|
||||
</t>
|
||||
<script t-attf-src="/fusion_authorizer_portal/static/src/js/portal_schedule_booking.js"></script>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -18,6 +18,41 @@
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Clock In/Out -->
|
||||
<t t-if="clock_enabled">
|
||||
<div class="tech-clock-card mb-3"
|
||||
id="techClockCard"
|
||||
t-att-data-checked-in="'true' if clock_checked_in else 'false'"
|
||||
t-att-data-check-in-time="clock_check_in_time or ''">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="tech-clock-dot" t-att-class="'tech-clock-dot--active' if clock_checked_in else ''"/>
|
||||
<div>
|
||||
<div class="tech-clock-status" id="clockStatusText">
|
||||
<t t-if="clock_checked_in">Clocked In</t>
|
||||
<t t-else="">Not Clocked In</t>
|
||||
</div>
|
||||
<div class="tech-clock-timer" id="clockTimer">00:00:00</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="tech-clock-btn" id="clockActionBtn"
|
||||
t-att-class="'tech-clock-btn--out' if clock_checked_in else 'tech-clock-btn--in'"
|
||||
onclick="handleClockAction()">
|
||||
<t t-if="clock_checked_in">
|
||||
<i class="fa fa-stop-circle-o"/> Clock Out
|
||||
</t>
|
||||
<t t-else="">
|
||||
<i class="fa fa-play-circle-o"/> Clock In
|
||||
</t>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tech-clock-error" id="clockError" style="display:none;">
|
||||
<i class="fa fa-exclamation-triangle"/>
|
||||
<span id="clockErrorText"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Quick Stats Bar -->
|
||||
<div class="tech-stats-bar mb-4">
|
||||
<div class="tech-stat-card tech-stat-total">
|
||||
@@ -32,10 +67,6 @@
|
||||
<div class="stat-number"><t t-out="completed_today"/></div>
|
||||
<div class="stat-label">Done</div>
|
||||
</div>
|
||||
<div class="tech-stat-card tech-stat-travel">
|
||||
<div class="stat-number"><t t-out="total_travel"/></div>
|
||||
<div class="stat-label">Travel min</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current / Next Task Hero Card -->
|
||||
@@ -55,21 +86,24 @@
|
||||
</div>
|
||||
<span class="text-muted"><t t-out="current_task.time_start_display"/> - <t t-out="current_task.time_end_display"/></span>
|
||||
</div>
|
||||
<p class="mb-1"><i class="fa fa-user me-1 text-muted"/><t t-out="current_task.partner_id.name or 'N/A'"/></p>
|
||||
<p class="mb-1"><i class="fa fa-user me-1 text-muted"/><t t-out="current_task.client_display_name or 'N/A'"/></p>
|
||||
<p class="mb-2 text-muted"><i class="fa fa-map-marker me-1"/><t t-out="current_task.address_display or 'No address'"/></p>
|
||||
<t t-if="current_task.description">
|
||||
<p class="mb-2 small"><t t-out="current_task.description"/></p>
|
||||
</t>
|
||||
<div class="d-flex gap-2 flex-wrap mt-3">
|
||||
<a t-if="current_task.get_google_maps_url()" t-att-href="current_task.get_google_maps_url()"
|
||||
class="tech-action-btn tech-btn-navigate" target="_blank">
|
||||
<a t-if="current_task.get_google_maps_url()"
|
||||
href="#" class="tech-action-btn tech-btn-navigate"
|
||||
t-att-data-nav-url="current_task.get_google_maps_url()"
|
||||
t-att-data-nav-addr="current_task.address_display or ''"
|
||||
onclick="openGoogleMapsNav(this); return false;">
|
||||
<i class="fa fa-location-arrow"/>Navigate
|
||||
</a>
|
||||
<a t-attf-href="/my/technician/task/#{current_task.id}"
|
||||
class="tech-action-btn tech-btn-complete">
|
||||
<i class="fa fa-check"/>Complete
|
||||
</a>
|
||||
<a t-if="current_task.partner_phone" t-attf-href="tel:#{current_task.partner_phone}"
|
||||
<a t-if="current_task.client_display_phone" t-attf-href="tel:#{current_task.client_display_phone}"
|
||||
class="tech-action-btn tech-btn-call">
|
||||
<i class="fa fa-phone"/>Call
|
||||
</a>
|
||||
@@ -94,7 +128,7 @@
|
||||
<span class="ms-2 fw-bold"><t t-out="next_task.name"/></span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mb-1"><i class="fa fa-user me-1 text-muted"/><t t-out="next_task.partner_id.name or 'N/A'"/></p>
|
||||
<p class="mb-1"><i class="fa fa-user me-1 text-muted"/><t t-out="next_task.client_display_name or 'N/A'"/></p>
|
||||
<p class="mb-1 text-muted"><i class="fa fa-map-marker me-1"/><t t-out="next_task.address_display or 'No address'"/></p>
|
||||
<t t-if="next_task.travel_time_minutes">
|
||||
<p class="mb-2 small text-purple"><i class="fa fa-car me-1"/><t t-out="next_task.travel_time_minutes"/> min drive
|
||||
@@ -102,8 +136,11 @@
|
||||
</p>
|
||||
</t>
|
||||
<div class="d-flex gap-2 flex-wrap mt-3">
|
||||
<a t-if="next_task.get_google_maps_url()" t-att-href="next_task.get_google_maps_url()"
|
||||
class="tech-action-btn tech-btn-navigate" target="_blank">
|
||||
<a t-if="next_task.get_google_maps_url()"
|
||||
href="#" class="tech-action-btn tech-btn-navigate"
|
||||
t-att-data-nav-url="next_task.get_google_maps_url()"
|
||||
t-att-data-nav-addr="next_task.address_display or ''"
|
||||
onclick="openGoogleMapsNav(this); return false;">
|
||||
<i class="fa fa-location-arrow"/>Navigate
|
||||
</a>
|
||||
<button class="tech-action-btn tech-btn-enroute"
|
||||
@@ -155,7 +192,7 @@
|
||||
<span t-attf-class="tech-badge tech-badge-#{task.task_type} me-1">
|
||||
<t t-out="dict(task._fields['task_type'].selection).get(task.task_type, '')"/>
|
||||
</span>
|
||||
<t t-out="task.partner_id.name or task.name"/>
|
||||
<t t-out="task.client_display_name or task.name"/>
|
||||
</div>
|
||||
<div class="tech-timeline-meta">
|
||||
<i class="fa fa-map-marker me-1"/><t t-out="task.address_city or 'No address'"/>
|
||||
@@ -170,25 +207,22 @@
|
||||
</t>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div class="row g-2 mb-4">
|
||||
<div class="col-4">
|
||||
<a href="/my/technician/tasks" class="btn btn-outline-primary w-100 py-3">
|
||||
<i class="fa fa-list me-1"/>All Tasks
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<a href="/my/technician/tomorrow" class="btn btn-outline-secondary w-100 py-3">
|
||||
<i class="fa fa-calendar me-1"/>Tomorrow
|
||||
<t t-if="tomorrow_count">
|
||||
<span class="badge bg-primary ms-1"><t t-out="tomorrow_count"/></span>
|
||||
</t>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<a href="/repair-form" class="btn btn-outline-warning w-100 py-3">
|
||||
<i class="fa fa-wrench me-1"/>Repair Form
|
||||
</a>
|
||||
</div>
|
||||
<div class="tech-quick-links mb-4">
|
||||
<a href="/my/technician/tasks" class="tech-quick-link tech-quick-link-primary">
|
||||
<i class="fa fa-list"/>
|
||||
<span>All Tasks</span>
|
||||
</a>
|
||||
<a href="/my/technician/tomorrow" class="tech-quick-link tech-quick-link-secondary">
|
||||
<i class="fa fa-calendar"/>
|
||||
<span>Tomorrow</span>
|
||||
<t t-if="tomorrow_count">
|
||||
<span class="tech-quick-link-badge"><t t-out="tomorrow_count"/></span>
|
||||
</t>
|
||||
</a>
|
||||
<a href="/repair-form" class="tech-quick-link tech-quick-link-warning">
|
||||
<i class="fa fa-wrench"/>
|
||||
<span>Repair Form</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- My Start Location -->
|
||||
@@ -221,30 +255,146 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clock In/Out JS -->
|
||||
<script type="text/javascript">
|
||||
(function() {
|
||||
var card = document.getElementById('techClockCard');
|
||||
if (!card) return;
|
||||
|
||||
var isCheckedIn = card.dataset.checkedIn === 'true';
|
||||
var checkInTime = card.dataset.checkInTime ? new Date(card.dataset.checkInTime + 'Z') : null;
|
||||
var timerInterval = null;
|
||||
|
||||
function updateTimer() {
|
||||
if (!checkInTime) return;
|
||||
var diff = Math.max(0, Math.floor((new Date() - checkInTime) / 1000));
|
||||
var h = Math.floor(diff / 3600);
|
||||
var m = Math.floor((diff % 3600) / 60);
|
||||
var s = diff % 60;
|
||||
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
|
||||
document.getElementById('clockTimer').textContent = pad(h) + ':' + pad(m) + ':' + pad(s);
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
stopTimer();
|
||||
updateTimer();
|
||||
timerInterval = setInterval(updateTimer, 1000);
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
|
||||
}
|
||||
|
||||
function applyState() {
|
||||
var dot = card.querySelector('.tech-clock-dot');
|
||||
var statusEl = document.getElementById('clockStatusText');
|
||||
var btn = document.getElementById('clockActionBtn');
|
||||
var timerEl = document.getElementById('clockTimer');
|
||||
|
||||
if (dot) dot.className = 'tech-clock-dot' + (isCheckedIn ? ' tech-clock-dot--active' : '');
|
||||
if (statusEl) statusEl.textContent = isCheckedIn ? 'Clocked In' : 'Not Clocked In';
|
||||
if (btn) {
|
||||
btn.className = 'tech-clock-btn ' + (isCheckedIn ? 'tech-clock-btn--out' : 'tech-clock-btn--in');
|
||||
btn.innerHTML = isCheckedIn
|
||||
? '<i class="fa fa-stop-circle-o"></i> Clock Out'
|
||||
: '<i class="fa fa-play-circle-o"></i> Clock In';
|
||||
}
|
||||
if (!isCheckedIn && timerEl) timerEl.textContent = '00:00:00';
|
||||
}
|
||||
|
||||
if (isCheckedIn && checkInTime) startTimer();
|
||||
|
||||
window.handleClockAction = function() {
|
||||
var btn = document.getElementById('clockActionBtn');
|
||||
var errEl = document.getElementById('clockError');
|
||||
var errText = document.getElementById('clockErrorText');
|
||||
btn.disabled = true;
|
||||
errEl.style.display = 'none';
|
||||
|
||||
window.fusionGetLocation().then(function(coords) {
|
||||
fetch('/fusion_clock/clock_action', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
accuracy: coords.accuracy,
|
||||
source: 'portal'
|
||||
}})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var result = data.result || {};
|
||||
if (result.error) {
|
||||
errText.textContent = result.error;
|
||||
errEl.style.display = 'flex';
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (result.action === 'clock_in') {
|
||||
isCheckedIn = true;
|
||||
checkInTime = new Date(result.check_in + 'Z');
|
||||
startTimer();
|
||||
} else {
|
||||
isCheckedIn = false;
|
||||
checkInTime = null;
|
||||
stopTimer();
|
||||
}
|
||||
applyState();
|
||||
btn.disabled = false;
|
||||
})
|
||||
.catch(function() {
|
||||
errText.textContent = 'Network error. Please try again.';
|
||||
errEl.style.display = 'flex';
|
||||
btn.disabled = false;
|
||||
});
|
||||
}).catch(function() {
|
||||
errText.textContent = 'Location access is required for clock in/out.';
|
||||
errEl.style.display = 'flex';
|
||||
btn.disabled = false;
|
||||
});
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Inline JS for task actions -->
|
||||
<script type="text/javascript">
|
||||
function techTaskAction(btn, action) {
|
||||
var taskId = btn.dataset.taskId;
|
||||
var origHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> ...';
|
||||
fetch('/my/technician/task/' + taskId + '/action', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {action: action}})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.result && data.result.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(data.result ? data.result.error : 'Error');
|
||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Getting location...';
|
||||
window.fusionGetLocation().then(function(coords) {
|
||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> ...';
|
||||
fetch('/my/technician/task/' + taskId + '/action', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
|
||||
action: action,
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
accuracy: coords.accuracy
|
||||
}})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.result && data.result.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(data.result ? data.result.error : 'Error');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
alert('Network error. Please try again.');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fa fa-road"></i> En Route';
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
btn.innerHTML = origHtml;
|
||||
});
|
||||
}).catch(function() {
|
||||
alert('Location access is required. Please enable GPS and try again.');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fa fa-road"></i> En Route';
|
||||
btn.innerHTML = origHtml;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -375,7 +525,7 @@
|
||||
<span class="ms-1"><t t-out="task.time_start_display"/></span>
|
||||
</span>
|
||||
<span>
|
||||
<i class="fa fa-user me-1"/><t t-out="task.partner_id.name or '-'"/>
|
||||
<i class="fa fa-user me-1"/><t t-out="task.client_display_name or '-'"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-muted small mt-1">
|
||||
@@ -462,19 +612,22 @@
|
||||
<!-- ===== QUICK ACTIONS ROW ===== -->
|
||||
<div class="tech-quick-actions mb-3">
|
||||
<t t-if="task.get_google_maps_url()">
|
||||
<a t-att-href="task.get_google_maps_url()" class="tech-quick-btn" target="_blank">
|
||||
<a href="#" class="tech-quick-btn"
|
||||
t-att-data-nav-url="task.get_google_maps_url()"
|
||||
t-att-data-nav-addr="task.address_display or ''"
|
||||
onclick="openGoogleMapsNav(this); return false;">
|
||||
<i class="fa fa-location-arrow"/>
|
||||
<span>Navigate</span>
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="task.partner_phone">
|
||||
<a t-attf-href="tel:#{task.partner_phone}" class="tech-quick-btn">
|
||||
<t t-if="task.client_display_phone">
|
||||
<a t-attf-href="tel:#{task.client_display_phone}" class="tech-quick-btn">
|
||||
<i class="fa fa-phone"/>
|
||||
<span>Call</span>
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="task.partner_phone">
|
||||
<a t-attf-href="sms:#{task.partner_phone}" class="tech-quick-btn">
|
||||
<t t-if="task.client_display_phone">
|
||||
<a t-attf-href="sms:#{task.client_display_phone}" class="tech-quick-btn">
|
||||
<i class="fa fa-comment"/>
|
||||
<span>Text</span>
|
||||
</a>
|
||||
@@ -512,10 +665,10 @@
|
||||
<i class="fa fa-user"/>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-semibold"><t t-out="task.partner_id.name or 'No client'"/></div>
|
||||
<t t-if="task.partner_phone">
|
||||
<a t-attf-href="tel:#{task.partner_phone}" class="text-muted small text-decoration-none">
|
||||
<i class="fa fa-phone me-1"/><t t-out="task.partner_phone"/>
|
||||
<div class="fw-semibold"><t t-out="task.client_display_name or 'No client'"/></div>
|
||||
<t t-if="task.client_display_phone">
|
||||
<a t-attf-href="tel:#{task.client_display_phone}" class="text-muted small text-decoration-none">
|
||||
<i class="fa fa-phone me-1"/><t t-out="task.client_display_phone"/>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
@@ -565,8 +718,8 @@
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- ===== POD (if required and linked to a sale order) ===== -->
|
||||
<t t-if="task.pod_required and task.sale_order_id">
|
||||
<!-- ===== POD (if required) ===== -->
|
||||
<t t-if="task.pod_required">
|
||||
<div class="tech-card mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="tech-card-icon bg-warning-subtle text-warning">
|
||||
@@ -574,14 +727,16 @@
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-semibold">Proof of Delivery</div>
|
||||
<t t-if="task.sale_order_id.x_fc_pod_signature">
|
||||
<t t-set="has_task_pod" t-value="bool(task.pod_signature)"/>
|
||||
<t t-set="has_order_pod" t-value="bool(task.sale_order_id and task.sale_order_id.x_fc_pod_signature)"/>
|
||||
<t t-if="has_task_pod or has_order_pod">
|
||||
<span class="text-success small d-block"><i class="fa fa-check me-1"/>Signature collected</span>
|
||||
<a t-attf-href="/my/pod/#{task.sale_order_id.id}" class="btn btn-sm btn-outline-warning mt-1">
|
||||
<a t-attf-href="/my/technician/task/#{task.id}/pod" class="btn btn-sm btn-outline-warning mt-1">
|
||||
<i class="fa fa-refresh me-1"/>Re-collect Signature
|
||||
</a>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<a t-attf-href="/my/pod/#{task.sale_order_id.id}" class="btn btn-sm btn-warning mt-1">
|
||||
<a t-attf-href="/my/technician/task/#{task.id}/pod" class="btn btn-sm btn-warning mt-1">
|
||||
<i class="fa fa-pencil me-1"/>Collect Signature
|
||||
</a>
|
||||
</t>
|
||||
@@ -693,10 +848,18 @@
|
||||
t-att-data-task-id="task.id">
|
||||
<i class="fa fa-play"/>Start
|
||||
</button>
|
||||
<button class="tech-action-btn tech-btn-complete"
|
||||
onclick="techCompleteTask(this)"
|
||||
t-att-data-task-id="task.id">
|
||||
<i class="fa fa-check-circle"/>Complete
|
||||
</button>
|
||||
</t>
|
||||
<t t-if="task.status == 'en_route'">
|
||||
<a t-if="task.get_google_maps_url()" t-att-href="task.get_google_maps_url()"
|
||||
class="tech-action-btn tech-btn-navigate" target="_blank">
|
||||
<a t-if="task.get_google_maps_url()"
|
||||
href="#" class="tech-action-btn tech-btn-navigate"
|
||||
t-att-data-nav-url="task.get_google_maps_url()"
|
||||
t-att-data-nav-addr="task.address_display or ''"
|
||||
onclick="openGoogleMapsNav(this); return false;">
|
||||
<i class="fa fa-location-arrow"/>Navigate
|
||||
</a>
|
||||
<button class="tech-action-btn tech-btn-start"
|
||||
@@ -704,6 +867,11 @@
|
||||
t-att-data-task-id="task.id">
|
||||
<i class="fa fa-play"/>Start
|
||||
</button>
|
||||
<button class="tech-action-btn tech-btn-complete"
|
||||
onclick="techCompleteTask(this)"
|
||||
t-att-data-task-id="task.id">
|
||||
<i class="fa fa-check-circle"/>Complete
|
||||
</button>
|
||||
</t>
|
||||
<t t-if="task.status == 'in_progress'">
|
||||
<button class="tech-action-btn tech-btn-complete"
|
||||
@@ -750,46 +918,78 @@
|
||||
var recordingSeconds = 0;
|
||||
|
||||
function techTaskAction(btn, action) {
|
||||
var origHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i>';
|
||||
fetch('/my/technician/task/' + taskId + '/action', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {action: action}})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.result && data.result.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(data.result ? data.result.error : 'Error');
|
||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Getting location...';
|
||||
window.fusionGetLocation().then(function(coords) {
|
||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i>';
|
||||
fetch('/my/technician/task/' + taskId + '/action', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
|
||||
action: action,
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
accuracy: coords.accuracy
|
||||
}})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.result && data.result.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(data.result ? data.result.error : 'Error');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
alert('Network error. Please try again.');
|
||||
btn.disabled = false;
|
||||
}
|
||||
btn.innerHTML = origHtml;
|
||||
});
|
||||
}).catch(function() {
|
||||
alert('Location access is required. Please enable GPS and try again.');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
});
|
||||
}
|
||||
|
||||
function techCompleteTask(btn) {
|
||||
var origHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Completing...';
|
||||
fetch('/my/technician/task/' + taskId + '/action', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {action: 'complete'}})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.result && data.result.success) {
|
||||
showCompletionOverlay(data.result);
|
||||
} else {
|
||||
alert(data.result ? data.result.error : 'Error completing task');
|
||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Getting location...';
|
||||
window.fusionGetLocation().then(function(coords) {
|
||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Completing...';
|
||||
fetch('/my/technician/task/' + taskId + '/action', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
|
||||
action: 'complete',
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
accuracy: coords.accuracy
|
||||
}})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.result && data.result.success) {
|
||||
showCompletionOverlay(data.result);
|
||||
} else {
|
||||
alert(data.result ? data.result.error : 'Error completing task');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
alert('Network error. Please try again.');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fa fa-check-circle"></i> Complete Task';
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
alert('Network error. Please try again.');
|
||||
btn.innerHTML = origHtml;
|
||||
});
|
||||
}).catch(function() {
|
||||
alert('Location access is required. Please enable GPS and try again.');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fa fa-check-circle"></i> Complete Task';
|
||||
btn.innerHTML = origHtml;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1184,10 +1384,16 @@
|
||||
btns.forEach(function(b){b.disabled = true;});
|
||||
|
||||
try {
|
||||
var coords = await window.fusionGetLocation();
|
||||
var resp = await fetch('/my/technician/task/' + taskId + '/voice-complete', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {transcription: text}})
|
||||
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
|
||||
transcription: text,
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
accuracy: coords.accuracy
|
||||
}})
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (data.result && data.result.success) {
|
||||
@@ -1197,7 +1403,11 @@
|
||||
btns.forEach(function(b){b.disabled = false;});
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Error: ' + err.message);
|
||||
if (err instanceof GeolocationPositionError || err.code) {
|
||||
alert('Location access is required. Please enable GPS and try again.');
|
||||
} else {
|
||||
alert('Error: ' + err.message);
|
||||
}
|
||||
btns.forEach(function(b){b.disabled = false;});
|
||||
}
|
||||
}
|
||||
@@ -1271,15 +1481,15 @@
|
||||
</t>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<strong><t t-out="task.partner_id.name or task.name"/></strong>
|
||||
<strong><t t-out="task.client_display_name or task.name"/></strong>
|
||||
</div>
|
||||
<div class="text-muted small">
|
||||
<i class="fa fa-map-marker me-1"/><t t-out="task.address_display or 'No address'"/>
|
||||
</div>
|
||||
<t t-if="task.partner_phone">
|
||||
<t t-if="task.client_display_phone">
|
||||
<div class="small mt-1">
|
||||
<a t-attf-href="tel:#{task.partner_phone}" class="text-decoration-none">
|
||||
<i class="fa fa-phone me-1"/><t t-out="task.partner_phone"/>
|
||||
<a t-attf-href="tel:#{task.client_display_phone}" class="text-decoration-none">
|
||||
<i class="fa fa-phone me-1"/><t t-out="task.client_display_phone"/>
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
@@ -1353,7 +1563,7 @@
|
||||
<span t-attf-class="tech-badge tech-badge-#{task.task_type} me-1">
|
||||
<t t-out="dict(task._fields['task_type'].selection).get(task.task_type, '')"/>
|
||||
</span>
|
||||
<t t-out="task.partner_id.name or task.name"/>
|
||||
<t t-out="task.client_display_name or task.name"/>
|
||||
</div>
|
||||
<div class="tech-timeline-meta">
|
||||
<i class="fa fa-map-marker me-1"/><t t-out="task.address_city or 'No address'"/>
|
||||
@@ -1384,7 +1594,7 @@
|
||||
<div class="container-fluid py-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h3><i class="fa fa-map-marker"/> Technician Locations</h3>
|
||||
<a href="/web#action=fusion_claims.action_technician_locations" class="btn btn-secondary btn-sm">
|
||||
<a href="/web#action=fusion_tasks.action_technician_locations" class="btn btn-secondary btn-sm">
|
||||
<i class="fa fa-list"/> View History
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -188,7 +188,90 @@
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
|
||||
<!-- My Schedule (All portal roles) -->
|
||||
<div class="col-md-6">
|
||||
<a href="/my/schedule" class="card h-100 border-0 shadow-sm text-decoration-none" style="border-radius: 12px; min-height: 100px;">
|
||||
<div class="card-body d-flex align-items-center p-4">
|
||||
<div class="me-3">
|
||||
<div class="rounded-circle d-flex align-items-center justify-content-center" t-attf-style="width: 50px; height: 50px; background: {{fc_gradient}};">
|
||||
<i class="fa fa-calendar-check-o fa-lg text-white"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="mb-1 text-dark">My Schedule</h5>
|
||||
<small class="text-muted">View and book appointments</small>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Clock In/Out -->
|
||||
<t t-if="clock_enabled">
|
||||
<div class="col-md-6">
|
||||
<style>
|
||||
@keyframes hcPulseGreen {
|
||||
0% { box-shadow: 0 0 0 0 rgba(16,185,129,0.5); }
|
||||
70% { box-shadow: 0 0 0 14px rgba(16,185,129,0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(16,185,129,0); }
|
||||
}
|
||||
@keyframes hcPulseRed {
|
||||
0% { box-shadow: 0 0 0 0 rgba(239,68,68,0.5); }
|
||||
70% { box-shadow: 0 0 0 14px rgba(239,68,68,0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(239,68,68,0); }
|
||||
}
|
||||
.hc-btn-ring {
|
||||
width: 56px; height: 56px; border-radius: 50%; border: none;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0; pointer-events: none;
|
||||
}
|
||||
.hc-btn-ring--in {
|
||||
background: #10b981;
|
||||
animation: hcPulseGreen 2s ease-in-out infinite;
|
||||
}
|
||||
.hc-btn-ring--out {
|
||||
background: #ef4444;
|
||||
animation: hcPulseRed 2s ease-in-out infinite;
|
||||
}
|
||||
.hc-btn-ring i { color: #fff; font-size: 1.4rem; }
|
||||
.hc-btn-ring--in i { padding-left: 3px; }
|
||||
.hc-timer-badge {
|
||||
display: inline-block; font-family: monospace; font-size: 0.75rem; font-weight: 700;
|
||||
color: #10b981; background: rgba(16,185,129,0.1); border-radius: 20px;
|
||||
padding: 2px 10px; letter-spacing: 0.05em;
|
||||
}
|
||||
.hc-clock-link { text-decoration: none; }
|
||||
.hc-clock-link:hover { text-decoration: none; }
|
||||
.hc-clock-link:hover .card { box-shadow: 0 4px 16px rgba(0,0,0,0.12) !important; }
|
||||
.hc-clock-link:active .hc-btn-ring { transform: scale(0.92); }
|
||||
</style>
|
||||
<a href="/my/clock" class="hc-clock-link"
|
||||
id="homeClockCard"
|
||||
t-att-data-checked-in="'true' if clock_checked_in else 'false'"
|
||||
t-att-data-check-in-time="clock_check_in_time or ''">
|
||||
<div class="card h-100 border-0 shadow-sm" style="border-radius: 12px; min-height: 100px;">
|
||||
<div class="card-body d-flex align-items-center p-4">
|
||||
<div class="me-3">
|
||||
<div t-attf-class="hc-btn-ring #{clock_checked_in and 'hc-btn-ring--out' or 'hc-btn-ring--in'}">
|
||||
<i t-attf-class="fa #{clock_checked_in and 'fa-stop' or 'fa-play'}"/>
|
||||
</div>
|
||||
</div>
|
||||
<div style="min-width: 0;">
|
||||
<h5 class="mb-0 text-dark" id="homeClockStatus">
|
||||
<t t-if="clock_checked_in">Clocked In</t>
|
||||
<t t-else="">Clock In</t>
|
||||
</h5>
|
||||
<div id="homeClockTimer">
|
||||
<t t-if="clock_checked_in"><span class="hc-timer-badge">00:00:00</span></t>
|
||||
<t t-else=""><small class="text-muted">Tap to start your shift</small></t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Funding Claims (Clients/Authorizers) -->
|
||||
<t t-if="request.env.user.partner_id.is_client_portal or request.env.user.partner_id.is_authorizer">
|
||||
<div class="col-md-6">
|
||||
@@ -251,6 +334,28 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Home Clock Timer (display only, links to /my/clock) -->
|
||||
<script type="text/javascript">
|
||||
(function() {
|
||||
var card = document.getElementById('homeClockCard');
|
||||
if (!card) return;
|
||||
var isCheckedIn = card.dataset.checkedIn === 'true';
|
||||
var checkInTime = card.dataset.checkInTime ? new Date(card.dataset.checkInTime + 'Z') : null;
|
||||
if (!isCheckedIn || !checkInTime) return;
|
||||
|
||||
function pad(n) { return n < 10 ? '0' + n : '' + n; }
|
||||
var badge = document.querySelector('#homeClockTimer .hc-timer-badge');
|
||||
if (!badge) return;
|
||||
|
||||
function tick() {
|
||||
var diff = Math.max(0, Math.floor((new Date() - checkInTime) / 1000));
|
||||
badge.textContent = pad(Math.floor(diff / 3600)) + ':' + pad(Math.floor((diff % 3600) / 60)) + ':' + pad(diff % 60);
|
||||
}
|
||||
tick();
|
||||
setInterval(tick, 1000);
|
||||
})();
|
||||
</script>
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
@@ -1086,7 +1191,42 @@
|
||||
<p class="text-muted">Welcome back, <t t-out="partner.name"/>!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Clock In/Out -->
|
||||
<t t-if="clock_enabled">
|
||||
<div class="tech-clock-card mb-3"
|
||||
id="techClockCard"
|
||||
t-att-data-checked-in="'true' if clock_checked_in else 'false'"
|
||||
t-att-data-check-in-time="clock_check_in_time or ''">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="tech-clock-dot" t-att-class="'tech-clock-dot--active' if clock_checked_in else ''"/>
|
||||
<div>
|
||||
<div class="tech-clock-status" id="clockStatusText">
|
||||
<t t-if="clock_checked_in">Clocked In</t>
|
||||
<t t-else="">Not Clocked In</t>
|
||||
</div>
|
||||
<div class="tech-clock-timer" id="clockTimer">00:00:00</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="tech-clock-btn" id="clockActionBtn"
|
||||
t-att-class="'tech-clock-btn--out' if clock_checked_in else 'tech-clock-btn--in'"
|
||||
onclick="handleClockAction()">
|
||||
<t t-if="clock_checked_in">
|
||||
<i class="fa fa-stop-circle-o"/> Clock Out
|
||||
</t>
|
||||
<t t-else="">
|
||||
<i class="fa fa-play-circle-o"/> Clock In
|
||||
</t>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tech-clock-error" id="clockError" style="display:none;">
|
||||
<i class="fa fa-exclamation-triangle"/>
|
||||
<span id="clockErrorText"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Stats Cards - 2x2 on mobile, 4 columns on desktop -->
|
||||
<div class="row mb-3 g-2">
|
||||
<div class="col-6 col-md-3">
|
||||
@@ -1377,6 +1517,113 @@
|
||||
<!-- Include loaner modals -->
|
||||
<t t-call="fusion_authorizer_portal.loaner_checkout_modal"/>
|
||||
<t t-call="fusion_authorizer_portal.loaner_return_modal"/>
|
||||
|
||||
<!-- Clock In/Out JS -->
|
||||
<script type="text/javascript">
|
||||
(function() {
|
||||
var card = document.getElementById('techClockCard');
|
||||
if (!card) return;
|
||||
|
||||
var isCheckedIn = card.dataset.checkedIn === 'true';
|
||||
var checkInTime = card.dataset.checkInTime ? new Date(card.dataset.checkInTime + 'Z') : null;
|
||||
var timerInterval = null;
|
||||
|
||||
function updateTimer() {
|
||||
if (!checkInTime) return;
|
||||
var diff = Math.max(0, Math.floor((new Date() - checkInTime) / 1000));
|
||||
var h = Math.floor(diff / 3600);
|
||||
var m = Math.floor((diff % 3600) / 60);
|
||||
var s = diff % 60;
|
||||
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
|
||||
document.getElementById('clockTimer').textContent = pad(h) + ':' + pad(m) + ':' + pad(s);
|
||||
}
|
||||
|
||||
function startTimer() { stopTimer(); updateTimer(); timerInterval = setInterval(updateTimer, 1000); }
|
||||
function stopTimer() { if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } }
|
||||
|
||||
function applyState() {
|
||||
var dot = card.querySelector('.tech-clock-dot');
|
||||
var statusEl = document.getElementById('clockStatusText');
|
||||
var btn = document.getElementById('clockActionBtn');
|
||||
var timerEl = document.getElementById('clockTimer');
|
||||
if (dot) dot.className = 'tech-clock-dot' + (isCheckedIn ? ' tech-clock-dot--active' : '');
|
||||
if (statusEl) statusEl.textContent = isCheckedIn ? 'Clocked In' : 'Not Clocked In';
|
||||
if (btn) {
|
||||
btn.className = 'tech-clock-btn ' + (isCheckedIn ? 'tech-clock-btn--out' : 'tech-clock-btn--in');
|
||||
btn.innerHTML = isCheckedIn
|
||||
? '<i class="fa fa-stop-circle-o"></i> Clock Out'
|
||||
: '<i class="fa fa-play-circle-o"></i> Clock In';
|
||||
}
|
||||
if (!isCheckedIn && timerEl) timerEl.textContent = '00:00:00';
|
||||
}
|
||||
|
||||
if (isCheckedIn && checkInTime) startTimer();
|
||||
|
||||
window.handleClockAction = function() {
|
||||
var btn = document.getElementById('clockActionBtn');
|
||||
var errEl = document.getElementById('clockError');
|
||||
var errText = document.getElementById('clockErrorText');
|
||||
btn.disabled = true;
|
||||
errEl.style.display = 'none';
|
||||
|
||||
function doClockAction(lat, lng, acc) {
|
||||
fetch('/fusion_clock/clock_action', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
|
||||
latitude: lat, longitude: lng, accuracy: acc, source: 'portal'
|
||||
}})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var result = data.result || {};
|
||||
if (result.error) {
|
||||
errText.textContent = result.error;
|
||||
errEl.style.display = 'flex';
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (result.action === 'clock_in') {
|
||||
isCheckedIn = true;
|
||||
checkInTime = new Date(result.check_in + 'Z');
|
||||
startTimer();
|
||||
} else {
|
||||
isCheckedIn = false;
|
||||
checkInTime = null;
|
||||
stopTimer();
|
||||
}
|
||||
applyState();
|
||||
btn.disabled = false;
|
||||
})
|
||||
.catch(function() {
|
||||
errText.textContent = 'Network error. Please try again.';
|
||||
errEl.style.display = 'flex';
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
if (window.fusionGetLocation) {
|
||||
window.fusionGetLocation().then(function(coords) {
|
||||
doClockAction(coords.latitude, coords.longitude, coords.accuracy);
|
||||
}).catch(function() {
|
||||
errText.textContent = 'Location access is required for clock in/out.';
|
||||
errEl.style.display = 'flex';
|
||||
btn.disabled = false;
|
||||
});
|
||||
} else if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(function(pos) {
|
||||
doClockAction(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy);
|
||||
}, function() {
|
||||
errText.textContent = 'Location access is required for clock in/out.';
|
||||
errEl.style.display = 'flex';
|
||||
btn.disabled = false;
|
||||
}, {enableHighAccuracy: true, timeout: 15000});
|
||||
} else {
|
||||
doClockAction(0, 0, 0);
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
@@ -3699,4 +3946,232 @@
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- TASK-LEVEL POD SIGNATURE (works for shadow + regular tasks) -->
|
||||
<!-- ============================================================ -->
|
||||
<template id="portal_task_pod_signature" name="Task POD Signature Capture">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="False"/>
|
||||
<t t-set="no_breadcrumbs" t-value="True"/>
|
||||
|
||||
<div class="container mt-3">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-0">
|
||||
<li class="breadcrumb-item"><a href="/my/home">Home</a></li>
|
||||
<li class="breadcrumb-item"><a href="/my/technician">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/my/technician/tasks">Tasks</a></li>
|
||||
<li class="breadcrumb-item"><a t-attf-href="/my/technician/task/#{task.id}"><t t-out="task.name"/></a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">Collect POD Signature</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="container py-4">
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h2>
|
||||
<i class="fa fa-pencil-square-o me-2"/>
|
||||
Proof of Delivery - <t t-out="task.name"/>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-7">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0"><i class="fa fa-truck me-2"/>Delivery Summary</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p><strong>Client:</strong> <t t-out="task.client_display_name or 'N/A'"/></p>
|
||||
<p><strong>Task:</strong> <t t-out="task.name"/></p>
|
||||
<p><strong>Type:</strong>
|
||||
<t t-out="dict(task._fields['task_type'].selection).get(task.task_type, '')"/>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p><strong>Delivery Address:</strong></p>
|
||||
<p class="mb-0 text-muted">
|
||||
<t t-out="task.address_display or 'No address'"/>
|
||||
</p>
|
||||
<t t-if="task.scheduled_date">
|
||||
<p class="mt-2"><strong>Scheduled:</strong>
|
||||
<t t-out="task.scheduled_date" t-options="{'widget': 'date'}"/>
|
||||
<t t-out="task.time_start_display"/> - <t t-out="task.time_end_display"/>
|
||||
</p>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-5">
|
||||
<div class="card" id="task-pod-signature-section">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="mb-0"><i class="fa fa-pencil me-2"/>Client Signature</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<t t-if="has_existing_signature">
|
||||
<div class="alert alert-warning">
|
||||
<i class="fa fa-exclamation-triangle me-2"/>
|
||||
A signature has already been collected. Submitting a new one will replace it.
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<form id="taskPodSignatureForm">
|
||||
<div class="mb-3">
|
||||
<label for="task_client_name" class="form-label">
|
||||
Client Name <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="task_client_name"
|
||||
name="client_name" required=""
|
||||
t-att-value="task.pod_client_name or task.client_display_name or ''"
|
||||
placeholder="Enter the client's full name"/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="task_signature_date" class="form-label">Signature Date</label>
|
||||
<input type="date" class="form-control" id="task_signature_date" name="signature_date"/>
|
||||
<script>document.getElementById('task_signature_date').value = new Date().toISOString().slice(0,10);</script>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Signature <span class="text-danger">*</span></label>
|
||||
<div class="border rounded p-2 bg-white">
|
||||
<canvas id="task-signature-canvas"
|
||||
style="width:100%;height:200px;border:1px dashed #ccc;border-radius:4px;touch-action:none;">
|
||||
</canvas>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mt-2">
|
||||
<small class="text-muted">Draw signature above</small>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="clearTaskSignature()">
|
||||
<i class="fa fa-eraser me-1"/>Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="button" class="btn btn-success btn-lg"
|
||||
onclick="submitTaskPODSignature()">
|
||||
<i class="fa fa-check me-2"/>Submit Signature
|
||||
</button>
|
||||
<a t-attf-href="/my/technician/task/#{task.id}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tpod-overlay { display:none; position:fixed; top:0; left:0; width:100%; height:100%;
|
||||
background:rgba(0,0,0,0.65); backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px);
|
||||
z-index:9999; align-items:center; justify-content:center; }
|
||||
.tpod-overlay.show { display:flex; }
|
||||
.tpod-overlay-card { background:#fff; border-radius:20px; padding:2.5rem 2rem;
|
||||
max-width:420px; width:90%; text-align:center; animation:tpodSlideUp 0.3s ease;
|
||||
box-shadow:0 8px 32px rgba(0,0,0,0.2); }
|
||||
.tpod-overlay-icon { font-size:3.5rem; margin-bottom:1rem; }
|
||||
@keyframes tpodSlideUp { from { opacity:0; transform:translateY(30px); } to { opacity:1; transform:translateY(0); } }
|
||||
</style>
|
||||
<div id="taskPodOverlay" class="tpod-overlay">
|
||||
<div class="tpod-overlay-card">
|
||||
<div class="tpod-overlay-icon" id="tpodIcon"></div>
|
||||
<h4 id="tpodTitle" style="font-weight:700;"></h4>
|
||||
<p id="tpodMsg" style="color:#6c757d;margin-bottom:1.5rem;"></p>
|
||||
<div id="tpodActions"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
var tCanvas, tCtx, tIsDrawing = false;
|
||||
|
||||
function showTaskPodOverlay(type, title, msg, url) {
|
||||
var ov = document.getElementById('taskPodOverlay');
|
||||
document.getElementById('tpodIcon').innerHTML = type === 'success'
|
||||
? '<i class="fa fa-check-circle text-success"></i>'
|
||||
: '<i class="fa fa-exclamation-circle text-danger"></i>';
|
||||
document.getElementById('tpodTitle').textContent = title;
|
||||
document.getElementById('tpodTitle').className = type === 'success' ? 'text-success' : 'text-danger';
|
||||
document.getElementById('tpodMsg').textContent = msg;
|
||||
var acts = document.getElementById('tpodActions');
|
||||
if (type === 'success' && url) {
|
||||
acts.innerHTML = '<a href="' + url + '" class="btn btn-success w-100 rounded-pill mb-2">Continue</a>' +
|
||||
'<p class="text-muted small mb-0">Redirecting in <span id="tpodCD">3</span>s...</p>';
|
||||
ov.classList.add('show');
|
||||
var s = 3, t = setInterval(function() { s--; var c = document.getElementById('tpodCD');
|
||||
if (c) c.textContent = s; if (s <= 0) { clearInterval(t); window.location.href = url; } }, 1000);
|
||||
} else {
|
||||
acts.innerHTML = '<button class="btn btn-outline-secondary w-100 rounded-pill" onclick="document.getElementById(\'taskPodOverlay\').classList.remove(\'show\')">OK</button>';
|
||||
ov.classList.add('show');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
tCanvas = document.getElementById('task-signature-canvas');
|
||||
if (!tCanvas) return;
|
||||
tCtx = tCanvas.getContext('2d');
|
||||
var r = tCanvas.getBoundingClientRect();
|
||||
tCanvas.width = r.width * 2; tCanvas.height = r.height * 2;
|
||||
tCtx.scale(2, 2); tCtx.lineCap = 'round'; tCtx.lineJoin = 'round';
|
||||
tCtx.lineWidth = 2; tCtx.strokeStyle = '#000';
|
||||
tCanvas.addEventListener('mousedown', tStart);
|
||||
tCanvas.addEventListener('mousemove', tDraw);
|
||||
tCanvas.addEventListener('mouseup', tStop);
|
||||
tCanvas.addEventListener('mouseout', tStop);
|
||||
tCanvas.addEventListener('touchstart', function(e) { e.preventDefault(); tStart(e); }, {passive:false});
|
||||
tCanvas.addEventListener('touchmove', function(e) { e.preventDefault(); tDraw(e); }, {passive:false});
|
||||
tCanvas.addEventListener('touchend', tStop);
|
||||
var sec = document.getElementById('task-pod-signature-section');
|
||||
if (sec) setTimeout(function() { sec.scrollIntoView({behavior:'smooth', block:'start'}); }, 300);
|
||||
});
|
||||
|
||||
function tPos(e) {
|
||||
var r = tCanvas.getBoundingClientRect();
|
||||
if (e.touches) return { x: e.touches[0].clientX - r.left, y: e.touches[0].clientY - r.top };
|
||||
return { x: e.clientX - r.left, y: e.clientY - r.top };
|
||||
}
|
||||
function tStart(e) { tIsDrawing = true; var p = tPos(e); tCtx.beginPath(); tCtx.moveTo(p.x, p.y); }
|
||||
function tDraw(e) { if (!tIsDrawing) return; var p = tPos(e); tCtx.lineTo(p.x, p.y); tCtx.stroke(); }
|
||||
function tStop() { tIsDrawing = false; }
|
||||
function clearTaskSignature() { if (tCtx) tCtx.clearRect(0, 0, tCanvas.width, tCanvas.height); }
|
||||
|
||||
function submitTaskPODSignature() {
|
||||
var name = document.getElementById('task_client_name').value.trim();
|
||||
var sigDate = document.getElementById('task_signature_date').value;
|
||||
if (!name) { showTaskPodOverlay('error', 'Missing Information', 'Please enter the client name.'); return; }
|
||||
var blank = document.createElement('canvas');
|
||||
blank.width = tCanvas.width; blank.height = tCanvas.height;
|
||||
if (tCanvas.toDataURL() === blank.toDataURL()) {
|
||||
showTaskPodOverlay('error', 'Missing Signature', 'Please draw a signature before submitting.'); return;
|
||||
}
|
||||
var sigData = tCanvas.toDataURL('image/png');
|
||||
var btn = document.querySelector('button[onclick="submitTaskPODSignature()"]');
|
||||
var orig = btn.innerHTML; btn.innerHTML = '<i class="fa fa-spinner fa-spin me-2"></i>Saving...'; btn.disabled = true;
|
||||
fetch('<t t-out="'/my/technician/task/' + str(task.id) + '/pod/sign'"/>', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ jsonrpc:'2.0', method:'call',
|
||||
params: { client_name: name, signature_data: sigData, signature_date: sigDate || null },
|
||||
id: Math.floor(Math.random()*1000000) })
|
||||
}).then(function(r) { return r.json(); }).then(function(d) {
|
||||
if (d.result && d.result.success) {
|
||||
showTaskPodOverlay('success', 'Signature Saved!', 'Proof of Delivery recorded.', d.result.redirect_url);
|
||||
} else {
|
||||
showTaskPodOverlay('error', 'Error', d.result?.error || 'Unknown error');
|
||||
btn.innerHTML = orig; btn.disabled = false;
|
||||
}
|
||||
}).catch(function() {
|
||||
showTaskPodOverlay('error', 'Connection Error', 'Please check your connection.');
|
||||
btn.innerHTML = orig; btn.disabled = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
|
||||
Reference in New Issue
Block a user