update
This commit is contained in:
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
# ==========================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
@@ -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>
|
||||
@@ -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],
|
||||
)
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
})();
|
||||
34
fusion_authorizer_portal/static/src/js/timezone_detect.js
Normal file
34
fusion_authorizer_portal/static/src/js/timezone_detect.js
Normal 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;
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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 & 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>
|
||||
@@ -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
|
||||
? '<i class="fa fa-stop-circle-o"></i> Clock Out'
|
||||
: '<i class="fa fa-play-circle-o"></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 && timerEl) timerEl.textContent = '00:00:00';
|
||||
}
|
||||
|
||||
if (isCheckedIn && 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 && 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 = '<i class="fa fa-spinner fa-spin"></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 = '<i class="fa fa-check"></i> Submit';
|
||||
|
||||
if (data.error) {
|
||||
showClockError((data.error.data && 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 = '<i class="fa fa-check"></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 && ipData.longitude) ? ipData.latitude : 0;
|
||||
var lng = (ipData.latitude && 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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user