This commit is contained in:
gsinghpal
2026-03-01 14:42:49 -05:00
parent b925766966
commit a3e85a23ef
28 changed files with 2283 additions and 195 deletions

View File

@@ -2,3 +2,29 @@
from . import models
from . import controllers
def _reactivate_views(env):
"""Ensure all module views are active after install/update.
Odoo silently deactivates inherited views when an xpath fails
validation (e.g. parent view structure changed between versions).
Once deactivated, subsequent -u runs never reactivate them.
This hook prevents that from silently breaking the portal.
"""
views = env['ir.ui.view'].sudo().search([
('key', 'like', 'fusion_authorizer_portal.%'),
('active', '=', False),
])
if views:
views.write({'active': True})
env.cr.execute("""
SELECT key FROM ir_ui_view
WHERE key LIKE 'fusion_authorizer_portal.%%'
AND id = ANY(%s)
""", [views.ids])
keys = [r[0] for r in env.cr.fetchall()]
import logging
logging.getLogger(__name__).warning(
"Reactivated %d deactivated views: %s", len(keys), keys
)

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
{
'name': 'Fusion Authorizer & Sales Portal',
'version': '19.0.2.2.0',
'version': '19.0.2.5.0',
'category': 'Sales/Portal',
'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms',
'description': """
@@ -50,6 +50,7 @@ This module provides external portal access for:
'website',
'mail',
'calendar',
'appointment',
'knowledge',
'fusion_claims',
],
@@ -62,6 +63,7 @@ This module provides external portal access for:
'data/portal_menu_data.xml',
'data/ir_actions_server_data.xml',
'data/welcome_articles.xml',
'data/appointment_invite_data.xml',
# Views
'views/res_partner_views.xml',
'views/sale_order_views.xml',
@@ -77,6 +79,7 @@ This module provides external portal access for:
'views/portal_technician_templates.xml',
'views/portal_book_assessment.xml',
'views/portal_repair_form.xml',
'views/portal_schedule.xml',
],
'assets': {
'web.assets_backend': [
@@ -93,9 +96,11 @@ This module provides external portal access for:
'fusion_authorizer_portal/static/src/js/pdf_field_editor.js',
'fusion_authorizer_portal/static/src/js/technician_push.js',
'fusion_authorizer_portal/static/src/js/technician_location.js',
'fusion_authorizer_portal/static/src/js/portal_schedule_booking.js',
],
},
'images': ['static/description/icon.png'],
'post_init_hook': '_reactivate_views',
'installable': True,
'application': False,
'auto_install': False,

View File

@@ -3,4 +3,5 @@
from . import portal_main
from . import portal_assessment
from . import pdf_editor
from . import portal_repair
from . import portal_repair
from . import portal_schedule

View File

@@ -2137,6 +2137,94 @@ class AuthorizerPortal(CustomerPortal):
_logger.error(f"Error saving POD signature: {e}")
return {'success': False, 'error': str(e)}
# ==================== TASK-LEVEL POD SIGNATURE ====================
@http.route('/my/technician/task/<int:task_id>/pod', type='http', auth='user', website=True)
def task_pod_signature_page(self, task_id, **kw):
"""Task-level POD signature capture page (works for all tasks including shadow)."""
if not self._check_technician_access():
return request.redirect('/my')
user = request.env.user
Task = request.env['fusion.technician.task'].sudo()
try:
task = Task.browse(task_id)
if not task.exists() or (
task.technician_id.id != user.id
and user.id not in task.additional_technician_ids.ids
):
raise AccessError(_('You do not have access to this task.'))
except (AccessError, MissingError):
return request.redirect('/my/technician/tasks')
values = {
'task': task,
'has_existing_signature': bool(task.pod_signature),
'page_name': 'task_pod_signature',
}
return request.render('fusion_authorizer_portal.portal_task_pod_signature', values)
@http.route('/my/technician/task/<int:task_id>/pod/sign', type='json', auth='user', methods=['POST'])
def task_pod_save_signature(self, task_id, client_name, signature_data, signature_date=None, **kw):
"""Save POD signature directly on a task."""
if not self._check_technician_access():
return {'success': False, 'error': 'Access denied'}
user = request.env.user
Task = request.env['fusion.technician.task'].sudo()
try:
task = Task.browse(task_id)
if not task.exists() or (
task.technician_id.id != user.id
and user.id not in task.additional_technician_ids.ids
):
return {'success': False, 'error': 'Task not found'}
if not client_name or not client_name.strip():
return {'success': False, 'error': 'Client name is required'}
if not signature_data:
return {'success': False, 'error': 'Signature is required'}
if ',' in signature_data:
signature_data = signature_data.split(',')[1]
from datetime import datetime as dt_datetime
sig_date = None
if signature_date:
try:
sig_date = dt_datetime.strptime(signature_date, '%Y-%m-%d').date()
except ValueError:
pass
task.write({
'pod_signature': signature_data,
'pod_client_name': client_name.strip(),
'pod_signature_date': sig_date,
'pod_signed_by_user_id': user.id,
'pod_signed_datetime': fields.Datetime.now(),
})
if task.sale_order_id:
task.sale_order_id.write({
'x_fc_pod_signature': signature_data,
'x_fc_pod_client_name': client_name.strip(),
'x_fc_pod_signature_date': sig_date,
'x_fc_pod_signed_by_user_id': user.id,
'x_fc_pod_signed_datetime': fields.Datetime.now(),
})
return {
'success': True,
'message': 'Signature saved successfully',
'redirect_url': f'/my/technician/task/{task_id}',
}
except Exception as e:
_logger.error(f"Error saving task POD signature: {e}")
return {'success': False, 'error': str(e)}
def _generate_signed_pod_pdf(self, order, save_to_field=True):
"""Generate a signed POD PDF with the signature embedded.

View File

@@ -0,0 +1,327 @@
# -*- coding: utf-8 -*-
from odoo import http, _, fields
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal
from odoo.exceptions import AccessError, ValidationError
from datetime import datetime, timedelta
import json
import logging
import pytz
_logger = logging.getLogger(__name__)
class PortalSchedule(CustomerPortal):
"""Portal controller for appointment scheduling and calendar management."""
def _get_schedule_values(self):
"""Common values for schedule pages."""
ICP = request.env['ir.config_parameter'].sudo()
g_start = ICP.get_param('fusion_claims.portal_gradient_start', '#5ba848')
g_mid = ICP.get_param('fusion_claims.portal_gradient_mid', '#3a8fb7')
g_end = ICP.get_param('fusion_claims.portal_gradient_end', '#2e7aad')
gradient = 'linear-gradient(135deg, %s 0%%, %s 60%%, %s 100%%)' % (g_start, g_mid, g_end)
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
return {
'portal_gradient': gradient,
'google_maps_api_key': google_maps_api_key,
}
def _get_user_timezone(self):
tz_name = request.env.user.tz or 'America/Toronto'
try:
return pytz.timezone(tz_name)
except pytz.exceptions.UnknownTimeZoneError:
return pytz.timezone('America/Toronto')
def _get_appointment_types(self):
"""Get appointment types available to the current user."""
return request.env['appointment.type'].sudo().search([
('staff_user_ids', 'in', [request.env.user.id]),
])
@http.route(['/my/schedule'], type='http', auth='user', website=True)
def schedule_page(self, **kw):
"""Schedule overview: upcoming appointments and shareable link."""
partner = request.env.user.partner_id
user = request.env.user
now = fields.Datetime.now()
upcoming_events = request.env['calendar.event'].sudo().search([
('partner_ids', 'in', [partner.id]),
('start', '>=', now),
], order='start asc', limit=20)
today_events = request.env['calendar.event'].sudo().search([
('partner_ids', 'in', [partner.id]),
('start', '>=', now.replace(hour=0, minute=0, second=0)),
('start', '<', (now + timedelta(days=1)).replace(hour=0, minute=0, second=0)),
], order='start asc')
invite = request.env['appointment.invite'].sudo().search([
('staff_user_ids', 'in', [user.id]),
], limit=1)
share_url = invite.book_url if invite else ''
appointment_types = self._get_appointment_types()
tz = self._get_user_timezone()
values = self._get_schedule_values()
values.update({
'page_name': 'schedule',
'upcoming_events': upcoming_events,
'today_events': today_events,
'share_url': share_url,
'appointment_types': appointment_types,
'user_tz': tz,
'now': now,
})
return request.render('fusion_authorizer_portal.portal_schedule_page', values)
@http.route(['/my/schedule/book'], type='http', auth='user', website=True)
def schedule_book(self, appointment_type_id=None, **kw):
"""Booking form for a new appointment."""
appointment_types = self._get_appointment_types()
if not appointment_types:
return request.redirect('/my/schedule')
if appointment_type_id:
selected_type = request.env['appointment.type'].sudo().browse(int(appointment_type_id))
if not selected_type.exists():
selected_type = appointment_types[0]
else:
selected_type = appointment_types[0]
values = self._get_schedule_values()
values.update({
'page_name': 'schedule_book',
'appointment_types': appointment_types,
'selected_type': selected_type,
'now': fields.Datetime.now(),
'error': kw.get('error'),
'success': kw.get('success'),
})
return request.render('fusion_authorizer_portal.portal_schedule_book', values)
@http.route('/my/schedule/available-slots', type='json', auth='user', website=True)
def schedule_available_slots(self, appointment_type_id, selected_date=None, **kw):
"""JSON-RPC endpoint: return available time slots for a date."""
appointment_type = request.env['appointment.type'].sudo().browse(int(appointment_type_id))
if not appointment_type.exists():
return {'error': 'Appointment type not found', 'slots': []}
user = request.env.user
tz_name = user.tz or 'America/Toronto'
tz = self._get_user_timezone()
ref_date = fields.Datetime.now()
slot_data = appointment_type._get_appointment_slots(
timezone=tz_name,
filter_users=request.env['res.users'].sudo().browse(user.id),
asked_capacity=1,
reference_date=ref_date,
)
filtered_slots = []
target_date = None
if selected_date:
try:
target_date = datetime.strptime(selected_date, '%Y-%m-%d').date()
except ValueError:
return {'error': 'Invalid date format', 'slots': []}
for month_data in slot_data:
for week in month_data.get('weeks', []):
for day_info in week:
if not day_info:
continue
day = day_info.get('day')
if target_date and day != target_date:
continue
for slot in day_info.get('slots', []):
slot_dt_str = slot.get('datetime')
if not slot_dt_str:
continue
filtered_slots.append({
'datetime': slot_dt_str,
'start_hour': slot.get('start_hour', ''),
'end_hour': slot.get('end_hour', ''),
'duration': slot.get('slot_duration', str(appointment_type.appointment_duration)),
'staff_user_id': slot.get('staff_user_id', user.id),
})
available_dates = []
if not target_date:
seen = set()
for month_data in slot_data:
for week in month_data.get('weeks', []):
for day_info in week:
if not day_info:
continue
day = day_info.get('day')
if day and day_info.get('slots') and str(day) not in seen:
seen.add(str(day))
available_dates.append(str(day))
return {
'slots': filtered_slots,
'available_dates': sorted(available_dates),
'duration': appointment_type.appointment_duration,
'timezone': tz_name,
}
@http.route('/my/schedule/week-events', type='json', auth='user', website=True)
def schedule_week_events(self, selected_date, **kw):
"""Return the user's calendar events for the Mon-Sun week containing selected_date."""
try:
target = datetime.strptime(selected_date, '%Y-%m-%d').date()
except (ValueError, TypeError):
return {'error': 'Invalid date format', 'events': [], 'week_days': []}
monday = target - timedelta(days=target.weekday())
sunday = monday + timedelta(days=6)
partner = request.env.user.partner_id
tz = self._get_user_timezone()
monday_start_local = tz.localize(datetime.combine(monday, datetime.min.time()))
sunday_end_local = tz.localize(datetime.combine(sunday, datetime.max.time()))
monday_start_utc = monday_start_local.astimezone(pytz.utc).replace(tzinfo=None)
sunday_end_utc = sunday_end_local.astimezone(pytz.utc).replace(tzinfo=None)
events = request.env['calendar.event'].sudo().search([
('partner_ids', 'in', [partner.id]),
('start', '>=', monday_start_utc),
('start', '<=', sunday_end_utc),
], order='start asc')
event_list = []
for ev in events:
start_utc = ev.start
stop_utc = ev.stop
start_local = pytz.utc.localize(start_utc).astimezone(tz)
stop_local = pytz.utc.localize(stop_utc).astimezone(tz)
event_list.append({
'name': ev.name or '',
'start': start_local.strftime('%Y-%m-%d %H:%M'),
'end': stop_local.strftime('%Y-%m-%d %H:%M'),
'start_time': start_local.strftime('%I:%M %p'),
'end_time': stop_local.strftime('%I:%M %p'),
'day_of_week': start_local.weekday(),
'date': start_local.strftime('%Y-%m-%d'),
'location': ev.location or '',
'duration': ev.duration,
})
day_labels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
week_days = []
for i in range(7):
day = monday + timedelta(days=i)
week_days.append({
'label': day_labels[i],
'date': day.strftime('%Y-%m-%d'),
'day_num': day.day,
'is_selected': day == target,
})
return {
'events': event_list,
'week_days': week_days,
'selected_date': selected_date,
}
@http.route('/my/schedule/book/submit', type='http', auth='user', website=True, methods=['POST'])
def schedule_book_submit(self, **post):
"""Process the booking form submission."""
appointment_type_id = int(post.get('appointment_type_id', 0))
appointment_type = request.env['appointment.type'].sudo().browse(appointment_type_id)
if not appointment_type.exists():
return request.redirect('/my/schedule/book?error=Invalid+appointment+type')
client_name = (post.get('client_name') or '').strip()
client_street = (post.get('client_street') or '').strip()
client_city = (post.get('client_city') or '').strip()
client_province = (post.get('client_province') or '').strip()
client_postal = (post.get('client_postal') or '').strip()
notes = (post.get('notes') or '').strip()
slot_datetime = (post.get('slot_datetime') or '').strip()
slot_duration = post.get('slot_duration', str(appointment_type.appointment_duration))
if not client_name or not slot_datetime:
return request.redirect('/my/schedule/book?error=Client+name+and+time+slot+are+required')
user = request.env.user
tz = self._get_user_timezone()
try:
start_dt_naive = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S')
start_dt_local = tz.localize(start_dt_naive)
start_dt_utc = start_dt_local.astimezone(pytz.utc).replace(tzinfo=None)
except (ValueError, Exception) as e:
_logger.error("Failed to parse slot datetime %s: %s", slot_datetime, e)
return request.redirect('/my/schedule/book?error=Invalid+time+slot')
duration = float(slot_duration)
stop_dt_utc = start_dt_utc + timedelta(hours=duration)
is_valid = appointment_type._check_appointment_is_valid_slot(
staff_user=user,
resources=request.env['appointment.resource'],
asked_capacity=1,
timezone=str(tz),
start_dt=start_dt_utc,
duration=duration,
allday=False,
)
if not is_valid:
return request.redirect('/my/schedule/book?error=This+slot+is+no+longer+available.+Please+choose+another+time.')
address_parts = [p for p in [client_street, client_city, client_province, client_postal] if p]
location = ', '.join(address_parts)
description_lines = []
if client_name:
description_lines.append(f"Client: {client_name}")
if location:
description_lines.append(f"Address: {location}")
if notes:
description_lines.append(f"Notes: {notes}")
description = '\n'.join(description_lines)
event_name = f"{client_name} - {appointment_type.name}"
booking_line_values = [{
'appointment_user_id': user.id,
'capacity_reserved': 1,
'capacity_used': 1,
}]
try:
event_vals = appointment_type._prepare_calendar_event_values(
asked_capacity=1,
booking_line_values=booking_line_values,
description=description,
duration=duration,
allday=False,
appointment_invite=request.env['appointment.invite'],
guests=request.env['res.partner'],
name=event_name,
customer=user.partner_id,
staff_user=user,
start=start_dt_utc,
stop=stop_dt_utc,
)
event_vals['location'] = location
event = request.env['calendar.event'].sudo().create(event_vals)
_logger.info(
"Appointment booked: %s at %s (event ID: %s)",
event_name, start_dt_utc, event.id,
)
except Exception as e:
_logger.error("Failed to create appointment: %s", e)
return request.redirect('/my/schedule/book?error=Failed+to+create+appointment.+Please+try+again.')
return request.redirect('/my/schedule?success=Appointment+booked+successfully')

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- Auto-create a shareable booking link for staff members.
URL: /book/book-appointment
Filtered to appointment type "Assessment" and staff users configured on that type. -->
<record id="default_appointment_invite" model="appointment.invite">
<field name="short_code">book-appointment</field>
<field name="appointment_type_ids" eval="[(6, 0, [])]"/>
</record>
</odoo>

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""Reactivate any views that Odoo silently deactivated.
Odoo deactivates inherited views when xpath validation fails (e.g. parent
view structure changed between versions). Once deactivated, subsequent
``-u`` runs never reactivate them. This end-migration script catches
that scenario on every version bump.
"""
import logging
_logger = logging.getLogger(__name__)
MODULE = 'fusion_authorizer_portal'
def migrate(cr, version):
if not version:
return
cr.execute("""
UPDATE ir_ui_view v
SET active = true
FROM ir_model_data d
WHERE d.res_id = v.id
AND d.model = 'ir.ui.view'
AND d.module = %s
AND v.active = false
RETURNING v.id, v.name, v.key
""", [MODULE])
rows = cr.fetchall()
if rows:
_logger.warning(
"Reactivated %d deactivated views for %s: %s",
len(rows), MODULE, [r[2] or r[1] for r in rows],
)

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""Reactivate any views that Odoo silently deactivated.
Odoo deactivates inherited views when xpath validation fails (e.g. parent
view structure changed between versions). Once deactivated, subsequent
``-u`` runs never reactivate them. This end-migration script catches
that scenario on every version bump.
"""
import logging
_logger = logging.getLogger(__name__)
MODULE = 'fusion_authorizer_portal'
def migrate(cr, version):
if not version:
return
cr.execute("""
UPDATE ir_ui_view v
SET active = true
FROM ir_model_data d
WHERE d.res_id = v.id
AND d.model = 'ir.ui.view'
AND d.module = %s
AND v.active = false
RETURNING v.id, v.name, v.key
""", [MODULE])
rows = cr.fetchall()
if rows:
_logger.warning(
"Reactivated %d deactivated views for %s: %s",
len(rows), MODULE, [r[2] or r[1] for r in rows],
)

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""Reactivate any views that Odoo silently deactivated.
Odoo deactivates inherited views when xpath validation fails (e.g. parent
view structure changed between versions). Once deactivated, subsequent
``-u`` runs never reactivate them. This end-migration script catches
that scenario on every version bump.
"""
import logging
_logger = logging.getLogger(__name__)
MODULE = 'fusion_authorizer_portal'
def migrate(cr, version):
if not version:
return
cr.execute("""
UPDATE ir_ui_view v
SET active = true
FROM ir_model_data d
WHERE d.res_id = v.id
AND d.model = 'ir.ui.view'
AND d.module = %s
AND v.active = false
RETURNING v.id, v.name, v.key
""", [MODULE])
rows = cr.fetchall()
if rows:
_logger.warning(
"Reactivated %d deactivated views for %s: %s",
len(rows), MODULE, [r[2] or r[1] for r in rows],
)

View File

@@ -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;
});
};
})();

View File

@@ -0,0 +1,348 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ==================== SCHEDULE OVERVIEW PAGE ==================== -->
<template id="portal_schedule_page" name="My Schedule">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<div class="container py-4">
<!-- Success/Error Messages -->
<t t-if="request.params.get('success')">
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="fa fa-check-circle me-2"/><t t-out="request.params.get('success')"/>
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
</div>
</t>
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h3 class="mb-1"><i class="fa fa-calendar-check-o me-2"/>My Schedule</h3>
<p class="text-muted mb-0">View your appointments and book new ones</p>
</div>
<div class="d-flex gap-2 flex-wrap">
<t t-if="share_url">
<div class="input-group" style="max-width: 350px;">
<input type="text" class="form-control form-control-sm" t-att-value="share_url"
id="shareBookingUrl" readonly="readonly" style="font-size: 13px;"/>
<button class="btn btn-outline-secondary btn-sm" type="button"
id="btnCopyShareUrl">
<i class="fa fa-copy" id="copyIcon"/> <span id="copyText">Copy</span>
</button>
<script type="text/javascript">
(function() {
var btn = document.getElementById('btnCopyShareUrl');
if (!btn) return;
btn.addEventListener('click', function() {
var url = document.getElementById('shareBookingUrl').value;
navigator.clipboard.writeText(url);
var icon = document.getElementById('copyIcon');
var text = document.getElementById('copyText');
icon.className = 'fa fa-check';
text.textContent = 'Copied';
setTimeout(function() {
icon.className = 'fa fa-copy';
text.textContent = 'Copy';
}, 2000);
});
})();
</script>
</div>
</t>
<a href="/my/schedule/book" class="btn btn-primary">
<i class="fa fa-plus me-1"/> Book Appointment
</a>
</div>
</div>
<!-- Today's Appointments -->
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
<div class="card-header bg-white border-bottom-0 pt-3 pb-2 px-4"
style="border-radius: 12px 12px 0 0;">
<h5 class="mb-0"><i class="fa fa-sun-o me-2 text-warning"/>Today's Appointments</h5>
</div>
<div class="card-body px-4 pb-4 pt-2">
<t t-if="today_events">
<div class="list-group list-group-flush">
<t t-foreach="today_events" t-as="event">
<div class="list-group-item px-0 py-3 border-start-0 border-end-0">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<div class="rounded-3 text-center px-3 py-2 me-3"
t-attf-style="background: #{portal_gradient}; min-width: 70px;">
<div class="text-white fw-bold" style="font-size: 14px;">
<t t-out="event.start.astimezone(user_tz).strftime('%I:%M')"/>
</div>
<div class="text-white" style="font-size: 10px;">
<t t-out="event.start.astimezone(user_tz).strftime('%p')"/>
</div>
</div>
<div>
<h6 class="mb-0"><t t-out="event.name"/></h6>
<small class="text-muted">
<t t-if="event.location">
<i class="fa fa-map-marker me-1"/><t t-out="event.location"/>
</t>
</small>
</div>
</div>
<div class="text-end">
<span class="badge bg-light text-dark">
<t t-out="'%.0f' % (event.duration * 60)"/> min
</span>
</div>
</div>
</div>
</t>
</div>
</t>
<t t-else="">
<p class="text-muted mb-0 py-3 text-center">
<i class="fa fa-calendar-o me-1"/> No appointments scheduled for today.
</p>
</t>
</div>
</div>
<!-- Upcoming Appointments -->
<div class="card border-0 shadow-sm" style="border-radius: 12px;">
<div class="card-header bg-white border-bottom-0 pt-3 pb-2 px-4"
style="border-radius: 12px 12px 0 0;">
<h5 class="mb-0"><i class="fa fa-calendar me-2 text-primary"/>Upcoming Appointments</h5>
</div>
<div class="card-body px-4 pb-4 pt-2">
<t t-if="upcoming_events">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th style="border-top:none;">Date</th>
<th style="border-top:none;">Time</th>
<th style="border-top:none;">Appointment</th>
<th style="border-top:none;">Location</th>
<th style="border-top:none;">Duration</th>
</tr>
</thead>
<tbody>
<t t-foreach="upcoming_events" t-as="event">
<tr>
<td>
<strong><t t-out="event.start.astimezone(user_tz).strftime('%b %d')"/></strong>
<br/>
<small class="text-muted">
<t t-out="event.start.astimezone(user_tz).strftime('%A')"/>
</small>
</td>
<td>
<t t-out="event.start.astimezone(user_tz).strftime('%I:%M %p')"/>
</td>
<td><t t-out="event.name"/></td>
<td>
<t t-if="event.location">
<small><t t-out="event.location"/></small>
</t>
<t t-else="">
<small class="text-muted">-</small>
</t>
</td>
<td>
<span class="badge bg-light text-dark">
<t t-out="'%.0f' % (event.duration * 60)"/> min
</span>
</td>
</tr>
</t>
</tbody>
</table>
</div>
</t>
<t t-else="">
<p class="text-muted mb-0 py-3 text-center">
<i class="fa fa-calendar-o me-1"/> No upcoming appointments.
<a href="/my/schedule/book">Book one now</a>
</p>
</t>
</div>
</div>
</div>
</t>
</template>
<!-- ==================== BOOKING FORM ==================== -->
<template id="portal_schedule_book" name="Book Appointment">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<div class="container py-4" style="max-width: 800px;">
<!-- Error Messages -->
<t t-if="error">
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="fa fa-exclamation-circle me-2"/><t t-out="error"/>
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
</div>
</t>
<!-- Header -->
<div class="mb-4">
<a href="/my/schedule" class="text-muted text-decoration-none mb-2 d-inline-block">
<i class="fa fa-arrow-left me-1"/> Back to Schedule
</a>
<h3 class="mb-1"><i class="fa fa-plus-circle me-2"/>Book Appointment</h3>
<p class="text-muted mb-0">Select a time slot and enter client details</p>
</div>
<form action="/my/schedule/book/submit" method="post" id="bookingForm">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<!-- Step 1: Appointment Type + Date/Time -->
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
<div class="card-header bg-white border-bottom pt-3 pb-2 px-4"
style="border-radius: 12px 12px 0 0;">
<h5 class="mb-0">
<span class="badge rounded-pill me-2"
t-attf-style="background: #{portal_gradient};">1</span>
Date &amp; Time
</h5>
</div>
<div class="card-body px-4 pb-4">
<!-- Appointment Type (if multiple) -->
<t t-if="len(appointment_types) > 1">
<div class="mb-3">
<label class="form-label fw-semibold">Appointment Type</label>
<select name="appointment_type_id" class="form-select"
id="appointmentTypeSelect">
<t t-foreach="appointment_types" t-as="atype">
<option t-att-value="atype.id"
t-att-selected="atype.id == selected_type.id"
t-att-data-duration="atype.appointment_duration">
<t t-out="atype.name"/>
(<t t-out="'%.0f' % (atype.appointment_duration * 60)"/> min)
</option>
</t>
</select>
</div>
</t>
<t t-else="">
<input type="hidden" name="appointment_type_id"
t-att-value="selected_type.id"/>
</t>
<!-- Date Picker -->
<div class="mb-3">
<label class="form-label fw-semibold">Select Date</label>
<input type="date" class="form-control" id="bookingDate"
required="required"
t-att-min="now.strftime('%Y-%m-%d')"/>
</div>
<!-- Week Calendar Preview -->
<div id="weekCalendarContainer" class="mb-3" style="display: none;">
<label class="form-label fw-semibold">
<i class="fa fa-calendar me-1"/>Your Week
</label>
<div id="weekCalendarLoading" class="text-center py-3" style="display: none;">
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"/>
Loading calendar...
</div>
<div id="weekCalendarGrid" class="border rounded-3 overflow-hidden" style="display: none;">
<div id="weekCalendarHeader" class="d-flex bg-light border-bottom" style="min-height: 40px;"></div>
<div id="weekCalendarBody" class="d-flex" style="min-height: 80px;"></div>
</div>
<div id="weekCalendarEmpty" class="text-muted py-2 text-center" style="display: none;">
<i class="fa fa-calendar-o me-1"/> No events this week -- your schedule is open.
</div>
</div>
<!-- Available Slots -->
<div id="slotsContainer" style="display: none;">
<label class="form-label fw-semibold">Available Time Slots</label>
<div id="slotsLoading" class="text-center py-3" style="display: none;">
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"/>
Loading available slots...
</div>
<div id="slotsGrid" class="d-flex flex-wrap gap-2 mb-2"></div>
<div id="noSlots" class="text-muted py-2" style="display: none;">
<i class="fa fa-info-circle me-1"/> No available slots for this date.
Try another date.
</div>
<input type="hidden" name="slot_datetime" id="slotDatetime"/>
<input type="hidden" name="slot_duration" id="slotDuration"
t-att-value="selected_type.appointment_duration"/>
</div>
</div>
</div>
<!-- Step 2: Client Details -->
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
<div class="card-header bg-white border-bottom pt-3 pb-2 px-4"
style="border-radius: 12px 12px 0 0;">
<h5 class="mb-0">
<span class="badge rounded-pill me-2"
t-attf-style="background: #{portal_gradient};">2</span>
Client Details
</h5>
</div>
<div class="card-body px-4 pb-4">
<div class="mb-3">
<label class="form-label fw-semibold">Client Name <span class="text-danger">*</span></label>
<input type="text" name="client_name" class="form-control"
placeholder="Enter client's full name" required="required"/>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Address</label>
<input type="text" name="client_street" class="form-control mb-2"
id="clientStreet"
placeholder="Start typing address..."/>
</div>
<div class="row g-2 mb-3">
<div class="col-md-4">
<input type="text" name="client_city" class="form-control"
id="clientCity" placeholder="City"/>
</div>
<div class="col-md-4">
<input type="text" name="client_province" class="form-control"
id="clientProvince" placeholder="Province"/>
</div>
<div class="col-md-4">
<input type="text" name="client_postal" class="form-control"
id="clientPostal" placeholder="Postal Code"/>
</div>
</div>
<div class="mb-0">
<label class="form-label fw-semibold">Notes</label>
<textarea name="notes" class="form-control" rows="3"
placeholder="e.g. Equipment to bring, special instructions, reason for visit..."></textarea>
</div>
</div>
</div>
<!-- Submit -->
<div class="d-flex justify-content-between">
<a href="/my/schedule" class="btn btn-outline-secondary">
<i class="fa fa-arrow-left me-1"/> Cancel
</a>
<button type="submit" class="btn btn-primary btn-lg px-4" id="btnSubmitBooking"
disabled="disabled">
<i class="fa fa-calendar-check-o me-1"/> Book Appointment
</button>
</div>
</form>
</div>
<!-- Google Maps Places API -->
<t t-if="google_maps_api_key">
<script t-attf-src="https://maps.googleapis.com/maps/api/js?key=#{google_maps_api_key}&amp;libraries=places&amp;callback=initScheduleAddressAutocomplete"
async="async" defer="defer"></script>
</t>
<script t-attf-src="/fusion_authorizer_portal/static/src/js/portal_schedule_booking.js"></script>
</t>
</template>
</odoo>

View File

@@ -86,7 +86,7 @@
</div>
<span class="text-muted"><t t-out="current_task.time_start_display"/> - <t t-out="current_task.time_end_display"/></span>
</div>
<p class="mb-1"><i class="fa fa-user me-1 text-muted"/><t t-out="current_task.partner_id.name or 'N/A'"/></p>
<p class="mb-1"><i class="fa fa-user me-1 text-muted"/><t t-out="current_task.client_display_name or 'N/A'"/></p>
<p class="mb-2 text-muted"><i class="fa fa-map-marker me-1"/><t t-out="current_task.address_display or 'No address'"/></p>
<t t-if="current_task.description">
<p class="mb-2 small"><t t-out="current_task.description"/></p>
@@ -103,7 +103,7 @@
class="tech-action-btn tech-btn-complete">
<i class="fa fa-check"/>Complete
</a>
<a t-if="current_task.partner_phone" t-attf-href="tel:#{current_task.partner_phone}"
<a t-if="current_task.client_display_phone" t-attf-href="tel:#{current_task.client_display_phone}"
class="tech-action-btn tech-btn-call">
<i class="fa fa-phone"/>Call
</a>
@@ -128,7 +128,7 @@
<span class="ms-2 fw-bold"><t t-out="next_task.name"/></span>
</div>
</div>
<p class="mb-1"><i class="fa fa-user me-1 text-muted"/><t t-out="next_task.partner_id.name or 'N/A'"/></p>
<p class="mb-1"><i class="fa fa-user me-1 text-muted"/><t t-out="next_task.client_display_name or 'N/A'"/></p>
<p class="mb-1 text-muted"><i class="fa fa-map-marker me-1"/><t t-out="next_task.address_display or 'No address'"/></p>
<t t-if="next_task.travel_time_minutes">
<p class="mb-2 small text-purple"><i class="fa fa-car me-1"/><t t-out="next_task.travel_time_minutes"/> min drive
@@ -192,7 +192,7 @@
<span t-attf-class="tech-badge tech-badge-#{task.task_type} me-1">
<t t-out="dict(task._fields['task_type'].selection).get(task.task_type, '')"/>
</span>
<t t-out="task.partner_id.name or task.name"/>
<t t-out="task.client_display_name or task.name"/>
</div>
<div class="tech-timeline-meta">
<i class="fa fa-map-marker me-1"/><t t-out="task.address_city or 'No address'"/>
@@ -525,7 +525,7 @@
<span class="ms-1"><t t-out="task.time_start_display"/></span>
</span>
<span>
<i class="fa fa-user me-1"/><t t-out="task.partner_id.name or '-'"/>
<i class="fa fa-user me-1"/><t t-out="task.client_display_name or '-'"/>
</span>
</div>
<div class="text-muted small mt-1">
@@ -620,14 +620,14 @@
<span>Navigate</span>
</a>
</t>
<t t-if="task.partner_phone">
<a t-attf-href="tel:#{task.partner_phone}" class="tech-quick-btn">
<t t-if="task.client_display_phone">
<a t-attf-href="tel:#{task.client_display_phone}" class="tech-quick-btn">
<i class="fa fa-phone"/>
<span>Call</span>
</a>
</t>
<t t-if="task.partner_phone">
<a t-attf-href="sms:#{task.partner_phone}" class="tech-quick-btn">
<t t-if="task.client_display_phone">
<a t-attf-href="sms:#{task.client_display_phone}" class="tech-quick-btn">
<i class="fa fa-comment"/>
<span>Text</span>
</a>
@@ -665,10 +665,10 @@
<i class="fa fa-user"/>
</div>
<div class="flex-grow-1">
<div class="fw-semibold"><t t-out="task.partner_id.name or 'No client'"/></div>
<t t-if="task.partner_phone">
<a t-attf-href="tel:#{task.partner_phone}" class="text-muted small text-decoration-none">
<i class="fa fa-phone me-1"/><t t-out="task.partner_phone"/>
<div class="fw-semibold"><t t-out="task.client_display_name or 'No client'"/></div>
<t t-if="task.client_display_phone">
<a t-attf-href="tel:#{task.client_display_phone}" class="text-muted small text-decoration-none">
<i class="fa fa-phone me-1"/><t t-out="task.client_display_phone"/>
</a>
</t>
</div>
@@ -718,8 +718,8 @@
</div>
</t>
<!-- ===== POD (if required and linked to a sale order) ===== -->
<t t-if="task.pod_required and task.sale_order_id">
<!-- ===== POD (if required) ===== -->
<t t-if="task.pod_required">
<div class="tech-card mb-3">
<div class="d-flex align-items-center">
<div class="tech-card-icon bg-warning-subtle text-warning">
@@ -727,14 +727,16 @@
</div>
<div class="flex-grow-1">
<div class="fw-semibold">Proof of Delivery</div>
<t t-if="task.sale_order_id.x_fc_pod_signature">
<t t-set="has_task_pod" t-value="bool(task.pod_signature)"/>
<t t-set="has_order_pod" t-value="bool(task.sale_order_id and task.sale_order_id.x_fc_pod_signature)"/>
<t t-if="has_task_pod or has_order_pod">
<span class="text-success small d-block"><i class="fa fa-check me-1"/>Signature collected</span>
<a t-attf-href="/my/pod/#{task.sale_order_id.id}" class="btn btn-sm btn-outline-warning mt-1">
<a t-attf-href="/my/technician/task/#{task.id}/pod" class="btn btn-sm btn-outline-warning mt-1">
<i class="fa fa-refresh me-1"/>Re-collect Signature
</a>
</t>
<t t-else="">
<a t-attf-href="/my/pod/#{task.sale_order_id.id}" class="btn btn-sm btn-warning mt-1">
<a t-attf-href="/my/technician/task/#{task.id}/pod" class="btn btn-sm btn-warning mt-1">
<i class="fa fa-pencil me-1"/>Collect Signature
</a>
</t>
@@ -846,6 +848,11 @@
t-att-data-task-id="task.id">
<i class="fa fa-play"/>Start
</button>
<button class="tech-action-btn tech-btn-complete"
onclick="techCompleteTask(this)"
t-att-data-task-id="task.id">
<i class="fa fa-check-circle"/>Complete
</button>
</t>
<t t-if="task.status == 'en_route'">
<a t-if="task.get_google_maps_url()"
@@ -860,6 +867,11 @@
t-att-data-task-id="task.id">
<i class="fa fa-play"/>Start
</button>
<button class="tech-action-btn tech-btn-complete"
onclick="techCompleteTask(this)"
t-att-data-task-id="task.id">
<i class="fa fa-check-circle"/>Complete
</button>
</t>
<t t-if="task.status == 'in_progress'">
<button class="tech-action-btn tech-btn-complete"
@@ -1469,15 +1481,15 @@
</t>
</div>
<div class="mt-2">
<strong><t t-out="task.partner_id.name or task.name"/></strong>
<strong><t t-out="task.client_display_name or task.name"/></strong>
</div>
<div class="text-muted small">
<i class="fa fa-map-marker me-1"/><t t-out="task.address_display or 'No address'"/>
</div>
<t t-if="task.partner_phone">
<t t-if="task.client_display_phone">
<div class="small mt-1">
<a t-attf-href="tel:#{task.partner_phone}" class="text-decoration-none">
<i class="fa fa-phone me-1"/><t t-out="task.partner_phone"/>
<a t-attf-href="tel:#{task.client_display_phone}" class="text-decoration-none">
<i class="fa fa-phone me-1"/><t t-out="task.client_display_phone"/>
</a>
</div>
</t>
@@ -1551,7 +1563,7 @@
<span t-attf-class="tech-badge tech-badge-#{task.task_type} me-1">
<t t-out="dict(task._fields['task_type'].selection).get(task.task_type, '')"/>
</span>
<t t-out="task.partner_id.name or task.name"/>
<t t-out="task.client_display_name or task.name"/>
</div>
<div class="tech-timeline-meta">
<i class="fa fa-map-marker me-1"/><t t-out="task.address_city or 'No address'"/>

View File

@@ -189,6 +189,23 @@
</div>
</t>
<!-- My Schedule (All portal roles) -->
<div class="col-md-6">
<a href="/my/schedule" class="card h-100 border-0 shadow-sm text-decoration-none" style="border-radius: 12px; min-height: 100px;">
<div class="card-body d-flex align-items-center p-4">
<div class="me-3">
<div class="rounded-circle d-flex align-items-center justify-content-center" t-attf-style="width: 50px; height: 50px; background: {{fc_gradient}};">
<i class="fa fa-calendar-check-o fa-lg text-white"/>
</div>
</div>
<div>
<h5 class="mb-1 text-dark">My Schedule</h5>
<small class="text-muted">View and book appointments</small>
</div>
</div>
</a>
</div>
<!-- Clock In/Out -->
<t t-if="clock_enabled">
<div class="col-md-6">
@@ -3929,4 +3946,232 @@
</t>
</template>
<!-- ============================================================ -->
<!-- TASK-LEVEL POD SIGNATURE (works for shadow + regular tasks) -->
<!-- ============================================================ -->
<template id="portal_task_pod_signature" name="Task POD Signature Capture">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="False"/>
<t t-set="no_breadcrumbs" t-value="True"/>
<div class="container mt-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="/my/home">Home</a></li>
<li class="breadcrumb-item"><a href="/my/technician">Dashboard</a></li>
<li class="breadcrumb-item"><a href="/my/technician/tasks">Tasks</a></li>
<li class="breadcrumb-item"><a t-attf-href="/my/technician/task/#{task.id}"><t t-out="task.name"/></a></li>
<li class="breadcrumb-item active" aria-current="page">Collect POD Signature</li>
</ol>
</nav>
</div>
<div class="container py-4">
<div class="row mb-4">
<div class="col-12">
<h2>
<i class="fa fa-pencil-square-o me-2"/>
Proof of Delivery - <t t-out="task.name"/>
</h2>
</div>
</div>
<div class="row">
<div class="col-lg-7">
<div class="card mb-4">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="fa fa-truck me-2"/>Delivery Summary</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<p><strong>Client:</strong> <t t-out="task.client_display_name or 'N/A'"/></p>
<p><strong>Task:</strong> <t t-out="task.name"/></p>
<p><strong>Type:</strong>
<t t-out="dict(task._fields['task_type'].selection).get(task.task_type, '')"/>
</p>
</div>
<div class="col-md-6">
<p><strong>Delivery Address:</strong></p>
<p class="mb-0 text-muted">
<t t-out="task.address_display or 'No address'"/>
</p>
<t t-if="task.scheduled_date">
<p class="mt-2"><strong>Scheduled:</strong>
<t t-out="task.scheduled_date" t-options="{'widget': 'date'}"/>
<t t-out="task.time_start_display"/> - <t t-out="task.time_end_display"/>
</p>
</t>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="card" id="task-pod-signature-section">
<div class="card-header bg-success text-white">
<h5 class="mb-0"><i class="fa fa-pencil me-2"/>Client Signature</h5>
</div>
<div class="card-body">
<t t-if="has_existing_signature">
<div class="alert alert-warning">
<i class="fa fa-exclamation-triangle me-2"/>
A signature has already been collected. Submitting a new one will replace it.
</div>
</t>
<form id="taskPodSignatureForm">
<div class="mb-3">
<label for="task_client_name" class="form-label">
Client Name <span class="text-danger">*</span>
</label>
<input type="text" class="form-control" id="task_client_name"
name="client_name" required=""
t-att-value="task.pod_client_name or task.client_display_name or ''"
placeholder="Enter the client's full name"/>
</div>
<div class="mb-3">
<label for="task_signature_date" class="form-label">Signature Date</label>
<input type="date" class="form-control" id="task_signature_date" name="signature_date"/>
<script>document.getElementById('task_signature_date').value = new Date().toISOString().slice(0,10);</script>
</div>
<div class="mb-3">
<label class="form-label">Signature <span class="text-danger">*</span></label>
<div class="border rounded p-2 bg-white">
<canvas id="task-signature-canvas"
style="width:100%;height:200px;border:1px dashed #ccc;border-radius:4px;touch-action:none;">
</canvas>
</div>
<div class="d-flex justify-content-between mt-2">
<small class="text-muted">Draw signature above</small>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="clearTaskSignature()">
<i class="fa fa-eraser me-1"/>Clear
</button>
</div>
</div>
<div class="d-grid gap-2">
<button type="button" class="btn btn-success btn-lg"
onclick="submitTaskPODSignature()">
<i class="fa fa-check me-2"/>Submit Signature
</button>
<a t-attf-href="/my/technician/task/#{task.id}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<style>
.tpod-overlay { display:none; position:fixed; top:0; left:0; width:100%; height:100%;
background:rgba(0,0,0,0.65); backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px);
z-index:9999; align-items:center; justify-content:center; }
.tpod-overlay.show { display:flex; }
.tpod-overlay-card { background:#fff; border-radius:20px; padding:2.5rem 2rem;
max-width:420px; width:90%; text-align:center; animation:tpodSlideUp 0.3s ease;
box-shadow:0 8px 32px rgba(0,0,0,0.2); }
.tpod-overlay-icon { font-size:3.5rem; margin-bottom:1rem; }
@keyframes tpodSlideUp { from { opacity:0; transform:translateY(30px); } to { opacity:1; transform:translateY(0); } }
</style>
<div id="taskPodOverlay" class="tpod-overlay">
<div class="tpod-overlay-card">
<div class="tpod-overlay-icon" id="tpodIcon"></div>
<h4 id="tpodTitle" style="font-weight:700;"></h4>
<p id="tpodMsg" style="color:#6c757d;margin-bottom:1.5rem;"></p>
<div id="tpodActions"></div>
</div>
</div>
<script type="text/javascript">
var tCanvas, tCtx, tIsDrawing = false;
function showTaskPodOverlay(type, title, msg, url) {
var ov = document.getElementById('taskPodOverlay');
document.getElementById('tpodIcon').innerHTML = type === 'success'
? '&lt;i class="fa fa-check-circle text-success">&lt;/i>'
: '&lt;i class="fa fa-exclamation-circle text-danger">&lt;/i>';
document.getElementById('tpodTitle').textContent = title;
document.getElementById('tpodTitle').className = type === 'success' ? 'text-success' : 'text-danger';
document.getElementById('tpodMsg').textContent = msg;
var acts = document.getElementById('tpodActions');
if (type === 'success' &amp;&amp; url) {
acts.innerHTML = '&lt;a href="' + url + '" class="btn btn-success w-100 rounded-pill mb-2">Continue&lt;/a>' +
'&lt;p class="text-muted small mb-0">Redirecting in &lt;span id="tpodCD">3&lt;/span>s...&lt;/p>';
ov.classList.add('show');
var s = 3, t = setInterval(function() { s--; var c = document.getElementById('tpodCD');
if (c) c.textContent = s; if (s &lt;= 0) { clearInterval(t); window.location.href = url; } }, 1000);
} else {
acts.innerHTML = '&lt;button class="btn btn-outline-secondary w-100 rounded-pill" onclick="document.getElementById(\'taskPodOverlay\').classList.remove(\'show\')">OK&lt;/button>';
ov.classList.add('show');
}
}
document.addEventListener('DOMContentLoaded', function() {
tCanvas = document.getElementById('task-signature-canvas');
if (!tCanvas) return;
tCtx = tCanvas.getContext('2d');
var r = tCanvas.getBoundingClientRect();
tCanvas.width = r.width * 2; tCanvas.height = r.height * 2;
tCtx.scale(2, 2); tCtx.lineCap = 'round'; tCtx.lineJoin = 'round';
tCtx.lineWidth = 2; tCtx.strokeStyle = '#000';
tCanvas.addEventListener('mousedown', tStart);
tCanvas.addEventListener('mousemove', tDraw);
tCanvas.addEventListener('mouseup', tStop);
tCanvas.addEventListener('mouseout', tStop);
tCanvas.addEventListener('touchstart', function(e) { e.preventDefault(); tStart(e); }, {passive:false});
tCanvas.addEventListener('touchmove', function(e) { e.preventDefault(); tDraw(e); }, {passive:false});
tCanvas.addEventListener('touchend', tStop);
var sec = document.getElementById('task-pod-signature-section');
if (sec) setTimeout(function() { sec.scrollIntoView({behavior:'smooth', block:'start'}); }, 300);
});
function tPos(e) {
var r = tCanvas.getBoundingClientRect();
if (e.touches) return { x: e.touches[0].clientX - r.left, y: e.touches[0].clientY - r.top };
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function tStart(e) { tIsDrawing = true; var p = tPos(e); tCtx.beginPath(); tCtx.moveTo(p.x, p.y); }
function tDraw(e) { if (!tIsDrawing) return; var p = tPos(e); tCtx.lineTo(p.x, p.y); tCtx.stroke(); }
function tStop() { tIsDrawing = false; }
function clearTaskSignature() { if (tCtx) tCtx.clearRect(0, 0, tCanvas.width, tCanvas.height); }
function submitTaskPODSignature() {
var name = document.getElementById('task_client_name').value.trim();
var sigDate = document.getElementById('task_signature_date').value;
if (!name) { showTaskPodOverlay('error', 'Missing Information', 'Please enter the client name.'); return; }
var blank = document.createElement('canvas');
blank.width = tCanvas.width; blank.height = tCanvas.height;
if (tCanvas.toDataURL() === blank.toDataURL()) {
showTaskPodOverlay('error', 'Missing Signature', 'Please draw a signature before submitting.'); return;
}
var sigData = tCanvas.toDataURL('image/png');
var btn = document.querySelector('button[onclick="submitTaskPODSignature()"]');
var orig = btn.innerHTML; btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin me-2">&lt;/i>Saving...'; btn.disabled = true;
fetch('<t t-out="'/my/technician/task/' + str(task.id) + '/pod/sign'"/>', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ jsonrpc:'2.0', method:'call',
params: { client_name: name, signature_data: sigData, signature_date: sigDate || null },
id: Math.floor(Math.random()*1000000) })
}).then(function(r) { return r.json(); }).then(function(d) {
if (d.result &amp;&amp; d.result.success) {
showTaskPodOverlay('success', 'Signature Saved!', 'Proof of Delivery recorded.', d.result.redirect_url);
} else {
showTaskPodOverlay('error', 'Error', d.result?.error || 'Unknown error');
btn.innerHTML = orig; btn.disabled = false;
}
}).catch(function() {
showTaskPodOverlay('error', 'Connection Error', 'Please check your connection.');
btn.innerHTML = orig; btn.disabled = false;
});
}
</script>
</t>
</template>
</odoo>

View File

@@ -163,7 +163,7 @@
<field name="model_id" ref="model_fusion_task_sync_config"/>
<field name="state">code</field>
<field name="code">model._cron_pull_remote_tasks()</field>
<field name="interval_number">5</field>
<field name="interval_number">2</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>

View File

@@ -30,9 +30,13 @@ SYNC_TASK_FIELDS = [
'task_type', 'status',
'scheduled_date', 'time_start', 'time_end', 'duration_hours',
'address_street', 'address_street2', 'address_city', 'address_zip',
'address_lat', 'address_lng', 'priority', 'partner_id',
'address_state_id', 'address_buzz_code',
'address_lat', 'address_lng', 'priority', 'partner_id', 'partner_phone',
'pod_required', 'description',
]
TERMINAL_STATUSES = ('completed', 'cancelled')
class FusionTaskSyncConfig(models.Model):
_name = 'fusion.task.sync.config'
@@ -268,17 +272,58 @@ class FusionTaskSyncConfig(models.Model):
self._rpc('fusion.technician.task', 'write',
[existing, {'status': 'cancelled', 'active': False}], ctx)
@api.model
def _push_shadow_status(self, shadow_tasks):
"""Push local status changes on shadow tasks back to their source instance.
When a tech completes (or cancels) a shadow task locally, update the
original task on the remote instance so both sides stay in sync.
"""
configs = self.sudo().search([('active', '=', True)])
config_by_instance = {c.instance_id: c for c in configs}
ctx = {'context': {'skip_task_sync': True, 'skip_travel_recalc': True}}
for task in shadow_tasks:
config = config_by_instance.get(task.x_fc_sync_source)
if not config or not task.x_fc_sync_remote_id:
continue
try:
update_vals = {'status': task.status}
if task.status == 'completed' and task.completion_datetime:
update_vals['completion_datetime'] = str(task.completion_datetime)
config._rpc(
'fusion.technician.task', 'write',
[[task.x_fc_sync_remote_id], update_vals], ctx)
_logger.info(
"Pushed status '%s' for shadow task %s back to %s (remote id %d)",
task.status, task.name, config.name, task.x_fc_sync_remote_id)
if task.status == 'completed':
try:
config._rpc(
'fusion.technician.task',
'_notify_scheduler_on_completion',
[[task.x_fc_sync_remote_id]])
except Exception:
_logger.warning(
"Could not trigger completion notification on remote for %s",
task.name)
except Exception:
_logger.exception(
"Failed to push status for shadow task %s to %s",
task.name, config.name)
# ------------------------------------------------------------------
# PULL: cron-based full reconciliation
# ------------------------------------------------------------------
@api.model
def _cron_pull_remote_tasks(self):
"""Cron job: pull tasks from all active remote instances."""
"""Cron job: pull tasks and technician locations from all active remote instances."""
configs = self.sudo().search([('active', '=', True)])
for config in configs:
try:
config._pull_tasks_from_remote()
config._pull_technician_locations()
config.sudo().write({
'last_sync': fields.Datetime.now(),
'last_sync_error': False,
@@ -288,7 +333,11 @@ class FusionTaskSyncConfig(models.Model):
config.sudo().write({'last_sync_error': str(e)})
def _pull_tasks_from_remote(self):
"""Pull all active tasks for matched technicians from the remote instance."""
"""Pull all active tasks for matched technicians from the remote instance.
After syncing, recalculates travel chains for all affected tech+date
combos so route planning accounts for both local and shadow tasks.
"""
self.ensure_one()
local_syncid_to_uid = self._get_local_syncid_to_uid()
if not local_syncid_to_uid:
@@ -325,6 +374,8 @@ class FusionTaskSyncConfig(models.Model):
skip_task_sync=True, skip_travel_recalc=True)
remote_uuids = set()
affected_combos = set()
for rt in remote_tasks:
sync_uuid = rt.get('x_fc_sync_uuid')
if not sync_uuid:
@@ -340,6 +391,12 @@ class FusionTaskSyncConfig(models.Model):
partner_raw = rt.get('partner_id')
client_name = partner_raw[1] if isinstance(partner_raw, (list, tuple)) and len(partner_raw) > 1 else ''
client_phone = rt.get('partner_phone', '') or ''
state_raw = rt.get('address_state_id')
state_name = ''
if isinstance(state_raw, (list, tuple)) and len(state_raw) > 1:
state_name = state_raw[1]
# Map additional technicians from remote to local
local_additional_ids = []
@@ -352,6 +409,8 @@ class FusionTaskSyncConfig(models.Model):
if local_add_uid:
local_additional_ids.append(local_add_uid)
sched_date = rt.get('scheduled_date')
vals = {
'x_fc_sync_uuid': sync_uuid,
'x_fc_sync_source': self.instance_id,
@@ -361,7 +420,7 @@ class FusionTaskSyncConfig(models.Model):
'additional_technician_ids': [(6, 0, local_additional_ids)],
'task_type': rt.get('task_type', 'delivery'),
'status': rt.get('status', 'scheduled'),
'scheduled_date': rt.get('scheduled_date'),
'scheduled_date': sched_date,
'time_start': rt.get('time_start', 9.0),
'time_end': rt.get('time_end', 10.0),
'duration_hours': rt.get('duration_hours', 1.0),
@@ -369,19 +428,36 @@ class FusionTaskSyncConfig(models.Model):
'address_street2': rt.get('address_street2', ''),
'address_city': rt.get('address_city', ''),
'address_zip': rt.get('address_zip', ''),
'address_buzz_code': rt.get('address_buzz_code', ''),
'address_lat': rt.get('address_lat', 0),
'address_lng': rt.get('address_lng', 0),
'priority': rt.get('priority', 'normal'),
'pod_required': rt.get('pod_required', False),
'description': rt.get('description', ''),
'x_fc_sync_client_name': client_name,
'x_fc_sync_client_phone': client_phone,
}
if state_name:
state_rec = self.env['res.country.state'].sudo().search(
[('name', '=', state_name)], limit=1)
if state_rec:
vals['address_state_id'] = state_rec.id
existing = Task.search([('x_fc_sync_uuid', '=', sync_uuid)], limit=1)
if existing:
if existing.status in TERMINAL_STATUSES:
vals.pop('status', None)
existing.write(vals)
else:
vals['sale_order_id'] = False
Task.create([vals])
if sched_date:
affected_combos.add((local_uid, sched_date))
for add_uid in local_additional_ids:
affected_combos.add((add_uid, sched_date))
stale_shadows = Task.search([
('x_fc_sync_source', '=', self.instance_id),
('x_fc_sync_uuid', 'not in', list(remote_uuids)),
@@ -389,10 +465,149 @@ class FusionTaskSyncConfig(models.Model):
('active', '=', True),
])
if stale_shadows:
for st in stale_shadows:
if st.scheduled_date and st.technician_id:
affected_combos.add((st.technician_id.id, st.scheduled_date))
for tech in st.additional_technician_ids:
if st.scheduled_date:
affected_combos.add((tech.id, st.scheduled_date))
stale_shadows.write({'active': False, 'status': 'cancelled'})
_logger.info("Deactivated %d stale shadow tasks from %s",
len(stale_shadows), self.instance_id)
if affected_combos:
today = fields.Date.today()
today_str = str(today)
future_combos = set()
for tid, d in affected_combos:
if not d:
continue
d_str = str(d) if not isinstance(d, str) else d
if d_str >= today_str:
future_combos.add((tid, d_str))
if future_combos:
TaskModel = self.env['fusion.technician.task'].sudo()
try:
ungeocode = TaskModel.search([
('x_fc_sync_source', '=', self.instance_id),
('active', '=', True),
('scheduled_date', '>=', today_str),
('status', 'not in', ['cancelled']),
'|',
('address_lat', '=', 0), ('address_lat', '=', False),
])
geocoded = 0
for shadow in ungeocode:
if shadow.address_display:
if shadow.with_context(skip_travel_recalc=True)._geocode_address():
geocoded += 1
if geocoded:
_logger.info("Geocoded %d shadow tasks from %s",
geocoded, self.name)
except Exception:
_logger.exception(
"Shadow task geocoding after sync from %s failed", self.name)
try:
TaskModel._recalculate_combos_travel(future_combos)
_logger.info(
"Recalculated travel for %d tech+date combos after sync from %s",
len(future_combos), self.name)
except Exception:
_logger.exception(
"Travel recalculation after sync from %s failed", self.name)
# ------------------------------------------------------------------
# PULL: technician locations from remote instance
# ------------------------------------------------------------------
def _pull_technician_locations(self):
"""Pull latest GPS locations for matched technicians from the remote instance.
Creates local location records with source='sync' so the map view
shows technician positions from both instances. Only keeps the single
most recent synced location per technician (replaces older synced
records to avoid clutter).
"""
self.ensure_one()
local_syncid_to_uid = self._get_local_syncid_to_uid()
if not local_syncid_to_uid:
return
remote_map = self._get_remote_tech_map()
if not remote_map:
return
matched_sync_ids = set(local_syncid_to_uid.keys()) & set(remote_map.keys())
if not matched_sync_ids:
return
remote_tech_ids = [remote_map[sid] for sid in matched_sync_ids]
remote_syncid_by_uid = {v: k for k, v in remote_map.items()}
remote_locations = self._rpc(
'fusion.technician.location', 'search_read',
[[
('user_id', 'in', remote_tech_ids),
('logged_at', '>', str(fields.Datetime.subtract(
fields.Datetime.now(), hours=24))),
('source', '!=', 'sync'),
]],
{
'fields': ['user_id', 'latitude', 'longitude',
'accuracy', 'logged_at'],
'order': 'logged_at desc',
})
if not remote_locations:
return
Location = self.env['fusion.technician.location'].sudo()
seen_techs = set()
synced_count = 0
for rloc in remote_locations:
remote_uid_raw = rloc['user_id']
remote_uid = (remote_uid_raw[0]
if isinstance(remote_uid_raw, (list, tuple))
else remote_uid_raw)
if remote_uid in seen_techs:
continue
seen_techs.add(remote_uid)
sync_id = remote_syncid_by_uid.get(remote_uid)
local_uid = local_syncid_to_uid.get(sync_id) if sync_id else None
if not local_uid:
continue
lat = rloc.get('latitude', 0)
lng = rloc.get('longitude', 0)
if not lat or not lng:
continue
old_synced = Location.search([
('user_id', '=', local_uid),
('source', '=', 'sync'),
('sync_instance', '=', self.instance_id),
])
if old_synced:
old_synced.unlink()
Location.create({
'user_id': local_uid,
'latitude': lat,
'longitude': lng,
'accuracy': rloc.get('accuracy', 0),
'logged_at': rloc.get('logged_at', fields.Datetime.now()),
'source': 'sync',
'sync_instance': self.instance_id,
})
synced_count += 1
if synced_count:
_logger.info("Synced %d technician location(s) from %s",
synced_count, self.name)
# ------------------------------------------------------------------
# CLEANUP
# ------------------------------------------------------------------
@@ -419,6 +634,7 @@ class FusionTaskSyncConfig(models.Model):
"""Manually trigger a full sync for this config."""
self.ensure_one()
self._pull_tasks_from_remote()
self._pull_technician_locations()
self.sudo().write({
'last_sync': fields.Datetime.now(),
'last_sync_error': False,
@@ -426,12 +642,18 @@ class FusionTaskSyncConfig(models.Model):
shadow_count = self.env['fusion.technician.task'].sudo().search_count([
('x_fc_sync_source', '=', self.instance_id),
])
loc_count = self.env['fusion.technician.location'].sudo().search_count([
('source', '=', 'sync'),
('sync_instance', '=', self.instance_id),
])
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Sync Complete',
'message': f'Synced from {self.name}. {shadow_count} shadow task(s) now visible.',
'message': (f'Synced from {self.name}. '
f'{shadow_count} shadow task(s), '
f'{loc_count} technician location(s) visible.'),
'type': 'success',
'sticky': False,
},

View File

@@ -48,7 +48,12 @@ class FusionTechnicianLocation(models.Model):
source = fields.Selection([
('portal', 'Portal'),
('app', 'Mobile App'),
('sync', 'Synced'),
], string='Source', default='portal')
sync_instance = fields.Char(
'Sync Instance', index=True,
help='Source instance ID if synced (e.g. westin, mobility)',
)
@api.model
def log_location(self, latitude, longitude, accuracy=None):
@@ -63,18 +68,27 @@ class FusionTechnicianLocation(models.Model):
@api.model
def get_latest_locations(self):
"""Get the most recent location for each technician (for map view)."""
"""Get the most recent location for each technician (for map view).
Includes both local GPS pings and synced locations from remote
instances, so the map shows all shared technicians regardless of
which Odoo instance they are clocked into.
"""
self.env.cr.execute("""
SELECT DISTINCT ON (user_id)
user_id, latitude, longitude, accuracy, logged_at
user_id, latitude, longitude, accuracy, logged_at,
COALESCE(sync_instance, '') AS sync_instance
FROM fusion_technician_location
WHERE logged_at > NOW() - INTERVAL '24 hours'
ORDER BY user_id, logged_at DESC
""")
rows = self.env.cr.dictfetchall()
local_id = self.env['ir.config_parameter'].sudo().get_param(
'fusion_claims.sync_instance_id', '')
result = []
for row in rows:
user = self.env['res.users'].sudo().browse(row['user_id'])
src = row.get('sync_instance') or local_id
result.append({
'user_id': row['user_id'],
'name': user.name,
@@ -82,6 +96,7 @@ class FusionTechnicianLocation(models.Model):
'longitude': row['longitude'],
'accuracy': row['accuracy'],
'logged_at': str(row['logged_at']),
'sync_instance': src,
})
return result

View File

@@ -95,6 +95,17 @@ class FusionTechnicianTask(models.Model):
'Synced Client Name', readonly=True,
help='Client name from the remote instance (shadow tasks only)',
)
x_fc_sync_client_phone = fields.Char(
'Synced Client Phone', readonly=True,
help='Client phone from the remote instance (shadow tasks only)',
)
client_display_name = fields.Char(
compute='_compute_client_display', string='Client Name (Display)',
)
client_display_phone = fields.Char(
compute='_compute_client_display', string='Client Phone (Display)',
)
x_fc_source_label = fields.Char(
'Source', compute='_compute_is_shadow', store=True,
@@ -108,6 +119,17 @@ class FusionTechnicianTask(models.Model):
task.x_fc_is_shadow = bool(task.x_fc_sync_source)
task.x_fc_source_label = task.x_fc_sync_source or local_id
@api.depends('x_fc_sync_source', 'x_fc_sync_client_name',
'x_fc_sync_client_phone', 'partner_id')
def _compute_client_display(self):
for task in self:
if task.x_fc_sync_source:
task.client_display_name = task.x_fc_sync_client_name or task.name or ''
task.client_display_phone = task.x_fc_sync_client_phone or ''
else:
task.client_display_name = task.partner_id.name if task.partner_id else ''
task.client_display_phone = task.partner_id.phone if task.partner_id else ''
technician_id = fields.Many2one(
'res.users',
string='Technician',
@@ -288,6 +310,14 @@ class FusionTechnicianTask(models.Model):
help='Combined end datetime for calendar display',
)
calendar_event_id = fields.Many2one(
'calendar.event',
string='Calendar Event',
copy=False,
ondelete='set null',
help='Linked calendar event for external calendar sync',
)
# Schedule info helper for the form
schedule_info_html = fields.Html(
string='Schedule Info',
@@ -377,6 +407,17 @@ class FusionTechnicianTask(models.Model):
default=False,
help='Proof of Delivery signature required',
)
pod_signature = fields.Binary(
string='POD Signature', attachment=True,
)
pod_client_name = fields.Char(string='POD Signer Name')
pod_signature_date = fields.Date(string='POD Signature Date')
pod_signed_by_user_id = fields.Many2one(
'res.users', string='POD Collected By', readonly=True,
)
pod_signed_datetime = fields.Datetime(
string='POD Collected At', readonly=True,
)
# ------------------------------------------------------------------
# COMPLETION
@@ -1442,11 +1483,22 @@ class FusionTechnicianTask(models.Model):
local_records = records.filtered(lambda r: not r.x_fc_sync_source)
if local_records and not self.env.context.get('skip_task_sync'):
self.env['fusion.task.sync.config']._push_tasks(local_records, 'create')
# Sync to calendar for external calendar integrations
records._sync_calendar_event()
return records
def write(self, vals):
if self.env.context.get('skip_travel_recalc'):
return super().write(vals)
res = super().write(vals)
if ('status' in vals and vals['status'] in ('completed', 'cancelled')
and not self.env.context.get('skip_task_sync')):
shadow_records = self.filtered(lambda r: r.x_fc_sync_source)
if shadow_records:
self.env['fusion.task.sync.config']._push_shadow_status(shadow_records)
local_records = self.filtered(lambda r: not r.x_fc_sync_source)
if local_records:
self.env['fusion.task.sync.config']._push_tasks(local_records, 'write')
return res
# Safety: ensure time_end is consistent when start/duration change
# but time_end wasn't sent (readonly field in view may not save)
@@ -1516,8 +1568,63 @@ class FusionTechnicianTask(models.Model):
local_records = self.filtered(lambda r: not r.x_fc_sync_source)
if local_records:
self.env['fusion.task.sync.config']._push_tasks(local_records, 'write')
if 'status' in vals and vals['status'] in ('completed', 'cancelled'):
shadow_records = self.filtered(lambda r: r.x_fc_sync_source)
if shadow_records:
self.env['fusion.task.sync.config']._push_shadow_status(shadow_records)
# Re-sync calendar event when schedule fields change
cal_fields = {'scheduled_date', 'time_start', 'time_end',
'duration_hours', 'technician_id', 'task_type',
'partner_id', 'address_street', 'address_city', 'notes'}
if cal_fields & set(vals.keys()):
self._sync_calendar_event()
return res
def _sync_calendar_event(self):
"""Create or update a linked calendar.event for external calendar sync.
Only syncs tasks that have a scheduled date and an assigned technician.
Uses sudo() because portal users should not need calendar write access.
"""
CalendarEvent = self.env['calendar.event'].sudo()
for task in self:
if not task.datetime_start or not task.datetime_end or not task.technician_id:
if task.calendar_event_id:
task.calendar_event_id.unlink()
task.with_context(skip_travel_recalc=True).write({'calendar_event_id': False})
continue
partner = task.partner_id or task.sale_order_id.partner_id if task.sale_order_id else task.partner_id
client_name = partner.name if partner else ''
type_label = dict(self._fields['task_type'].selection).get(task.task_type, task.task_type or '')
event_name = f"{type_label}: {client_name}" if client_name else f"{type_label} - {task.name}"
location_parts = [task.address_street, task.address_city]
location = ', '.join(p for p in location_parts if p) or ''
description_parts = []
if task.sale_order_id:
description_parts.append(f"SO: {task.sale_order_id.name}")
if task.notes:
description_parts.append(task.notes)
vals = {
'name': event_name,
'start': task.datetime_start,
'stop': task.datetime_end,
'user_id': task.technician_id.id,
'location': location,
'partner_ids': [(6, 0, [task.technician_id.partner_id.id])],
'show_as': 'busy',
'description': '\n'.join(description_parts),
}
if task.calendar_event_id:
task.calendar_event_id.write(vals)
else:
event = CalendarEvent.create(vals)
task.with_context(skip_travel_recalc=True).write({'calendar_event_id': event.id})
@api.model
def _fill_address_vals(self, vals, partner):
"""Helper to fill address vals dict from a partner record."""
@@ -1674,19 +1781,43 @@ class FusionTechnicianTask(models.Model):
return 0.0, 0.0
def _recalculate_combos_travel(self, combos):
"""Recalculate travel for a set of (tech_id, date) combinations."""
"""Recalculate travel for a set of (tech_id, date) combinations.
Start-point priority per technician (for today only):
1. Actual GPS from today's fusion_clock check-in
2. Personal start address (x_fc_start_address)
3. Company default HQ address
For future dates, only 2 and 3 apply.
"""
ICP = self.env['ir.config_parameter'].sudo()
enabled = ICP.get_param('fusion_claims.google_distance_matrix_enabled', False)
if not enabled:
return
api_key = self._get_google_maps_api_key()
# Cache geocoded start addresses per technician to avoid repeated API calls
start_coords_cache = {}
today = fields.Date.today()
today_str = str(today)
today_tech_ids = {tid for tid, d in combos
if tid and str(d) == today_str}
clock_locations = {}
if today_tech_ids:
clock_locations = self._get_clock_in_locations(today_tech_ids, today)
for tech_id, date in combos:
if not tech_id or not date:
continue
cache_key = (tech_id, str(date))
if cache_key not in start_coords_cache:
if str(date) == today_str and tech_id in clock_locations:
cl = clock_locations[tech_id]
start_coords_cache[cache_key] = (cl['lat'], cl['lng'])
else:
addr = self._get_technician_start_address(tech_id)
start_coords_cache[cache_key] = self._geocode_address_string(addr, api_key)
all_day_tasks = self.sudo().search([
'|',
('technician_id', '=', tech_id),
@@ -1697,12 +1828,7 @@ class FusionTechnicianTask(models.Model):
if not all_day_tasks:
continue
# Get this technician's start location (personal or company default)
if tech_id not in start_coords_cache:
addr = self._get_technician_start_address(tech_id)
start_coords_cache[tech_id] = self._geocode_address_string(addr, api_key)
prev_lat, prev_lng = start_coords_cache[tech_id]
prev_lat, prev_lng = start_coords_cache[cache_key]
for i, task in enumerate(all_day_tasks):
if not (task.address_lat and task.address_lng):
task._geocode_address()
@@ -1710,7 +1836,7 @@ class FusionTechnicianTask(models.Model):
if prev_lat and prev_lng and task.address_lat and task.address_lng:
task.with_context(skip_travel_recalc=True)._calculate_travel_time(prev_lat, prev_lng)
travel_vals['previous_task_id'] = all_day_tasks[i - 1].id if i > 0 else False
travel_vals['travel_origin'] = 'Start Location' if i == 0 else f'Task {all_day_tasks[i - 1].name}'
travel_vals['travel_origin'] = 'Clock-In Location' if i == 0 and str(date) == today_str and tech_id in clock_locations else ('Start Location' if i == 0 else f'Task {all_day_tasks[i - 1].name}')
if travel_vals:
task.with_context(skip_travel_recalc=True).write(travel_vals)
prev_lat = task.address_lat or prev_lat
@@ -2044,53 +2170,66 @@ class FusionTechnicianTask(models.Model):
)
def _notify_scheduler_on_completion(self):
"""Send an Odoo notification to whoever created/scheduled the task."""
"""Send an Odoo notification to the person who scheduled the task.
Shadow tasks skip this -- the push-back to the source instance
triggers the notification there where the real scheduler exists.
"""
self.ensure_one()
# Notify the task creator (scheduler) if they're not the technician
if self.create_uid and self.create_uid not in self.all_technician_ids:
task_type_label = dict(self._fields['task_type'].selection).get(self.task_type, self.task_type)
task_url = f'/web#id={self.id}&model=fusion.technician.task&view_type=form'
client_name = self.partner_id.name or 'N/A'
order = self.sale_order_id or self.purchase_order_id
case_ref = order.name if order else ''
# Build address string
addr_parts = [p for p in [
self.address_street,
self.address_street2,
self.address_city,
self.address_state_id.name if self.address_state_id else '',
self.address_zip,
] if p]
address_str = ', '.join(addr_parts) or 'No address'
# Build subject
subject = f'Task Completed: {client_name}'
if case_ref:
subject += f' ({case_ref})'
body = Markup(
f'<div style="background:#d4edda;border-left:4px solid #28a745;padding:12px;border-radius:4px;margin-bottom:8px;">'
f'<p style="margin:0 0 8px 0;"><i class="fa fa-check-circle" style="color:#28a745;"></i> '
f'<strong>{task_type_label} Completed</strong></p>'
f'<table style="width:100%;border-collapse:collapse;">'
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Client:</td>'
f'<td style="padding:3px 0;">{client_name}</td></tr>'
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Case:</td>'
f'<td style="padding:3px 0;">{case_ref or "N/A"}</td></tr>'
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Task:</td>'
f'<td style="padding:3px 0;">{self.name}</td></tr>'
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Technician(s):</td>'
f'<td style="padding:3px 0;">{self.all_technician_names or self.technician_id.name}</td></tr>'
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Location:</td>'
f'<td style="padding:3px 0;">{address_str}</td></tr>'
f'</table>'
f'<p style="margin:8px 0 0 0;"><a href="{task_url}">View Task</a></p>'
f'</div>'
)
# Use Odoo's internal notification system
self.env['mail.thread'].sudo().message_notify(
partner_ids=[self.create_uid.partner_id.id],
body=body,
subject=subject,
)
if self.x_fc_sync_source:
return
recipient = None
if self.sale_order_id and self.sale_order_id.user_id:
recipient = self.sale_order_id.user_id
elif self.purchase_order_id and self.purchase_order_id.user_id:
recipient = self.purchase_order_id.user_id
elif self.create_uid:
recipient = self.create_uid
if not recipient or recipient in self.all_technician_ids:
return
task_type_label = dict(self._fields['task_type'].selection).get(self.task_type, self.task_type)
task_url = f'/web#id={self.id}&model=fusion.technician.task&view_type=form'
client_name = self.client_display_name or 'N/A'
order = self.sale_order_id or self.purchase_order_id
case_ref = order.name if order else ''
addr_parts = [p for p in [
self.address_street,
self.address_street2,
self.address_city,
self.address_state_id.name if self.address_state_id else '',
self.address_zip,
] if p]
address_str = ', '.join(addr_parts) or 'No address'
subject = f'Task Completed: {client_name}'
if case_ref:
subject += f' ({case_ref})'
body = Markup(
f'<div style="background:#d4edda;border-left:4px solid #28a745;padding:12px;border-radius:4px;margin-bottom:8px;">'
f'<p style="margin:0 0 8px 0;"><i class="fa fa-check-circle" style="color:#28a745;"></i> '
f'<strong>{task_type_label} Completed</strong></p>'
f'<table style="width:100%;border-collapse:collapse;">'
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Client:</td>'
f'<td style="padding:3px 0;">{client_name}</td></tr>'
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Case:</td>'
f'<td style="padding:3px 0;">{case_ref or "N/A"}</td></tr>'
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Task:</td>'
f'<td style="padding:3px 0;">{self.name}</td></tr>'
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Technician(s):</td>'
f'<td style="padding:3px 0;">{self.all_technician_names or self.technician_id.name}</td></tr>'
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Location:</td>'
f'<td style="padding:3px 0;">{address_str}</td></tr>'
f'</table>'
f'<p style="margin:8px 0 0 0;"><a href="{task_url}">View Task</a></p>'
f'</div>'
)
self.env['mail.thread'].sudo().message_notify(
partner_ids=[recipient.partner_id.id],
body=body,
subject=subject,
)
# ------------------------------------------------------------------
# TASK EMAIL NOTIFICATIONS
@@ -2483,7 +2622,13 @@ class FusionTechnicianTask(models.Model):
@api.model
def _get_clock_in_locations(self, tech_ids, today):
"""Get today's clock-in lat/lng from fusion_clock if installed."""
"""Get today's clock-in lat/lng from fusion_clock if installed.
Uses the technician's actual GPS position at the moment they clocked
in (from the activity log), not the geofenced location's fixed
coordinates. Falls back to the geofence center if no activity-log
GPS is available.
"""
result = {}
try:
module = self.env['ir.module.module'].sudo().search([
@@ -2498,6 +2643,7 @@ class FusionTechnicianTask(models.Model):
try:
Attendance = self.env['hr.attendance'].sudo()
Employee = self.env['hr.employee'].sudo()
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
except KeyError:
return result
@@ -2522,12 +2668,31 @@ class FusionTechnicianTask(models.Model):
uid = emp_to_user.get(att.employee_id.id)
if not uid or uid in result:
continue
loc = att.x_fclk_location_id if hasattr(att, 'x_fclk_location_id') else False
if loc and loc.latitude and loc.longitude:
lat, lng, address = 0, 0, ''
log = ActivityLog.search([
('attendance_id', '=', att.id),
('log_type', '=', 'clock_in'),
('latitude', '!=', 0),
('longitude', '!=', 0),
], limit=1)
if log:
lat, lng = log.latitude, log.longitude
loc = att.x_fclk_location_id if hasattr(att, 'x_fclk_location_id') else False
address = (loc.address or loc.name) if loc else ''
if not lat or not lng:
loc = att.x_fclk_location_id if hasattr(att, 'x_fclk_location_id') else False
if loc and loc.latitude and loc.longitude:
lat, lng = loc.latitude, loc.longitude
address = loc.address or loc.name or ''
if lat and lng:
result[uid] = {
'lat': loc.latitude,
'lng': loc.longitude,
'address': loc.address or loc.name or '',
'lat': lat,
'lng': lng,
'address': address,
'source': 'clock_in',
}

View File

@@ -496,15 +496,21 @@ export class FusionTaskMapController extends Component {
if (!loc.latitude || !loc.longitude) continue;
const pos = { lat: loc.latitude, lng: loc.longitude };
const initials = initialsOf(loc.name);
const src = loc.sync_instance || this.localInstanceId || "";
const isRemote = src && src !== this.localInstanceId;
const pinColor = isRemote
? (SOURCE_COLORS[src] || "#6c757d")
: "#1d4ed8";
const srcLabel = src ? src.charAt(0).toUpperCase() + src.slice(1) : "";
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">` +
`<rect x="2" y="2" width="44" height="44" rx="12" ry="12" fill="#1d4ed8" stroke="#fff" stroke-width="3"/>` +
`<rect x="2" y="2" width="44" height="44" rx="12" ry="12" fill="${pinColor}" stroke="#fff" stroke-width="3"/>` +
`<text x="24" y="30" text-anchor="middle" fill="#fff" font-size="17" font-family="Arial,Helvetica,sans-serif" font-weight="bold">${initials}</text>` +
`</svg>`;
const marker = new google.maps.Marker({
position: pos,
map: this.map,
title: loc.name,
title: loc.name + (isRemote ? ` [${srcLabel}]` : ""),
icon: {
url: "data:image/svg+xml;charset=UTF-8," + encodeURIComponent(svg),
scaledSize: new google.maps.Size(44, 44),
@@ -515,8 +521,9 @@ export class FusionTaskMapController extends Component {
marker.addListener("click", () => {
this.infoWindow.setContent(`
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:200px;color:#1f2937;">
<div style="background:#1d4ed8;color:#fff;padding:10px 14px;">
<div style="background:${pinColor};color:#fff;padding:10px 14px;">
<strong><i class="fa fa-user" style="margin-right:6px;"></i>${loc.name}</strong>
${srcLabel ? `<span style="float:right;font-size:10px;font-weight:600;background:rgba(255,255,255,.2);padding:2px 8px;border-radius:8px;">${srcLabel}</span>` : ""}
</div>
<div style="padding:12px 14px;font-size:13px;line-height:1.8;color:#1f2937;">
<div><strong style="color:#374151;">Last seen:</strong> <span style="color:#111827;">${loc.logged_at || "Unknown"}</span></div>

View File

@@ -81,7 +81,6 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'web.assets_frontend': [
'fusion_clock/static/src/css/portal_clock.css',
'fusion_clock/static/src/js/fusion_clock_portal.js',
'fusion_clock/static/src/js/fusion_clock_portal_fab.js',
'fusion_clock/static/src/js/fusion_clock_kiosk.js',
],
'web.assets_backend': [

View File

@@ -57,29 +57,34 @@ class FusionClockAPI(http.Controller):
if not locations:
return False, 0, 'no_locations', 'gps'
gps_available = latitude != 0 or longitude != 0
geocoded = locations.filtered(lambda l: l.latitude and l.longitude
and not (l.latitude == 0.0 and l.longitude == 0.0))
if not geocoded:
return False, 0, 'no_geocoded', 'gps'
nearest_location = False
nearest_distance = float('inf')
for loc in geocoded:
dist = haversine_distance(latitude, longitude, loc.latitude, loc.longitude)
if dist <= loc.radius:
return loc, dist, None, 'gps'
if dist < nearest_distance:
nearest_distance = dist
nearest_location = loc
if gps_available and geocoded:
for loc in geocoded:
dist = haversine_distance(latitude, longitude, loc.latitude, loc.longitude)
if dist <= loc.radius:
return loc, dist, None, 'gps'
if dist < nearest_distance:
nearest_distance = dist
# IP fallback check
# IP fallback -- try when GPS is unavailable OR GPS is outside all geofences
ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock.enable_ip_fallback', 'False') == 'True' and client_ip:
if client_ip:
for loc in locations:
if loc.check_ip_whitelist(client_ip):
return loc, 0, None, 'ip'
if not gps_available:
return False, 0, 'gps_unavailable', 'gps'
if not geocoded:
return False, 0, 'no_geocoded', 'gps'
return False, nearest_distance, 'outside', 'gps'
def _location_error_message(self, error_type, distance=0):
@@ -87,6 +92,8 @@ class FusionClockAPI(http.Controller):
return 'No clock locations configured. Ask your manager to set up locations in Fusion Clock > Locations.'
elif error_type == 'no_geocoded':
return 'Clock locations exist but have no GPS coordinates. Ask your manager to geocode them.'
elif error_type == 'gps_unavailable':
return 'Could not determine your location. Please enable GPS/location services in your browser and device settings, then try again.'
else:
dist_str = f"{int(distance)}m" if distance < 1000 else f"{distance/1000:.1f}km"
return f'You are {dist_str} away from the nearest clock location. Please clock in/out within the allowed area.'

View File

@@ -125,6 +125,53 @@ class FusionClockLocation(models.Model):
return False
return False
def action_detect_ip(self):
"""Detect the current public IP and append it to the whitelist."""
self.ensure_one()
try:
resp = requests.get('https://ipapi.co/json/', timeout=10)
data = resp.json()
ip = data.get('ip', '')
if not ip:
raise UserError(_("Could not detect public IP."))
except requests.exceptions.RequestException as e:
raise UserError(_("Network error detecting IP: %s") % str(e))
existing = (self.ip_whitelist or '').strip()
existing_lines = [l.strip() for l in existing.split('\n') if l.strip()] if existing else []
if ip in existing_lines:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Already Whitelisted'),
'message': _('%s is already in the whitelist.') % ip,
'type': 'warning',
'sticky': False,
},
}
existing_lines.append(ip)
self.ip_whitelist = '\n'.join(existing_lines)
city = data.get('city', '')
org = data.get('org', '')
detail = f"{ip}"
if city:
detail += f" ({city}"
if org:
detail += f" - {org}"
detail += ")"
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('IP Detected & Added'),
'message': _('Added %s to the whitelist.') % detail,
'type': 'success',
'sticky': False,
},
}
def action_geocode_address(self):
"""Geocode the address to get lat/lng using Google Geocoding API.
Falls back to Nominatim (OpenStreetMap) if Google fails.

View File

@@ -180,7 +180,21 @@ export class FusionClockKiosk extends Interaction {
lat = pos.coords.latitude;
lng = pos.coords.longitude;
} catch {
// GPS unavailable on kiosk device
// Native GPS unavailable -- try IP geolocation
}
if (lat === 0 && lng === 0) {
try {
const ipResp = await fetch("https://ipapi.co/json/");
if (ipResp.ok) {
const ipData = await ipResp.json();
if (ipData.latitude && ipData.longitude) {
lat = ipData.latitude;
lng = ipData.longitude;
}
}
} catch {
// IP geolocation also unavailable
}
}
const resp = await fetch("/fusion_clock/kiosk/clock", {

View File

@@ -199,15 +199,22 @@ export class FusionClockPortal extends Interaction {
(pos) => {
this._performClockAction(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy);
},
(err) => {
async () => {
let lat = 0, lng = 0;
try {
const ipResp = await fetch("https://ipapi.co/json/");
if (ipResp.ok) {
const ipData = await ipResp.json();
if (ipData.latitude && ipData.longitude) {
lat = ipData.latitude;
lng = ipData.longitude;
}
}
} catch {
// IP geolocation also unavailable
}
this._hideGPSOverlay();
let msg = "Could not get your location. ";
if (err.code === 1) msg += "Please allow location access.";
else if (err.code === 2) msg += "Location unavailable.";
else if (err.code === 3) msg += "Location request timed out.";
this._showToast(msg, "error");
this._shakeButton();
btn.disabled = false;
this._performClockAction(lat, lng, lat ? 5000 : 0);
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
);

View File

@@ -398,10 +398,23 @@ export class FusionClockPortalFAB extends Interaction {
lat = pos.coords.latitude;
lng = pos.coords.longitude;
acc = pos.coords.accuracy;
} catch (geoErr) {
this._showError("Location access denied. Please enable GPS.");
if (this.actionBtn) this.actionBtn.disabled = false;
return;
} catch {
// Native GPS unavailable (common on desktops) -- try IP geolocation
}
}
if (lat === 0 && lng === 0) {
try {
const ipResp = await fetch("https://ipapi.co/json/");
if (ipResp.ok) {
const ipData = await ipResp.json();
if (ipData.latitude && ipData.longitude) {
lat = ipData.latitude;
lng = ipData.longitude;
acc = 5000;
}
}
} catch {
// IP geolocation also unavailable
}
}

View File

@@ -134,10 +134,23 @@ export class FusionClockFAB extends Component {
lat = pos.coords.latitude;
lng = pos.coords.longitude;
acc = pos.coords.accuracy;
} catch (geoErr) {
this.state.error = "Location access denied.";
this.state.loading = false;
return;
} catch {
// Native GPS unavailable (common on desktops) -- try IP geolocation
}
}
if (lat === 0 && lng === 0) {
try {
const ipResp = await fetch("https://ipapi.co/json/");
if (ipResp.ok) {
const ipData = await ipResp.json();
if (ipData.latitude && ipData.longitude) {
lat = ipData.latitude;
lng = ipData.longitude;
acc = 5000;
}
}
} catch {
// IP geolocation also unavailable
}
}

View File

@@ -52,6 +52,12 @@
</group>
<group>
<group string="IP Whitelist">
<div colspan="2">
<button name="action_detect_ip" type="object"
string="Detect My IP" class="btn-secondary mb-2"
icon="fa-crosshairs"
title="Detect your current public IP and add it to the whitelist"/>
</div>
<field name="ip_whitelist" nolabel="1" colspan="2"
placeholder="One IP or CIDR per line, e.g.&#10;192.168.1.0/24&#10;10.0.0.100"/>
</group>

View File

@@ -8,7 +8,8 @@
<field name="name">Rental: Renewal Reminder</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">{{ object.company_id.name }} - Rental {{ object.name }} Renews Soon</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="email_from">{{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }}</field>
<field name="use_default_to" eval="False"/>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
@@ -40,7 +41,7 @@
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
<field name="auto_delete" eval="False"/>
</record>
<!-- ============================================================ -->
@@ -50,7 +51,8 @@
<field name="name">Rental: Renewal Confirmation</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">{{ object.company_id.name }} - Rental {{ object.name }} Renewed</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="email_from">{{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }}</field>
<field name="use_default_to" eval="False"/>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
@@ -80,7 +82,7 @@
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
<field name="auto_delete" eval="False"/>
</record>
<!-- ============================================================ -->
@@ -90,7 +92,8 @@
<field name="name">Rental: Payment Receipt</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">{{ object.company_id.name }} - Payment Receipt {{ object.name }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="email_from">{{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }}</field>
<field name="use_default_to" eval="False"/>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
@@ -119,7 +122,7 @@
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
<field name="auto_delete" eval="False"/>
</record>
<!-- ============================================================ -->
@@ -129,7 +132,8 @@
<field name="name">Rental: Cancellation Confirmed</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">{{ object.company_id.name }} - Cancellation Received {{ object.name }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="email_from">{{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }}</field>
<field name="use_default_to" eval="False"/>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
@@ -152,7 +156,7 @@
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
<field name="auto_delete" eval="False"/>
</record>
<!-- ============================================================ -->
@@ -162,7 +166,8 @@
<field name="name">Rental: Agreement for Signing</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">{{ object.company_id.name }} - Rental Agreement {{ object.name }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="email_from">{{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }}</field>
<field name="use_default_to" eval="False"/>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
@@ -194,7 +199,7 @@
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
<field name="auto_delete" eval="False"/>
</record>
<!-- ============================================================ -->
@@ -204,7 +209,8 @@
<field name="name">Rental: Signed Agreement Copy</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">{{ object.company_id.name }} - Your Signed Rental Agreement {{ object.name }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="email_from">{{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }}</field>
<field name="use_default_to" eval="False"/>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
@@ -235,7 +241,7 @@
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
<field name="auto_delete" eval="False"/>
</record>
<!-- ============================================================ -->
@@ -245,7 +251,8 @@
<field name="name">Rental: Purchase Conversion Offer</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">{{ object.company_id.name }} - Make Your Rental Yours {{ object.name }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="email_from">{{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }}</field>
<field name="use_default_to" eval="False"/>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
@@ -278,7 +285,7 @@
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
<field name="auto_delete" eval="False"/>
</record>
<!-- ============================================================ -->
@@ -288,7 +295,8 @@
<field name="name">Rental: Security Deposit Refund Initiated</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">{{ object.company_id.name }} - Security Deposit Refund Processing {{ object.name }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="email_from">{{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }}</field>
<field name="use_default_to" eval="False"/>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
@@ -318,7 +326,7 @@
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
<field name="auto_delete" eval="False"/>
</record>
<!-- ============================================================ -->
@@ -328,7 +336,8 @@
<field name="name">Rental: Security Deposit Refund Complete</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">{{ object.company_id.name }} - Security Deposit Refunded {{ object.name }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="email_from">{{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }}</field>
<field name="use_default_to" eval="False"/>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
@@ -359,7 +368,7 @@
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
<field name="auto_delete" eval="False"/>
</record>
<!-- ============================================================ -->
@@ -369,7 +378,8 @@
<field name="name">Rental: Invoice + Payment Receipt</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">{{ object.company_id.name }} - Invoice &amp; Payment Confirmation {{ object.name }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="email_from">{{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }}</field>
<field name="use_default_to" eval="False"/>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
@@ -412,7 +422,7 @@
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
<field name="auto_delete" eval="False"/>
</record>
<!-- ============================================================ -->
@@ -422,7 +432,8 @@
<field name="name">Rental: Damage Notification</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">{{ object.company_id.name }} - Rental Inspection Update {{ object.name }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="email_from">{{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }}</field>
<field name="use_default_to" eval="False"/>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
@@ -450,7 +461,7 @@
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
<field name="auto_delete" eval="False"/>
</record>
<!-- ============================================================ -->
@@ -460,7 +471,8 @@
<field name="name">Rental: Thank You + Review</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">{{ object.company_id.name }} - Thank You {{ object.name }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="email_from">{{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }}</field>
<field name="use_default_to" eval="False"/>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
@@ -493,7 +505,7 @@
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
<field name="auto_delete" eval="False"/>
</record>
<!-- ============================================================ -->
@@ -503,7 +515,8 @@
<field name="name">Rental: Card Reauthorization Request</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">{{ object.company_id.name }} - Update Payment Card {{ object.name }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="email_from">{{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }}</field>
<field name="use_default_to" eval="False"/>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
@@ -529,7 +542,7 @@
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
<field name="auto_delete" eval="False"/>
</record>
<!-- ============================================================ -->
@@ -539,7 +552,8 @@
<field name="name">Rental: Card Updated Confirmation</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">{{ object.company_id.name }} - Payment Card Updated {{ object.name }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="email_from">{{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }}</field>
<field name="use_default_to" eval="False"/>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
@@ -568,7 +582,7 @@
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
<field name="auto_delete" eval="False"/>
</record>
<!-- ============================================================ -->

View File

@@ -623,6 +623,7 @@ class SaleOrder(models.Model):
raise_if_not_found=False,
)
if not template:
_logger.error("Renewal confirmation template not found for %s", self.name)
return
attachment_ids = []
@@ -635,14 +636,18 @@ class SaleOrder(models.Model):
invoice, 'Renewal',
)
template.with_context(
payment_ok=payment_ok,
renewal_log=renewal_log,
).send_mail(
self.id,
force_send=True,
email_values={'attachment_ids': attachment_ids} if attachment_ids else {},
)
try:
template.with_context(
payment_ok=payment_ok,
renewal_log=renewal_log,
).send_mail(
self.id,
force_send=True,
email_values={'attachment_ids': attachment_ids} if attachment_ids else {},
)
_logger.warning("Renewal confirmation email sent for %s", self.name)
except Exception as e:
_logger.error("Failed to send renewal confirmation email for %s: %s", self.name, e)
def _send_payment_receipt_email(self, invoice, transaction):
"""Send payment receipt email after successful collection."""
@@ -912,7 +917,13 @@ class SaleOrder(models.Model):
raise_if_not_found=False,
)
if template:
template.send_mail(self.id, force_send=True)
try:
template.send_mail(self.id, force_send=True)
_logger.warning("Rental agreement email sent for %s", self.name)
except Exception as e:
_logger.error("Failed to send agreement email for %s: %s", self.name, e)
else:
_logger.error("Agreement email template not found for %s", self.name)
self.message_post(body=_("Rental agreement sent to customer for signing."))
def action_send_card_reauthorization(self):
@@ -1308,6 +1319,7 @@ class SaleOrder(models.Model):
raise_if_not_found=False,
)
if not template:
_logger.error("Deposit refund initiated template not found for %s", self.name)
return
attachment_ids = []
@@ -1317,11 +1329,15 @@ class SaleOrder(models.Model):
credit_note, 'Deposit Credit Note',
)
template.send_mail(
self.id,
force_send=True,
email_values={'attachment_ids': attachment_ids} if attachment_ids else {},
)
try:
template.send_mail(
self.id,
force_send=True,
email_values={'attachment_ids': attachment_ids} if attachment_ids else {},
)
_logger.warning("Deposit refund initiated email sent for %s", self.name)
except Exception as e:
_logger.error("Failed to send deposit refund initiated email for %s: %s", self.name, e)
def _send_deposit_refund_email(self):
"""Send the security deposit refund completion email with credit note and receipt."""
@@ -1331,6 +1347,7 @@ class SaleOrder(models.Model):
raise_if_not_found=False,
)
if not template:
_logger.error("Deposit refund template not found for %s", self.name)
return
attachment_ids = []
@@ -1342,11 +1359,15 @@ class SaleOrder(models.Model):
receipt_ids = self._find_poynt_receipt_attachments(credit_note)
attachment_ids.extend(receipt_ids)
template.send_mail(
self.id,
force_send=True,
email_values={'attachment_ids': attachment_ids} if attachment_ids else {},
)
try:
template.send_mail(
self.id,
force_send=True,
email_values={'attachment_ids': attachment_ids} if attachment_ids else {},
)
_logger.warning("Deposit refund email sent for %s", self.name)
except Exception as e:
_logger.error("Failed to send deposit refund email for %s: %s", self.name, e)
def _send_invoice_with_receipt(self, invoice, invoice_type=''):
"""Send invoice email with the invoice PDF and payment receipt attached.
@@ -1361,6 +1382,7 @@ class SaleOrder(models.Model):
raise_if_not_found=False,
)
if not template:
_logger.error("Invoice receipt email template not found for %s", self.name)
return
type_label = {
@@ -1374,14 +1396,24 @@ class SaleOrder(models.Model):
receipt_ids = self._find_poynt_receipt_attachments(invoice)
attachment_ids.extend(receipt_ids)
template.with_context(
rental_invoice=invoice,
rental_invoice_type=invoice_type,
).send_mail(
self.id,
force_send=True,
email_values={'attachment_ids': attachment_ids} if attachment_ids else {},
)
try:
template.with_context(
rental_invoice=invoice,
rental_invoice_type=invoice_type,
).send_mail(
self.id,
force_send=True,
email_values={'attachment_ids': attachment_ids} if attachment_ids else {},
)
_logger.warning(
"Invoice receipt email sent for %s (%s) with %d attachments",
self.name, type_label, len(attachment_ids),
)
except Exception as e:
_logger.error(
"Failed to send invoice receipt email for %s (%s): %s",
self.name, type_label, e,
)
# =================================================================
# Email Attachment Helpers
@@ -1605,11 +1637,17 @@ class SaleOrder(models.Model):
raise_if_not_found=False,
)
if template:
template.send_mail(
self.id,
force_send=True,
email_values={'attachment_ids': [attachment.id]},
)
try:
template.send_mail(
self.id,
force_send=True,
email_values={'attachment_ids': [attachment.id]},
)
_logger.warning("Signed agreement email sent for %s", self.name)
except Exception as e:
_logger.error("Failed to send signed agreement email for %s: %s", self.name, e)
else:
_logger.error("Signed agreement email template not found for %s", self.name)
def action_preview_rental_agreement(self):
"""Open the rental agreement PDF in a preview dialog."""
@@ -1702,14 +1740,19 @@ class SaleOrder(models.Model):
raise_if_not_found=False,
)
if not template:
_logger.error("Thank you email template not found for %s", self.name)
return
attachment_ids = self._generate_agreement_attachment_ids()
template.with_context(
google_review_url=self._get_google_review_url(),
).send_mail(
self.id,
force_send=True,
email_values={'attachment_ids': attachment_ids} if attachment_ids else {},
)
try:
template.with_context(
google_review_url=self._get_google_review_url(),
).send_mail(
self.id,
force_send=True,
email_values={'attachment_ids': attachment_ids} if attachment_ids else {},
)
_logger.warning("Thank you email sent for %s", self.name)
except Exception as e:
_logger.error("Failed to send thank you email for %s: %s", self.name, e)