This commit is contained in:
gsinghpal
2026-02-27 14:32:32 -05:00
parent b649246e81
commit b925766966
80 changed files with 7831 additions and 1041 deletions

View File

@@ -26,6 +26,7 @@ class AuthorizerPortal(CustomerPortal):
if hasattr(response, 'qcontext') and (partner.is_authorizer or partner.is_sales_rep_portal or partner.is_client_portal or partner.is_technician_portal):
posting_info = self._get_adp_posting_info()
response.qcontext.update(posting_info)
response.qcontext.update(self._get_clock_status_data())
# Add signature count (documents to sign) - only if Sign module is installed
sign_count = 0
@@ -724,7 +725,7 @@ class AuthorizerPortal(CustomerPortal):
'sale_type_filter': sale_type,
'status_filter': status,
}
values.update(self._get_clock_status_data())
return request.render('fusion_authorizer_portal.portal_sales_dashboard', values)
@http.route(['/my/sales/cases', '/my/sales/cases/page/<int:page>'], type='http', auth='user', website=True)
@@ -1090,14 +1091,60 @@ class AuthorizerPortal(CustomerPortal):
_logger.error(f"Error downloading proof of delivery: {e}")
return request.redirect('/my/funding-claims')
# ==================== CLOCK STATUS HELPER ====================
def _get_clock_status_data(self):
"""Get clock in/out status for the current portal user."""
try:
user = request.env.user
Employee = request.env['hr.employee'].sudo()
employee = Employee.search([('user_id', '=', user.id)], limit=1)
if not employee:
employee = Employee.search([
('name', '=', user.partner_id.name),
('user_id', '=', False),
], limit=1)
if not employee or not getattr(employee, 'x_fclk_enable_clock', False):
return {'clock_enabled': False}
is_checked_in = employee.attendance_state == 'checked_in'
check_in_time = ''
location_name = ''
if is_checked_in:
att = request.env['hr.attendance'].sudo().search([
('employee_id', '=', employee.id),
('check_out', '=', False),
], limit=1)
if att:
check_in_time = att.check_in.isoformat() if att.check_in else ''
location_name = att.x_fclk_location_id.name if att.x_fclk_location_id else ''
return {
'clock_enabled': True,
'clock_checked_in': is_checked_in,
'clock_check_in_time': check_in_time,
'clock_location_name': location_name,
}
except Exception as e:
_logger.warning("Clock status check failed: %s", e)
return {'clock_enabled': False}
# ==================== TECHNICIAN PORTAL ====================
def _check_technician_access(self):
"""Check if current user is a technician portal user."""
partner = request.env.user.partner_id
if not partner.is_technician_portal:
return False
return True
if partner.is_technician_portal:
return True
has_tasks = request.env['fusion.technician.task'].sudo().search_count([
'|',
('technician_id', '=', request.env.user.id),
('additional_technician_ids', 'in', [request.env.user.id]),
], limit=1)
if has_tasks:
partner.sudo().write({'is_technician_portal': True})
return True
return False
@http.route(['/my/technician', '/my/technician/dashboard'], type='http', auth='user', website=True)
def technician_dashboard(self, **kw):
@@ -1159,6 +1206,8 @@ class AuthorizerPortal(CustomerPortal):
ICP = request.env['ir.config_parameter'].sudo()
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
clock_data = self._get_clock_status_data()
values = {
'today_tasks': today_tasks,
'current_task': current_task,
@@ -1174,6 +1223,7 @@ class AuthorizerPortal(CustomerPortal):
'google_maps_api_key': google_maps_api_key,
'page_name': 'technician_dashboard',
}
values.update(clock_data)
return request.render('fusion_authorizer_portal.portal_technician_dashboard', values)
@http.route(['/my/technician/tasks', '/my/technician/tasks/page/<int:page>'], type='http', auth='user', website=True)
@@ -1423,11 +1473,17 @@ class AuthorizerPortal(CustomerPortal):
return {'success': False, 'error': str(e)}
@http.route('/my/technician/task/<int:task_id>/action', type='json', auth='user', website=True)
def technician_task_action(self, task_id, action, **kw):
"""Handle task status changes (start, complete, en_route, cancel)."""
def technician_task_action(self, task_id, action, latitude=None, longitude=None, accuracy=None, **kw):
"""Handle task status changes (start, complete, en_route, cancel).
Location is mandatory -- the client must send GPS coordinates."""
if not self._check_technician_access():
return {'success': False, 'error': 'Access denied'}
if not latitude or not longitude:
return {'success': False, 'error': 'Location is required. Please enable GPS and try again.'}
if not (-90 <= latitude <= 90 and -180 <= longitude <= 180):
return {'success': False, 'error': 'Invalid GPS coordinates.'}
user = request.env.user
Task = request.env['fusion.technician.task'].sudo()
@@ -1439,21 +1495,32 @@ class AuthorizerPortal(CustomerPortal):
):
return {'success': False, 'error': 'Task not found or not assigned to you'}
request.env['fusion.technician.location'].sudo().log_location(
latitude=latitude,
longitude=longitude,
accuracy=accuracy,
)
location_ctx = {
'action_latitude': latitude,
'action_longitude': longitude,
'action_accuracy': accuracy or 0,
}
if action == 'en_route':
task.action_start_en_route()
task.with_context(**location_ctx).action_start_en_route()
elif action == 'start':
task.action_start_task()
task.with_context(**location_ctx).action_start_task()
elif action == 'complete':
completion_notes = kw.get('completion_notes', '')
if completion_notes:
task.completion_notes = completion_notes
task.action_complete_task()
task.with_context(**location_ctx).action_complete_task()
elif action == 'cancel':
task.action_cancel_task()
task.with_context(**location_ctx).action_cancel_task()
else:
return {'success': False, 'error': f'Unknown action: {action}'}
# For completion, also return next task info
result = {
'success': True,
'status': task.status,
@@ -1600,10 +1667,14 @@ class AuthorizerPortal(CustomerPortal):
return {'success': False, 'error': str(e)}
@http.route('/my/technician/task/<int:task_id>/voice-complete', type='json', auth='user', website=True)
def technician_voice_complete(self, task_id, transcription, **kw):
def technician_voice_complete(self, task_id, transcription, latitude=None, longitude=None, accuracy=None, **kw):
"""Format transcription with GPT and complete the task."""
if not self._check_technician_access():
return {'success': False, 'error': 'Access denied'}
if not latitude or not longitude:
return {'success': False, 'error': 'Location is required. Please enable GPS and try again.'}
if not (-90 <= latitude <= 90 and -180 <= longitude <= 180):
return {'success': False, 'error': 'Invalid GPS coordinates.'}
user = request.env.user
Task = request.env['fusion.technician.task'].sudo()
@@ -1675,7 +1746,18 @@ class AuthorizerPortal(CustomerPortal):
'completion_notes': completion_html,
'voice_note_transcription': transcription,
})
task.action_complete_task()
request.env['fusion.technician.location'].sudo().log_location(
latitude=latitude,
longitude=longitude,
accuracy=accuracy,
)
location_ctx = {
'action_latitude': latitude,
'action_longitude': longitude,
'action_accuracy': accuracy or 0,
}
task.with_context(**location_ctx).action_complete_task()
return {
'success': True,

View File

@@ -499,6 +499,7 @@ class FusionAssessment(models.Model):
'res_model': 'sale.order',
'res_id': sale_order.id,
'view_mode': 'form',
'views': [(False, 'form')],
'target': 'current',
}
@@ -1482,6 +1483,7 @@ class FusionAssessment(models.Model):
'name': _('Documents'),
'res_model': 'fusion.adp.document',
'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': [('assessment_id', '=', self.id)],
'context': {'default_assessment_id': self.id},
}
@@ -1497,6 +1499,7 @@ class FusionAssessment(models.Model):
'res_model': 'sale.order',
'res_id': self.sale_order_id.id,
'view_mode': 'form',
'views': [(False, 'form')],
'target': 'current',
}

View File

@@ -23,5 +23,6 @@ class FusionLoanerCheckoutAssessment(models.Model):
'type': 'ir.actions.act_window',
'res_model': 'fusion.assessment',
'view_mode': 'form',
'views': [(False, 'form')],
'res_id': self.assessment_id.id,
}

View File

@@ -596,6 +596,7 @@ class ResPartner(models.Model):
'name': _('Assigned Cases'),
'res_model': 'sale.order',
'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': [('x_fc_authorizer_id', '=', self.id)],
'context': {'default_x_fc_authorizer_id': self.id},
}
@@ -614,6 +615,7 @@ class ResPartner(models.Model):
'name': _('Assessments'),
'res_model': 'fusion.assessment',
'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': domain,
}
@@ -697,6 +699,7 @@ class ResPartner(models.Model):
'name': _('Assigned Deliveries'),
'res_model': 'sale.order',
'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': [('x_fc_delivery_technician_ids', 'in', [self.authorizer_portal_user_id.id])],
}

View File

@@ -101,6 +101,7 @@ class SaleOrder(models.Model):
'name': 'Message Authorizer',
'res_model': 'mail.compose.message',
'view_mode': 'form',
'views': [(False, 'form')],
'target': 'new',
'context': {
'default_model': 'sale.order',
@@ -137,6 +138,7 @@ class SaleOrder(models.Model):
'name': _('Portal Comments'),
'res_model': 'fusion.authorizer.comment',
'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': [('sale_order_id', '=', self.id)],
'context': {'default_sale_order_id': self.id},
}
@@ -149,6 +151,7 @@ class SaleOrder(models.Model):
'name': _('Portal Documents'),
'res_model': 'fusion.adp.document',
'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': [('sale_order_id', '=', self.id)],
'context': {'default_sale_order_id': self.id},
}

View File

@@ -14,16 +14,12 @@
.tech-stats-bar {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.5rem;
scrollbar-width: none;
}
.tech-stats-bar::-webkit-scrollbar { display: none; }
.tech-stat-card {
flex: 0 0 auto;
min-width: 100px;
padding: 0.75rem 1rem;
flex: 1 1 0;
min-width: 0;
padding: 0.75rem 0.5rem;
border-radius: 12px;
text-align: center;
color: #fff;
@@ -42,7 +38,145 @@
.tech-stat-total { background: linear-gradient(135deg, #5ba848, #3a8fb7); }
.tech-stat-remaining { background: linear-gradient(135deg, #3498db, #2980b9); }
.tech-stat-completed { background: linear-gradient(135deg, #27ae60, #219a52); }
.tech-stat-travel { background: linear-gradient(135deg, #8e44ad, #7d3c98); }
/* ---- Clock In/Out Card ---- */
.tech-clock-card {
background: var(--o-main-card-bg, #fff);
border: 1px solid var(--o-main-border-color, #e9ecef);
border-radius: 14px;
padding: 0.875rem 1rem;
}
.tech-clock-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #adb5bd;
flex-shrink: 0;
}
.tech-clock-dot--active {
background: #10b981;
box-shadow: 0 0 6px rgba(16, 185, 129, 0.5);
animation: tech-clock-pulse 2s ease-in-out infinite;
}
@keyframes tech-clock-pulse {
0%, 100% { box-shadow: 0 0 6px rgba(16, 185, 129, 0.5); }
50% { box-shadow: 0 0 12px rgba(16, 185, 129, 0.8); }
}
.tech-clock-status {
font-size: 0.85rem;
font-weight: 600;
color: var(--o-main-text-color, #212529);
line-height: 1.2;
}
.tech-clock-timer {
font-size: 0.75rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: #6c757d;
}
.tech-clock-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
border-radius: 10px;
border: none;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.tech-clock-btn:active { transform: scale(0.96); }
.tech-clock-btn--in {
background: #10b981;
color: #fff;
}
.tech-clock-btn--in:hover { background: #059669; }
.tech-clock-btn--out {
background: #ef4444;
color: #fff;
}
.tech-clock-btn--out:hover { background: #dc2626; }
.tech-clock-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.tech-clock-error {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.5rem;
padding: 0.4rem 0.75rem;
border-radius: 8px;
background: #fef2f2;
color: #dc2626;
font-size: 0.8rem;
font-weight: 500;
}
/* ---- Quick Links (All Tasks / Tomorrow / Repair Form) ---- */
.tech-quick-links {
display: flex;
gap: 0.5rem;
}
.tech-quick-link {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.875rem 0.5rem;
border-radius: 12px;
border: 1.5px solid;
text-decoration: none !important;
font-weight: 600;
font-size: 0.8rem;
transition: all 0.15s;
position: relative;
}
.tech-quick-link:active { transform: scale(0.97); }
.tech-quick-link i { font-size: 1.1rem; }
.tech-quick-link-primary {
border-color: #3498db;
color: #3498db !important;
background: rgba(52, 152, 219, 0.04);
}
.tech-quick-link-primary:hover { background: rgba(52, 152, 219, 0.1); }
.tech-quick-link-secondary {
border-color: #6c757d;
color: #6c757d !important;
background: rgba(108, 117, 125, 0.04);
}
.tech-quick-link-secondary:hover { background: rgba(108, 117, 125, 0.1); }
.tech-quick-link-warning {
border-color: #e67e22;
color: #e67e22 !important;
background: rgba(230, 126, 34, 0.04);
}
.tech-quick-link-warning:hover { background: rgba(230, 126, 34, 0.1); }
.tech-quick-link-badge {
position: absolute;
top: -6px;
right: -6px;
background: #3498db;
color: #fff;
font-size: 0.65rem;
font-weight: 700;
min-width: 18px;
height: 18px;
line-height: 18px;
text-align: center;
border-radius: 9px;
padding: 0 4px;
}
/* ---- Hero Card (Dashboard Current Task) ---- */
.tech-hero-card {
@@ -475,12 +609,18 @@
gap: 1rem;
}
.tech-stat-card {
min-width: 130px;
padding: 1rem 1.5rem;
}
.tech-stat-card .stat-number {
font-size: 2rem;
}
.tech-quick-links {
gap: 1rem;
}
.tech-quick-link {
padding: 1rem 0.75rem;
font-size: 0.85rem;
}
.tech-bottom-bar {
position: static;
box-shadow: none;

View File

@@ -28,6 +28,9 @@ patch(Chatter.prototype, {
[thread.id],
);
if (result && result.type === "ir.actions.act_window") {
if (!result.views && result.view_mode) {
result.views = result.view_mode.split(",").map(v => [false, v.trim()]);
}
this._fapActionService.doAction(result);
}
} catch (e) {

View File

@@ -1,15 +1,144 @@
/**
* Technician Location Logger
* Logs GPS location every 5 minutes during working hours (9 AM - 6 PM)
* Only logs while the browser tab is visible.
* Technician Location Services
*
* 1. Background logger -- logs GPS every 5 minutes during working hours.
* 2. getLocation() -- returns a Promise that resolves to {latitude, longitude, accuracy}.
* If the user denies permission or the request times out a blocking modal is shown
* and the promise is rejected.
* 3. Blocking modal -- cannot be dismissed; forces the technician to grant permission.
*/
(function () {
'use strict';
var INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
var INTERVAL_MS = 5 * 60 * 1000;
var STORE_OPEN_HOUR = 9;
var STORE_CLOSE_HOUR = 18;
var locationTimer = null;
var permissionDenied = false;
// =====================================================================
// BLOCKING MODAL
// =====================================================================
var modalEl = null;
function ensureModal() {
if (modalEl) return;
var div = document.createElement('div');
div.id = 'fusionLocationModal';
div.innerHTML =
'<div style="position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:99999;display:flex;align-items:center;justify-content:center;">' +
'<div style="background:#fff;border-radius:16px;max-width:400px;width:90%;padding:2rem;text-align:center;box-shadow:0 8px 32px rgba(0,0,0,.3);">' +
'<div style="font-size:3rem;color:#dc3545;margin-bottom:1rem;"><i class="fa fa-map-marker"></i></div>' +
'<h4 style="margin-bottom:0.5rem;">Location Required</h4>' +
'<p style="color:#666;font-size:0.95rem;">Your GPS location is mandatory to perform this action. ' +
'Please allow location access in your browser settings and try again.</p>' +
'<p style="color:#999;font-size:0.85rem;">If you previously denied access, open your browser settings ' +
'and reset the location permission for this site.</p>' +
'<button id="fusionLocationRetryBtn" style="background:#0d6efd;color:#fff;border:none;border-radius:12px;padding:0.75rem 2rem;font-size:1rem;cursor:pointer;margin-top:0.5rem;width:100%;">' +
'<i class="fa fa-refresh" style="margin-right:6px;"></i>Try Again' +
'</button>' +
'</div>' +
'</div>';
document.body.appendChild(div);
modalEl = div;
document.getElementById('fusionLocationRetryBtn').addEventListener('click', function () {
hideModal();
window.fusionGetLocation().catch(function () {
showModal();
});
});
}
function showModal() {
ensureModal();
modalEl.style.display = '';
}
function hideModal() {
if (modalEl) modalEl.style.display = 'none';
}
// =====================================================================
// PERMISSION-DENIED BANNER (persistent warning for background logger)
// =====================================================================
var bannerEl = null;
function showDeniedBanner() {
if (bannerEl) return;
bannerEl = document.createElement('div');
bannerEl.id = 'fusionLocationBanner';
bannerEl.style.cssText =
'position:fixed;top:0;left:0;right:0;z-index:9999;background:#dc3545;color:#fff;' +
'padding:10px 16px;text-align:center;font-size:0.9rem;font-weight:600;box-shadow:0 2px 8px rgba(0,0,0,.2);';
bannerEl.innerHTML =
'<i class="fa fa-exclamation-triangle" style="margin-right:6px;"></i>' +
'Location access is denied. Your location is not being tracked. ' +
'Please enable location in browser settings.';
document.body.appendChild(bannerEl);
}
// =====================================================================
// getLocation() -- public API
// =====================================================================
function getLocation() {
return new Promise(function (resolve, reject) {
if (!navigator.geolocation) {
reject(new Error('Geolocation is not supported by this browser.'));
return;
}
navigator.geolocation.getCurrentPosition(
function (position) {
permissionDenied = false;
if (bannerEl) { bannerEl.remove(); bannerEl = null; }
resolve({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy || 0,
});
},
function (error) {
permissionDenied = true;
showDeniedBanner();
console.error('Fusion Location: GPS error', error.code, error.message);
reject(error);
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 30000 }
);
});
}
window.fusionGetLocation = getLocation;
// =====================================================================
// NAVIGATE -- opens Google Maps app on iOS/Android, browser fallback
// =====================================================================
function openGoogleMapsNav(el) {
var addr = (el.dataset.navAddr || '').trim();
var fallbackUrl = el.dataset.navUrl || '';
if (!addr && !fallbackUrl) return;
var dest = encodeURIComponent(addr) || fallbackUrl.split('destination=')[1];
var isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
var isAndroid = /Android/i.test(navigator.userAgent);
if (isIOS) {
window.location.href = 'comgooglemaps://?daddr=' + dest + '&directionsmode=driving';
} else if (isAndroid) {
window.location.href = 'google.navigation:q=' + dest;
} else {
window.open(fallbackUrl, '_blank');
}
}
window.openGoogleMapsNav = openGoogleMapsNav;
// =====================================================================
// BACKGROUND LOGGER
// =====================================================================
function isWorkingHours() {
var now = new Date();
@@ -18,77 +147,54 @@
}
function isTechnicianPortal() {
// Check if we're on a technician portal page
return window.location.pathname.indexOf('/my/technician') !== -1;
}
function logLocation() {
if (!isWorkingHours()) {
return;
}
if (document.hidden) {
return;
}
if (!navigator.geolocation) {
return;
}
if (!isWorkingHours() || document.hidden || !navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(
function (position) {
var data = {
getLocation().then(function (coords) {
fetch('/my/technician/location/log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy || 0,
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy,
}
};
fetch('/my/technician/location/log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).catch(function () {
// Silently fail - location logging is best-effort
});
},
function () {
// Geolocation permission denied or error - silently ignore
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
);
}),
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.result && !data.result.success) {
console.warn('Fusion Location: server rejected log', data.result);
}
})
.catch(function (err) {
console.warn('Fusion Location: network error', err);
});
}).catch(function () {
/* permission denied -- banner already shown */
});
}
function startLocationLogging() {
if (!isTechnicianPortal()) {
return;
}
// Log immediately on page load
if (!isTechnicianPortal()) return;
logLocation();
// Set interval for periodic logging
locationTimer = setInterval(logLocation, INTERVAL_MS);
// Pause/resume on tab visibility change
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
// Tab hidden - clear interval to save battery
if (locationTimer) {
clearInterval(locationTimer);
locationTimer = null;
}
if (locationTimer) { clearInterval(locationTimer); locationTimer = null; }
} else {
// Tab visible again - log immediately and restart interval
logLocation();
if (!locationTimer) {
locationTimer = setInterval(logLocation, INTERVAL_MS);
}
if (!locationTimer) { locationTimer = setInterval(logLocation, INTERVAL_MS); }
}
});
}
// Start when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startLocationLogging);
} else {

View File

@@ -18,6 +18,41 @@
</ol>
</nav>
<!-- Clock In/Out -->
<t t-if="clock_enabled">
<div class="tech-clock-card mb-3"
id="techClockCard"
t-att-data-checked-in="'true' if clock_checked_in else 'false'"
t-att-data-check-in-time="clock_check_in_time or ''">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<div class="tech-clock-dot" t-att-class="'tech-clock-dot--active' if clock_checked_in else ''"/>
<div>
<div class="tech-clock-status" id="clockStatusText">
<t t-if="clock_checked_in">Clocked In</t>
<t t-else="">Not Clocked In</t>
</div>
<div class="tech-clock-timer" id="clockTimer">00:00:00</div>
</div>
</div>
<button class="tech-clock-btn" id="clockActionBtn"
t-att-class="'tech-clock-btn--out' if clock_checked_in else 'tech-clock-btn--in'"
onclick="handleClockAction()">
<t t-if="clock_checked_in">
<i class="fa fa-stop-circle-o"/> Clock Out
</t>
<t t-else="">
<i class="fa fa-play-circle-o"/> Clock In
</t>
</button>
</div>
<div class="tech-clock-error" id="clockError" style="display:none;">
<i class="fa fa-exclamation-triangle"/>
<span id="clockErrorText"/>
</div>
</div>
</t>
<!-- Quick Stats Bar -->
<div class="tech-stats-bar mb-4">
<div class="tech-stat-card tech-stat-total">
@@ -32,10 +67,6 @@
<div class="stat-number"><t t-out="completed_today"/></div>
<div class="stat-label">Done</div>
</div>
<div class="tech-stat-card tech-stat-travel">
<div class="stat-number"><t t-out="total_travel"/></div>
<div class="stat-label">Travel min</div>
</div>
</div>
<!-- Current / Next Task Hero Card -->
@@ -61,8 +92,11 @@
<p class="mb-2 small"><t t-out="current_task.description"/></p>
</t>
<div class="d-flex gap-2 flex-wrap mt-3">
<a t-if="current_task.get_google_maps_url()" t-att-href="current_task.get_google_maps_url()"
class="tech-action-btn tech-btn-navigate" target="_blank">
<a t-if="current_task.get_google_maps_url()"
href="#" class="tech-action-btn tech-btn-navigate"
t-att-data-nav-url="current_task.get_google_maps_url()"
t-att-data-nav-addr="current_task.address_display or ''"
onclick="openGoogleMapsNav(this); return false;">
<i class="fa fa-location-arrow"/>Navigate
</a>
<a t-attf-href="/my/technician/task/#{current_task.id}"
@@ -102,8 +136,11 @@
</p>
</t>
<div class="d-flex gap-2 flex-wrap mt-3">
<a t-if="next_task.get_google_maps_url()" t-att-href="next_task.get_google_maps_url()"
class="tech-action-btn tech-btn-navigate" target="_blank">
<a t-if="next_task.get_google_maps_url()"
href="#" class="tech-action-btn tech-btn-navigate"
t-att-data-nav-url="next_task.get_google_maps_url()"
t-att-data-nav-addr="next_task.address_display or ''"
onclick="openGoogleMapsNav(this); return false;">
<i class="fa fa-location-arrow"/>Navigate
</a>
<button class="tech-action-btn tech-btn-enroute"
@@ -170,25 +207,22 @@
</t>
<!-- Quick Links -->
<div class="row g-2 mb-4">
<div class="col-4">
<a href="/my/technician/tasks" class="btn btn-outline-primary w-100 py-3">
<i class="fa fa-list me-1"/>All Tasks
</a>
</div>
<div class="col-4">
<a href="/my/technician/tomorrow" class="btn btn-outline-secondary w-100 py-3">
<i class="fa fa-calendar me-1"/>Tomorrow
<t t-if="tomorrow_count">
<span class="badge bg-primary ms-1"><t t-out="tomorrow_count"/></span>
</t>
</a>
</div>
<div class="col-4">
<a href="/repair-form" class="btn btn-outline-warning w-100 py-3">
<i class="fa fa-wrench me-1"/>Repair Form
</a>
</div>
<div class="tech-quick-links mb-4">
<a href="/my/technician/tasks" class="tech-quick-link tech-quick-link-primary">
<i class="fa fa-list"/>
<span>All Tasks</span>
</a>
<a href="/my/technician/tomorrow" class="tech-quick-link tech-quick-link-secondary">
<i class="fa fa-calendar"/>
<span>Tomorrow</span>
<t t-if="tomorrow_count">
<span class="tech-quick-link-badge"><t t-out="tomorrow_count"/></span>
</t>
</a>
<a href="/repair-form" class="tech-quick-link tech-quick-link-warning">
<i class="fa fa-wrench"/>
<span>Repair Form</span>
</a>
</div>
<!-- My Start Location -->
@@ -221,30 +255,146 @@
</div>
</div>
<!-- Clock In/Out JS -->
<script type="text/javascript">
(function() {
var card = document.getElementById('techClockCard');
if (!card) return;
var isCheckedIn = card.dataset.checkedIn === 'true';
var checkInTime = card.dataset.checkInTime ? new Date(card.dataset.checkInTime + 'Z') : null;
var timerInterval = null;
function updateTimer() {
if (!checkInTime) return;
var diff = Math.max(0, Math.floor((new Date() - checkInTime) / 1000));
var h = Math.floor(diff / 3600);
var m = Math.floor((diff % 3600) / 60);
var s = diff % 60;
var pad = function(n) { return n &lt; 10 ? '0' + n : '' + n; };
document.getElementById('clockTimer').textContent = pad(h) + ':' + pad(m) + ':' + pad(s);
}
function startTimer() {
stopTimer();
updateTimer();
timerInterval = setInterval(updateTimer, 1000);
}
function stopTimer() {
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
}
function applyState() {
var dot = card.querySelector('.tech-clock-dot');
var statusEl = document.getElementById('clockStatusText');
var btn = document.getElementById('clockActionBtn');
var timerEl = document.getElementById('clockTimer');
if (dot) dot.className = 'tech-clock-dot' + (isCheckedIn ? ' tech-clock-dot--active' : '');
if (statusEl) statusEl.textContent = isCheckedIn ? 'Clocked In' : 'Not Clocked In';
if (btn) {
btn.className = 'tech-clock-btn ' + (isCheckedIn ? 'tech-clock-btn--out' : 'tech-clock-btn--in');
btn.innerHTML = isCheckedIn
? '&lt;i class="fa fa-stop-circle-o">&lt;/i> Clock Out'
: '&lt;i class="fa fa-play-circle-o">&lt;/i> Clock In';
}
if (!isCheckedIn &amp;&amp; timerEl) timerEl.textContent = '00:00:00';
}
if (isCheckedIn &amp;&amp; checkInTime) startTimer();
window.handleClockAction = function() {
var btn = document.getElementById('clockActionBtn');
var errEl = document.getElementById('clockError');
var errText = document.getElementById('clockErrorText');
btn.disabled = true;
errEl.style.display = 'none';
window.fusionGetLocation().then(function(coords) {
fetch('/fusion_clock/clock_action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy,
source: 'portal'
}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
var result = data.result || {};
if (result.error) {
errText.textContent = result.error;
errEl.style.display = 'flex';
btn.disabled = false;
return;
}
if (result.action === 'clock_in') {
isCheckedIn = true;
checkInTime = new Date(result.check_in + 'Z');
startTimer();
} else {
isCheckedIn = false;
checkInTime = null;
stopTimer();
}
applyState();
btn.disabled = false;
})
.catch(function() {
errText.textContent = 'Network error. Please try again.';
errEl.style.display = 'flex';
btn.disabled = false;
});
}).catch(function() {
errText.textContent = 'Location access is required for clock in/out.';
errEl.style.display = 'flex';
btn.disabled = false;
});
};
})();
</script>
<!-- Inline JS for task actions -->
<script type="text/javascript">
function techTaskAction(btn, action) {
var taskId = btn.dataset.taskId;
var origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> ...';
fetch('/my/technician/task/' + taskId + '/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {action: action}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.result &amp;&amp; data.result.success) {
window.location.reload();
} else {
alert(data.result ? data.result.error : 'Error');
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Getting location...';
window.fusionGetLocation().then(function(coords) {
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> ...';
fetch('/my/technician/task/' + taskId + '/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
action: action,
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy
}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.result &amp;&amp; data.result.success) {
window.location.reload();
} else {
alert(data.result ? data.result.error : 'Error');
btn.disabled = false;
btn.innerHTML = origHtml;
}
})
.catch(function() {
alert('Network error. Please try again.');
btn.disabled = false;
btn.innerHTML = '<i class="fa fa-road"></i> En Route';
}
})
.catch(function() {
btn.innerHTML = origHtml;
});
}).catch(function() {
alert('Location access is required. Please enable GPS and try again.');
btn.disabled = false;
btn.innerHTML = '<i class="fa fa-road"></i> En Route';
btn.innerHTML = origHtml;
});
}
@@ -462,7 +612,10 @@
<!-- ===== QUICK ACTIONS ROW ===== -->
<div class="tech-quick-actions mb-3">
<t t-if="task.get_google_maps_url()">
<a t-att-href="task.get_google_maps_url()" class="tech-quick-btn" target="_blank">
<a href="#" class="tech-quick-btn"
t-att-data-nav-url="task.get_google_maps_url()"
t-att-data-nav-addr="task.address_display or ''"
onclick="openGoogleMapsNav(this); return false;">
<i class="fa fa-location-arrow"/>
<span>Navigate</span>
</a>
@@ -695,8 +848,11 @@
</button>
</t>
<t t-if="task.status == 'en_route'">
<a t-if="task.get_google_maps_url()" t-att-href="task.get_google_maps_url()"
class="tech-action-btn tech-btn-navigate" target="_blank">
<a t-if="task.get_google_maps_url()"
href="#" class="tech-action-btn tech-btn-navigate"
t-att-data-nav-url="task.get_google_maps_url()"
t-att-data-nav-addr="task.address_display or ''"
onclick="openGoogleMapsNav(this); return false;">
<i class="fa fa-location-arrow"/>Navigate
</a>
<button class="tech-action-btn tech-btn-start"
@@ -750,46 +906,78 @@
var recordingSeconds = 0;
function techTaskAction(btn, action) {
var origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i>';
fetch('/my/technician/task/' + taskId + '/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {action: action}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.result &amp;&amp; data.result.success) {
window.location.reload();
} else {
alert(data.result ? data.result.error : 'Error');
btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i> Getting location...';
window.fusionGetLocation().then(function(coords) {
btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i>';
fetch('/my/technician/task/' + taskId + '/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
action: action,
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy
}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.result &amp;&amp; data.result.success) {
window.location.reload();
} else {
alert(data.result ? data.result.error : 'Error');
btn.disabled = false;
btn.innerHTML = origHtml;
}
})
.catch(function() {
alert('Network error. Please try again.');
btn.disabled = false;
}
btn.innerHTML = origHtml;
});
}).catch(function() {
alert('Location access is required. Please enable GPS and try again.');
btn.disabled = false;
btn.innerHTML = origHtml;
});
}
function techCompleteTask(btn) {
var origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i> Completing...';
fetch('/my/technician/task/' + taskId + '/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {action: 'complete'}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.result &amp;&amp; data.result.success) {
showCompletionOverlay(data.result);
} else {
alert(data.result ? data.result.error : 'Error completing task');
btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i> Getting location...';
window.fusionGetLocation().then(function(coords) {
btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i> Completing...';
fetch('/my/technician/task/' + taskId + '/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
action: 'complete',
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy
}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.result &amp;&amp; data.result.success) {
showCompletionOverlay(data.result);
} else {
alert(data.result ? data.result.error : 'Error completing task');
btn.disabled = false;
btn.innerHTML = origHtml;
}
})
.catch(function(err) {
alert('Network error. Please try again.');
btn.disabled = false;
btn.innerHTML = '&lt;i class="fa fa-check-circle">&lt;/i> Complete Task';
}
})
.catch(function(err) {
alert('Network error. Please try again.');
btn.innerHTML = origHtml;
});
}).catch(function() {
alert('Location access is required. Please enable GPS and try again.');
btn.disabled = false;
btn.innerHTML = '&lt;i class="fa fa-check-circle">&lt;/i> Complete Task';
btn.innerHTML = origHtml;
});
}
@@ -1184,10 +1372,16 @@
btns.forEach(function(b){b.disabled = true;});
try {
var coords = await window.fusionGetLocation();
var resp = await fetch('/my/technician/task/' + taskId + '/voice-complete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {transcription: text}})
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
transcription: text,
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy
}})
});
var data = await resp.json();
if (data.result &amp;&amp; data.result.success) {
@@ -1197,7 +1391,11 @@
btns.forEach(function(b){b.disabled = false;});
}
} catch (err) {
alert('Error: ' + err.message);
if (err instanceof GeolocationPositionError || err.code) {
alert('Location access is required. Please enable GPS and try again.');
} else {
alert('Error: ' + err.message);
}
btns.forEach(function(b){b.disabled = false;});
}
}

View File

@@ -188,7 +188,73 @@
</a>
</div>
</t>
<!-- Clock In/Out -->
<t t-if="clock_enabled">
<div class="col-md-6">
<style>
@keyframes hcPulseGreen {
0% { box-shadow: 0 0 0 0 rgba(16,185,129,0.5); }
70% { box-shadow: 0 0 0 14px rgba(16,185,129,0); }
100% { box-shadow: 0 0 0 0 rgba(16,185,129,0); }
}
@keyframes hcPulseRed {
0% { box-shadow: 0 0 0 0 rgba(239,68,68,0.5); }
70% { box-shadow: 0 0 0 14px rgba(239,68,68,0); }
100% { box-shadow: 0 0 0 0 rgba(239,68,68,0); }
}
.hc-btn-ring {
width: 56px; height: 56px; border-radius: 50%; border: none;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; pointer-events: none;
}
.hc-btn-ring--in {
background: #10b981;
animation: hcPulseGreen 2s ease-in-out infinite;
}
.hc-btn-ring--out {
background: #ef4444;
animation: hcPulseRed 2s ease-in-out infinite;
}
.hc-btn-ring i { color: #fff; font-size: 1.4rem; }
.hc-btn-ring--in i { padding-left: 3px; }
.hc-timer-badge {
display: inline-block; font-family: monospace; font-size: 0.75rem; font-weight: 700;
color: #10b981; background: rgba(16,185,129,0.1); border-radius: 20px;
padding: 2px 10px; letter-spacing: 0.05em;
}
.hc-clock-link { text-decoration: none; }
.hc-clock-link:hover { text-decoration: none; }
.hc-clock-link:hover .card { box-shadow: 0 4px 16px rgba(0,0,0,0.12) !important; }
.hc-clock-link:active .hc-btn-ring { transform: scale(0.92); }
</style>
<a href="/my/clock" class="hc-clock-link"
id="homeClockCard"
t-att-data-checked-in="'true' if clock_checked_in else 'false'"
t-att-data-check-in-time="clock_check_in_time or ''">
<div class="card h-100 border-0 shadow-sm" style="border-radius: 12px; min-height: 100px;">
<div class="card-body d-flex align-items-center p-4">
<div class="me-3">
<div t-attf-class="hc-btn-ring #{clock_checked_in and 'hc-btn-ring--out' or 'hc-btn-ring--in'}">
<i t-attf-class="fa #{clock_checked_in and 'fa-stop' or 'fa-play'}"/>
</div>
</div>
<div style="min-width: 0;">
<h5 class="mb-0 text-dark" id="homeClockStatus">
<t t-if="clock_checked_in">Clocked In</t>
<t t-else="">Clock In</t>
</h5>
<div id="homeClockTimer">
<t t-if="clock_checked_in"><span class="hc-timer-badge">00:00:00</span></t>
<t t-else=""><small class="text-muted">Tap to start your shift</small></t>
</div>
</div>
</div>
</div>
</a>
</div>
</t>
<!-- Funding Claims (Clients/Authorizers) -->
<t t-if="request.env.user.partner_id.is_client_portal or request.env.user.partner_id.is_authorizer">
<div class="col-md-6">
@@ -251,6 +317,28 @@
</div>
</div>
</div>
<!-- Home Clock Timer (display only, links to /my/clock) -->
<script type="text/javascript">
(function() {
var card = document.getElementById('homeClockCard');
if (!card) return;
var isCheckedIn = card.dataset.checkedIn === 'true';
var checkInTime = card.dataset.checkInTime ? new Date(card.dataset.checkInTime + 'Z') : null;
if (!isCheckedIn || !checkInTime) return;
function pad(n) { return n &lt; 10 ? '0' + n : '' + n; }
var badge = document.querySelector('#homeClockTimer .hc-timer-badge');
if (!badge) return;
function tick() {
var diff = Math.max(0, Math.floor((new Date() - checkInTime) / 1000));
badge.textContent = pad(Math.floor(diff / 3600)) + ':' + pad(Math.floor((diff % 3600) / 60)) + ':' + pad(diff % 60);
}
tick();
setInterval(tick, 1000);
})();
</script>
</t>
</xpath>
</template>
@@ -1086,7 +1174,42 @@
<p class="text-muted">Welcome back, <t t-out="partner.name"/>!</p>
</div>
</div>
<!-- Clock In/Out -->
<t t-if="clock_enabled">
<div class="tech-clock-card mb-3"
id="techClockCard"
t-att-data-checked-in="'true' if clock_checked_in else 'false'"
t-att-data-check-in-time="clock_check_in_time or ''">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<div class="tech-clock-dot" t-att-class="'tech-clock-dot--active' if clock_checked_in else ''"/>
<div>
<div class="tech-clock-status" id="clockStatusText">
<t t-if="clock_checked_in">Clocked In</t>
<t t-else="">Not Clocked In</t>
</div>
<div class="tech-clock-timer" id="clockTimer">00:00:00</div>
</div>
</div>
<button class="tech-clock-btn" id="clockActionBtn"
t-att-class="'tech-clock-btn--out' if clock_checked_in else 'tech-clock-btn--in'"
onclick="handleClockAction()">
<t t-if="clock_checked_in">
<i class="fa fa-stop-circle-o"/> Clock Out
</t>
<t t-else="">
<i class="fa fa-play-circle-o"/> Clock In
</t>
</button>
</div>
<div class="tech-clock-error" id="clockError" style="display:none;">
<i class="fa fa-exclamation-triangle"/>
<span id="clockErrorText"/>
</div>
</div>
</t>
<!-- Stats Cards - 2x2 on mobile, 4 columns on desktop -->
<div class="row mb-3 g-2">
<div class="col-6 col-md-3">
@@ -1377,6 +1500,113 @@
<!-- Include loaner modals -->
<t t-call="fusion_authorizer_portal.loaner_checkout_modal"/>
<t t-call="fusion_authorizer_portal.loaner_return_modal"/>
<!-- Clock In/Out JS -->
<script type="text/javascript">
(function() {
var card = document.getElementById('techClockCard');
if (!card) return;
var isCheckedIn = card.dataset.checkedIn === 'true';
var checkInTime = card.dataset.checkInTime ? new Date(card.dataset.checkInTime + 'Z') : null;
var timerInterval = null;
function updateTimer() {
if (!checkInTime) return;
var diff = Math.max(0, Math.floor((new Date() - checkInTime) / 1000));
var h = Math.floor(diff / 3600);
var m = Math.floor((diff % 3600) / 60);
var s = diff % 60;
var pad = function(n) { return n &lt; 10 ? '0' + n : '' + n; };
document.getElementById('clockTimer').textContent = pad(h) + ':' + pad(m) + ':' + pad(s);
}
function startTimer() { stopTimer(); updateTimer(); timerInterval = setInterval(updateTimer, 1000); }
function stopTimer() { if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } }
function applyState() {
var dot = card.querySelector('.tech-clock-dot');
var statusEl = document.getElementById('clockStatusText');
var btn = document.getElementById('clockActionBtn');
var timerEl = document.getElementById('clockTimer');
if (dot) dot.className = 'tech-clock-dot' + (isCheckedIn ? ' tech-clock-dot--active' : '');
if (statusEl) statusEl.textContent = isCheckedIn ? 'Clocked In' : 'Not Clocked In';
if (btn) {
btn.className = 'tech-clock-btn ' + (isCheckedIn ? 'tech-clock-btn--out' : 'tech-clock-btn--in');
btn.innerHTML = isCheckedIn
? '&lt;i class="fa fa-stop-circle-o">&lt;/i> Clock Out'
: '&lt;i class="fa fa-play-circle-o">&lt;/i> Clock In';
}
if (!isCheckedIn &amp;&amp; timerEl) timerEl.textContent = '00:00:00';
}
if (isCheckedIn &amp;&amp; checkInTime) startTimer();
window.handleClockAction = function() {
var btn = document.getElementById('clockActionBtn');
var errEl = document.getElementById('clockError');
var errText = document.getElementById('clockErrorText');
btn.disabled = true;
errEl.style.display = 'none';
function doClockAction(lat, lng, acc) {
fetch('/fusion_clock/clock_action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
latitude: lat, longitude: lng, accuracy: acc, source: 'portal'
}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
var result = data.result || {};
if (result.error) {
errText.textContent = result.error;
errEl.style.display = 'flex';
btn.disabled = false;
return;
}
if (result.action === 'clock_in') {
isCheckedIn = true;
checkInTime = new Date(result.check_in + 'Z');
startTimer();
} else {
isCheckedIn = false;
checkInTime = null;
stopTimer();
}
applyState();
btn.disabled = false;
})
.catch(function() {
errText.textContent = 'Network error. Please try again.';
errEl.style.display = 'flex';
btn.disabled = false;
});
}
if (window.fusionGetLocation) {
window.fusionGetLocation().then(function(coords) {
doClockAction(coords.latitude, coords.longitude, coords.accuracy);
}).catch(function() {
errText.textContent = 'Location access is required for clock in/out.';
errEl.style.display = 'flex';
btn.disabled = false;
});
} else if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(function(pos) {
doClockAction(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy);
}, function() {
errText.textContent = 'Location access is required for clock in/out.';
errEl.style.display = 'flex';
btn.disabled = false;
}, {enableHighAccuracy: true, timeout: 15000});
} else {
doClockAction(0, 0, 0);
}
};
})();
</script>
</t>
</template>