changes
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,3 +4,4 @@ from . import portal_main
|
||||
from . import portal_assessment
|
||||
from . import pdf_editor
|
||||
from . import portal_repair
|
||||
from . import portal_schedule
|
||||
@@ -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.
|
||||
|
||||
|
||||
327
fusion_authorizer_portal/controllers/portal_schedule.py
Normal file
327
fusion_authorizer_portal/controllers/portal_schedule.py
Normal 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')
|
||||
13
fusion_authorizer_portal/data/appointment_invite_data.xml
Normal file
13
fusion_authorizer_portal/data/appointment_invite_data.xml
Normal 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>
|
||||
@@ -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],
|
||||
)
|
||||
@@ -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],
|
||||
)
|
||||
@@ -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],
|
||||
)
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
})();
|
||||
348
fusion_authorizer_portal/views/portal_schedule.xml
Normal file
348
fusion_authorizer_portal/views/portal_schedule.xml
Normal 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 & 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}&libraries=places&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>
|
||||
@@ -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'"/>
|
||||
|
||||
@@ -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'
|
||||
? '<i class="fa fa-check-circle text-success"></i>'
|
||||
: '<i class="fa fa-exclamation-circle text-danger"></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' && url) {
|
||||
acts.innerHTML = '<a href="' + url + '" class="btn btn-success w-100 rounded-pill mb-2">Continue</a>' +
|
||||
'<p class="text-muted small mb-0">Redirecting in <span id="tpodCD">3</span>s...</p>';
|
||||
ov.classList.add('show');
|
||||
var s = 3, t = setInterval(function() { s--; var c = document.getElementById('tpodCD');
|
||||
if (c) c.textContent = s; if (s <= 0) { clearInterval(t); window.location.href = url; } }, 1000);
|
||||
} else {
|
||||
acts.innerHTML = '<button class="btn btn-outline-secondary w-100 rounded-pill" onclick="document.getElementById(\'taskPodOverlay\').classList.remove(\'show\')">OK</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 = '<i class="fa fa-spinner fa-spin me-2"></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 && 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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': [
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. 192.168.1.0/24 10.0.0.100"/>
|
||||
</group>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user