feat: separate fusion field service and LTC into standalone modules, update core modules
- fusion_claims: separated field service logic, updated controllers/views - fusion_tasks: updated task views and map integration - fusion_authorizer_portal: added page 11 signing, schedule booking, migrations - fusion_shipping: new standalone shipping module (Canada Post, FedEx, DHL, Purolator) - fusion_ltc_management: new standalone LTC management module
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var dateInput = document.getElementById('bookingDate');
|
||||
var slotsContainer = document.getElementById('slotsContainer');
|
||||
var slotsGrid = document.getElementById('slotsGrid');
|
||||
var slotsLoading = document.getElementById('slotsLoading');
|
||||
var noSlots = document.getElementById('noSlots');
|
||||
var slotDatetimeInput = document.getElementById('slotDatetime');
|
||||
var slotDurationInput = document.getElementById('slotDuration');
|
||||
var submitBtn = document.getElementById('btnSubmitBooking');
|
||||
var typeSelect = document.getElementById('appointmentTypeSelect');
|
||||
var selectedSlotBtn = null;
|
||||
|
||||
var weekContainer = document.getElementById('weekCalendarContainer');
|
||||
var weekLoading = document.getElementById('weekCalendarLoading');
|
||||
var weekGrid = document.getElementById('weekCalendarGrid');
|
||||
var weekHeader = document.getElementById('weekCalendarHeader');
|
||||
var weekBody = document.getElementById('weekCalendarBody');
|
||||
var weekEmpty = document.getElementById('weekCalendarEmpty');
|
||||
|
||||
function getAppointmentTypeId() {
|
||||
if (typeSelect) return typeSelect.value;
|
||||
var hidden = document.querySelector('input[name="appointment_type_id"]');
|
||||
return hidden ? hidden.value : null;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function truncate(str, max) {
|
||||
if (!str) return '';
|
||||
return str.length > max ? str.substring(0, max) + '...' : str;
|
||||
}
|
||||
|
||||
function fetchWeekEvents(date) {
|
||||
if (!weekContainer || !date) return;
|
||||
|
||||
weekContainer.style.display = 'block';
|
||||
weekLoading.style.display = 'block';
|
||||
weekGrid.style.display = 'none';
|
||||
weekEmpty.style.display = 'none';
|
||||
|
||||
fetch('/my/schedule/week-events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'call',
|
||||
params: { selected_date: date },
|
||||
}),
|
||||
})
|
||||
.then(function (resp) { return resp.json(); })
|
||||
.then(function (data) {
|
||||
weekLoading.style.display = 'none';
|
||||
var result = data.result || {};
|
||||
var events = result.events || [];
|
||||
var weekDays = result.week_days || [];
|
||||
|
||||
if (result.error || !weekDays.length) {
|
||||
weekEmpty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
renderWeekCalendar(weekDays, events, date);
|
||||
})
|
||||
.catch(function () {
|
||||
weekLoading.style.display = 'none';
|
||||
weekEmpty.textContent = 'Failed to load calendar. Please try again.';
|
||||
weekEmpty.style.display = 'block';
|
||||
});
|
||||
}
|
||||
|
||||
function renderWeekCalendar(weekDays, events, selectedDate) {
|
||||
weekHeader.innerHTML = '';
|
||||
weekBody.innerHTML = '';
|
||||
|
||||
var eventsByDate = {};
|
||||
events.forEach(function (ev) {
|
||||
if (!eventsByDate[ev.date]) eventsByDate[ev.date] = [];
|
||||
eventsByDate[ev.date].push(ev);
|
||||
});
|
||||
|
||||
var hasAnyEvents = events.length > 0;
|
||||
|
||||
weekDays.forEach(function (day) {
|
||||
var isSelected = day.date === selectedDate;
|
||||
var isWeekend = day.label === 'Sat' || day.label === 'Sun';
|
||||
var dayEvents = eventsByDate[day.date] || [];
|
||||
|
||||
var headerCell = document.createElement('div');
|
||||
headerCell.className = 'text-center py-2 flex-fill';
|
||||
headerCell.style.cssText = 'min-width: 0; font-size: 12px; border-right: 1px solid #dee2e6;';
|
||||
if (isSelected) {
|
||||
headerCell.style.backgroundColor = '#e8f4fd';
|
||||
}
|
||||
if (isWeekend) {
|
||||
headerCell.style.opacity = '0.6';
|
||||
}
|
||||
|
||||
var labelEl = document.createElement('div');
|
||||
labelEl.className = 'fw-semibold text-muted';
|
||||
labelEl.textContent = day.label;
|
||||
|
||||
var numEl = document.createElement('div');
|
||||
numEl.className = isSelected ? 'fw-bold text-primary' : 'fw-semibold';
|
||||
numEl.style.fontSize = '14px';
|
||||
numEl.textContent = day.day_num;
|
||||
|
||||
headerCell.appendChild(labelEl);
|
||||
headerCell.appendChild(numEl);
|
||||
weekHeader.appendChild(headerCell);
|
||||
|
||||
var bodyCell = document.createElement('div');
|
||||
bodyCell.className = 'flex-fill p-1';
|
||||
bodyCell.style.cssText = 'min-width: 0; min-height: 70px; border-right: 1px solid #dee2e6; overflow: hidden;';
|
||||
if (isSelected) {
|
||||
bodyCell.style.backgroundColor = '#f0f8ff';
|
||||
}
|
||||
|
||||
if (dayEvents.length) {
|
||||
dayEvents.forEach(function (ev) {
|
||||
var card = document.createElement('div');
|
||||
card.className = 'mb-1 px-1 py-1 rounded';
|
||||
card.style.cssText = 'font-size: 11px; background: #eef6ff; border-left: 3px solid #3a8fb7; overflow: hidden; cursor: default;';
|
||||
card.title = ev.start_time + ' - ' + ev.end_time + '\n' + ev.name + (ev.location ? '\n' + ev.location : '');
|
||||
|
||||
var timeEl = document.createElement('div');
|
||||
timeEl.className = 'fw-semibold text-primary';
|
||||
timeEl.style.fontSize = '10px';
|
||||
timeEl.textContent = ev.start_time;
|
||||
|
||||
var nameEl = document.createElement('div');
|
||||
nameEl.className = 'text-truncate';
|
||||
nameEl.style.fontSize = '10px';
|
||||
nameEl.textContent = truncate(ev.name, 18);
|
||||
|
||||
card.appendChild(timeEl);
|
||||
card.appendChild(nameEl);
|
||||
bodyCell.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
weekBody.appendChild(bodyCell);
|
||||
});
|
||||
|
||||
if (hasAnyEvents) {
|
||||
weekGrid.style.display = 'block';
|
||||
weekEmpty.style.display = 'none';
|
||||
} else {
|
||||
weekGrid.style.display = 'none';
|
||||
weekEmpty.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function fetchSlots(date) {
|
||||
var typeId = getAppointmentTypeId();
|
||||
if (!typeId || !date) return;
|
||||
|
||||
slotsContainer.style.display = 'block';
|
||||
slotsLoading.style.display = 'block';
|
||||
slotsGrid.innerHTML = '';
|
||||
noSlots.style.display = 'none';
|
||||
slotDatetimeInput.value = '';
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
selectedSlotBtn = null;
|
||||
|
||||
fetch('/my/schedule/available-slots', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'call',
|
||||
params: {
|
||||
appointment_type_id: parseInt(typeId),
|
||||
selected_date: date,
|
||||
},
|
||||
}),
|
||||
})
|
||||
.then(function (resp) { return resp.json(); })
|
||||
.then(function (data) {
|
||||
slotsLoading.style.display = 'none';
|
||||
var result = data.result || {};
|
||||
var slots = result.slots || [];
|
||||
|
||||
if (result.error) {
|
||||
noSlots.textContent = result.error;
|
||||
noSlots.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!slots.length) {
|
||||
noSlots.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
var morningSlots = [];
|
||||
var afternoonSlots = [];
|
||||
slots.forEach(function (slot) {
|
||||
var hour = parseInt(slot.start_hour);
|
||||
if (isNaN(hour)) {
|
||||
var match = slot.start_hour.match(/(\d+)/);
|
||||
hour = match ? parseInt(match[1]) : 0;
|
||||
if (slot.start_hour.toLowerCase().indexOf('pm') > -1 && hour !== 12) hour += 12;
|
||||
if (slot.start_hour.toLowerCase().indexOf('am') > -1 && hour === 12) hour = 0;
|
||||
}
|
||||
if (hour < 12) {
|
||||
morningSlots.push(slot);
|
||||
} else {
|
||||
afternoonSlots.push(slot);
|
||||
}
|
||||
});
|
||||
|
||||
function renderGroup(label, icon, groupSlots) {
|
||||
if (!groupSlots.length) return;
|
||||
var header = document.createElement('div');
|
||||
header.className = 'w-100 mt-2 mb-1';
|
||||
header.innerHTML = '<small class="text-muted fw-semibold"><i class="fa ' + icon + ' me-1"></i>' + label + '</small>';
|
||||
slotsGrid.appendChild(header);
|
||||
|
||||
groupSlots.forEach(function (slot) {
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-outline-primary btn-sm slot-btn';
|
||||
btn.style.cssText = 'min-width: 100px; border-radius: 8px; padding: 8px 14px;';
|
||||
btn.textContent = slot.start_hour;
|
||||
btn.dataset.datetime = slot.datetime;
|
||||
btn.dataset.duration = slot.duration;
|
||||
btn.addEventListener('click', function () {
|
||||
if (selectedSlotBtn) {
|
||||
selectedSlotBtn.classList.remove('btn-primary');
|
||||
selectedSlotBtn.classList.add('btn-outline-primary');
|
||||
}
|
||||
btn.classList.remove('btn-outline-primary');
|
||||
btn.classList.add('btn-primary');
|
||||
selectedSlotBtn = btn;
|
||||
slotDatetimeInput.value = slot.datetime;
|
||||
slotDurationInput.value = slot.duration;
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
});
|
||||
slotsGrid.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
renderGroup('Morning', 'fa-sun-o', morningSlots);
|
||||
renderGroup('Afternoon', 'fa-cloud', afternoonSlots);
|
||||
})
|
||||
.catch(function (err) {
|
||||
slotsLoading.style.display = 'none';
|
||||
noSlots.textContent = 'Failed to load slots. Please try again.';
|
||||
noSlots.style.display = 'block';
|
||||
});
|
||||
}
|
||||
|
||||
if (dateInput) {
|
||||
dateInput.addEventListener('change', function () {
|
||||
var val = this.value;
|
||||
fetchWeekEvents(val);
|
||||
fetchSlots(val);
|
||||
});
|
||||
}
|
||||
|
||||
if (typeSelect) {
|
||||
typeSelect.addEventListener('change', function () {
|
||||
if (dateInput && dateInput.value) {
|
||||
fetchSlots(dateInput.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var bookingForm = document.getElementById('bookingForm');
|
||||
if (bookingForm) {
|
||||
bookingForm.addEventListener('submit', function (e) {
|
||||
if (!slotDatetimeInput || !slotDatetimeInput.value) {
|
||||
e.preventDefault();
|
||||
alert('Please select a time slot before booking.');
|
||||
return false;
|
||||
}
|
||||
var clientName = bookingForm.querySelector('input[name="client_name"]');
|
||||
if (!clientName || !clientName.value.trim()) {
|
||||
e.preventDefault();
|
||||
alert('Please enter the client name.');
|
||||
return false;
|
||||
}
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Booking...';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.initScheduleAddressAutocomplete = function () {
|
||||
var streetInput = document.getElementById('clientStreet');
|
||||
if (!streetInput) return;
|
||||
|
||||
var autocomplete = new google.maps.places.Autocomplete(streetInput, {
|
||||
componentRestrictions: { country: 'ca' },
|
||||
types: ['address'],
|
||||
});
|
||||
|
||||
autocomplete.addListener('place_changed', function () {
|
||||
var place = autocomplete.getPlace();
|
||||
if (!place.address_components) return;
|
||||
|
||||
var streetNumber = '';
|
||||
var streetName = '';
|
||||
var city = '';
|
||||
var province = '';
|
||||
var postalCode = '';
|
||||
|
||||
for (var i = 0; i < place.address_components.length; i++) {
|
||||
var component = place.address_components[i];
|
||||
var types = component.types;
|
||||
|
||||
if (types.indexOf('street_number') > -1) {
|
||||
streetNumber = component.long_name;
|
||||
} else if (types.indexOf('route') > -1) {
|
||||
streetName = component.long_name;
|
||||
} else if (types.indexOf('locality') > -1) {
|
||||
city = component.long_name;
|
||||
} else if (types.indexOf('administrative_area_level_1') > -1) {
|
||||
province = component.long_name;
|
||||
} else if (types.indexOf('postal_code') > -1) {
|
||||
postalCode = component.long_name;
|
||||
}
|
||||
}
|
||||
|
||||
streetInput.value = (streetNumber + ' ' + streetName).trim();
|
||||
var cityInput = document.getElementById('clientCity');
|
||||
if (cityInput) cityInput.value = city;
|
||||
var provInput = document.getElementById('clientProvince');
|
||||
if (provInput) provInput.value = province;
|
||||
var postalInput = document.getElementById('clientPostal');
|
||||
if (postalInput) postalInput.value = postalCode;
|
||||
});
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -1,94 +1,234 @@
|
||||
/**
|
||||
* 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 while the tech is clocked in.
|
||||
* 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 STORE_OPEN_HOUR = 9;
|
||||
var STORE_CLOSE_HOUR = 18;
|
||||
var INTERVAL_MS = 5 * 60 * 1000;
|
||||
var CLOCK_CHECK_MS = 60 * 1000; // check clock status every 60s
|
||||
var locationTimer = null;
|
||||
var clockCheckTimer = null;
|
||||
var isClockedIn = false;
|
||||
var permissionDenied = false;
|
||||
|
||||
function isWorkingHours() {
|
||||
var now = new Date();
|
||||
var hour = now.getHours();
|
||||
return hour >= STORE_OPEN_HOUR && hour < STORE_CLOSE_HOUR;
|
||||
// =====================================================================
|
||||
// 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 isTechnicianPortal() {
|
||||
// Check if we're on a technician portal page
|
||||
return window.location.pathname.indexOf('/my/technician') !== -1;
|
||||
function showModal() {
|
||||
ensureModal();
|
||||
modalEl.style.display = '';
|
||||
}
|
||||
|
||||
function logLocation() {
|
||||
if (!isWorkingHours()) {
|
||||
return;
|
||||
}
|
||||
if (document.hidden) {
|
||||
return;
|
||||
}
|
||||
if (!navigator.geolocation) {
|
||||
return;
|
||||
}
|
||||
function hideModal() {
|
||||
if (modalEl) modalEl.style.display = 'none';
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
function (position) {
|
||||
var data = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'call',
|
||||
params: {
|
||||
// =====================================================================
|
||||
// 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 (tied to clock-in / clock-out status)
|
||||
// =====================================================================
|
||||
|
||||
function isTechnicianPortal() {
|
||||
return window.location.pathname.indexOf('/my/technician') !== -1;
|
||||
}
|
||||
|
||||
function checkClockStatus() {
|
||||
fetch('/my/technician/clock-status', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: {} }),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var wasClocked = isClockedIn;
|
||||
isClockedIn = !!(data.result && data.result.clocked_in);
|
||||
if (isClockedIn && !wasClocked) {
|
||||
// Just clocked in — start tracking immediately
|
||||
startLocationTimer();
|
||||
} else if (!isClockedIn && wasClocked) {
|
||||
// Just clocked out — stop tracking
|
||||
stopLocationTimer();
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
/* network error: keep current state */
|
||||
});
|
||||
}
|
||||
|
||||
function logLocation() {
|
||||
if (!isClockedIn || document.hidden || !navigator.geolocation) return;
|
||||
|
||||
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: 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 startLocationTimer() {
|
||||
if (locationTimer) return; // already running
|
||||
logLocation(); // immediate first log
|
||||
locationTimer = setInterval(logLocation, INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stopLocationTimer() {
|
||||
if (locationTimer) {
|
||||
clearInterval(locationTimer);
|
||||
locationTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startLocationLogging() {
|
||||
if (!isTechnicianPortal()) {
|
||||
return;
|
||||
}
|
||||
if (!isTechnicianPortal()) return;
|
||||
|
||||
// Log immediately on page load
|
||||
logLocation();
|
||||
// Check clock status immediately, then every 60s
|
||||
checkClockStatus();
|
||||
clockCheckTimer = setInterval(checkClockStatus, CLOCK_CHECK_MS);
|
||||
|
||||
// Set interval for periodic logging
|
||||
locationTimer = setInterval(logLocation, INTERVAL_MS);
|
||||
|
||||
// Pause/resume on tab visibility change
|
||||
// Pause/resume on tab visibility
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.hidden) {
|
||||
// Tab hidden - clear interval to save battery
|
||||
if (locationTimer) {
|
||||
clearInterval(locationTimer);
|
||||
locationTimer = null;
|
||||
}
|
||||
} else {
|
||||
// Tab visible again - log immediately and restart interval
|
||||
logLocation();
|
||||
if (!locationTimer) {
|
||||
locationTimer = setInterval(logLocation, INTERVAL_MS);
|
||||
}
|
||||
stopLocationTimer();
|
||||
} else if (isClockedIn) {
|
||||
startLocationTimer();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Start when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', startLocationLogging);
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user