Files
Odoo-Modules/fusion_portal/views/portal_technician_templates.xml
gsinghpal 747c814249 refactor(fusion_portal): rename from fusion_authorizer_portal + modern photo cards on accessibility selector
Rename module fusion_authorizer_portal -> fusion_portal everywhere:
manifest/assets, controllers, models, views, JS (odoo.define + asset URLs),
migration MODULE constants; plus cross-module refs in fusion_schedule,
fusion_repairs, fusion_quotations (depends + inherit_id) and the pdf_filler
import in fusion_claims. Add rename_module.sql for the one-time in-place DB
rename (ir_module_module, ir_model_data, ir_ui_view.key,
ir_module_module_dependency) required on installed envs before -u fusion_portal.
Document the rename gotcha as rule 16 in CLAUDE.md.

Redesign the Accessibility Assessment selector: replace Font Awesome icon tiles
with photo-banner cards using 7 optimized images (1000x750 PNG -> 800x600 JPEG,
~8MB -> 488KB), per-type colour accent bar + centered pill button, hover
lift/zoom. Images ship as module static files so they deploy/sync with the module.

Drop the regenerable graphify-out cache from the module.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:38:14 -04:00

1888 lines
108 KiB
XML

<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ================================================================== -->
<!-- TECHNICIAN DASHBOARD (Redesigned - Task-based Timeline) -->
<!-- ================================================================== -->
<template id="portal_technician_dashboard" name="Technician Dashboard">
<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 py-3 tech-portal">
<!-- Breadcrumbs -->
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="/my/home">Home</a></li>
<li class="breadcrumb-item active">Technician Dashboard</li>
</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 ''"
t-att-data-today-hours="clock_today_hours or 0">
<div class="tech-clock-layout">
<div class="tech-clock-info">
<div class="tech-clock-status-line">
<span t-attf-class="tech-clock-dot {{ 'tech-clock-dot--active' if clock_checked_in else '' }}"/>
<span class="tech-clock-status" id="clockStatusText">
<t t-if="clock_checked_in">Clocked In</t>
<t t-else="">Not Clocked In</t>
</span>
</div>
<div class="tech-clock-timer" id="clockTimer">00:00:00</div>
<div class="tech-clock-hours" id="clockTodayHours">
Today: <t t-esc="'%.1fh' % (clock_today_hours or 0)"/>
</div>
</div>
<div class="tech-clock-action">
<div t-attf-class="tech-clock-orb-wrap {{ 'tech-clock-orb-wrap--active' if clock_checked_in else '' }}" id="clockOrbWrap">
<span class="tech-clock-wave tech-clock-wave--1"/>
<span class="tech-clock-wave tech-clock-wave--2"/>
<span class="tech-clock-wave tech-clock-wave--3"/>
<button t-attf-class="tech-clock-orb {{ 'tech-clock-orb--out' if clock_checked_in else '' }}"
id="clockActionBtn" onclick="handleClockAction()">
<svg class="tech-clock-orb-icon" id="clockIconPlay" width="22" height="22" viewBox="0 0 24 24" fill="white"
t-attf-style="display:{{ 'none' if clock_checked_in else 'block' }}">
<polygon points="6 3 20 12 6 21"/>
</svg>
<svg class="tech-clock-orb-icon" id="clockIconStop" width="18" height="18" viewBox="0 0 24 24" fill="white"
t-attf-style="display:{{ 'block' if clock_checked_in else 'none' }}">
<rect x="4" y="4" width="16" height="16" rx="2"/>
</svg>
</button>
</div>
<span class="tech-clock-label" id="clockBtnLabel">
<t t-if="clock_checked_in">Clock Out</t>
<t t-else="">Clock In</t>
</span>
</div>
</div>
<div class="tech-clock-error" id="clockError" style="display:none;">
<i class="fa fa-exclamation-triangle"/>
<span id="clockErrorText"/>
</div>
</div>
<!-- Missed Clock-Out Reason Modal -->
<div class="tech-reason-overlay" id="clockReasonModal" style="display:none;">
<div class="tech-reason-backdrop" onclick="document.getElementById('clockReasonModal').style.display='none'"/>
<div class="tech-reason-dialog">
<div class="tech-reason-header">
<i class="fa fa-exclamation-triangle text-warning" style="font-size:1.5rem;"/>
<h5>Missed Clock-Out</h5>
<p class="text-muted small mb-0">You didn't clock out on your last shift. Please provide details before clocking in.</p>
</div>
<div class="tech-reason-body">
<label class="form-label small fw-semibold" for="clockReasonText">
Reason <span class="text-danger">*</span>
</label>
<textarea id="clockReasonText" class="form-control mb-2" rows="3"
placeholder="Please explain why you didn't clock out..."/>
<label class="form-label small fw-semibold" for="clockReasonTime">
Departure Time <span class="text-muted">(optional)</span>
</label>
<input type="datetime-local" id="clockReasonTime" class="form-control"/>
</div>
<div class="tech-reason-footer">
<button class="btn btn-sm btn-secondary" onclick="document.getElementById('clockReasonModal').style.display='none'">Cancel</button>
<button class="btn btn-sm btn-success" id="clockReasonSubmitBtn" onclick="submitClockReason()">
<i class="fa fa-check"/> Submit
</button>
</div>
</div>
</div>
</t>
<!-- Quick Stats Bar -->
<div class="tech-stats-bar mb-4">
<div class="tech-stat-card tech-stat-total">
<div class="stat-number"><t t-out="total_today"/></div>
<div class="stat-label">Today</div>
</div>
<div class="tech-stat-card tech-stat-remaining">
<div class="stat-number"><t t-out="remaining_today"/></div>
<div class="stat-label">Remaining</div>
</div>
<div class="tech-stat-card tech-stat-completed">
<div class="stat-number"><t t-out="completed_today"/></div>
<div class="stat-label">Done</div>
</div>
</div>
<!-- Current / Next Task Hero Card -->
<t t-if="current_task">
<div class="tech-hero-card card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fa fa-wrench me-2"/>Current Task</h5>
<span class="tech-status-badge tech-status-in_progress">In Progress</span>
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<span t-attf-class="tech-badge tech-badge-#{current_task.task_type}">
<t t-out="dict(current_task._fields['task_type'].selection).get(current_task.task_type, '')"/>
</span>
<span class="ms-2 fw-bold"><t t-out="current_task.name"/></span>
</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.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()"
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.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>
</div>
</div>
</div>
</t>
<t t-elif="next_task">
<div class="tech-hero-card card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fa fa-arrow-right me-2"/>Next Task</h5>
<span class="tech-status-badge tech-status-scheduled">
<t t-out="next_task.time_start_display"/>
</span>
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<span t-attf-class="tech-badge tech-badge-#{next_task.task_type}">
<t t-out="dict(next_task._fields['task_type'].selection).get(next_task.task_type, '')"/>
</span>
<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.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
<t t-if="next_task.travel_distance_km"> (<t t-out="next_task.travel_distance_km"/> km)</t>
</p>
</t>
<div class="d-flex gap-2 flex-wrap mt-3">
<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"
onclick="techTaskAction(this, 'en_route')"
t-att-data-task-id="next_task.id">
<i class="fa fa-road"/>En Route
</button>
<a t-attf-href="/my/technician/task/#{next_task.id}"
class="tech-action-btn" style="background: #6c757d; color: #fff;">
<i class="fa fa-eye"/>Details
</a>
</div>
</div>
</div>
</t>
<t t-elif="total_today == 0">
<div class="text-center py-5">
<i class="fa fa-calendar-check-o fa-3x text-success mb-3" style="display:block;"/>
<h4>No tasks scheduled for today</h4>
<p class="text-muted">Check tomorrow's schedule or view all your tasks.</p>
</div>
</t>
<!-- Today's Timeline -->
<t t-if="today_tasks">
<h5 class="mb-3"><i class="fa fa-clock-o me-2"/>Today's Schedule</h5>
<div class="tech-timeline mb-4">
<t t-foreach="today_tasks" t-as="task">
<!-- Travel indicator -->
<t t-if="task.travel_time_minutes and not task_first">
<div class="tech-travel-indicator">
<i class="fa fa-car me-1"/><t t-out="task.travel_time_minutes"/> min
<t t-if="task.travel_distance_km"> / <t t-out="task.travel_distance_km"/> km</t>
</div>
</t>
<div class="tech-timeline-item">
<div t-attf-class="tech-timeline-dot status-#{task.status}"/>
<a t-attf-href="/my/technician/task/#{task.id}"
t-attf-class="tech-timeline-card #{'active' if task.status == 'in_progress' else ''}">
<div class="d-flex justify-content-between align-items-start">
<span class="tech-timeline-time">
<t t-out="task.time_start_display"/> - <t t-out="task.time_end_display"/>
</span>
<span t-attf-class="tech-status-badge tech-status-#{task.status}" style="font-size:0.7rem;">
<t t-out="dict(task._fields['status'].selection).get(task.status, '')"/>
</span>
</div>
<div class="tech-timeline-title">
<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.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'"/>
<t t-if="task.sale_order_id">
<span class="ms-2"><i class="fa fa-file-text-o me-1"/><t t-out="task.sale_order_id.name"/></span>
</t>
</div>
</a>
</div>
</t>
</div>
</t>
<!-- Quick Links -->
<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 -->
<div class="tech-card mb-4">
<div class="d-flex align-items-center mb-2">
<div class="tech-card-icon bg-primary-subtle text-primary" style="width:36px;height:36px;border-radius:10px;display:flex;align-items:center;justify-content:center;margin-right:0.75rem;">
<i class="fa fa-home"/>
</div>
<div>
<div class="fw-semibold" style="font-size:0.9rem;">My Start Location</div>
<div class="text-muted small">Where your day starts (for travel time calculations)</div>
</div>
</div>
<div class="input-group">
<input type="text" class="form-control" id="startLocationInput"
t-att-value="start_address or ''"
placeholder="e.g. 123 Main St, Brampton, ON"
style="border-radius:10px 0 0 10px;"/>
<button class="btn btn-primary" onclick="saveStartLocation()" id="saveStartBtn"
style="border-radius:0 10px 10px 0;">
<i class="fa fa-save"/>
</button>
</div>
<t t-if="not start_address">
<div class="text-muted small mt-1">
<i class="fa fa-info-circle me-1"/>Using company default location. Set yours above.
</div>
</t>
<span id="startLocStatus" class="small text-muted"></span>
</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');
var labelEl = document.getElementById('clockBtnLabel');
var orbWrap = document.getElementById('clockOrbWrap');
var playIcon = document.getElementById('clockIconPlay');
var stopIcon = document.getElementById('clockIconStop');
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-orb' + (isCheckedIn ? ' tech-clock-orb--out' : '');
}
if (orbWrap) {
orbWrap.className = 'tech-clock-orb-wrap' + (isCheckedIn ? ' tech-clock-orb-wrap--active' : '');
}
if (playIcon) playIcon.style.display = isCheckedIn ? 'none' : 'block';
if (stopIcon) stopIcon.style.display = isCheckedIn ? 'block' : 'none';
if (labelEl) labelEl.textContent = isCheckedIn ? 'Clock Out' : 'Clock In';
if (!isCheckedIn &amp;&amp; timerEl) timerEl.textContent = '00:00:00';
}
if (isCheckedIn &amp;&amp; checkInTime) startTimer();
function showClockError(msg) {
var errEl = document.getElementById('clockError');
var errText = document.getElementById('clockErrorText');
var btn = document.getElementById('clockActionBtn');
if (errText) errText.textContent = msg;
if (errEl) errEl.style.display = 'flex';
if (btn) btn.disabled = false;
}
function doClockAction(lat, lng, accuracy) {
var btn = document.getElementById('clockActionBtn');
var errEl = document.getElementById('clockError');
fetch('/fusion_clock/clock_action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', id: 1, method: 'call', params: {
latitude: lat,
longitude: lng,
accuracy: accuracy,
source: 'portal'
}})
})
.then(function(r) {
if (!r.ok) {
throw new Error('HTTP ' + r.status);
}
return r.json();
})
.then(function(data) {
if (data.error) {
var msg = (data.error.data &amp;&amp; data.error.data.message) || data.error.message || 'Server error';
showClockError(msg);
return;
}
var result = data.result;
if (!result) {
showClockError('No response from server. Please try again.');
return;
}
if (result.error) {
showClockError(result.error);
return;
}
if (result.requires_reason) {
var modal = document.getElementById('clockReasonModal');
if (modal) modal.style.display = 'flex';
btn.disabled = false;
return;
}
if (result.action === 'clock_in') {
isCheckedIn = true;
checkInTime = new Date(result.check_in + 'Z');
startTimer();
} else if (result.action === 'clock_out') {
isCheckedIn = false;
checkInTime = null;
stopTimer();
} else {
showClockError(result.message || 'Unexpected response. Please try again.');
return;
}
applyState();
btn.disabled = false;
})
.catch(function(e) {
showClockError('Network error. Please try again.');
});
}
window.submitClockReason = function() {
var reasonEl = document.getElementById('clockReasonText');
var timeEl = document.getElementById('clockReasonTime');
var submitBtn = document.getElementById('clockReasonSubmitBtn');
var reason = reasonEl ? reasonEl.value.trim() : '';
if (!reason) {
showClockError('Please provide a reason.');
return;
}
submitBtn.disabled = true;
submitBtn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i> Submitting...';
var rawTime = timeEl ? timeEl.value.trim() : '';
var depTime = rawTime ? new Date(rawTime).toISOString() : '';
fetch('/fusion_clock/submit_reason', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', id: 1, method: 'call', params: {
reason: reason,
departure_time: depTime
}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
submitBtn.disabled = false;
submitBtn.innerHTML = '&lt;i class="fa fa-check">&lt;/i> Submit';
if (data.error) {
showClockError((data.error.data &amp;&amp; data.error.data.message) || data.error.message || 'Server error');
return;
}
var result = data.result || {};
if (result.success) {
var modal = document.getElementById('clockReasonModal');
if (modal) modal.style.display = 'none';
if (reasonEl) reasonEl.value = '';
if (timeEl) timeEl.value = '';
var errEl = document.getElementById('clockError');
if (errEl) errEl.style.display = 'none';
handleClockAction();
} else {
showClockError(result.error || 'Failed to submit reason.');
}
})
.catch(function() {
submitBtn.disabled = false;
submitBtn.innerHTML = '&lt;i class="fa fa-check">&lt;/i> Submit';
showClockError('Network error. Please try again.');
});
};
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';
if (!navigator.geolocation) {
doClockAction(0, 0, 0);
return;
}
navigator.geolocation.getCurrentPosition(
function(pos) {
doClockAction(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy);
},
function() {
fetch('https://ipapi.co/json/')
.then(function(r) { return r.json(); })
.then(function(ipData) {
var lat = (ipData.latitude &amp;&amp; ipData.longitude) ? ipData.latitude : 0;
var lng = (ipData.latitude &amp;&amp; ipData.longitude) ? ipData.longitude : 0;
doClockAction(lat, lng, lat ? 5000 : 0);
})
.catch(function() {
doClockAction(0, 0, 0);
});
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
);
};
})();
</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> 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 = origHtml;
});
}).catch(function() {
alert('Location access is required. Please enable GPS and try again.');
btn.disabled = false;
btn.innerHTML = origHtml;
});
}
function saveStartLocation() {
var input = document.getElementById('startLocationInput');
var btn = document.getElementById('saveStartBtn');
var status = document.getElementById('startLocStatus');
var address = input.value.trim();
btn.disabled = true;
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i>';
status.textContent = '';
fetch('/my/technician/settings/start-location', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {address: address}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
btn.disabled = false;
btn.innerHTML = '<i class="fa fa-save"></i>';
if (data.result &amp;&amp; data.result.success) {
status.textContent = 'Saved!';
status.className = 'small text-success';
setTimeout(function() { status.textContent = ''; }, 3000);
} else {
status.textContent = data.result ? data.result.error : 'Error saving';
status.className = 'small text-danger';
}
})
.catch(function() {
btn.disabled = false;
btn.innerHTML = '<i class="fa fa-save"></i>';
status.textContent = 'Network error';
status.className = 'small text-danger';
});
}
/* Google Places Autocomplete for Start Location */
function initStartLocationAutocomplete() {
var input = document.getElementById('startLocationInput');
if (!input || !window.google || !google.maps || !google.maps.places) return;
var autocomplete = new google.maps.places.Autocomplete(input, {
types: ['address'],
componentRestrictions: {country: 'ca'},
fields: ['formatted_address']
});
autocomplete.addListener('place_changed', function() {
var place = autocomplete.getPlace();
if (place &amp;&amp; place.formatted_address) {
input.value = place.formatted_address;
}
});
}
</script>
<!-- Load Google Maps Places API for autocomplete -->
<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=initStartLocationAutocomplete"
async="" defer=""/>
</t>
</t>
</template>
<!-- ================================================================== -->
<!-- TECHNICIAN TASKS LIST -->
<!-- ================================================================== -->
<template id="portal_technician_tasks" name="Technician Tasks List">
<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 py-3 tech-portal">
<nav aria-label="breadcrumb" class="mb-3">
<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 active">All Tasks</li>
</ol>
</nav>
<h3 class="mb-3"><i class="fa fa-tasks me-2"/>My Tasks</h3>
<!-- Filters -->
<div class="row mb-3 g-2">
<div class="col-md-6">
<form method="GET" action="/my/technician/tasks">
<div class="input-group">
<input type="text" name="search" class="form-control"
placeholder="Search client, task #, city..."
t-att-value="search"/>
<button type="submit" class="btn btn-primary"><i class="fa fa-search"/></button>
</div>
<input type="hidden" name="filter_status" t-att-value="filter_status"/>
</form>
</div>
<div class="col-md-6">
<div class="d-flex gap-1 flex-wrap justify-content-md-end">
<a t-attf-href="/my/technician/tasks?filter_status=all"
t-attf-class="btn btn-sm btn-outline-secondary #{'active' if filter_status == 'all' else ''}">All</a>
<a t-attf-href="/my/technician/tasks?filter_status=active"
t-attf-class="btn btn-sm btn-outline-primary #{'active' if filter_status == 'active' else ''}">Active</a>
<a t-attf-href="/my/technician/tasks?filter_status=scheduled"
t-attf-class="btn btn-sm btn-outline-info #{'active' if filter_status == 'scheduled' else ''}">Scheduled</a>
<a t-attf-href="/my/technician/tasks?filter_status=in_progress"
t-attf-class="btn btn-sm btn-outline-warning #{'active' if filter_status == 'in_progress' else ''}">In Progress</a>
<a t-attf-href="/my/technician/tasks?filter_status=completed"
t-attf-class="btn btn-sm btn-outline-success #{'active' if filter_status == 'completed' else ''}">Completed</a>
</div>
</div>
</div>
<!-- Tasks Cards (mobile-friendly) -->
<t t-foreach="tasks" t-as="task">
<a t-attf-href="/my/technician/task/#{task.id}" class="tech-timeline-card mb-2">
<div class="d-flex justify-content-between align-items-start">
<div>
<span t-attf-class="tech-badge tech-badge-#{task.task_type}">
<t t-out="dict(task._fields['task_type'].selection).get(task.task_type, '')"/>
</span>
<strong class="ms-1"><t t-out="task.name"/></strong>
</div>
<span t-attf-class="tech-status-badge tech-status-#{task.status}">
<t t-out="dict(task._fields['status'].selection).get(task.status, '')"/>
</span>
</div>
<div class="mt-1">
<span class="text-muted me-3">
<i class="fa fa-calendar me-1"/><t t-out="task.scheduled_date" t-options="{'widget': 'date'}"/>
<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.client_display_name or '-'"/>
</span>
</div>
<div class="text-muted small mt-1">
<i class="fa fa-map-marker me-1"/><t t-out="task.address_city or 'No location'"/>
<t t-if="task.sale_order_id">
<span class="ms-2"><i class="fa fa-file-text-o me-1"/><t t-out="task.sale_order_id.name"/></span>
</t>
</div>
</a>
</t>
<t t-if="not tasks">
<div class="text-center py-5 text-muted">
<i class="fa fa-inbox fa-3x mb-2" style="display:block;"/>
<p>No tasks found</p>
</div>
</t>
<!-- Pager -->
<t t-if="pager">
<div class="d-flex justify-content-center mt-3">
<t t-call="portal.pager"/>
</div>
</t>
</div>
</t>
</template>
<!-- ================================================================== -->
<!-- TECHNICIAN TASK DETAIL -->
<!-- ================================================================== -->
<template id="portal_technician_task_detail" name="Technician Task Detail">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="False"/>
<t t-set="no_breadcrumbs" t-value="True"/>
<!-- Sequential enforcement: must complete earlier tasks first -->
<t t-if="earlier_incomplete">
<div class="container py-3">
<div class="alert alert-warning text-center" style="border-radius:12px;">
<h5><i class="fa fa-exclamation-triangle"/> Complete Previous Task First</h5>
<p class="mb-2">You need to complete
<strong><t t-out="earlier_incomplete.name"/></strong>
(<t t-out="earlier_incomplete.time_start_display"/>) before starting this task.
</p>
<a t-attf-href="/my/technician/task/#{earlier_incomplete.id}"
class="btn btn-warning">
<i class="fa fa-arrow-left"/> Go to <t t-out="earlier_incomplete.name"/>
</a>
</div>
</div>
</t>
<div class="container py-3 tech-portal has-bottom-bar">
<!-- ===== HERO HEADER ===== -->
<div class="tech-task-hero mb-3">
<div class="d-flex align-items-center mb-2">
<a href="/my/technician" class="tech-back-btn me-2"><i class="fa fa-chevron-left"/></a>
<span class="text-muted small"><t t-out="task.name"/></span>
</div>
<div class="d-flex align-items-center justify-content-between">
<div>
<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>
<span t-attf-class="tech-status-badge tech-status-#{task.status}">
<t t-out="dict(task._fields['status'].selection).get(task.status, '')"/>
</span>
</div>
<div class="text-end">
<div class="fw-bold"><t t-out="task.time_start_display"/> - <t t-out="task.time_end_display"/></div>
<div class="text-muted small"><t t-out="task.scheduled_date" t-options="{'widget': 'date'}"/></div>
</div>
</div>
<t t-if="task.travel_time_minutes">
<div class="mt-1">
<span class="badge text-bg-secondary"><i class="fa fa-car me-1"/><t t-out="task.travel_time_minutes"/> min drive
<t t-if="task.travel_distance_km"> - <t t-out="task.travel_distance_km"/> km</t>
</span>
</div>
</t>
</div>
<!-- ===== QUICK ACTIONS ROW ===== -->
<div class="tech-quick-actions mb-3">
<t t-if="task.get_google_maps_url()">
<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.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.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>
</t>
<t t-if="task.sale_order_id">
<a t-attf-href="/my/orders/#{task.sale_order_id.id}" class="tech-quick-btn">
<i class="fa fa-file-text-o"/>
<span>Case</span>
</a>
</t>
</div>
<!-- ===== LOCATION CARD ===== -->
<div class="tech-card mb-3">
<div class="d-flex align-items-start">
<div class="tech-card-icon bg-primary-subtle text-primary">
<i class="fa fa-map-marker"/>
</div>
<div class="flex-grow-1">
<div class="fw-semibold"><t t-out="task.address_street or 'No address'"/></div>
<t t-if="task.address_street2">
<div class="text-muted small">Unit/Suite: <t t-out="task.address_street2"/></div>
</t>
<t t-if="task.address_buzz_code">
<span class="badge text-bg-warning mt-1"><i class="fa fa-key me-1"/>Buzz: <t t-out="task.address_buzz_code"/></span>
</t>
</div>
</div>
</div>
<!-- ===== CLIENT CARD ===== -->
<div class="tech-card mb-3">
<div class="d-flex align-items-center">
<div class="tech-card-icon bg-success-subtle text-success">
<i class="fa fa-user"/>
</div>
<div class="flex-grow-1">
<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>
</div>
</div>
<!-- ===== INSTRUCTIONS ===== -->
<t t-if="task.description">
<div class="tech-card mb-3">
<div class="text-muted small text-uppercase fw-semibold mb-1">
<i class="fa fa-file-text-o me-1"/>Instructions
</div>
<div style="white-space:pre-wrap;font-size:0.9rem;"><t t-out="task.description"/></div>
</div>
</t>
<!-- ===== ORDER DETAILS (Sale Order or Purchase Order) ===== -->
<t t-if="linked_order">
<div class="tech-card tech-order-card mb-3">
<div class="d-flex align-items-center justify-content-between mb-2">
<div class="d-flex align-items-center">
<div t-attf-class="tech-card-icon #{linked_order_type == 'sale' and 'bg-info-subtle text-info' or 'bg-purple-subtle text-purple'}">
<i t-attf-class="fa #{linked_order_type == 'sale' and 'fa-shopping-cart' or 'fa-truck'}"/>
</div>
<div>
<div class="text-muted small text-uppercase fw-semibold" style="font-size:0.68rem;letter-spacing:0.05em;">
<t t-if="linked_order_type == 'sale'">Sale Order</t>
<t t-else="">Purchase Order</t>
</div>
<div class="fw-bold" style="font-size:0.95rem;"><t t-out="linked_order.name"/></div>
</div>
</div>
<t t-if="linked_order_type == 'sale'">
<a t-attf-href="/my/orders/#{linked_order.id}" class="btn btn-sm btn-outline-secondary rounded-pill"
style="font-size:0.75rem;">
<i class="fa fa-external-link me-1"/>View
</a>
</t>
</div>
<!-- Order Lines: Products & Services -->
<t t-if="order_lines">
<div class="tech-order-lines">
<div class="text-muted small text-uppercase fw-semibold mb-2" style="font-size:0.68rem;letter-spacing:0.05em;">
<i class="fa fa-cube me-1"/>Items (<t t-out="len(order_lines)"/>)
</div>
<t t-foreach="order_lines" t-as="line">
<div t-attf-class="tech-order-line-item #{not line_last and 'tech-order-line-border' or ''}">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1 me-2">
<div class="fw-semibold" style="font-size:0.88rem;">
<t t-out="line.product_id.name"/>
</div>
<!-- Product description (differs from product name) -->
<t t-if="linked_order_type == 'sale'">
<t t-set="line_desc" t-value="line.name or ''"/>
</t>
<t t-else="">
<t t-set="line_desc" t-value="line.name or ''"/>
</t>
<t t-if="line_desc and line_desc != line.product_id.name">
<div class="text-muted" style="font-size:0.8rem;white-space:pre-wrap;line-height:1.35;">
<t t-out="line_desc"/>
</div>
</t>
<!-- Serial number if available -->
<t t-if="linked_order_type == 'sale' and line.sudo()._fields.get('x_fc_serial_number') and line.x_fc_serial_number">
<div class="mt-1">
<span class="badge text-bg-light border" style="font-size:0.72rem;">
<i class="fa fa-barcode me-1"/>S/N: <t t-out="line.x_fc_serial_number"/>
</span>
</div>
</t>
</div>
<div class="text-end flex-shrink-0">
<span class="tech-qty-badge">
<t t-if="linked_order_type == 'sale'">
<t t-out="int(line.product_uom_qty)"/>
</t>
<t t-else="">
<t t-out="int(line.product_qty)"/>
</t>
</span>
<div class="text-muted" style="font-size:0.68rem;">qty</div>
</div>
</div>
</div>
</t>
</div>
</t>
<t t-if="not order_lines">
<div class="text-muted small text-center py-2">
<i class="fa fa-info-circle me-1"/>No line items on this order
</div>
</t>
</div>
</t>
<!-- ===== EQUIPMENT NEEDED ===== -->
<t t-if="task.equipment_needed">
<div class="tech-card mb-3">
<div class="tech-equipment-tag">
<div class="text-muted small text-uppercase fw-semibold mb-1">
<i class="fa fa-wrench me-1"/>Equipment Needed
</div>
<div style="white-space:pre-wrap;font-size:0.9rem;"><t t-out="task.equipment_needed"/></div>
</div>
</div>
</t>
<!-- ===== 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">
<i class="fa fa-pencil-square-o"/>
</div>
<div class="flex-grow-1">
<div class="fw-semibold">Proof of Delivery</div>
<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/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/technician/task/#{task.id}/pod" class="btn btn-sm btn-warning mt-1">
<i class="fa fa-pencil me-1"/>Collect Signature
</a>
</t>
</div>
</div>
</div>
</t>
<!-- ===== COMPLETION REPORT (completed tasks) ===== -->
<t t-if="task.status == 'completed'">
<div class="tech-card tech-card-success mb-3">
<div class="text-muted small text-uppercase fw-semibold mb-2">
<i class="fa fa-check-circle me-1"/>Completion Report
</div>
<t t-if="task.completion_notes">
<div id="existingNotes"><t t-out="task.completion_notes"/></div>
</t>
<t t-else="">
<p class="text-muted mb-0" id="existingNotes">No notes added yet.</p>
</t>
</div>
<!-- Add Notes Section -->
<div class="tech-card mb-3" id="addNotesSection">
<div class="text-muted small text-uppercase fw-semibold mb-2">
<i class="fa fa-plus-circle me-1"/>Add Notes
</div>
<!-- Voice toggle -->
<div class="d-none text-center py-3 mb-2" id="completedVoicePanel"
style="border:2px dashed var(--o-main-border-color, #dee2e6);border-radius:12px;">
<button class="btn btn-lg rounded-circle" id="completedRecordBtn"
onclick="toggleCompletedRecording()"
style="width:64px;height:64px;font-size:24px;border:2px solid #dc3545;color:#dc3545;">
<i class="fa fa-microphone"/>
</button>
<div class="d-none mt-2 fw-bold" id="completedRecordTimer" style="color:#dc3545;">0:00</div>
<p class="small text-muted mt-1 mb-0">Tap to record. Speak in any language.</p>
</div>
<span id="completedRecordStatus" class="small text-muted d-block mb-1"></span>
<textarea class="form-control mb-2" id="additionalNotes" rows="3"
placeholder="Type or use voice to add notes..." style="border-radius:10px;"></textarea>
<!-- Photo Attachments -->
<div class="mb-2">
<input type="file" id="notePhotoInput" accept="image/*" multiple="multiple"
class="d-none" onchange="handleNotePhotos(this)"/>
<div id="notePhotoPreview" class="d-flex flex-wrap gap-2 mb-2"></div>
</div>
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-outline-secondary btn-sm rounded-pill" id="completedVoiceBtn"
onclick="toggleCompletedVoice()">
<i class="fa fa-microphone me-1"/>Voice
</button>
<button class="btn btn-outline-secondary btn-sm rounded-pill"
onclick="document.getElementById('notePhotoInput').click()">
<i class="fa fa-camera me-1"/>Photo
</button>
<button class="btn btn-outline-primary btn-sm rounded-pill" id="aiFormatBtn" onclick="aiFormatNotes()">
<i class="fa fa-magic me-1"/>AI Format
</button>
<button class="btn btn-success btn-sm rounded-pill ms-auto" onclick="saveCompletedTaskNotes()">
<i class="fa fa-save me-1"/>Save
</button>
<span id="notesSaveStatus" class="small text-muted align-self-center"></span>
</div>
</div>
</t>
<!-- ===== VOICE SECTION (active tasks) ===== -->
<t t-if="task.status in ('in_progress', 'en_route', 'scheduled')">
<div class="tech-card mb-3" id="voiceSection">
<div class="text-muted small text-uppercase fw-semibold mb-2">
<i class="fa fa-microphone me-1"/>Voice Note
</div>
<div class="tech-voice-recorder" id="voiceRecorder">
<p class="text-muted mb-3 small">Record what you did. Speak in any language.</p>
<button class="tech-record-btn" id="recordBtn" onclick="toggleRecording()">
<i class="fa fa-microphone"/>
</button>
<div class="tech-record-timer d-none" id="recordTimer">0:00</div>
<p class="small text-muted mt-2" id="recordStatus">Tap to start recording</p>
</div>
<div class="d-none mt-3" id="reviewPanel">
<label class="form-label fw-bold small">Review Transcription:</label>
<textarea class="form-control mb-2" id="transcriptionText" rows="4" style="border-radius:10px;"></textarea>
<div class="d-flex gap-2">
<button class="tech-action-btn tech-btn-complete flex-grow-1" onclick="submitVoiceCompletion()">
<i class="fa fa-check"/>Submit &amp; Complete
</button>
<button class="btn btn-outline-secondary" onclick="resetRecording()" style="border-radius:12px;">
<i class="fa fa-refresh"/>
</button>
</div>
</div>
</div>
</t>
<!-- ===== BOTTOM ACTION BAR ===== -->
<div class="tech-bottom-bar" t-if="task.status not in ('completed', 'cancelled')">
<t t-if="task.status == 'scheduled'">
<button class="tech-action-btn tech-btn-enroute"
onclick="techTaskAction(this, 'en_route')"
t-att-data-task-id="task.id">
<i class="fa fa-road"/>En Route
</button>
<button class="tech-action-btn tech-btn-start"
onclick="techTaskAction(this, 'start')"
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()"
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"
onclick="techTaskAction(this, 'start')"
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"
onclick="techCompleteTask(this)"
t-att-data-task-id="task.id"
style="flex:1;">
<i class="fa fa-check-circle"/>Complete
</button>
<a href="#voiceSection" class="tech-action-btn tech-btn-navigate" style="flex:0 0 auto;">
<i class="fa fa-microphone"/>
</a>
</t>
</div>
<!-- ===== COMPLETION OVERLAY ===== -->
<div id="completionOverlay" class="tech-overlay">
<div class="tech-overlay-card">
<div class="tech-overlay-icon text-success">
<i class="fa fa-check-circle"/>
</div>
<h3 id="completionTitle" class="text-success mb-2">Task Completed!</h3>
<p id="completionMessage" class="text-muted mb-4"></p>
<div id="completionActions">
<a id="nextTaskBtn" href="#" class="btn btn-lg btn-primary w-100 mb-2 rounded-pill" style="display:none;">
<i class="fa fa-arrow-right me-1"/> Next Task
</a>
<p id="autoRedirectMsg" class="text-muted small" style="display:none;">
Auto-redirecting in <span id="countdownSec">5</span>s...
</p>
<a href="/my/technician" class="btn btn-outline-secondary w-100 rounded-pill">
<i class="fa fa-home me-1"/> Dashboard
</a>
</div>
</div>
</div>
</div>
<!-- Voice Recording JS -->
<script type="text/javascript">
var taskId = <t t-out="task.id"/>;
var mediaRecorder = null;
var audioChunks = [];
var recordingTimer = null;
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> 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> 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 = origHtml;
});
}).catch(function() {
alert('Location access is required. Please enable GPS and try again.');
btn.disabled = false;
btn.innerHTML = origHtml;
});
}
function showCompletionOverlay(result) {
var overlay = document.getElementById('completionOverlay');
var msg = document.getElementById('completionMessage');
var nextBtn = document.getElementById('nextTaskBtn');
var autoMsg = document.getElementById('autoRedirectMsg');
var countdown = document.getElementById('countdownSec');
overlay.style.display = 'flex';
if (result.next_task_id) {
msg.textContent = 'Next: ' + result.next_task_name + ' at ' + result.next_task_time;
nextBtn.href = result.next_task_url;
nextBtn.style.display = 'block';
autoMsg.style.display = 'block';
// Auto-redirect countdown
var sec = 5;
countdown.textContent = sec;
var timer = setInterval(function() {
sec--;
countdown.textContent = sec;
if (sec &lt;= 0) {
clearInterval(timer);
window.location.href = result.next_task_url;
}
}, 1000);
// Cancel auto-redirect if user clicks anything
overlay.addEventListener('click', function(e) {
if (e.target !== overlay) {
clearInterval(timer);
autoMsg.style.display = 'none';
}
});
} else {
msg.textContent = 'All tasks complete for today! Great work.';
nextBtn.style.display = 'none';
autoMsg.style.display = 'none';
}
}
// --- Image compression helper ---
function compressImage(file, maxDim, quality) {
maxDim = maxDim || 1920;
quality = quality || 0.80;
return new Promise(function(resolve) {
var img = new Image();
img.onload = function() {
var w = img.width, h = img.height;
if (w > maxDim || h > maxDim) {
var ratio = Math.min(maxDim / w, maxDim / h);
w = Math.round(w * ratio);
h = Math.round(h * ratio);
}
var canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
var dataUrl = canvas.toDataURL('image/jpeg', quality);
URL.revokeObjectURL(img.src);
resolve(dataUrl);
};
img.src = URL.createObjectURL(file);
});
}
// --- Photo attachments for notes ---
var notePhotosBase64 = [];
function handleNotePhotos(input) {
var files = input.files;
if (!files || !files.length) return;
var preview = document.getElementById('notePhotoPreview');
for (var i = 0; i &lt; files.length; i++) {
(function(file) {
if (!file.type.startsWith('image/')) return;
// Limit to 10 MB per raw file
if (file.size > 10 * 1024 * 1024) {
alert('Photo too large (max 10 MB): ' + file.name);
return;
}
// Compress via Canvas then store
compressImage(file).then(function(dataUrl) {
var idx = notePhotosBase64.length;
notePhotosBase64.push({
data: dataUrl,
name: file.name,
type: 'image/jpeg'
});
// Build thumbnail
var wrapper = document.createElement('div');
wrapper.className = 'position-relative';
wrapper.style.cssText = 'width:72px;height:72px;';
wrapper.setAttribute('data-photo-idx', idx);
var img = document.createElement('img');
img.src = dataUrl;
img.style.cssText = 'width:72px;height:72px;object-fit:cover;border-radius:8px;border:1px solid var(--o-main-border-color, #dee2e6);';
var removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn-sm position-absolute';
removeBtn.style.cssText = 'top:-6px;right:-6px;width:22px;height:22px;padding:0;line-height:22px;text-align:center;border-radius:50%;background:#dc3545;color:#fff;font-size:12px;border:none;';
removeBtn.innerHTML = '&amp;times;';
removeBtn.onclick = function() {
notePhotosBase64[idx] = null; // mark as removed
wrapper.remove();
};
wrapper.appendChild(img);
wrapper.appendChild(removeBtn);
preview.appendChild(wrapper);
});
})(files[i]);
}
// Reset input so same file can be re-selected
input.value = '';
}
// --- Voice recording for completed tasks ---
var completedRecorder = null;
var completedChunks = [];
var completedTimer = null;
var completedSeconds = 0;
function toggleCompletedVoice() {
var panel = document.getElementById('completedVoicePanel');
panel.classList.toggle('d-none');
}
function toggleCompletedRecording() {
if (completedRecorder &amp;&amp; completedRecorder.state === 'recording') {
stopCompletedRecording();
} else {
startCompletedRecording();
}
}
async function startCompletedRecording() {
try {
var stream = await navigator.mediaDevices.getUserMedia({
audio: {echoCancellation: true, noiseSuppression: true}
});
completedChunks = [];
completedRecorder = new MediaRecorder(stream, {mimeType: 'audio/webm'});
completedRecorder.ondataavailable = function(e) {
if (e.data.size > 0) completedChunks.push(e.data);
};
completedRecorder.onstop = function() {
stream.getTracks().forEach(function(t) { t.stop(); });
transcribeCompletedVoice();
};
completedRecorder.start();
completedSeconds = 0;
var timerEl = document.getElementById('completedRecordTimer');
timerEl.classList.remove('d-none');
completedTimer = setInterval(function() {
completedSeconds++;
var m = Math.floor(completedSeconds / 60);
var s = completedSeconds % 60;
timerEl.textContent = m + ':' + (s &lt; 10 ? '0' : '') + s;
}, 1000);
var btn = document.getElementById('completedRecordBtn');
btn.style.background = '#dc3545';
btn.style.color = 'white';
btn.innerHTML = '&lt;i class="fa fa-stop">&lt;/i>';
document.getElementById('completedRecordStatus').textContent = 'Recording...';
} catch(err) {
alert('Microphone access needed for voice notes.');
}
}
function stopCompletedRecording() {
if (completedRecorder &amp;&amp; completedRecorder.state === 'recording') {
completedRecorder.stop();
}
clearInterval(completedTimer);
var btn = document.getElementById('completedRecordBtn');
btn.style.background = '';
btn.style.color = '#dc3545';
btn.innerHTML = '&lt;i class="fa fa-microphone">&lt;/i>';
document.getElementById('completedRecordStatus').textContent = 'Transcribing...';
}
async function transcribeCompletedVoice() {
var blob = new Blob(completedChunks, {type: 'audio/webm'});
var reader = new FileReader();
reader.onloadend = function() {
var base64 = reader.result.split(',')[1];
fetch('/my/technician/task/' + taskId + '/voice-transcribe', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
jsonrpc: '2.0', method: 'call',
params: {audio_data: base64, mime_type: 'audio/webm'}
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.result &amp;&amp; data.result.success) {
var textarea = document.getElementById('additionalNotes');
var existing = textarea.value.trim();
textarea.value = existing ? existing + '\n' + data.result.transcription : data.result.transcription;
document.getElementById('completedRecordStatus').textContent = 'Transcribed! Edit if needed, then Save.';
} else {
document.getElementById('completedRecordStatus').textContent = 'Transcription failed: ' + (data.result ? data.result.error : 'Error');
}
document.getElementById('completedRecordTimer').classList.add('d-none');
})
.catch(function() {
document.getElementById('completedRecordStatus').textContent = 'Network error';
document.getElementById('completedRecordTimer').classList.add('d-none');
});
};
reader.readAsDataURL(blob);
}
function aiFormatNotes() {
var textarea = document.getElementById('additionalNotes');
var text = textarea.value.trim();
if (!text) {
document.getElementById('notesSaveStatus').textContent = 'Type or record something first.';
return;
}
var btn = document.getElementById('aiFormatBtn');
btn.disabled = true;
btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin me-1">&lt;/i>Formatting...';
document.getElementById('notesSaveStatus').textContent = '';
fetch('/my/technician/task/' + taskId + '/ai-format', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {text: text}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
btn.disabled = false;
btn.innerHTML = '&lt;i class="fa fa-magic me-1">&lt;/i>AI Format';
if (data.result &amp;&amp; data.result.success) {
textarea.value = data.result.formatted_text;
document.getElementById('notesSaveStatus').textContent = 'Formatted! Review and Save.';
document.getElementById('notesSaveStatus').style.color = '#28a745';
} else {
document.getElementById('notesSaveStatus').textContent = data.result ? data.result.error : 'Formatting failed';
document.getElementById('notesSaveStatus').style.color = '#dc3545';
}
})
.catch(function() {
btn.disabled = false;
btn.innerHTML = '&lt;i class="fa fa-magic me-1">&lt;/i>AI Format';
document.getElementById('notesSaveStatus').textContent = 'Network error';
});
}
function saveCompletedTaskNotes() {
var notes = document.getElementById('additionalNotes').value.trim();
// Collect non-null photos
var photos = notePhotosBase64.filter(function(p) { return p !== null; });
if (!notes &amp;&amp; !photos.length) return;
var statusEl = document.getElementById('notesSaveStatus');
statusEl.textContent = 'Saving...';
statusEl.style.color = '#666';
fetch('/my/technician/task/' + taskId + '/add-notes', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
notes: notes,
photos: photos.map(function(p) { return {data: p.data, name: p.name}; })
}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.result &amp;&amp; data.result.success) {
statusEl.textContent = 'Saved!';
statusEl.style.color = '#28a745';
document.getElementById('additionalNotes').value = '';
// Clear photo previews
notePhotosBase64 = [];
document.getElementById('notePhotoPreview').innerHTML = '';
// Refresh to show updated notes
setTimeout(function() { window.location.reload(); }, 1000);
} else {
statusEl.textContent = data.result ? data.result.error : 'Error saving';
statusEl.style.color = '#dc3545';
}
})
.catch(function() {
statusEl.textContent = 'Network error';
statusEl.style.color = '#dc3545';
});
}
function toggleRecording() {
if (mediaRecorder &amp;&amp; mediaRecorder.state === 'recording') {
stopRecording();
} else {
startRecording();
}
}
async function startRecording() {
try {
var stream = await navigator.mediaDevices.getUserMedia({
audio: {echoCancellation: true, noiseSuppression: true}
});
var mimeType = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' :
MediaRecorder.isTypeSupported('audio/ogg') ? 'audio/ogg' : 'audio/mp4';
mediaRecorder = new MediaRecorder(stream, {mimeType: mimeType});
audioChunks = [];
mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) audioChunks.push(e.data); };
mediaRecorder.onstop = function() { stream.getTracks().forEach(function(t){t.stop();}); processRecording(); };
mediaRecorder.start();
document.getElementById('recordBtn').classList.add('recording');
document.getElementById('recordBtn').innerHTML = '<i class="fa fa-stop"></i>';
document.getElementById('recordTimer').classList.remove('d-none');
document.getElementById('recordStatus').textContent = 'Recording... Tap to stop';
document.getElementById('voiceRecorder').classList.add('recording');
recordingSeconds = 0;
recordingTimer = setInterval(function() {
recordingSeconds++;
var m = Math.floor(recordingSeconds / 60);
var s = recordingSeconds % 60;
document.getElementById('recordTimer').textContent = m + ':' + (s &lt; 10 ? '0' : '') + s;
}, 1000);
} catch (err) {
alert('Microphone access denied. Please allow microphone access.');
}
}
function stopRecording() {
if (mediaRecorder &amp;&amp; mediaRecorder.state === 'recording') {
mediaRecorder.stop();
clearInterval(recordingTimer);
}
}
async function processRecording() {
document.getElementById('recordBtn').classList.remove('recording');
document.getElementById('recordBtn').innerHTML = '<i class="fa fa-spinner fa-spin"></i>';
document.getElementById('recordStatus').textContent = 'Transcribing...';
document.getElementById('voiceRecorder').classList.remove('recording');
var blob = new Blob(audioChunks, {type: mediaRecorder.mimeType});
var reader = new FileReader();
reader.onload = async function() {
var base64 = reader.result.split(',')[1];
try {
var resp = await fetch('/my/technician/task/' + taskId + '/voice-transcribe', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
jsonrpc: '2.0', method: 'call',
params: {audio_data: base64, mime_type: mediaRecorder.mimeType}
})
});
var data = await resp.json();
if (data.result &amp;&amp; data.result.success) {
document.getElementById('transcriptionText').value = data.result.transcription;
document.getElementById('reviewPanel').classList.remove('d-none');
document.getElementById('voiceRecorder').classList.add('d-none');
} else {
alert('Transcription failed: ' + (data.result ? data.result.error : 'Unknown error'));
resetRecording();
}
} catch (err) {
alert('Error: ' + err.message);
resetRecording();
}
};
reader.readAsDataURL(blob);
}
function resetRecording() {
document.getElementById('voiceRecorder').classList.remove('d-none', 'recording');
document.getElementById('reviewPanel').classList.add('d-none');
document.getElementById('recordBtn').classList.remove('recording');
document.getElementById('recordBtn').innerHTML = '<i class="fa fa-microphone"></i>';
document.getElementById('recordTimer').classList.add('d-none');
document.getElementById('recordStatus').textContent = 'Tap to start recording';
}
async function submitVoiceCompletion() {
var text = document.getElementById('transcriptionText').value;
if (!text.trim()) { alert('Please record or type completion notes.'); return; }
var btns = document.querySelectorAll('#reviewPanel button');
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,
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy
}})
});
var data = await resp.json();
if (data.result &amp;&amp; data.result.success) {
window.location.reload();
} else {
alert('Error: ' + (data.result ? data.result.error : 'Unknown'));
btns.forEach(function(b){b.disabled = false;});
}
} catch (err) {
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;});
}
}
</script>
</t>
</template>
<!-- ================================================================== -->
<!-- TOMORROW / NEXT DAY PREPARATION -->
<!-- ================================================================== -->
<template id="portal_technician_tomorrow" name="Next Day Preparation">
<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 py-3 tech-portal">
<nav aria-label="breadcrumb" class="mb-3">
<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 active">Tomorrow</li>
</ol>
</nav>
<h3 class="mb-1"><i class="fa fa-calendar me-2"/>Tomorrow's Schedule</h3>
<p class="text-muted mb-4">
<t t-out="tomorrow_date" t-options="{'widget': 'date', 'format': 'EEEE, MMMM d, yyyy'}"/>
</p>
<!-- Summary Stats -->
<div class="tech-stats-bar mb-4">
<div class="tech-stat-card tech-stat-total">
<div class="stat-number"><t t-out="len(tomorrow_tasks)"/></div>
<div class="stat-label">Tasks</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 class="tech-stat-card" style="background: linear-gradient(135deg, #e67e22, #d35400);">
<div class="stat-number"><t t-out="'%.0f' % total_distance"/></div>
<div class="stat-label">km total</div>
</div>
</div>
<!-- Equipment Preparation -->
<t t-if="all_equipment">
<div class="tech-prep-equipment mb-4">
<h6 class="mb-2"><i class="fa fa-wrench me-1"/>Equipment to Prepare</h6>
<ul class="mb-0">
<t t-foreach="all_equipment" t-as="equip">
<li><t t-out="equip"/></li>
</t>
</ul>
</div>
</t>
<!-- Task List for Tomorrow -->
<t t-if="tomorrow_tasks">
<t t-foreach="tomorrow_tasks" t-as="task">
<div class="tech-prep-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="prep-time"><t t-out="task.time_start_display"/> - <t t-out="task.time_end_display"/></span>
<span t-attf-class="tech-badge tech-badge-#{task.task_type} prep-type">
<t t-out="dict(task._fields['task_type'].selection).get(task.task_type, '')"/>
</span>
</div>
<t t-if="task.travel_time_minutes">
<span class="text-muted small"><i class="fa fa-car me-1"/><t t-out="task.travel_time_minutes"/> min</span>
</t>
</div>
<div class="mt-2">
<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.client_display_phone">
<div class="small mt-1">
<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>
<t t-if="task.description">
<div class="small mt-1 text-muted"><t t-out="task.description"/></div>
</t>
<t t-if="task.equipment_needed">
<div class="small mt-1 text-warning"><i class="fa fa-wrench me-1"/><t t-out="task.equipment_needed"/></div>
</t>
</div>
</t>
</t>
<t t-else="">
<div class="text-center py-5 text-muted">
<i class="fa fa-umbrella fa-3x mb-2" style="display:block;"/>
<h5>No tasks scheduled for tomorrow</h5>
<p>Enjoy your day off, or check back later for schedule updates.</p>
</div>
</t>
</div>
</t>
</template>
<!-- ================================================================== -->
<!-- SCHEDULE FOR SPECIFIC DATE -->
<!-- ================================================================== -->
<template id="portal_technician_schedule_date" name="Technician Schedule Date">
<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 py-3 tech-portal">
<nav aria-label="breadcrumb" class="mb-3">
<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 active">
<t t-out="schedule_date" t-options="{'widget': 'date'}"/>
</li>
</ol>
</nav>
<h3 class="mb-3">
<i class="fa fa-calendar me-2"/>
<t t-out="schedule_date" t-options="{'widget': 'date', 'format': 'EEEE, MMMM d'}"/>
</h3>
<t t-if="total_travel">
<p class="text-muted"><i class="fa fa-car me-1"/>Total travel: <t t-out="total_travel"/> minutes</p>
</t>
<div class="tech-timeline">
<t t-foreach="tasks" t-as="task">
<t t-if="task.travel_time_minutes and not task_first">
<div class="tech-travel-indicator">
<i class="fa fa-car me-1"/><t t-out="task.travel_time_minutes"/> min
</div>
</t>
<div class="tech-timeline-item">
<div t-attf-class="tech-timeline-dot status-#{task.status}"/>
<a t-attf-href="/my/technician/task/#{task.id}" class="tech-timeline-card">
<div class="d-flex justify-content-between">
<span class="tech-timeline-time">
<t t-out="task.time_start_display"/> - <t t-out="task.time_end_display"/>
</span>
<span t-attf-class="tech-status-badge tech-status-#{task.status}" style="font-size:0.7rem;">
<t t-out="dict(task._fields['status'].selection).get(task.status, '')"/>
</span>
</div>
<div class="tech-timeline-title">
<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.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'"/>
</div>
</a>
</div>
</t>
</div>
<t t-if="not tasks">
<div class="text-center py-5 text-muted">
<i class="fa fa-calendar-times-o fa-3x mb-2" style="display:block;"/>
<p>No tasks scheduled for this date.</p>
</div>
</t>
</div>
</t>
</template>
<!-- ================================================================== -->
<!-- TECHNICIAN LOCATION MAP (Admin View) -->
<!-- ================================================================== -->
<template id="portal_technician_map" name="Technician Location Map">
<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-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_tasks.action_technician_locations" class="btn btn-secondary btn-sm">
<i class="fa fa-list"/> View History
</a>
</div>
<div id="techMap" style="width:100%;height:70vh;border-radius:12px;border:1px solid #ddd;"></div>
<!-- Legend -->
<div class="mt-3">
<strong>Last updated locations (past 24 hours):</strong>
<ul class="list-inline mt-2">
<t t-foreach="locations" t-as="loc">
<li class="list-inline-item badge bg-primary me-2 mb-1">
<i class="fa fa-user"/> <t t-out="loc['name']"/>
<small class="ms-1">(<t t-out="loc['logged_at'][:16]"/>)</small>
</li>
</t>
</ul>
<t t-if="not locations">
<p class="text-muted">No location data in the past 24 hours.</p>
</t>
</div>
</div>
<script t-if="google_maps_api_key">
var techLocations = <t t-out="json.dumps(locations)" t-options="{'widget': 'text'}"/>;
function initMap() {
var center = {lat: 43.7, lng: -79.4}; // Default: GTA
if (techLocations.length > 0) {
center = {lat: techLocations[0].latitude, lng: techLocations[0].longitude};
}
var map = new google.maps.Map(document.getElementById('techMap'), {
zoom: 11,
center: center,
mapTypeId: 'roadmap',
});
var bounds = new google.maps.LatLngBounds();
techLocations.forEach(function(loc) {
var pos = {lat: loc.latitude, lng: loc.longitude};
var marker = new google.maps.Marker({
position: pos,
map: map,
title: loc.name,
label: {text: loc.name.charAt(0), color: 'white'},
});
var infoWindow = new google.maps.InfoWindow({
content: '<div><strong>' + loc.name + '</strong><br/>' +
'<small>Last seen: ' + loc.logged_at + '</small><br/>' +
'<small>Accuracy: ' + (loc.accuracy || 'N/A') + 'm</small></div>'
});
marker.addListener('click', function() {
infoWindow.open(map, marker);
});
bounds.extend(pos);
});
if (techLocations.length > 1) {
map.fitBounds(bounds);
}
}
</script>
<script t-if="google_maps_api_key"
t-attf-src="https://maps.googleapis.com/maps/api/js?key=#{google_maps_api_key}&amp;callback=initMap"
async="" defer=""/>
<t t-if="not google_maps_api_key">
<div class="alert alert-warning mt-3">
<i class="fa fa-exclamation-triangle"/> Google Maps API key not configured.
Please set it in Settings > Fusion Claims > Google Maps API Key.
</div>
</t>
</t>
</template>
</odoo>