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>
1888 lines
108 KiB
XML
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 < 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 && timerEl) timerEl.textContent = '00:00:00';
|
|
}
|
|
|
|
if (isCheckedIn && 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 && 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 = '<i class="fa fa-spinner fa-spin"></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 = '<i class="fa fa-check"></i> Submit';
|
|
|
|
if (data.error) {
|
|
showClockError((data.error.data && 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 = '<i class="fa fa-check"></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 && ipData.longitude) ? ipData.latitude : 0;
|
|
var lng = (ipData.latitude && 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 && 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 && 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 && 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}&libraries=places&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 & 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 = '<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> 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 = 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 <= 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 < 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 = '&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 && 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 < 10 ? '0' : '') + s;
|
|
}, 1000);
|
|
var btn = document.getElementById('completedRecordBtn');
|
|
btn.style.background = '#dc3545';
|
|
btn.style.color = 'white';
|
|
btn.innerHTML = '<i class="fa fa-stop"></i>';
|
|
document.getElementById('completedRecordStatus').textContent = 'Recording...';
|
|
} catch(err) {
|
|
alert('Microphone access needed for voice notes.');
|
|
}
|
|
}
|
|
|
|
function stopCompletedRecording() {
|
|
if (completedRecorder && completedRecorder.state === 'recording') {
|
|
completedRecorder.stop();
|
|
}
|
|
clearInterval(completedTimer);
|
|
var btn = document.getElementById('completedRecordBtn');
|
|
btn.style.background = '';
|
|
btn.style.color = '#dc3545';
|
|
btn.innerHTML = '<i class="fa fa-microphone"></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 && 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 = '<i class="fa fa-spinner fa-spin me-1"></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 = '<i class="fa fa-magic me-1"></i>AI Format';
|
|
if (data.result && 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 = '<i class="fa fa-magic me-1"></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 && !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 && 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 && 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 < 10 ? '0' : '') + s;
|
|
}, 1000);
|
|
} catch (err) {
|
|
alert('Microphone access denied. Please allow microphone access.');
|
|
}
|
|
}
|
|
|
|
function stopRecording() {
|
|
if (mediaRecorder && 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 && 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 && 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}&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>
|