This commit is contained in:
gsinghpal
2026-03-16 08:14:56 -04:00
parent fdca9518ab
commit e56974d46f
196 changed files with 19739 additions and 3471 deletions

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
{
'name': 'Fusion Authorizer & Sales Portal',
'version': '19.0.2.5.0',
'version': '19.0.2.7.0',
'category': 'Sales/Portal',
'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms',
'description': """
@@ -50,10 +50,10 @@ This module provides external portal access for:
'website',
'mail',
'calendar',
'appointment',
'knowledge',
'fusion_claims',
'fusion_tasks',
'fusion_loaners_management',
],
'data': [
# Security
@@ -64,7 +64,6 @@ 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',
@@ -79,7 +78,6 @@ This module provides external portal access for:
'views/portal_accessibility_forms.xml',
'views/portal_technician_templates.xml',
'views/portal_book_assessment.xml',
'views/portal_schedule.xml',
'views/portal_page11_sign_templates.xml',
],
'assets': {
@@ -93,11 +91,11 @@ This module provides external portal access for:
'fusion_authorizer_portal/static/src/js/portal_search.js',
'fusion_authorizer_portal/static/src/js/assessment_form.js',
'fusion_authorizer_portal/static/src/js/signature_pad.js',
'fusion_authorizer_portal/static/src/js/loaner_portal.js',
'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',
'fusion_authorizer_portal/static/src/js/timezone_detect.js',
],
},
'images': ['static/description/icon.png'],

View File

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

View File

@@ -348,13 +348,13 @@ class AssessmentPortal(CustomerPortal):
vals = {
'signature_page_11': signature_data,
'signature_page_11_name': signer_name,
'signature_page_11_date': datetime.now(),
'signature_page_11_date': fields.Datetime.now(),
}
elif signature_type == 'page_12':
vals = {
'signature_page_12': signature_data,
'signature_page_12_name': signer_name,
'signature_page_12_date': datetime.now(),
'signature_page_12_date': fields.Datetime.now(),
}
else:
return {'success': False, 'error': 'Invalid signature type'}
@@ -1018,227 +1018,6 @@ class AssessmentPortal(CustomerPortal):
('Nunavut', 'Nunavut'),
]
# =========================================================================
# LOANER PORTAL ROUTES
# =========================================================================
@http.route('/my/loaner/categories', type='jsonrpc', auth='user', website=True)
def portal_loaner_categories(self, **kw):
"""Return loaner product categories."""
parent = request.env.ref('fusion_claims.product_category_loaner', raise_if_not_found=False)
if not parent:
return []
categories = request.env['product.category'].sudo().search([
('parent_id', '=', parent.id),
], order='name')
return [{'id': c.id, 'name': c.name} for c in categories]
@http.route('/my/loaner/products', type='jsonrpc', auth='user', website=True)
def portal_loaner_products(self, **kw):
"""Return available loaner products and their serial numbers."""
domain = [('x_fc_can_be_loaned', '=', True)]
category_id = kw.get('category_id')
if category_id:
domain.append(('categ_id', '=', int(category_id)))
products = request.env['product.product'].sudo().search(domain)
loaner_location = request.env.ref('fusion_claims.stock_location_loaner', raise_if_not_found=False)
result = []
for p in products:
lots = []
if loaner_location:
quants = request.env['stock.quant'].sudo().search([
('product_id', '=', p.id),
('location_id', '=', loaner_location.id),
('quantity', '>', 0),
])
for q in quants:
if q.lot_id:
lots.append({'id': q.lot_id.id, 'name': q.lot_id.name})
result.append({
'id': p.id,
'name': p.name,
'category_id': p.categ_id.id,
'period_days': p.product_tmpl_id.x_fc_loaner_period_days or 7,
'lots': lots,
})
return result
@http.route('/my/loaner/locations', type='jsonrpc', auth='user', website=True)
def portal_loaner_locations(self, **kw):
"""Return internal stock locations for return."""
locations = request.env['stock.location'].sudo().search([
('usage', '=', 'internal'),
('company_id', '=', request.env.company.id),
])
return [{'id': loc.id, 'name': loc.complete_name} for loc in locations]
@http.route('/my/loaner/checkout', type='jsonrpc', auth='user', website=True)
def portal_loaner_checkout(self, **kw):
"""Checkout a loaner from the portal."""
partner = request.env.user.partner_id
if not partner.is_sales_rep_portal and not partner.is_authorizer:
return {'error': 'Unauthorized'}
product_id = int(kw.get('product_id', 0))
lot_id = int(kw.get('lot_id', 0)) if kw.get('lot_id') else False
sale_order_id = int(kw.get('sale_order_id', 0)) if kw.get('sale_order_id') else False
client_id = int(kw.get('client_id', 0)) if kw.get('client_id') else False
loaner_period = int(kw.get('loaner_period_days', 7))
condition = kw.get('checkout_condition', 'good')
notes = kw.get('checkout_notes', '')
if not product_id:
return {'error': 'Product is required'}
vals = {
'product_id': product_id,
'loaner_period_days': loaner_period,
'checkout_condition': condition,
'checkout_notes': notes,
'sales_rep_id': request.env.user.id,
}
if lot_id:
vals['lot_id'] = lot_id
if sale_order_id:
so = request.env['sale.order'].sudo().browse(sale_order_id)
if so.exists():
vals['sale_order_id'] = so.id
vals['partner_id'] = so.partner_id.id
vals['authorizer_id'] = so.x_fc_authorizer_id.id if so.x_fc_authorizer_id else False
vals['delivery_address'] = so.partner_shipping_id.contact_address if so.partner_shipping_id else ''
if client_id and not vals.get('partner_id'):
vals['partner_id'] = client_id
if not vals.get('partner_id'):
return {'error': 'Client is required'}
try:
checkout = request.env['fusion.loaner.checkout'].sudo().create(vals)
checkout.action_checkout()
return {
'success': True,
'checkout_id': checkout.id,
'name': checkout.name,
'message': f'Loaner {checkout.name} checked out successfully',
}
except Exception as e:
_logger.error(f"Loaner checkout error: {e}")
return {'error': str(e)}
@http.route('/my/loaner/create-product', type='jsonrpc', auth='user', website=True)
def portal_loaner_create_product(self, **kw):
"""Quick-create a loaner product with serial number from the portal."""
partner = request.env.user.partner_id
if not partner.is_sales_rep_portal and not partner.is_authorizer:
return {'error': 'Unauthorized'}
product_name = kw.get('product_name', '').strip()
serial_number = kw.get('serial_number', '').strip()
if not product_name:
return {'error': 'Product name is required'}
if not serial_number:
return {'error': 'Serial number is required'}
try:
# Use provided category or default to Loaner Equipment
category_id = kw.get('category_id')
if category_id:
category = request.env['product.category'].sudo().browse(int(category_id))
if not category.exists():
category = None
else:
category = None
if not category:
category = request.env.ref('fusion_claims.product_category_loaner', raise_if_not_found=False)
if not category:
category = request.env['product.category'].sudo().search([
('name', '=', 'Loaner Equipment'),
], limit=1)
if not category:
category = request.env['product.category'].sudo().create({
'name': 'Loaner Equipment',
})
# Create product template
product_tmpl = request.env['product.template'].sudo().create({
'name': product_name,
'type': 'consu',
'tracking': 'serial',
'categ_id': category.id,
'x_fc_can_be_loaned': True,
'x_fc_loaner_period_days': 7,
'sale_ok': False,
'purchase_ok': False,
})
product = product_tmpl.product_variant_id
# Create serial number (lot)
lot = request.env['stock.lot'].sudo().create({
'name': serial_number,
'product_id': product.id,
'company_id': request.env.company.id,
})
# Add stock in loaner location
loaner_location = request.env.ref('fusion_claims.stock_location_loaner', raise_if_not_found=False)
if loaner_location:
request.env['stock.quant'].sudo().create({
'product_id': product.id,
'location_id': loaner_location.id,
'lot_id': lot.id,
'quantity': 1,
})
return {
'success': True,
'product_id': product.id,
'product_name': product.name,
'lot_id': lot.id,
'lot_name': lot.name,
}
except Exception as e:
_logger.error(f"Loaner product creation error: {e}")
return {'error': str(e)}
@http.route('/my/loaner/return', type='jsonrpc', auth='user', website=True)
def portal_loaner_return(self, **kw):
"""Return/pickup a loaner from the portal."""
partner = request.env.user.partner_id
if not partner.is_sales_rep_portal and not partner.is_authorizer:
return {'error': 'Unauthorized'}
checkout_id = int(kw.get('checkout_id', 0))
return_condition = kw.get('return_condition', 'good')
return_notes = kw.get('return_notes', '')
return_location_id = int(kw.get('return_location_id', 0)) if kw.get('return_location_id') else None
if not checkout_id:
return {'error': 'Checkout ID is required'}
try:
checkout = request.env['fusion.loaner.checkout'].sudo().browse(checkout_id)
if not checkout.exists():
return {'error': 'Checkout not found'}
if checkout.state not in ('checked_out', 'overdue', 'rental_pending'):
return {'error': 'This loaner is not currently checked out'}
checkout.action_process_return(
return_condition=return_condition,
return_notes=return_notes,
return_location_id=return_location_id,
)
return {
'success': True,
'message': f'Loaner {checkout.name} returned successfully',
}
except Exception as e:
_logger.error(f"Loaner return error: {e}")
return {'error': str(e)}
# ==========================================================================
# PUBLIC ASSESSMENT BOOKING
# ==========================================================================

View File

@@ -14,6 +14,45 @@ _logger = logging.getLogger(__name__)
class AuthorizerPortal(CustomerPortal):
"""Portal controller for Authorizers (OTs/Therapists)"""
def _get_user_tz(self):
"""Return a pytz timezone from the best available source.
Priority: user tz > browser cookie > company calendar > UTC."""
candidates = [
request.env.user.tz,
]
try:
candidates.append(request.httprequest.cookies.get('tz'))
except Exception:
pass
try:
cal = request.env.company.resource_calendar_id
if cal:
candidates.append(cal.tz)
except Exception:
pass
for tz_name in candidates:
if tz_name:
try:
return pytz.timezone(tz_name)
except pytz.exceptions.UnknownTimeZoneError:
continue
return pytz.UTC
@http.route('/my/timezone/detect', type='jsonrpc', auth='user', website=True)
def timezone_auto_detect(self, timezone=None, **kw):
"""Auto-save browser-detected timezone to the user profile if not already set."""
if not timezone:
return {'status': 'ignored'}
user = request.env.user
if user.tz:
return {'status': 'already_set', 'tz': user.tz}
try:
pytz.timezone(timezone)
except pytz.exceptions.UnknownTimeZoneError:
return {'status': 'invalid'}
user.sudo().write({'tz': timezone})
return {'status': 'saved', 'tz': timezone}
@http.route(['/my', '/my/home'], type='http', auth='user', website=True)
def home(self, **kw):
"""Override home to add ADP posting info for Fusion users"""
@@ -111,15 +150,8 @@ class AuthorizerPortal(CustomerPortal):
except (ValueError, TypeError):
base_date = date(2026, 1, 23)
# Get user's timezone for accurate date display
user_tz = request.env.user.tz or 'UTC'
try:
tz = pytz.timezone(user_tz)
except pytz.exceptions.UnknownTimeZoneError:
tz = pytz.UTC
# Get today's date in user's timezone
from datetime import datetime
tz = self._get_user_tz()
now_utc = datetime.now(pytz.UTC)
now_local = now_utc.astimezone(tz)
today = now_local.date()
@@ -1116,14 +1148,27 @@ class AuthorizerPortal(CustomerPortal):
('check_out', '=', False),
], limit=1)
if att:
check_in_time = att.check_in.isoformat() if att.check_in else ''
check_in_time = (att.check_in.isoformat() + 'Z') if att.check_in else ''
location_name = att.x_fclk_location_id.name if att.x_fclk_location_id else ''
from datetime import datetime
tz = self._get_user_tz()
now_local = pytz.utc.localize(fields.Datetime.now()).astimezone(tz)
today_local = now_local.date()
today_start = tz.localize(datetime.combine(today_local, datetime.min.time())).astimezone(pytz.utc).replace(tzinfo=None)
today_atts = request.env['hr.attendance'].sudo().search([
('employee_id', '=', employee.id),
('check_in', '>=', today_start),
('check_out', '!=', False),
])
today_hours = sum(a.x_fclk_net_hours or 0 for a in today_atts)
return {
'clock_enabled': True,
'clock_checked_in': is_checked_in,
'clock_check_in_time': check_in_time,
'clock_location_name': location_name,
'clock_today_hours': round(today_hours, 1),
}
except Exception as e:
_logger.warning("Clock status check failed: %s", e)
@@ -1310,10 +1355,18 @@ class AuthorizerPortal(CustomerPortal):
('id', '!=', task.id),
], order='time_start', limit=1)
# Get order lines if linked to a sale order
# Get order lines from linked sale order or purchase order
order_lines = []
linked_order = False
linked_order_type = ''
if task.sale_order_id:
linked_order = task.sale_order_id
linked_order_type = 'sale'
order_lines = task.sale_order_id.order_line.filtered(lambda l: not l.display_type)
elif task.purchase_order_id:
linked_order = task.purchase_order_id
linked_order_type = 'purchase'
order_lines = task.purchase_order_id.order_line
# Get VAPID public key for push notifications
vapid_public = request.env['ir.config_parameter'].sudo().get_param(
@@ -1323,6 +1376,8 @@ class AuthorizerPortal(CustomerPortal):
values = {
'task': task,
'order_lines': order_lines,
'linked_order': linked_order,
'linked_order_type': linked_order_type,
'vapid_public_key': vapid_public,
'page_name': 'technician_task_detail',
'earlier_incomplete': earlier_incomplete,
@@ -1384,7 +1439,9 @@ class AuthorizerPortal(CustomerPortal):
safe_notes = str(escape(notes or ''))
formatted_notes = re.sub(r'\n', '<br/>', safe_notes)
timestamp = fields.Datetime.now().strftime("%b %d, %Y %I:%M %p")
tz = self._get_user_tz()
local_now = pytz.utc.localize(fields.Datetime.now()).astimezone(tz)
timestamp = local_now.strftime("%b %d, %Y %I:%M %p")
safe_user = str(escape(user.name))
safe_task = str(escape(task.name))
@@ -2077,7 +2134,7 @@ class AuthorizerPortal(CustomerPortal):
'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': datetime.now(),
'x_fc_pod_signed_datetime': fields.Datetime.now(),
})
# Generate the signed POD PDF

View File

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

View File

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

View File

@@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
"""Clean up schedule-related views moved to fusion_schedule module.
The portal_schedule_page, portal_schedule_book templates and
appointment_invite_data have been moved to the standalone
fusion_schedule module. This migration removes stale ir.model.data
references so Odoo doesn't complain about orphaned records.
Also reactivates any views that Odoo silently deactivated.
"""
import logging
_logger = logging.getLogger(__name__)
MODULE = 'fusion_authorizer_portal'
MOVED_XMLIDS = [
'portal_schedule_page',
'portal_schedule_book',
'default_appointment_invite',
]
def migrate(cr, version):
if not version:
return
cr.execute("""
SELECT d.res_id FROM ir_model_data d
WHERE d.module = %s AND d.name = 'default_appointment_invite'
AND d.model = 'appointment.invite'
""", [MODULE])
row = cr.fetchone()
if row:
cr.execute("DELETE FROM appointment_invite WHERE id = %s", [row[0]])
_logger.info("Deleted old appointment.invite id=%d (moving to fusion_schedule)", row[0])
for xmlid in MOVED_XMLIDS:
cr.execute("""
DELETE FROM ir_model_data
WHERE module = %s AND name = %s
""", [MODULE, xmlid])
if cr.rowcount:
_logger.info(
"Removed stale ir.model.data %s.%s (moved to fusion_schedule)",
MODULE, xmlid,
)
cr.execute("""
UPDATE ir_ui_view v
SET active = true
FROM ir_model_data d
WHERE d.res_id = v.id
AND d.model = 'ir.ui.view'
AND d.module = %s
AND v.active = false
RETURNING v.id, v.name, v.key
""", [MODULE])
rows = cr.fetchall()
if rows:
_logger.warning(
"Reactivated %d deactivated views for %s: %s",
len(rows), MODULE, [r[2] or r[1] for r in rows],
)

View File

@@ -43,8 +43,24 @@
.tech-clock-card {
background: var(--o-main-card-bg, #fff);
border: 1px solid var(--o-main-border-color, #e9ecef);
border-radius: 14px;
padding: 0.875rem 1rem;
border-radius: 16px;
padding: 1.125rem 1.25rem;
transition: border-color 0.3s, box-shadow 0.3s;
}
.tech-clock-layout {
display: flex;
align-items: center;
justify-content: space-between;
}
.tech-clock-info {
flex: 1;
min-width: 0;
}
.tech-clock-status-line {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 2px;
}
.tech-clock-dot {
width: 10px;
@@ -52,6 +68,7 @@
border-radius: 50%;
background: #adb5bd;
flex-shrink: 0;
transition: all 0.3s;
}
.tech-clock-dot--active {
background: #10b981;
@@ -60,61 +77,199 @@
}
@keyframes tech-clock-pulse {
0%, 100% { box-shadow: 0 0 6px rgba(16, 185, 129, 0.5); }
50% { box-shadow: 0 0 12px rgba(16, 185, 129, 0.8); }
50% { box-shadow: 0 0 14px rgba(16, 185, 129, 0.8); }
}
.tech-clock-status {
font-size: 0.85rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--o-main-text-color, #212529);
line-height: 1.2;
}
.tech-clock-timer {
font-size: 0.75rem;
font-weight: 600;
font-size: 1.6rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
color: var(--o-main-text-color, #212529);
letter-spacing: 1px;
line-height: 1.3;
}
.tech-clock-hours {
font-size: 0.72rem;
color: #6c757d;
font-weight: 500;
}
.tech-clock-btn {
display: inline-flex;
/* Circular orb button */
.tech-clock-action {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
border-radius: 10px;
gap: 5px;
flex-shrink: 0;
margin-left: 12px;
}
.tech-clock-orb-wrap {
position: relative;
width: 68px;
height: 68px;
display: flex;
align-items: center;
justify-content: center;
}
.tech-clock-orb {
width: 56px;
height: 56px;
border-radius: 50%;
border: none;
font-weight: 600;
font-size: 0.85rem;
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 2;
box-shadow: 0 4px 18px rgba(16, 185, 129, 0.35);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
outline: none;
-webkit-tap-highlight-color: transparent;
}
.tech-clock-btn:active { transform: scale(0.96); }
.tech-clock-btn--in {
background: #10b981;
color: #fff;
.tech-clock-orb:hover {
transform: scale(1.08);
box-shadow: 0 6px 24px rgba(16, 185, 129, 0.45);
}
.tech-clock-btn--in:hover { background: #059669; }
.tech-clock-btn--out {
background: #ef4444;
color: #fff;
.tech-clock-orb:active {
transform: scale(0.92);
}
.tech-clock-btn--out:hover { background: #dc2626; }
.tech-clock-btn:disabled {
opacity: 0.6;
.tech-clock-orb--out {
background: linear-gradient(135deg, #ef4444, #dc2626);
box-shadow: 0 4px 18px rgba(239, 68, 68, 0.35);
}
.tech-clock-orb--out:hover {
box-shadow: 0 6px 24px rgba(239, 68, 68, 0.45);
}
.tech-clock-orb:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none !important;
}
.tech-clock-orb-icon {
display: flex;
align-items: center;
justify-content: center;
}
svg#clockIconPlay {
transform: translateX(1px);
}
/* Wave animation rings - active when clocked in */
.tech-clock-wave {
position: absolute;
top: 50%;
left: 50%;
width: 56px;
height: 56px;
border-radius: 50%;
border: 2px solid transparent;
transform: translate(-50%, -50%) scale(1);
pointer-events: none;
z-index: 1;
opacity: 0;
}
.tech-clock-orb-wrap--active .tech-clock-wave--1 {
animation: tech-clock-wave 2.4s ease-out infinite;
border-color: rgba(16, 185, 129, 0.5);
}
.tech-clock-orb-wrap--active .tech-clock-wave--2 {
animation: tech-clock-wave 2.4s ease-out 0.8s infinite;
border-color: rgba(16, 185, 129, 0.4);
}
.tech-clock-orb-wrap--active .tech-clock-wave--3 {
animation: tech-clock-wave 2.4s ease-out 1.6s infinite;
border-color: rgba(16, 185, 129, 0.3);
}
@keyframes tech-clock-wave {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 0.6;
}
100% {
transform: translate(-50%, -50%) scale(2.2);
opacity: 0;
}
}
.tech-clock-label {
font-size: 0.65rem;
color: #6c757d;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.tech-clock-error {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.5rem;
padding: 0.4rem 0.75rem;
border-radius: 8px;
margin-top: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: 10px;
background: #fef2f2;
color: #dc2626;
font-size: 0.8rem;
font-size: 0.78rem;
font-weight: 500;
}
/* Missed clock-out reason modal */
.tech-reason-overlay {
position: fixed;
inset: 0;
z-index: 1060;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.tech-reason-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.tech-reason-dialog {
position: relative;
background: var(--o-main-card-bg, #fff);
border: 1px solid var(--o-main-border-color, #e5e7eb);
border-radius: 16px;
width: 100%;
max-width: 400px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.15);
animation: techReasonIn 0.25s ease;
}
@keyframes techReasonIn {
from { opacity: 0; transform: scale(0.95) translateY(8px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.tech-reason-header {
text-align: center;
padding: 1.25rem 1.25rem 0.75rem;
border-bottom: 1px solid var(--o-main-border-color, #e9ecef);
}
.tech-reason-header h5 {
margin: 0.5rem 0 0.25rem;
font-weight: 700;
}
.tech-reason-body {
padding: 1rem 1.25rem;
}
.tech-reason-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.75rem 1.25rem 1rem;
border-top: 1px solid var(--o-main-border-color, #e9ecef);
}
/* ---- Quick Links (All Tasks / Tomorrow / Repair Form) ---- */
.tech-quick-links {
display: flex;
@@ -430,6 +585,40 @@
padding: 0.75rem;
}
/* ---- Order Details Card ---- */
.tech-order-card {
border-left: 3px solid #17a2b8;
}
.tech-order-card .bg-purple-subtle {
background: #f3e8ff;
}
.tech-order-card .text-purple {
color: #7c3aed;
}
.tech-order-lines {
border-top: 1px solid var(--o-main-border-color, #eee);
padding-top: 0.75rem;
margin-top: 0.5rem;
}
.tech-order-line-item {
padding: 0.625rem 0;
}
.tech-order-line-border {
border-bottom: 1px solid var(--o-main-border-color, #f0f0f0);
}
.tech-qty-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
border-radius: 8px;
background: #e9ecef;
font-weight: 700;
font-size: 0.9rem;
color: #495057;
}
/* ---- Action Buttons (Large Touch Targets) ---- */
.tech-action-btn {
display: inline-flex;

View File

@@ -1,343 +0,0 @@
(function () {
'use strict';
var dateInput = document.getElementById('bookingDate');
var slotsContainer = document.getElementById('slotsContainer');
var slotsGrid = document.getElementById('slotsGrid');
var slotsLoading = document.getElementById('slotsLoading');
var noSlots = document.getElementById('noSlots');
var slotDatetimeInput = document.getElementById('slotDatetime');
var slotDurationInput = document.getElementById('slotDuration');
var submitBtn = document.getElementById('btnSubmitBooking');
var typeSelect = document.getElementById('appointmentTypeSelect');
var selectedSlotBtn = null;
var weekContainer = document.getElementById('weekCalendarContainer');
var weekLoading = document.getElementById('weekCalendarLoading');
var weekGrid = document.getElementById('weekCalendarGrid');
var weekHeader = document.getElementById('weekCalendarHeader');
var weekBody = document.getElementById('weekCalendarBody');
var weekEmpty = document.getElementById('weekCalendarEmpty');
function getAppointmentTypeId() {
if (typeSelect) return typeSelect.value;
var hidden = document.querySelector('input[name="appointment_type_id"]');
return hidden ? hidden.value : null;
}
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function truncate(str, max) {
if (!str) return '';
return str.length > max ? str.substring(0, max) + '...' : str;
}
function fetchWeekEvents(date) {
if (!weekContainer || !date) return;
weekContainer.style.display = 'block';
weekLoading.style.display = 'block';
weekGrid.style.display = 'none';
weekEmpty.style.display = 'none';
fetch('/my/schedule/week-events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: { selected_date: date },
}),
})
.then(function (resp) { return resp.json(); })
.then(function (data) {
weekLoading.style.display = 'none';
var result = data.result || {};
var events = result.events || [];
var weekDays = result.week_days || [];
if (result.error || !weekDays.length) {
weekEmpty.style.display = 'block';
return;
}
renderWeekCalendar(weekDays, events, date);
})
.catch(function () {
weekLoading.style.display = 'none';
weekEmpty.textContent = 'Failed to load calendar. Please try again.';
weekEmpty.style.display = 'block';
});
}
function renderWeekCalendar(weekDays, events, selectedDate) {
weekHeader.innerHTML = '';
weekBody.innerHTML = '';
var eventsByDate = {};
events.forEach(function (ev) {
if (!eventsByDate[ev.date]) eventsByDate[ev.date] = [];
eventsByDate[ev.date].push(ev);
});
var hasAnyEvents = events.length > 0;
weekDays.forEach(function (day) {
var isSelected = day.date === selectedDate;
var isWeekend = day.label === 'Sat' || day.label === 'Sun';
var dayEvents = eventsByDate[day.date] || [];
var headerCell = document.createElement('div');
headerCell.className = 'text-center py-2 flex-fill';
headerCell.style.cssText = 'min-width: 0; font-size: 12px; border-right: 1px solid #dee2e6;';
if (isSelected) {
headerCell.style.backgroundColor = '#e8f4fd';
}
if (isWeekend) {
headerCell.style.opacity = '0.6';
}
var labelEl = document.createElement('div');
labelEl.className = 'fw-semibold text-muted';
labelEl.textContent = day.label;
var numEl = document.createElement('div');
numEl.className = isSelected ? 'fw-bold text-primary' : 'fw-semibold';
numEl.style.fontSize = '14px';
numEl.textContent = day.day_num;
headerCell.appendChild(labelEl);
headerCell.appendChild(numEl);
weekHeader.appendChild(headerCell);
var bodyCell = document.createElement('div');
bodyCell.className = 'flex-fill p-1';
bodyCell.style.cssText = 'min-width: 0; min-height: 70px; border-right: 1px solid #dee2e6; overflow: hidden;';
if (isSelected) {
bodyCell.style.backgroundColor = '#f0f8ff';
}
if (dayEvents.length) {
dayEvents.forEach(function (ev) {
var card = document.createElement('div');
card.className = 'mb-1 px-1 py-1 rounded';
card.style.cssText = 'font-size: 11px; background: #eef6ff; border-left: 3px solid #3a8fb7; overflow: hidden; cursor: default;';
card.title = ev.start_time + ' - ' + ev.end_time + '\n' + ev.name + (ev.location ? '\n' + ev.location : '');
var timeEl = document.createElement('div');
timeEl.className = 'fw-semibold text-primary';
timeEl.style.fontSize = '10px';
timeEl.textContent = ev.start_time;
var nameEl = document.createElement('div');
nameEl.className = 'text-truncate';
nameEl.style.fontSize = '10px';
nameEl.textContent = truncate(ev.name, 18);
card.appendChild(timeEl);
card.appendChild(nameEl);
bodyCell.appendChild(card);
});
}
weekBody.appendChild(bodyCell);
});
if (hasAnyEvents) {
weekGrid.style.display = 'block';
weekEmpty.style.display = 'none';
} else {
weekGrid.style.display = 'none';
weekEmpty.style.display = 'block';
}
}
function fetchSlots(date) {
var typeId = getAppointmentTypeId();
if (!typeId || !date) return;
slotsContainer.style.display = 'block';
slotsLoading.style.display = 'block';
slotsGrid.innerHTML = '';
noSlots.style.display = 'none';
slotDatetimeInput.value = '';
if (submitBtn) submitBtn.disabled = true;
selectedSlotBtn = null;
fetch('/my/schedule/available-slots', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: {
appointment_type_id: parseInt(typeId),
selected_date: date,
},
}),
})
.then(function (resp) { return resp.json(); })
.then(function (data) {
slotsLoading.style.display = 'none';
var result = data.result || {};
var slots = result.slots || [];
if (result.error) {
noSlots.textContent = result.error;
noSlots.style.display = 'block';
return;
}
if (!slots.length) {
noSlots.style.display = 'block';
return;
}
var morningSlots = [];
var afternoonSlots = [];
slots.forEach(function (slot) {
var hour = parseInt(slot.start_hour);
if (isNaN(hour)) {
var match = slot.start_hour.match(/(\d+)/);
hour = match ? parseInt(match[1]) : 0;
if (slot.start_hour.toLowerCase().indexOf('pm') > -1 && hour !== 12) hour += 12;
if (slot.start_hour.toLowerCase().indexOf('am') > -1 && hour === 12) hour = 0;
}
if (hour < 12) {
morningSlots.push(slot);
} else {
afternoonSlots.push(slot);
}
});
function renderGroup(label, icon, groupSlots) {
if (!groupSlots.length) return;
var header = document.createElement('div');
header.className = 'w-100 mt-2 mb-1';
header.innerHTML = '<small class="text-muted fw-semibold"><i class="fa ' + icon + ' me-1"></i>' + label + '</small>';
slotsGrid.appendChild(header);
groupSlots.forEach(function (slot) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-outline-primary btn-sm slot-btn';
btn.style.cssText = 'min-width: 100px; border-radius: 8px; padding: 8px 14px;';
btn.textContent = slot.start_hour;
btn.dataset.datetime = slot.datetime;
btn.dataset.duration = slot.duration;
btn.addEventListener('click', function () {
if (selectedSlotBtn) {
selectedSlotBtn.classList.remove('btn-primary');
selectedSlotBtn.classList.add('btn-outline-primary');
}
btn.classList.remove('btn-outline-primary');
btn.classList.add('btn-primary');
selectedSlotBtn = btn;
slotDatetimeInput.value = slot.datetime;
slotDurationInput.value = slot.duration;
if (submitBtn) submitBtn.disabled = false;
});
slotsGrid.appendChild(btn);
});
}
renderGroup('Morning', 'fa-sun-o', morningSlots);
renderGroup('Afternoon', 'fa-cloud', afternoonSlots);
})
.catch(function (err) {
slotsLoading.style.display = 'none';
noSlots.textContent = 'Failed to load slots. Please try again.';
noSlots.style.display = 'block';
});
}
if (dateInput) {
dateInput.addEventListener('change', function () {
var val = this.value;
fetchWeekEvents(val);
fetchSlots(val);
});
}
if (typeSelect) {
typeSelect.addEventListener('change', function () {
if (dateInput && dateInput.value) {
fetchSlots(dateInput.value);
}
});
}
var bookingForm = document.getElementById('bookingForm');
if (bookingForm) {
bookingForm.addEventListener('submit', function (e) {
if (!slotDatetimeInput || !slotDatetimeInput.value) {
e.preventDefault();
alert('Please select a time slot before booking.');
return false;
}
var clientName = bookingForm.querySelector('input[name="client_name"]');
if (!clientName || !clientName.value.trim()) {
e.preventDefault();
alert('Please enter the client name.');
return false;
}
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Booking...';
}
});
}
window.initScheduleAddressAutocomplete = function () {
var streetInput = document.getElementById('clientStreet');
if (!streetInput) return;
var autocomplete = new google.maps.places.Autocomplete(streetInput, {
componentRestrictions: { country: 'ca' },
types: ['address'],
});
autocomplete.addListener('place_changed', function () {
var place = autocomplete.getPlace();
if (!place.address_components) return;
var streetNumber = '';
var streetName = '';
var city = '';
var province = '';
var postalCode = '';
for (var i = 0; i < place.address_components.length; i++) {
var component = place.address_components[i];
var types = component.types;
if (types.indexOf('street_number') > -1) {
streetNumber = component.long_name;
} else if (types.indexOf('route') > -1) {
streetName = component.long_name;
} else if (types.indexOf('locality') > -1) {
city = component.long_name;
} else if (types.indexOf('administrative_area_level_1') > -1) {
province = component.long_name;
} else if (types.indexOf('postal_code') > -1) {
postalCode = component.long_name;
}
}
streetInput.value = (streetNumber + ' ' + streetName).trim();
var cityInput = document.getElementById('clientCity');
if (cityInput) cityInput.value = city;
var provInput = document.getElementById('clientProvince');
if (provInput) provInput.value = province;
var postalInput = document.getElementById('clientPostal');
if (postalInput) postalInput.value = postalCode;
});
};
})();

View File

@@ -0,0 +1,34 @@
/** @odoo-module **/
import publicWidget from "@web/legacy/js/public/public_widget";
publicWidget.registry.TimezoneAutoDetect = publicWidget.Widget.extend({
selector: 'body',
start() {
this._super(...arguments);
this._detectAndSaveTimezone();
},
_detectAndSaveTimezone() {
let detectedTz;
try {
detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return;
}
if (!detectedTz) return;
const cookieTz = this._getCookie('tz');
if (cookieTz === detectedTz) return;
document.cookie = `tz=${detectedTz};path=/;max-age=${60 * 60 * 24 * 365};SameSite=Lax`;
this._rpc('/my/timezone/detect', { timezone: detectedTz }).catch(() => {});
},
_getCookie(name) {
const match = document.cookie.match(new RegExp('(?:^|;\\s*)' + name + '=([^;]*)'));
return match ? decodeURIComponent(match[1]) : null;
},
});

View File

@@ -5,7 +5,7 @@
<record id="view_fusion_loaner_checkout_form_assessment" model="ir.ui.view">
<field name="name">fusion.loaner.checkout.form.assessment</field>
<field name="model">fusion.loaner.checkout</field>
<field name="inherit_id" ref="fusion_claims.view_fusion_loaner_checkout_form"/>
<field name="inherit_id" ref="fusion_loaners_management.view_fusion_loaner_checkout_form"/>
<field name="arch" type="xml">
<xpath expr="//button[@name='action_view_partner']" position="before">
<button name="action_view_assessment" type="object"

View File

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

View File

@@ -23,34 +23,79 @@
<div class="tech-clock-card mb-3"
id="techClockCard"
t-att-data-checked-in="'true' if clock_checked_in else 'false'"
t-att-data-check-in-time="clock_check_in_time or ''">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<div class="tech-clock-dot" t-att-class="'tech-clock-dot--active' if clock_checked_in else ''"/>
<div>
<div class="tech-clock-status" id="clockStatusText">
t-att-data-check-in-time="clock_check_in_time or ''"
t-att-data-today-hours="clock_today_hours or 0">
<div class="tech-clock-layout">
<div class="tech-clock-info">
<div class="tech-clock-status-line">
<span t-attf-class="tech-clock-dot {{ 'tech-clock-dot--active' if clock_checked_in else '' }}"/>
<span class="tech-clock-status" id="clockStatusText">
<t t-if="clock_checked_in">Clocked In</t>
<t t-else="">Not Clocked In</t>
</div>
<div class="tech-clock-timer" id="clockTimer">00:00:00</div>
</span>
</div>
<div class="tech-clock-timer" id="clockTimer">00:00:00</div>
<div class="tech-clock-hours" id="clockTodayHours">
Today: <t t-esc="'%.1fh' % (clock_today_hours or 0)"/>
</div>
</div>
<button class="tech-clock-btn" id="clockActionBtn"
t-att-class="'tech-clock-btn--out' if clock_checked_in else 'tech-clock-btn--in'"
onclick="handleClockAction()">
<t t-if="clock_checked_in">
<i class="fa fa-stop-circle-o"/> Clock Out
</t>
<t t-else="">
<i class="fa fa-play-circle-o"/> Clock In
</t>
</button>
<div class="tech-clock-action">
<div t-attf-class="tech-clock-orb-wrap {{ 'tech-clock-orb-wrap--active' if clock_checked_in else '' }}" id="clockOrbWrap">
<span class="tech-clock-wave tech-clock-wave--1"/>
<span class="tech-clock-wave tech-clock-wave--2"/>
<span class="tech-clock-wave tech-clock-wave--3"/>
<button t-attf-class="tech-clock-orb {{ 'tech-clock-orb--out' if clock_checked_in else '' }}"
id="clockActionBtn" onclick="handleClockAction()">
<svg class="tech-clock-orb-icon" id="clockIconPlay" width="22" height="22" viewBox="0 0 24 24" fill="white"
t-attf-style="display:{{ 'none' if clock_checked_in else 'block' }}">
<polygon points="6 3 20 12 6 21"/>
</svg>
<svg class="tech-clock-orb-icon" id="clockIconStop" width="18" height="18" viewBox="0 0 24 24" fill="white"
t-attf-style="display:{{ 'block' if clock_checked_in else 'none' }}">
<rect x="4" y="4" width="16" height="16" rx="2"/>
</svg>
</button>
</div>
<span class="tech-clock-label" id="clockBtnLabel">
<t t-if="clock_checked_in">Clock Out</t>
<t t-else="">Clock In</t>
</span>
</div>
</div>
<div class="tech-clock-error" id="clockError" style="display:none;">
<i class="fa fa-exclamation-triangle"/>
<span id="clockErrorText"/>
</div>
</div>
<!-- Missed Clock-Out Reason Modal -->
<div class="tech-reason-overlay" id="clockReasonModal" style="display:none;">
<div class="tech-reason-backdrop" onclick="document.getElementById('clockReasonModal').style.display='none'"/>
<div class="tech-reason-dialog">
<div class="tech-reason-header">
<i class="fa fa-exclamation-triangle text-warning" style="font-size:1.5rem;"/>
<h5>Missed Clock-Out</h5>
<p class="text-muted small mb-0">You didn't clock out on your last shift. Please provide details before clocking in.</p>
</div>
<div class="tech-reason-body">
<label class="form-label small fw-semibold" for="clockReasonText">
Reason <span class="text-danger">*</span>
</label>
<textarea id="clockReasonText" class="form-control mb-2" rows="3"
placeholder="Please explain why you didn't clock out..."/>
<label class="form-label small fw-semibold" for="clockReasonTime">
Departure Time <span class="text-muted">(optional)</span>
</label>
<input type="datetime-local" id="clockReasonTime" class="form-control"/>
</div>
<div class="tech-reason-footer">
<button class="btn btn-sm btn-secondary" onclick="document.getElementById('clockReasonModal').style.display='none'">Cancel</button>
<button class="btn btn-sm btn-success" id="clockReasonSubmitBtn" onclick="submitClockReason()">
<i class="fa fa-check"/> Submit
</button>
</div>
</div>
</div>
</t>
<!-- Quick Stats Bar -->
@@ -290,20 +335,150 @@
var statusEl = document.getElementById('clockStatusText');
var btn = document.getElementById('clockActionBtn');
var timerEl = document.getElementById('clockTimer');
var labelEl = document.getElementById('clockBtnLabel');
var orbWrap = document.getElementById('clockOrbWrap');
var playIcon = document.getElementById('clockIconPlay');
var stopIcon = document.getElementById('clockIconStop');
if (dot) dot.className = 'tech-clock-dot' + (isCheckedIn ? ' tech-clock-dot--active' : '');
if (statusEl) statusEl.textContent = isCheckedIn ? 'Clocked In' : 'Not Clocked In';
if (btn) {
btn.className = 'tech-clock-btn ' + (isCheckedIn ? 'tech-clock-btn--out' : 'tech-clock-btn--in');
btn.innerHTML = isCheckedIn
? '&lt;i class="fa fa-stop-circle-o">&lt;/i> Clock Out'
: '&lt;i class="fa fa-play-circle-o">&lt;/i> Clock In';
btn.className = 'tech-clock-orb' + (isCheckedIn ? ' tech-clock-orb--out' : '');
}
if (orbWrap) {
orbWrap.className = 'tech-clock-orb-wrap' + (isCheckedIn ? ' tech-clock-orb-wrap--active' : '');
}
if (playIcon) playIcon.style.display = isCheckedIn ? 'none' : 'block';
if (stopIcon) stopIcon.style.display = isCheckedIn ? 'block' : 'none';
if (labelEl) labelEl.textContent = isCheckedIn ? 'Clock Out' : 'Clock In';
if (!isCheckedIn &amp;&amp; timerEl) timerEl.textContent = '00:00:00';
}
if (isCheckedIn &amp;&amp; checkInTime) startTimer();
function showClockError(msg) {
var errEl = document.getElementById('clockError');
var errText = document.getElementById('clockErrorText');
var btn = document.getElementById('clockActionBtn');
if (errText) errText.textContent = msg;
if (errEl) errEl.style.display = 'flex';
if (btn) btn.disabled = false;
}
function doClockAction(lat, lng, accuracy) {
var btn = document.getElementById('clockActionBtn');
var errEl = document.getElementById('clockError');
fetch('/fusion_clock/clock_action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', id: 1, method: 'call', params: {
latitude: lat,
longitude: lng,
accuracy: accuracy,
source: 'portal'
}})
})
.then(function(r) {
if (!r.ok) {
throw new Error('HTTP ' + r.status);
}
return r.json();
})
.then(function(data) {
if (data.error) {
var msg = (data.error.data &amp;&amp; data.error.data.message) || data.error.message || 'Server error';
showClockError(msg);
return;
}
var result = data.result;
if (!result) {
showClockError('No response from server. Please try again.');
return;
}
if (result.error) {
showClockError(result.error);
return;
}
if (result.requires_reason) {
var modal = document.getElementById('clockReasonModal');
if (modal) modal.style.display = 'flex';
btn.disabled = false;
return;
}
if (result.action === 'clock_in') {
isCheckedIn = true;
checkInTime = new Date(result.check_in + 'Z');
startTimer();
} else if (result.action === 'clock_out') {
isCheckedIn = false;
checkInTime = null;
stopTimer();
} else {
showClockError(result.message || 'Unexpected response. Please try again.');
return;
}
applyState();
btn.disabled = false;
})
.catch(function(e) {
showClockError('Network error. Please try again.');
});
}
window.submitClockReason = function() {
var reasonEl = document.getElementById('clockReasonText');
var timeEl = document.getElementById('clockReasonTime');
var submitBtn = document.getElementById('clockReasonSubmitBtn');
var reason = reasonEl ? reasonEl.value.trim() : '';
if (!reason) {
showClockError('Please provide a reason.');
return;
}
submitBtn.disabled = true;
submitBtn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i> Submitting...';
var rawTime = timeEl ? timeEl.value.trim() : '';
var depTime = rawTime ? new Date(rawTime).toISOString() : '';
fetch('/fusion_clock/submit_reason', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', id: 1, method: 'call', params: {
reason: reason,
departure_time: depTime
}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
submitBtn.disabled = false;
submitBtn.innerHTML = '&lt;i class="fa fa-check">&lt;/i> Submit';
if (data.error) {
showClockError((data.error.data &amp;&amp; data.error.data.message) || data.error.message || 'Server error');
return;
}
var result = data.result || {};
if (result.success) {
var modal = document.getElementById('clockReasonModal');
if (modal) modal.style.display = 'none';
if (reasonEl) reasonEl.value = '';
if (timeEl) timeEl.value = '';
var errEl = document.getElementById('clockError');
if (errEl) errEl.style.display = 'none';
handleClockAction();
} else {
showClockError(result.error || 'Failed to submit reason.');
}
})
.catch(function() {
submitBtn.disabled = false;
submitBtn.innerHTML = '&lt;i class="fa fa-check">&lt;/i> Submit';
showClockError('Network error. Please try again.');
});
};
window.handleClockAction = function() {
var btn = document.getElementById('clockActionBtn');
var errEl = document.getElementById('clockError');
@@ -311,48 +486,29 @@
btn.disabled = true;
errEl.style.display = 'none';
window.fusionGetLocation().then(function(coords) {
fetch('/fusion_clock/clock_action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy,
source: 'portal'
}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
var result = data.result || {};
if (result.error) {
errText.textContent = result.error;
errEl.style.display = 'flex';
btn.disabled = false;
return;
}
if (result.action === 'clock_in') {
isCheckedIn = true;
checkInTime = new Date(result.check_in + 'Z');
startTimer();
} else {
isCheckedIn = false;
checkInTime = null;
stopTimer();
}
applyState();
btn.disabled = false;
})
.catch(function() {
errText.textContent = 'Network error. Please try again.';
errEl.style.display = 'flex';
btn.disabled = false;
});
}).catch(function() {
errText.textContent = 'Location access is required for clock in/out.';
errEl.style.display = 'flex';
btn.disabled = false;
});
if (!navigator.geolocation) {
doClockAction(0, 0, 0);
return;
}
navigator.geolocation.getCurrentPosition(
function(pos) {
doClockAction(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy);
},
function() {
fetch('https://ipapi.co/json/')
.then(function(r) { return r.json(); })
.then(function(ipData) {
var lat = (ipData.latitude &amp;&amp; ipData.longitude) ? ipData.latitude : 0;
var lng = (ipData.latitude &amp;&amp; ipData.longitude) ? ipData.longitude : 0;
doClockAction(lat, lng, lat ? 5000 : 0);
})
.catch(function() {
doClockAction(0, 0, 0);
});
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
);
};
})();
</script>
@@ -675,46 +831,107 @@
</div>
</div>
<!-- ===== TASK DETAILS (collapsible) ===== -->
<t t-if="task.description or task.equipment_needed">
<!-- ===== INSTRUCTIONS ===== -->
<t t-if="task.description">
<div class="tech-card mb-3">
<t t-if="task.description">
<div class="mb-2">
<div class="text-muted small text-uppercase fw-semibold mb-1">
<i class="fa fa-file-text-o me-1"/>Instructions
<div class="text-muted small text-uppercase fw-semibold mb-1">
<i class="fa fa-file-text-o me-1"/>Instructions
</div>
<div style="white-space:pre-wrap;font-size:0.9rem;"><t t-out="task.description"/></div>
</div>
</t>
<!-- ===== ORDER DETAILS (Sale Order or Purchase Order) ===== -->
<t t-if="linked_order">
<div class="tech-card tech-order-card mb-3">
<div class="d-flex align-items-center justify-content-between mb-2">
<div class="d-flex align-items-center">
<div t-attf-class="tech-card-icon #{linked_order_type == 'sale' and 'bg-info-subtle text-info' or 'bg-purple-subtle text-purple'}">
<i t-attf-class="fa #{linked_order_type == 'sale' and 'fa-shopping-cart' or 'fa-truck'}"/>
</div>
<div style="white-space:pre-wrap;font-size:0.9rem;"><t t-out="task.description"/></div>
<div>
<div class="text-muted small text-uppercase fw-semibold" style="font-size:0.68rem;letter-spacing:0.05em;">
<t t-if="linked_order_type == 'sale'">Sale Order</t>
<t t-else="">Purchase Order</t>
</div>
<div class="fw-bold" style="font-size:0.95rem;"><t t-out="linked_order.name"/></div>
</div>
</div>
<t t-if="linked_order_type == 'sale'">
<a t-attf-href="/my/orders/#{linked_order.id}" class="btn btn-sm btn-outline-secondary rounded-pill"
style="font-size:0.75rem;">
<i class="fa fa-external-link me-1"/>View
</a>
</t>
</div>
<!-- Order Lines: Products & Services -->
<t t-if="order_lines">
<div class="tech-order-lines">
<div class="text-muted small text-uppercase fw-semibold mb-2" style="font-size:0.68rem;letter-spacing:0.05em;">
<i class="fa fa-cube me-1"/>Items (<t t-out="len(order_lines)"/>)
</div>
<t t-foreach="order_lines" t-as="line">
<div t-attf-class="tech-order-line-item #{not line_last and 'tech-order-line-border' or ''}">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1 me-2">
<div class="fw-semibold" style="font-size:0.88rem;">
<t t-out="line.product_id.name"/>
</div>
<!-- Product description (differs from product name) -->
<t t-if="linked_order_type == 'sale'">
<t t-set="line_desc" t-value="line.name or ''"/>
</t>
<t t-else="">
<t t-set="line_desc" t-value="line.name or ''"/>
</t>
<t t-if="line_desc and line_desc != line.product_id.name">
<div class="text-muted" style="font-size:0.8rem;white-space:pre-wrap;line-height:1.35;">
<t t-out="line_desc"/>
</div>
</t>
<!-- Serial number if available -->
<t t-if="linked_order_type == 'sale' and line.sudo()._fields.get('x_fc_serial_number') and line.x_fc_serial_number">
<div class="mt-1">
<span class="badge text-bg-light border" style="font-size:0.72rem;">
<i class="fa fa-barcode me-1"/>S/N: <t t-out="line.x_fc_serial_number"/>
</span>
</div>
</t>
</div>
<div class="text-end flex-shrink-0">
<span class="tech-qty-badge">
<t t-if="linked_order_type == 'sale'">
<t t-out="int(line.product_uom_qty)"/>
</t>
<t t-else="">
<t t-out="int(line.product_qty)"/>
</t>
</span>
<div class="text-muted" style="font-size:0.68rem;">qty</div>
</div>
</div>
</div>
</t>
</div>
</t>
<t t-if="task.equipment_needed">
<div class="tech-equipment-tag">
<div class="text-muted small text-uppercase fw-semibold mb-1">
<i class="fa fa-wrench me-1"/>Equipment Needed
</div>
<div style="white-space:pre-wrap;font-size:0.9rem;"><t t-out="task.equipment_needed"/></div>
<t t-if="not order_lines">
<div class="text-muted small text-center py-2">
<i class="fa fa-info-circle me-1"/>No line items on this order
</div>
</t>
</div>
</t>
<!-- ===== PRODUCTS / ITEMS ===== -->
<t t-if="order_lines">
<!-- ===== EQUIPMENT NEEDED ===== -->
<t t-if="task.equipment_needed">
<div class="tech-card mb-3">
<div class="text-muted small text-uppercase fw-semibold mb-2">
<i class="fa fa-cube me-1"/>Products
</div>
<t t-foreach="order_lines" t-as="line">
<div class="d-flex justify-content-between align-items-center py-2"
t-attf-style="#{not line_last and 'border-bottom:1px solid var(--o-main-border-color, #eee);' or ''}">
<div>
<div class="fw-medium" style="font-size:0.9rem;"><t t-out="line.product_id.name"/></div>
<t t-if="line.sudo()._fields.get('x_fc_serial_number') and line.x_fc_serial_number">
<div class="text-muted small">S/N: <t t-out="line.x_fc_serial_number"/></div>
</t>
</div>
<span class="badge text-bg-secondary rounded-pill">x<t t-out="int(line.product_uom_qty)"/></span>
<div class="tech-equipment-tag">
<div class="text-muted small text-uppercase fw-semibold mb-1">
<i class="fa fa-wrench me-1"/>Equipment Needed
</div>
</t>
<div style="white-space:pre-wrap;font-size:0.9rem;"><t t-out="task.equipment_needed"/></div>
</div>
</div>
</t>

View File

@@ -189,23 +189,6 @@
</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">