update
This commit is contained in:
4
fusion_schedule/__init__.py
Normal file
4
fusion_schedule/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import controllers
|
||||
from . import models
|
||||
63
fusion_schedule/__manifest__.py
Normal file
63
fusion_schedule/__manifest__.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'Fusion Schedule',
|
||||
'version': '19.0.2.0.0',
|
||||
'category': 'Services/Appointment',
|
||||
'summary': 'Multi-calendar sync, portal booking, and shareable scheduling links',
|
||||
'description': """
|
||||
Fusion Schedule
|
||||
===============
|
||||
|
||||
Multi-account calendar synchronisation hub for Odoo 19.
|
||||
|
||||
**Features**
|
||||
- Connect multiple Google and Microsoft Outlook calendars per user
|
||||
- Automatic bidirectional sync every 5 minutes
|
||||
- Cross-calendar busy blocking — busy on one, blocked on all
|
||||
- Portal "My Schedule" dashboard with today's and upcoming appointments
|
||||
- Appointment booking form with date picker, weekly calendar preview, and available time slots
|
||||
- Public booking page — share a link so external visitors can book your available time
|
||||
- Google Maps / Places API address autocomplete for client location
|
||||
- Shareable public booking link (via appointment.invite)
|
||||
- Dedicated Settings page for OAuth credentials (falls back to Odoo defaults)
|
||||
""",
|
||||
'author': 'Fusion Claims',
|
||||
'website': 'https://fusionclaims.com',
|
||||
'license': 'LGPL-3',
|
||||
'depends': [
|
||||
'base',
|
||||
'portal',
|
||||
'website',
|
||||
'calendar',
|
||||
'appointment',
|
||||
'google_account',
|
||||
'microsoft_account',
|
||||
'fusion_authorizer_portal',
|
||||
],
|
||||
'data': [
|
||||
'security/security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/appointment_invite_data.xml',
|
||||
'data/mail_template_data.xml',
|
||||
'data/ir_cron_data.xml',
|
||||
'views/fusion_calendar_account_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/portal_schedule_tile.xml',
|
||||
'views/portal_schedule.xml',
|
||||
'views/public_booking.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_frontend': [
|
||||
'fusion_schedule/static/src/css/portal_schedule.css',
|
||||
'fusion_schedule/static/src/js/portal_schedule_booking.js',
|
||||
'fusion_schedule/static/src/js/portal_schedule_accounts.js',
|
||||
],
|
||||
'web.assets_backend': [
|
||||
'fusion_schedule/static/src/views/fusion_calendar_controller.js',
|
||||
'fusion_schedule/static/src/views/fusion_calendar_controller.xml',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'auto_install': False,
|
||||
}
|
||||
3
fusion_schedule/controllers/__init__.py
Normal file
3
fusion_schedule/controllers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import portal_schedule
|
||||
1606
fusion_schedule/controllers/portal_schedule.py
Normal file
1606
fusion_schedule/controllers/portal_schedule.py
Normal file
File diff suppressed because it is too large
Load Diff
13
fusion_schedule/data/appointment_invite_data.xml
Normal file
13
fusion_schedule/data/appointment_invite_data.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<!-- Auto-create a shareable booking link for staff members.
|
||||
URL: /book/book-appointment
|
||||
Filtered to appointment type "Assessment" and staff users configured on that type. -->
|
||||
|
||||
<record id="default_appointment_invite" model="appointment.invite">
|
||||
<field name="short_code">book-appointment</field>
|
||||
<field name="appointment_type_ids" eval="[(6, 0, [])]"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
15
fusion_schedule/data/ir_cron_data.xml
Normal file
15
fusion_schedule/data/ir_cron_data.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="ir_cron_fusion_calendar_sync" model="ir.cron" forcecreate="True">
|
||||
<field name="name">Fusion Schedule: Multi-Calendar Sync</field>
|
||||
<field name="model_id" ref="fusion_schedule.model_fusion_calendar_account"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_sync_all_accounts()</field>
|
||||
<field name="user_id" ref="base.user_root"/>
|
||||
<field name="interval_number">5</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
157
fusion_schedule/data/mail_template_data.xml
Normal file
157
fusion_schedule/data/mail_template_data.xml
Normal file
@@ -0,0 +1,157 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="fusion_schedule_booking_confirmation" model="mail.template">
|
||||
<field name="name">Fusion Schedule: Booking Confirmation</field>
|
||||
<field name="model_id" ref="calendar.model_calendar_event"/>
|
||||
<field name="subject">Your Appointment — {{ object.name }}</field>
|
||||
<field name="email_from">{{ (object.user_id.email_formatted or object.user_id.company_id.email_formatted or '') }}</field>
|
||||
<field name="email_to">{{ object.x_fc_client_email }}</field>
|
||||
<field name="lang">{{ object.partner_ids[:1].lang }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
<field name="body_html" type="html">
|
||||
<div style="margin:0;padding:0;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0"
|
||||
style="padding:24px 0;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="560" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
|
||||
<!-- Logo -->
|
||||
<tr>
|
||||
<td style="padding:0 0 24px;text-align:center;">
|
||||
<t t-if="object.user_id.company_id.logo_web">
|
||||
<img t-att-src="'/web/image/res.company/%s/logo_web' % object.user_id.company_id.id"
|
||||
style="max-height:44px;max-width:180px;" alt="Logo"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span style="font-size:18px;font-weight:700;letter-spacing:0.5px;">
|
||||
<t t-out="object.user_id.company_id.name or ''"/>
|
||||
</span>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Confirmation badge -->
|
||||
<tr>
|
||||
<td style="padding:0 0 20px;text-align:center;">
|
||||
<span style="display:inline-block;background-color:#16a34a;color:#ffffff;font-size:13px;font-weight:600;padding:6px 18px;border-radius:20px;letter-spacing:0.3px;">
|
||||
✓ Confirmed
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Greeting -->
|
||||
<tr>
|
||||
<td style="padding:0 0 28px;text-align:center;">
|
||||
<p style="margin:0;font-size:15px;line-height:1.6;">
|
||||
Hi<t t-if="object.x_fc_client_email"> <t t-out="object.partner_ids.filtered(lambda p: p.email == object.x_fc_client_email)[:1].name or ''"/></t>,
|
||||
your appointment has been confirmed.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Details card -->
|
||||
<tr>
|
||||
<td style="padding:0 0 28px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0"
|
||||
style="border:1px solid #d1d5db;border-radius:10px;border-collapse:separate;">
|
||||
<!-- Date -->
|
||||
<tr>
|
||||
<td style="padding:20px 24px;border-bottom:1px solid #d1d5db;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td width="32" valign="top" style="padding-right:14px;font-size:20px;">📅</td>
|
||||
<td>
|
||||
<span style="font-size:16px;font-weight:600;">
|
||||
<t t-out="object.start" t-options="{'widget': 'datetime', 'format': 'EEEE, MMMM d, yyyy'}"/>
|
||||
</span>
|
||||
<br/>
|
||||
<span style="font-size:14px;">
|
||||
<t t-out="object.start" t-options="{'widget': 'datetime', 'format': 'h:mm a'}"/>
|
||||
–
|
||||
<t t-out="object.stop" t-options="{'widget': 'datetime', 'format': 'h:mm a'}"/>
|
||||
(<t t-out="'%g' % object.duration"/> hr<t t-if="object.duration != 1.0">s</t>)
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Location -->
|
||||
<t t-if="object.location">
|
||||
<tr>
|
||||
<td style="padding:20px 24px;border-bottom:1px solid #d1d5db;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td width="32" valign="top" style="padding-right:14px;font-size:20px;">📍</td>
|
||||
<td>
|
||||
<span style="font-size:14px;">
|
||||
<t t-out="object.location"/>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
<!-- Scheduled with -->
|
||||
<tr>
|
||||
<td style="padding:20px 24px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td width="32" valign="top" style="padding-right:14px;font-size:20px;">👤</td>
|
||||
<td>
|
||||
<span style="font-size:14px;font-weight:600;">
|
||||
<t t-out="object.user_id.name"/>
|
||||
</span>
|
||||
<t t-if="object.user_id.company_id.phone">
|
||||
<br/>
|
||||
<span style="font-size:13px;">
|
||||
<t t-out="object.user_id.company_id.phone"/>
|
||||
</span>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Manage button -->
|
||||
<t t-if="object.x_fc_manage_token">
|
||||
<tr>
|
||||
<td style="padding:0 0 32px;text-align:center;">
|
||||
<a t-att-href="'%s/schedule/manage/%s' % (object.user_id.company_id.website or (object.get_base_url()), object.x_fc_manage_token)"
|
||||
style="display:inline-block;background-color:#2563eb;color:#ffffff;padding:13px 36px;border-radius:8px;text-decoration:none;font-size:14px;font-weight:600;">
|
||||
Manage Appointment
|
||||
</a>
|
||||
<br/>
|
||||
<span style="font-size:12px;margin-top:10px;display:inline-block;">
|
||||
Reschedule or cancel anytime
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
<!-- Divider -->
|
||||
<tr><td style="border-top:1px solid #d1d5db;padding:0;font-size:0;line-height:0;"> </td></tr>
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding:20px 0 0;text-align:center;">
|
||||
<p style="margin:0;font-size:12px;line-height:1.6;">
|
||||
<t t-out="object.user_id.company_id.name or ''"/>
|
||||
<t t-if="object.user_id.company_id.street">
|
||||
<br/><t t-out="object.user_id.company_id.street"/>
|
||||
<t t-if="object.user_id.company_id.city">, <t t-out="object.user_id.company_id.city"/></t>
|
||||
<t t-if="object.user_id.company_id.state_id"> <t t-out="object.user_id.company_id.state_id.code"/></t>
|
||||
<t t-if="object.user_id.company_id.zip"> <t t-out="object.user_id.company_id.zip"/></t>
|
||||
</t>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
7
fusion_schedule/models/__init__.py
Normal file
7
fusion_schedule/models/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import fusion_calendar_account
|
||||
from . import fusion_calendar_event_link
|
||||
from . import calendar_event
|
||||
from . import res_users
|
||||
from . import res_config_settings
|
||||
101
fusion_schedule/models/calendar_event.py
Normal file
101
fusion_schedule/models/calendar_event.py
Normal file
@@ -0,0 +1,101 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CalendarEvent(models.Model):
|
||||
_inherit = 'calendar.event'
|
||||
|
||||
x_fc_source_account_id = fields.Many2one(
|
||||
'fusion.calendar.account', string='Source Calendar Account',
|
||||
help='The external calendar account that originally synced this event.',
|
||||
)
|
||||
x_fc_is_external = fields.Boolean(
|
||||
string='External Event', compute='_compute_is_external', store=True,
|
||||
)
|
||||
x_fc_link_ids = fields.One2many(
|
||||
'fusion.calendar.event.link', 'x_fc_event_id', string='External Links',
|
||||
)
|
||||
x_fc_manage_token = fields.Char(
|
||||
string='Manage Token', index=True, copy=False,
|
||||
help='Random token allowing public visitors to manage their booking.',
|
||||
)
|
||||
x_fc_client_email = fields.Char(string='Client Email')
|
||||
x_fc_client_phone = fields.Char(string='Client Phone')
|
||||
x_fc_address_lat = fields.Float(string='Location Latitude', digits=(10, 7))
|
||||
x_fc_address_lng = fields.Float(string='Location Longitude', digits=(10, 7))
|
||||
x_fc_travel_minutes_before = fields.Integer(
|
||||
string='Travel Time Before (min)',
|
||||
help='Estimated travel time to reach this appointment from the previous one.',
|
||||
)
|
||||
x_fc_is_travel_block = fields.Boolean(
|
||||
string='Travel Block',
|
||||
help='Auto-generated travel time placeholder event.',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_source_account_id')
|
||||
def _compute_is_external(self):
|
||||
for event in self:
|
||||
event.x_fc_is_external = bool(event.x_fc_source_account_id)
|
||||
|
||||
def _skip_fc_sync(self):
|
||||
"""Check if Fusion Schedule should skip syncing (native Odoo sync active)."""
|
||||
ctx = self.env.context
|
||||
return ctx.get('no_calendar_sync') or ctx.get('dont_notify')
|
||||
|
||||
def unlink(self):
|
||||
"""On delete, also remove from all linked external calendars."""
|
||||
if not self._skip_fc_sync():
|
||||
for event in self:
|
||||
if not event.x_fc_link_ids:
|
||||
continue
|
||||
for link in event.x_fc_link_ids:
|
||||
account = link.x_fc_account_id
|
||||
if not account.x_fc_active or not account.sudo().x_fc_rtoken:
|
||||
continue
|
||||
try:
|
||||
token = account._get_valid_token()
|
||||
if not token:
|
||||
continue
|
||||
if account.x_fc_provider == 'google':
|
||||
account._google_delete_event(link.x_fc_external_id, token)
|
||||
elif account.x_fc_provider == 'microsoft':
|
||||
account._microsoft_delete_event(link.x_fc_external_id, token)
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Failed to delete external event %s from account %s: %s",
|
||||
link.x_fc_external_id, account.id, e,
|
||||
)
|
||||
return super().unlink()
|
||||
|
||||
def write(self, vals):
|
||||
"""On update, push changes to all linked external calendars."""
|
||||
res = super().write(vals)
|
||||
if self._skip_fc_sync():
|
||||
return res
|
||||
|
||||
sync_fields = {'name', 'description', 'location', 'start', 'stop', 'allday',
|
||||
'start_date', 'stop_date', 'privacy', 'show_as', 'active'}
|
||||
if not sync_fields.intersection(vals.keys()):
|
||||
return res
|
||||
|
||||
for event in self:
|
||||
if not event.x_fc_link_ids:
|
||||
continue
|
||||
for link in event.x_fc_link_ids:
|
||||
account = link.x_fc_account_id
|
||||
if not account.x_fc_active or account.x_fc_sync_status != 'active':
|
||||
continue
|
||||
try:
|
||||
account._sync_push_event(event)
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Failed to push event update %s to account %s: %s",
|
||||
event.id, account.id, e,
|
||||
)
|
||||
return res
|
||||
1169
fusion_schedule/models/fusion_calendar_account.py
Normal file
1169
fusion_schedule/models/fusion_calendar_account.py
Normal file
File diff suppressed because it is too large
Load Diff
35
fusion_schedule/models/fusion_calendar_event_link.py
Normal file
35
fusion_schedule/models/fusion_calendar_event_link.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FusionCalendarEventLink(models.Model):
|
||||
_name = 'fusion.calendar.event.link'
|
||||
_description = 'Calendar Event to External Account Link'
|
||||
_order = 'x_fc_last_synced desc'
|
||||
|
||||
x_fc_event_id = fields.Many2one(
|
||||
'calendar.event', string='Calendar Event',
|
||||
required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
x_fc_account_id = fields.Many2one(
|
||||
'fusion.calendar.account', string='Calendar Account',
|
||||
required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
x_fc_external_id = fields.Char(
|
||||
string='External Event ID', required=True, index=True,
|
||||
)
|
||||
x_fc_universal_id = fields.Char(
|
||||
string='Universal Event ID (iCalUID)', index=True,
|
||||
)
|
||||
x_fc_last_synced = fields.Datetime(string='Last Synced')
|
||||
x_fc_sync_direction = fields.Selection([
|
||||
('pull', 'Pulled from External'),
|
||||
('push', 'Pushed to External'),
|
||||
('both', 'Bidirectional'),
|
||||
], string='Sync Direction', default='pull')
|
||||
|
||||
_unique_account_external = models.Constraint(
|
||||
'UNIQUE(x_fc_account_id, x_fc_external_id)',
|
||||
'An external event can only be linked once per account.',
|
||||
)
|
||||
83
fusion_schedule/models/res_config_settings.py
Normal file
83
fusion_schedule/models/res_config_settings.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
# Google OAuth — dedicated keys with fallback to Odoo defaults
|
||||
x_fc_google_client_id = fields.Char(
|
||||
string='Google Client ID',
|
||||
config_parameter='fusion_schedule_google_client_id',
|
||||
)
|
||||
x_fc_google_client_secret = fields.Char(
|
||||
string='Google Client Secret',
|
||||
config_parameter='fusion_schedule_google_client_secret',
|
||||
)
|
||||
x_fc_google_has_fallback = fields.Boolean(
|
||||
string='Google Using Odoo Default',
|
||||
compute='_compute_google_has_fallback',
|
||||
)
|
||||
|
||||
# Microsoft OAuth — dedicated keys with fallback to Odoo defaults
|
||||
x_fc_microsoft_client_id = fields.Char(
|
||||
string='Microsoft Client ID',
|
||||
config_parameter='fusion_schedule_microsoft_client_id',
|
||||
)
|
||||
x_fc_microsoft_client_secret = fields.Char(
|
||||
string='Microsoft Client Secret',
|
||||
config_parameter='fusion_schedule_microsoft_client_secret',
|
||||
)
|
||||
x_fc_microsoft_has_fallback = fields.Boolean(
|
||||
string='Microsoft Using Odoo Default',
|
||||
compute='_compute_microsoft_has_fallback',
|
||||
)
|
||||
|
||||
# Sync settings
|
||||
x_fc_sync_interval_minutes = fields.Integer(
|
||||
string='Sync Interval (minutes)',
|
||||
config_parameter='fusion_schedule_sync_interval',
|
||||
default=5,
|
||||
)
|
||||
|
||||
# Schedule defaults
|
||||
x_fc_default_work_start = fields.Float(
|
||||
string='Default Work Start',
|
||||
config_parameter='fusion_schedule.default_work_start',
|
||||
default=9.0,
|
||||
)
|
||||
x_fc_default_work_end = fields.Float(
|
||||
string='Default Work End',
|
||||
config_parameter='fusion_schedule.default_work_end',
|
||||
default=17.0,
|
||||
)
|
||||
x_fc_default_break_start = fields.Float(
|
||||
string='Default Break Start',
|
||||
config_parameter='fusion_schedule.default_break_start',
|
||||
default=12.0,
|
||||
)
|
||||
x_fc_default_break_duration = fields.Float(
|
||||
string='Default Break Duration',
|
||||
config_parameter='fusion_schedule.default_break_duration',
|
||||
default=0.5,
|
||||
)
|
||||
x_fc_default_travel_buffer = fields.Integer(
|
||||
string='Default Travel Buffer (min)',
|
||||
config_parameter='fusion_schedule.default_travel_buffer',
|
||||
default=30,
|
||||
)
|
||||
|
||||
@api.depends('x_fc_google_client_id')
|
||||
def _compute_google_has_fallback(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
odoo_default = ICP.get_param('google_calendar_client_id', '')
|
||||
for rec in self:
|
||||
rec.x_fc_google_has_fallback = bool(not rec.x_fc_google_client_id and odoo_default)
|
||||
|
||||
@api.depends('x_fc_microsoft_client_id')
|
||||
def _compute_microsoft_has_fallback(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
odoo_default = ICP.get_param('microsoft_calendar_client_id', '')
|
||||
for rec in self:
|
||||
rec.x_fc_microsoft_has_fallback = bool(not rec.x_fc_microsoft_client_id and odoo_default)
|
||||
77
fusion_schedule/models/res_users.py
Normal file
77
fusion_schedule/models/res_users.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResUsers(models.Model):
|
||||
_inherit = 'res.users'
|
||||
|
||||
x_fc_calendar_account_ids = fields.One2many(
|
||||
'fusion.calendar.account', 'x_fc_user_id',
|
||||
string='Connected Calendar Accounts',
|
||||
)
|
||||
x_fc_schedule_slug = fields.Char(
|
||||
string='Booking URL Slug',
|
||||
help='Unique slug for your public booking page, e.g. /schedule/john-doe',
|
||||
copy=False,
|
||||
)
|
||||
x_fc_booking_enabled = fields.Boolean(
|
||||
string='Public Booking Enabled',
|
||||
default=False,
|
||||
help='Allow external visitors to book your time via a public link.',
|
||||
)
|
||||
x_fc_work_start = fields.Float(
|
||||
string='Work Day Start', default=9.0,
|
||||
help='Start of your work day as decimal hours (e.g. 9.0 = 9:00 AM)',
|
||||
)
|
||||
x_fc_work_end = fields.Float(
|
||||
string='Work Day End', default=17.0,
|
||||
help='End of your work day as decimal hours (e.g. 17.0 = 5:00 PM)',
|
||||
)
|
||||
x_fc_break_start = fields.Float(
|
||||
string='Break Start', default=12.0,
|
||||
help='Fixed break/lunch start time (e.g. 12.0 = 12:00 PM)',
|
||||
)
|
||||
x_fc_break_duration = fields.Float(
|
||||
string='Break Duration (hours)', default=0.5,
|
||||
help='Break duration in hours (e.g. 0.5 = 30 minutes)',
|
||||
)
|
||||
x_fc_travel_buffer = fields.Integer(
|
||||
string='Travel Buffer (minutes)', default=30,
|
||||
help='Minimum travel buffer between appointments in minutes',
|
||||
)
|
||||
x_fc_home_address = fields.Char(
|
||||
string='Base/Office Address',
|
||||
help='Starting address for first appointment travel calculation',
|
||||
)
|
||||
x_fc_home_lat = fields.Float(string='Base Latitude', digits=(10, 7))
|
||||
x_fc_home_lng = fields.Float(string='Base Longitude', digits=(10, 7))
|
||||
|
||||
_unique_schedule_slug = models.Constraint(
|
||||
'UNIQUE(x_fc_schedule_slug)',
|
||||
'This booking URL slug is already taken. Please choose a different one.',
|
||||
)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
users = super().create(vals_list)
|
||||
for user in users:
|
||||
if not user.x_fc_schedule_slug:
|
||||
user.x_fc_schedule_slug = user._generate_schedule_slug()
|
||||
return users
|
||||
|
||||
def _generate_schedule_slug(self):
|
||||
"""Generate a URL-safe slug from the user's name."""
|
||||
self.ensure_one()
|
||||
name = self.name or 'user'
|
||||
# Normalize: lowercase, replace spaces with hyphens, remove special chars
|
||||
slug = re.sub(r'[^a-z0-9\-]', '', name.lower().replace(' ', '-'))
|
||||
slug = re.sub(r'-+', '-', slug).strip('-')
|
||||
if not slug:
|
||||
slug = 'user'
|
||||
# Add short unique suffix
|
||||
suffix = uuid.uuid4().hex[:4]
|
||||
return '%s-%s' % (slug, suffix)
|
||||
5
fusion_schedule/security/ir.model.access.csv
Normal file
5
fusion_schedule/security/ir.model.access.csv
Normal file
@@ -0,0 +1,5 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fusion_calendar_account_user,fusion.calendar.account.user,model_fusion_calendar_account,base.group_user,1,1,1,1
|
||||
access_fusion_calendar_account_public,fusion.calendar.account.public,model_fusion_calendar_account,base.group_public,0,0,0,0
|
||||
access_fusion_calendar_event_link_user,fusion.calendar.event.link.user,model_fusion_calendar_event_link,base.group_user,1,1,1,0
|
||||
access_fusion_calendar_event_link_system,fusion.calendar.event.link.system,model_fusion_calendar_event_link,base.group_system,1,1,1,1
|
||||
|
20
fusion_schedule/security/security.xml
Normal file
20
fusion_schedule/security/security.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Users can only see/manage their own calendar accounts -->
|
||||
<record id="fusion_calendar_account_user_rule" model="ir.rule">
|
||||
<field name="name">Users: own calendar accounts only</field>
|
||||
<field name="model_id" ref="model_fusion_calendar_account"/>
|
||||
<field name="domain_force">[('x_fc_user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Users can only see event links for their own events -->
|
||||
<record id="fusion_calendar_event_link_user_rule" model="ir.rule">
|
||||
<field name="name">Users: own event links only</field>
|
||||
<field name="model_id" ref="model_fusion_calendar_event_link"/>
|
||||
<field name="domain_force">[('x_fc_account_id.x_fc_user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
BIN
fusion_schedule/static/description/icon.png
Normal file
BIN
fusion_schedule/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
56
fusion_schedule/static/src/css/portal_schedule.css
Normal file
56
fusion_schedule/static/src/css/portal_schedule.css
Normal file
@@ -0,0 +1,56 @@
|
||||
/* Fusion Schedule - Portal responsive styles */
|
||||
|
||||
/* Collapse chevron rotation */
|
||||
[data-bs-toggle="collapse"][aria-expanded="true"] .fa-chevron-down {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Min-width utility for text truncation */
|
||||
.min-width-0 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Mobile-friendly button sizing */
|
||||
@media (max-width: 575.98px) {
|
||||
.js-reschedule-event,
|
||||
.js-cancel-event {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-header h5,
|
||||
.card-header h6 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
font-size: 13px;
|
||||
padding: 0.5rem 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Slot buttons responsive grid */
|
||||
@media (max-width: 575.98px) {
|
||||
#slotsGrid .btn,
|
||||
#rescheduleSlotsGrid .btn,
|
||||
#publicSlotsGrid .btn,
|
||||
#publicRescheduleSlotsGrid .btn {
|
||||
min-width: 80px !important;
|
||||
padding: 6px 10px !important;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Modal responsive */
|
||||
@media (max-width: 575.98px) {
|
||||
.modal-dialog {
|
||||
margin: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Connected calendar compact badges */
|
||||
.badge i.fa-google,
|
||||
.badge i.fa-windows {
|
||||
vertical-align: middle;
|
||||
}
|
||||
489
fusion_schedule/static/src/js/portal_schedule_accounts.js
Normal file
489
fusion_schedule/static/src/js/portal_schedule_accounts.js
Normal file
@@ -0,0 +1,489 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function localDateStr(d) {
|
||||
d = d || new Date();
|
||||
var y = d.getFullYear();
|
||||
var m = ('0' + (d.getMonth() + 1)).slice(-2);
|
||||
var day = ('0' + d.getDate()).slice(-2);
|
||||
return y + '-' + m + '-' + day;
|
||||
}
|
||||
|
||||
(function setTzCookie() {
|
||||
try {
|
||||
var tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (tz && document.cookie.indexOf('tz=' + tz) === -1) {
|
||||
document.cookie = 'tz=' + tz + ';path=/;max-age=31536000;SameSite=Lax';
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
|
||||
function jsonRpc(url, params) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: params }),
|
||||
}).then(function (r) { return r.json(); });
|
||||
}
|
||||
|
||||
// ---- Reusable confirmation modal ----
|
||||
function fusionConfirm(opts) {
|
||||
return new Promise(function (resolve) {
|
||||
var modal = document.getElementById('fusionConfirmModal');
|
||||
if (!modal) { resolve(window.confirm(opts.message)); return; }
|
||||
var titleEl = document.getElementById('fusionConfirmTitle');
|
||||
var msgEl = document.getElementById('fusionConfirmMessage');
|
||||
var okBtn = document.getElementById('fusionConfirmOk');
|
||||
|
||||
titleEl.textContent = opts.title || 'Confirm';
|
||||
msgEl.textContent = opts.message || 'Are you sure?';
|
||||
|
||||
okBtn.className = 'btn ' + (opts.btnClass || 'btn-danger');
|
||||
okBtn.innerHTML = '<i class="fa ' + (opts.icon || 'fa-check') + ' me-1"></i>' + (opts.okLabel || 'Yes, proceed');
|
||||
|
||||
function openModal() {
|
||||
modal.classList.add('show');
|
||||
modal.style.display = 'block';
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('modal-open');
|
||||
var bd = document.createElement('div');
|
||||
bd.className = 'modal-backdrop fade show';
|
||||
bd.id = 'fusionConfirmBackdrop';
|
||||
document.body.appendChild(bd);
|
||||
bd.addEventListener('click', onDismiss);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.classList.remove('show');
|
||||
modal.style.display = 'none';
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
document.body.classList.remove('modal-open');
|
||||
var bd = document.getElementById('fusionConfirmBackdrop');
|
||||
if (bd) bd.remove();
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
okBtn.removeEventListener('click', onOk);
|
||||
modal.querySelectorAll('[data-bs-dismiss="modal"]').forEach(function (el) {
|
||||
el.removeEventListener('click', onDismiss);
|
||||
});
|
||||
modal.removeEventListener('click', onBackdrop);
|
||||
}
|
||||
|
||||
function onOk() { cleanup(); closeModal(); resolve(true); }
|
||||
function onDismiss() { cleanup(); closeModal(); resolve(false); }
|
||||
function onBackdrop(e) { if (e.target === modal) onDismiss(); }
|
||||
|
||||
okBtn.addEventListener('click', onOk);
|
||||
modal.querySelectorAll('[data-bs-dismiss="modal"]').forEach(function (el) {
|
||||
el.addEventListener('click', onDismiss);
|
||||
});
|
||||
modal.addEventListener('click', onBackdrop);
|
||||
openModal();
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Reusable toast notification ----
|
||||
function fusionToast(message, type) {
|
||||
var existing = document.getElementById('fusionToastLive');
|
||||
if (existing) existing.remove();
|
||||
|
||||
var colors = { success: '#16a34a', danger: '#dc2626' };
|
||||
var bg = colors[type] || '#374151';
|
||||
|
||||
var toast = document.createElement('div');
|
||||
toast.id = 'fusionToastLive';
|
||||
toast.style.cssText = 'position:fixed;bottom:24px;right:24px;z-index:9999;background:' + bg +
|
||||
';color:#fff;padding:14px 22px;border-radius:10px;font-size:14px;font-weight:500;' +
|
||||
'box-shadow:0 4px 16px rgba(0,0,0,0.18);opacity:0;transition:opacity .3s ease;max-width:380px;';
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
toast.style.opacity = '1';
|
||||
});
|
||||
setTimeout(function () {
|
||||
toast.style.opacity = '0';
|
||||
setTimeout(function () { toast.remove(); }, 350);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
// ---- Disconnect account ----
|
||||
document.querySelectorAll('.js-disconnect-account').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var accountId = btn.dataset.accountId;
|
||||
var accountEmail = btn.dataset.accountEmail || 'this account';
|
||||
|
||||
fusionConfirm({
|
||||
title: 'Disconnect Calendar',
|
||||
message: 'Disconnect ' + accountEmail + '? Events synced from this account will remain in Odoo.',
|
||||
okLabel: 'Disconnect',
|
||||
icon: 'fa-unlink',
|
||||
btnClass: 'btn-warning',
|
||||
}).then(function (yes) {
|
||||
if (!yes) return;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i>';
|
||||
|
||||
jsonRpc('/my/schedule/disconnect', { account_id: parseInt(accountId) })
|
||||
.then(function (data) {
|
||||
if ((data.result || {}).success) { window.location.reload(); }
|
||||
else { fusionToast((data.result || {}).error || 'Failed to disconnect.', 'danger'); btn.disabled = false; btn.innerHTML = '<i class="fa fa-times"></i>'; }
|
||||
})
|
||||
.catch(function () { fusionToast('Network error.', 'danger'); btn.disabled = false; btn.innerHTML = '<i class="fa fa-times"></i>'; });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Sync Now ----
|
||||
document.querySelectorAll('.js-sync-account').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var accountId = this.dataset.accountId;
|
||||
btn.disabled = true;
|
||||
var origHtml = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i>';
|
||||
|
||||
jsonRpc('/my/schedule/sync-now', { account_id: parseInt(accountId) })
|
||||
.then(function (data) {
|
||||
if ((data.result || {}).success) { window.location.reload(); }
|
||||
else { fusionToast((data.result || {}).error || 'Sync failed.', 'danger'); btn.disabled = false; btn.innerHTML = origHtml; }
|
||||
})
|
||||
.catch(function () { fusionToast('Network error.', 'danger'); btn.disabled = false; btn.innerHTML = origHtml; });
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Share Booking Link ----
|
||||
document.querySelectorAll('.js-share-booking').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
var url = this.dataset.url;
|
||||
if (!url) return;
|
||||
|
||||
if (navigator.share) {
|
||||
navigator.share({ title: 'Book an Appointment', url: url }).catch(function () {});
|
||||
} else {
|
||||
navigator.clipboard.writeText(url).then(function () {
|
||||
var orig = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="fa fa-check me-1"></i> Copied!';
|
||||
btn.classList.add('btn-success');
|
||||
btn.classList.remove('btn-outline-secondary', 'btn-primary');
|
||||
setTimeout(function () {
|
||||
btn.innerHTML = orig;
|
||||
btn.classList.remove('btn-success');
|
||||
btn.classList.add('btn-primary');
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Save Schedule Preferences ----
|
||||
var savePrefsBtn = document.getElementById('btnSavePrefs');
|
||||
if (savePrefsBtn) {
|
||||
savePrefsBtn.addEventListener('click', function () {
|
||||
var form = document.getElementById('schedulePrefsForm');
|
||||
if (!form) return;
|
||||
|
||||
var workStartParts = (form.querySelector('[name="work_start"]').value || '09:00').split(':');
|
||||
var workEndParts = (form.querySelector('[name="work_end"]').value || '17:00').split(':');
|
||||
var breakStartParts = (form.querySelector('[name="break_start"]').value || '12:00').split(':');
|
||||
var breakDurMin = parseInt(form.querySelector('[name="break_duration_min"]').value || '30');
|
||||
|
||||
var params = {
|
||||
work_start: parseInt(workStartParts[0]) + parseInt(workStartParts[1] || 0) / 60,
|
||||
work_end: parseInt(workEndParts[0]) + parseInt(workEndParts[1] || 0) / 60,
|
||||
break_start: parseInt(breakStartParts[0]) + parseInt(breakStartParts[1] || 0) / 60,
|
||||
break_duration: breakDurMin / 60,
|
||||
travel_buffer: parseInt(form.querySelector('[name="travel_buffer"]').value || '30'),
|
||||
home_address: form.querySelector('[name="home_address"]').value || '',
|
||||
};
|
||||
|
||||
savePrefsBtn.disabled = true;
|
||||
savePrefsBtn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Saving...';
|
||||
|
||||
jsonRpc('/my/schedule/preferences', params)
|
||||
.then(function (data) {
|
||||
savePrefsBtn.disabled = false;
|
||||
savePrefsBtn.innerHTML = '<i class="fa fa-save me-1"></i> Save Preferences';
|
||||
if ((data.result || {}).success) {
|
||||
var msg = document.getElementById('prefsSavedMsg');
|
||||
if (msg) {
|
||||
msg.style.display = 'inline';
|
||||
setTimeout(function () { msg.style.display = 'none'; }, 3000);
|
||||
}
|
||||
} else {
|
||||
fusionToast('Failed to save preferences.', 'danger');
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
savePrefsBtn.disabled = false;
|
||||
savePrefsBtn.innerHTML = '<i class="fa fa-save me-1"></i> Save Preferences';
|
||||
fusionToast('Network error.', 'danger');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Cancel Event ----
|
||||
document.querySelectorAll('.js-cancel-event').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
var eventId = btn.dataset.eventId;
|
||||
var eventName = btn.dataset.eventName || 'this appointment';
|
||||
|
||||
fusionConfirm({
|
||||
title: 'Cancel Appointment',
|
||||
message: 'Cancel "' + eventName + '"? This action cannot be undone.',
|
||||
okLabel: 'Cancel appointment',
|
||||
icon: 'fa-trash-o',
|
||||
}).then(function (yes) {
|
||||
if (!yes) return;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i>';
|
||||
|
||||
jsonRpc('/my/schedule/event/cancel', { event_id: parseInt(eventId) })
|
||||
.then(function (data) {
|
||||
if ((data.result || {}).success) { window.location.reload(); }
|
||||
else { fusionToast((data.result || {}).error || 'Failed to cancel.', 'danger'); btn.disabled = false; btn.innerHTML = '<i class="fa fa-trash-o"></i>'; }
|
||||
})
|
||||
.catch(function () { fusionToast('Network error.', 'danger'); btn.disabled = false; btn.innerHTML = '<i class="fa fa-trash-o"></i>'; });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Reschedule Event (Modal) ----
|
||||
var rescheduleModal = document.getElementById('rescheduleModal');
|
||||
if (!rescheduleModal) return;
|
||||
|
||||
var rescheduleDateInput = document.getElementById('rescheduleDate');
|
||||
var rescheduleSlotsContainer = document.getElementById('rescheduleSlotsContainer');
|
||||
var rescheduleSlotsGrid = document.getElementById('rescheduleSlotsGrid');
|
||||
var rescheduleSlotsLoading = document.getElementById('rescheduleSlotsLoading');
|
||||
var rescheduleNoSlots = document.getElementById('rescheduleNoSlots');
|
||||
var rescheduleEventIdInput = document.getElementById('rescheduleEventId');
|
||||
var rescheduleSlotDatetimeInput = document.getElementById('rescheduleSlotDatetime');
|
||||
var rescheduleEventDurationInput = document.getElementById('rescheduleEventDuration');
|
||||
var rescheduleEventNameEl = document.getElementById('rescheduleEventName');
|
||||
var confirmRescheduleBtn = document.getElementById('btnConfirmReschedule');
|
||||
var rescheduleAppTypeInput = document.getElementById('rescheduleAppTypeId');
|
||||
var rescheduleSelectedBtn = null;
|
||||
|
||||
document.querySelectorAll('.js-reschedule-event').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
rescheduleEventIdInput.value = this.dataset.eventId;
|
||||
rescheduleEventDurationInput.value = this.dataset.eventDuration || '';
|
||||
rescheduleEventNameEl.textContent = this.dataset.eventName || '';
|
||||
|
||||
rescheduleDateInput.value = '';
|
||||
rescheduleSlotsContainer.style.display = 'none';
|
||||
rescheduleSlotsGrid.innerHTML = '';
|
||||
rescheduleSlotDatetimeInput.value = '';
|
||||
confirmRescheduleBtn.disabled = true;
|
||||
rescheduleSelectedBtn = null;
|
||||
|
||||
var today = new Date();
|
||||
rescheduleDateInput.min = localDateStr(today);
|
||||
|
||||
rescheduleModal.classList.add('show');
|
||||
rescheduleModal.style.display = 'block';
|
||||
rescheduleModal.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('modal-open');
|
||||
|
||||
var backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop fade show';
|
||||
backdrop.id = 'rescheduleBackdrop';
|
||||
document.body.appendChild(backdrop);
|
||||
});
|
||||
});
|
||||
|
||||
function closeRescheduleModal() {
|
||||
rescheduleModal.classList.remove('show');
|
||||
rescheduleModal.style.display = 'none';
|
||||
rescheduleModal.setAttribute('aria-hidden', 'true');
|
||||
document.body.classList.remove('modal-open');
|
||||
var backdrop = document.getElementById('rescheduleBackdrop');
|
||||
if (backdrop) backdrop.remove();
|
||||
}
|
||||
|
||||
rescheduleModal.querySelectorAll('[data-bs-dismiss="modal"]').forEach(function (el) {
|
||||
el.addEventListener('click', closeRescheduleModal);
|
||||
});
|
||||
|
||||
rescheduleModal.addEventListener('click', function (e) {
|
||||
if (e.target === rescheduleModal) closeRescheduleModal();
|
||||
});
|
||||
|
||||
if (rescheduleDateInput) {
|
||||
rescheduleDateInput.addEventListener('change', function () {
|
||||
var date = this.value;
|
||||
if (!date) return;
|
||||
|
||||
rescheduleSlotsContainer.style.display = 'block';
|
||||
rescheduleSlotsLoading.style.display = 'block';
|
||||
rescheduleSlotsGrid.innerHTML = '';
|
||||
rescheduleNoSlots.style.display = 'none';
|
||||
rescheduleSlotDatetimeInput.value = '';
|
||||
confirmRescheduleBtn.disabled = true;
|
||||
rescheduleSelectedBtn = null;
|
||||
|
||||
var appTypeId = rescheduleAppTypeInput ? parseInt(rescheduleAppTypeInput.value) : 0;
|
||||
|
||||
jsonRpc('/my/schedule/available-slots', {
|
||||
selected_date: date,
|
||||
appointment_type_id: appTypeId,
|
||||
})
|
||||
.then(function (data) {
|
||||
rescheduleSlotsLoading.style.display = 'none';
|
||||
var slots = (data.result || {}).slots || [];
|
||||
if (!slots.length) {
|
||||
rescheduleNoSlots.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
slots.forEach(function (s) {
|
||||
var slotBtn = document.createElement('button');
|
||||
slotBtn.type = 'button';
|
||||
slotBtn.className = 'btn btn-outline-primary btn-sm';
|
||||
slotBtn.style.cssText = 'min-width: 90px; border-radius: 8px; padding: 8px 12px;';
|
||||
slotBtn.textContent = s.start_hour;
|
||||
slotBtn.addEventListener('click', function () {
|
||||
if (rescheduleSelectedBtn) {
|
||||
rescheduleSelectedBtn.classList.remove('btn-primary');
|
||||
rescheduleSelectedBtn.classList.add('btn-outline-primary');
|
||||
}
|
||||
slotBtn.classList.remove('btn-outline-primary');
|
||||
slotBtn.classList.add('btn-primary');
|
||||
rescheduleSelectedBtn = slotBtn;
|
||||
rescheduleSlotDatetimeInput.value = s.datetime;
|
||||
confirmRescheduleBtn.disabled = false;
|
||||
});
|
||||
rescheduleSlotsGrid.appendChild(slotBtn);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
rescheduleSlotsLoading.style.display = 'none';
|
||||
rescheduleNoSlots.textContent = 'Failed to load slots.';
|
||||
rescheduleNoSlots.style.display = 'block';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (confirmRescheduleBtn) {
|
||||
confirmRescheduleBtn.addEventListener('click', function () {
|
||||
var eventId = rescheduleEventIdInput.value;
|
||||
var newDatetime = rescheduleSlotDatetimeInput.value;
|
||||
var duration = rescheduleEventDurationInput.value;
|
||||
if (!eventId || !newDatetime) return;
|
||||
|
||||
confirmRescheduleBtn.disabled = true;
|
||||
confirmRescheduleBtn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Saving...';
|
||||
|
||||
jsonRpc('/my/schedule/event/reschedule', {
|
||||
event_id: parseInt(eventId),
|
||||
new_datetime: newDatetime,
|
||||
new_duration: duration || null,
|
||||
})
|
||||
.then(function (data) {
|
||||
var result = data.result || {};
|
||||
if (result.success) {
|
||||
closeRescheduleModal();
|
||||
window.location.reload();
|
||||
} else {
|
||||
fusionToast(result.error || 'Failed to reschedule.', 'danger');
|
||||
confirmRescheduleBtn.disabled = false;
|
||||
confirmRescheduleBtn.innerHTML = '<i class="fa fa-check me-1"></i> Confirm';
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
fusionToast('Network error.', 'danger');
|
||||
confirmRescheduleBtn.disabled = false;
|
||||
confirmRescheduleBtn.innerHTML = '<i class="fa fa-check me-1"></i> Confirm';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Optimize Schedule (AI) ----
|
||||
var optimizeBtn = document.getElementById('btnOptimizeSchedule');
|
||||
var optimizeModal = document.getElementById('optimizeModal');
|
||||
if (optimizeBtn && optimizeModal) {
|
||||
optimizeBtn.addEventListener('click', function () {
|
||||
optimizeModal.classList.add('show');
|
||||
optimizeModal.style.display = 'block';
|
||||
optimizeModal.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('modal-open');
|
||||
var backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop fade show';
|
||||
backdrop.id = 'optimizeBackdrop';
|
||||
document.body.appendChild(backdrop);
|
||||
|
||||
var loading = document.getElementById('optimizeLoading');
|
||||
var result = document.getElementById('optimizeResult');
|
||||
var errDiv = document.getElementById('optimizeError');
|
||||
loading.style.display = 'block';
|
||||
result.style.display = 'none';
|
||||
errDiv.style.display = 'none';
|
||||
|
||||
var today = localDateStr();
|
||||
jsonRpc('/my/schedule/ai/optimize', { selected_date: today })
|
||||
.then(function (data) {
|
||||
loading.style.display = 'none';
|
||||
var r = data.result || {};
|
||||
if (r.error) {
|
||||
errDiv.textContent = r.error;
|
||||
errDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
var opt = r.optimization;
|
||||
if (!opt) {
|
||||
errDiv.textContent = 'No optimization data returned.';
|
||||
errDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
document.getElementById('optimizeCurrentTravel').textContent =
|
||||
(opt.current_travel_total_min || 0) + ' min';
|
||||
document.getElementById('optimizeNewTravel').textContent =
|
||||
(opt.optimized_travel_total_min || 0) + ' min';
|
||||
var savings = opt.savings_min || 0;
|
||||
document.getElementById('optimizeSavings').textContent =
|
||||
savings > 0 ? 'Save ' + savings + ' min' : '';
|
||||
|
||||
var listEl = document.getElementById('optimizeScheduleList');
|
||||
listEl.innerHTML = '';
|
||||
(opt.schedule || []).forEach(function (item) {
|
||||
var div = document.createElement('div');
|
||||
div.className = 'd-flex justify-content-between align-items-center py-2 border-bottom';
|
||||
div.innerHTML =
|
||||
'<div><strong>' + (item.name || '') + '</strong>' +
|
||||
'<br/><small class="text-muted">' + (item.reason || '') + '</small></div>' +
|
||||
'<span class="badge text-bg-info">' + (item.suggested_time || '') + '</span>';
|
||||
listEl.appendChild(div);
|
||||
});
|
||||
result.style.display = 'block';
|
||||
})
|
||||
.catch(function () {
|
||||
loading.style.display = 'none';
|
||||
errDiv.textContent = 'Network error.';
|
||||
errDiv.style.display = 'block';
|
||||
});
|
||||
});
|
||||
|
||||
function closeOptimizeModal() {
|
||||
optimizeModal.classList.remove('show');
|
||||
optimizeModal.style.display = 'none';
|
||||
optimizeModal.setAttribute('aria-hidden', 'true');
|
||||
document.body.classList.remove('modal-open');
|
||||
var b = document.getElementById('optimizeBackdrop');
|
||||
if (b) b.remove();
|
||||
}
|
||||
optimizeModal.querySelectorAll('[data-bs-dismiss="modal"]').forEach(function (el) {
|
||||
el.addEventListener('click', closeOptimizeModal);
|
||||
});
|
||||
optimizeModal.addEventListener('click', function (e) {
|
||||
if (e.target === optimizeModal) closeOptimizeModal();
|
||||
});
|
||||
}
|
||||
})();
|
||||
575
fusion_schedule/static/src/js/portal_schedule_booking.js
Normal file
575
fusion_schedule/static/src/js/portal_schedule_booking.js
Normal file
@@ -0,0 +1,575 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
(function setTzCookie() {
|
||||
try {
|
||||
var tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (tz && document.cookie.indexOf('tz=' + tz) === -1) {
|
||||
document.cookie = 'tz=' + tz + ';path=/;max-age=31536000;SameSite=Lax';
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
|
||||
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');
|
||||
var weekNav = document.getElementById('weekCalendarNav');
|
||||
|
||||
var currentWeekDays = [];
|
||||
var currentWeekEvents = [];
|
||||
|
||||
function getAppointmentTypeId() {
|
||||
if (typeSelect) return typeSelect.value;
|
||||
var hidden = document.querySelector('input[name="appointment_type_id"]');
|
||||
return hidden ? hidden.value : null;
|
||||
}
|
||||
|
||||
function truncate(str, max) {
|
||||
if (!str) return '';
|
||||
return str.length > max ? str.substring(0, max) + '...' : str;
|
||||
}
|
||||
|
||||
function formatDateStr(d) {
|
||||
var y = d.getFullYear();
|
||||
var m = ('0' + (d.getMonth() + 1)).slice(-2);
|
||||
var day = ('0' + d.getDate()).slice(-2);
|
||||
return y + '-' + m + '-' + day;
|
||||
}
|
||||
|
||||
function addDays(dateStr, n) {
|
||||
var d = new Date(dateStr + 'T12:00:00');
|
||||
d.setDate(d.getDate() + n);
|
||||
return formatDateStr(d);
|
||||
}
|
||||
|
||||
function getMonday(dateStr) {
|
||||
var d = new Date(dateStr + 'T12:00:00');
|
||||
var dow = d.getDay();
|
||||
var diff = dow === 0 ? -6 : 1 - dow;
|
||||
d.setDate(d.getDate() + diff);
|
||||
return formatDateStr(d);
|
||||
}
|
||||
|
||||
function selectDay(dateStr) {
|
||||
if (dateInput) {
|
||||
dateInput.value = dateStr;
|
||||
}
|
||||
fetchSlots(dateStr);
|
||||
if (currentWeekDays.length) {
|
||||
renderWeekCalendar(currentWeekDays, currentWeekEvents, dateStr);
|
||||
}
|
||||
}
|
||||
|
||||
function fetchWeekEvents(date, selectDate) {
|
||||
if (!weekContainer || !date) return;
|
||||
|
||||
weekContainer.style.display = 'block';
|
||||
weekLoading.style.display = 'block';
|
||||
weekGrid.style.display = 'none';
|
||||
weekEmpty.style.display = 'none';
|
||||
if (weekNav) weekNav.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;
|
||||
}
|
||||
|
||||
currentWeekDays = weekDays;
|
||||
currentWeekEvents = events;
|
||||
|
||||
var sel = selectDate || date;
|
||||
renderWeekCalendar(weekDays, events, sel);
|
||||
})
|
||||
.catch(function () {
|
||||
weekLoading.style.display = 'none';
|
||||
weekEmpty.textContent = 'Failed to load calendar. Please try again.';
|
||||
weekEmpty.style.display = 'block';
|
||||
});
|
||||
}
|
||||
|
||||
function navigateWeek(direction) {
|
||||
var workDays = currentWeekDays.filter(function (d) {
|
||||
return d.label !== 'Sat' && d.label !== 'Sun';
|
||||
});
|
||||
if (!workDays.length) return;
|
||||
|
||||
var refDate = direction > 0 ? workDays[workDays.length - 1].date : workDays[0].date;
|
||||
var newDate = addDays(refDate, direction > 0 ? 7 : -7);
|
||||
var monday = getMonday(newDate);
|
||||
|
||||
var today = formatDateStr(new Date());
|
||||
var targetSelect = monday;
|
||||
if (today >= monday && today <= addDays(monday, 4)) {
|
||||
targetSelect = today;
|
||||
}
|
||||
|
||||
fetchWeekEvents(monday, targetSelect);
|
||||
selectDay(targetSelect);
|
||||
}
|
||||
|
||||
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 workDays = weekDays.filter(function (d) {
|
||||
return d.label !== 'Sat' && d.label !== 'Sun';
|
||||
});
|
||||
|
||||
if (!workDays.length) {
|
||||
weekGrid.style.display = 'none';
|
||||
weekEmpty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
var monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
var firstD = new Date(workDays[0].date + 'T12:00:00');
|
||||
var lastD = new Date(workDays[workDays.length - 1].date + 'T12:00:00');
|
||||
var rangeLabel = monthNames[firstD.getMonth()] + ' ' + firstD.getDate();
|
||||
if (firstD.getMonth() !== lastD.getMonth()) {
|
||||
rangeLabel += ' - ' + monthNames[lastD.getMonth()] + ' ' + lastD.getDate();
|
||||
} else {
|
||||
rangeLabel += ' - ' + lastD.getDate();
|
||||
}
|
||||
rangeLabel += ', ' + firstD.getFullYear();
|
||||
|
||||
if (weekNav) {
|
||||
weekNav.style.display = 'flex';
|
||||
var rangeEl = weekNav.querySelector('#weekRangeLabel');
|
||||
if (rangeEl) rangeEl.textContent = rangeLabel;
|
||||
}
|
||||
|
||||
weekGrid.style.cssText = 'display: grid; grid-template-columns: repeat(' + workDays.length + ', 1fr); border-radius: 10px; overflow: hidden; border: 1px solid #e5e7eb;';
|
||||
weekHeader.style.cssText = 'display: contents;';
|
||||
weekBody.style.cssText = 'display: contents;';
|
||||
|
||||
workDays.forEach(function (day) {
|
||||
var isSelected = day.date === selectedDate;
|
||||
var isToday = day.date === formatDateStr(new Date());
|
||||
var dayEvents = eventsByDate[day.date] || [];
|
||||
|
||||
var col = document.createElement('div');
|
||||
col.style.cssText = 'cursor: pointer; user-select: none;';
|
||||
col.dataset.date = day.date;
|
||||
col.addEventListener('click', function () {
|
||||
selectDay(day.date);
|
||||
});
|
||||
|
||||
var headerCell = document.createElement('div');
|
||||
headerCell.style.cssText = 'text-align: center; padding: 10px 4px 8px; border-right: 1px solid #f0f0f0; border-bottom: 1px solid #e5e7eb; transition: background 0.15s;';
|
||||
if (isSelected) {
|
||||
headerCell.style.background = 'linear-gradient(180deg, #eff6ff 0%, #dbeafe 100%)';
|
||||
} else {
|
||||
headerCell.style.background = '#fafbfc';
|
||||
}
|
||||
|
||||
var labelEl = document.createElement('div');
|
||||
labelEl.style.cssText = 'font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: ' + (isSelected ? '#2563eb' : '#9ca3af') + ';';
|
||||
labelEl.textContent = day.label;
|
||||
|
||||
var numEl = document.createElement('div');
|
||||
if (isSelected) {
|
||||
numEl.innerHTML = '<span style="display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #2563eb; color: #fff; font-size: 16px; font-weight: 700;">' + day.day_num + '</span>';
|
||||
} else if (isToday) {
|
||||
numEl.innerHTML = '<span style="display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #059669; color: #fff; font-size: 16px; font-weight: 700;">' + day.day_num + '</span>';
|
||||
} else {
|
||||
numEl.style.cssText = 'font-size: 18px; font-weight: 700; line-height: 1.2; color: #374151;';
|
||||
numEl.textContent = day.day_num;
|
||||
}
|
||||
|
||||
headerCell.appendChild(labelEl);
|
||||
headerCell.appendChild(numEl);
|
||||
weekHeader.appendChild(col);
|
||||
col.appendChild(headerCell);
|
||||
|
||||
var bodyCell = document.createElement('div');
|
||||
bodyCell.style.cssText = 'padding: 6px; min-height: 90px; border-right: 1px solid #f0f0f0; overflow-y: auto; transition: background 0.15s;';
|
||||
bodyCell.style.background = isSelected ? '#f0f7ff' : '#fff';
|
||||
|
||||
if (dayEvents.length) {
|
||||
dayEvents.forEach(function (ev) {
|
||||
var card = document.createElement('div');
|
||||
card.style.cssText = 'margin-bottom: 4px; padding: 5px 7px; border-radius: 6px; background: linear-gradient(135deg, #eff6ff 0%, #f0f7ff 100%); border-left: 3px solid #3b82f6; cursor: pointer; transition: box-shadow 0.15s;';
|
||||
card.title = ev.start_time + ' - ' + ev.end_time + '\n' + ev.name + (ev.location ? '\n' + ev.location : '');
|
||||
card.onmouseenter = function () { card.style.boxShadow = '0 2px 8px rgba(59,130,246,0.15)'; };
|
||||
card.onmouseleave = function () { card.style.boxShadow = 'none'; };
|
||||
|
||||
var timeEl = document.createElement('div');
|
||||
timeEl.style.cssText = 'font-size: 10px; font-weight: 600; color: #3b82f6;';
|
||||
timeEl.textContent = ev.start_time;
|
||||
|
||||
var nameEl = document.createElement('div');
|
||||
nameEl.style.cssText = 'font-size: 11px; color: #374151; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;';
|
||||
nameEl.textContent = truncate(ev.name, 20);
|
||||
|
||||
card.appendChild(timeEl);
|
||||
card.appendChild(nameEl);
|
||||
bodyCell.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
var bodyCol = document.createElement('div');
|
||||
bodyCol.style.cssText = 'cursor: pointer;';
|
||||
bodyCol.dataset.date = day.date;
|
||||
bodyCol.addEventListener('click', function () {
|
||||
selectDay(day.date);
|
||||
});
|
||||
bodyCol.appendChild(bodyCell);
|
||||
weekBody.appendChild(bodyCol);
|
||||
});
|
||||
|
||||
weekGrid.style.display = 'grid';
|
||||
weekEmpty.style.display = 'none';
|
||||
}
|
||||
|
||||
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';
|
||||
slotsGrid.innerHTML = '';
|
||||
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 text = slot.start_hour.toLowerCase();
|
||||
var match = text.match(/(\d+)/);
|
||||
var hour = match ? parseInt(match[1]) : 0;
|
||||
if (text.indexOf('pm') > -1 && hour !== 12) hour += 12;
|
||||
if (text.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);
|
||||
|
||||
fetchAiSuggestions(date);
|
||||
})
|
||||
.catch(function (err) {
|
||||
slotsLoading.style.display = 'none';
|
||||
noSlots.textContent = 'Failed to load slots. Please try again.';
|
||||
noSlots.style.display = 'block';
|
||||
});
|
||||
}
|
||||
|
||||
var aiRequestCounter = 0;
|
||||
|
||||
function fetchAiSuggestions(date) {
|
||||
var section = document.getElementById('aiSuggestSection');
|
||||
var loading = document.getElementById('aiSuggestLoading');
|
||||
var grid = document.getElementById('aiSuggestGrid');
|
||||
if (!section || !grid) return;
|
||||
|
||||
var myRequestId = ++aiRequestCounter;
|
||||
|
||||
section.style.display = 'block';
|
||||
loading.style.display = 'block';
|
||||
grid.innerHTML = '';
|
||||
|
||||
var streetInput = document.getElementById('clientStreet');
|
||||
var latInput = document.getElementById('clientLat');
|
||||
var lngInput = document.getElementById('clientLng');
|
||||
var durationInput = document.getElementById('slotDuration');
|
||||
|
||||
var params = {
|
||||
selected_date: date,
|
||||
appointment_type_id: getAppointmentTypeId() || 0,
|
||||
location: streetInput ? streetInput.value : '',
|
||||
lat: latInput ? parseFloat(latInput.value) || 0 : 0,
|
||||
lng: lngInput ? parseFloat(lngInput.value) || 0 : 0,
|
||||
duration: durationInput ? parseFloat(durationInput.value) || 1.0 : 1.0,
|
||||
};
|
||||
|
||||
fetch('/my/schedule/ai/suggest', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: params }),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (myRequestId !== aiRequestCounter) return;
|
||||
loading.style.display = 'none';
|
||||
grid.innerHTML = '';
|
||||
var result = data.result || {};
|
||||
var suggestions = result.suggestions || [];
|
||||
if (!suggestions.length) {
|
||||
section.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
suggestions.forEach(function (s) {
|
||||
var card = document.createElement('div');
|
||||
card.className = 'border rounded-3 p-2 mb-2 d-flex justify-content-between align-items-center';
|
||||
card.style.cssText = 'background: linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%); cursor: pointer; transition: all 0.15s;';
|
||||
card.innerHTML =
|
||||
'<div><strong class="text-primary">' + (s.time || '') + '</strong>' +
|
||||
'<br/><small class="text-muted">' + (s.reason || '') + '</small></div>' +
|
||||
'<i class="fa fa-magic text-info"></i>';
|
||||
card.addEventListener('click', function () {
|
||||
grid.querySelectorAll('.ai-card-selected').forEach(function (el) {
|
||||
el.classList.remove('ai-card-selected');
|
||||
el.style.border = '';
|
||||
el.style.boxShadow = '';
|
||||
});
|
||||
card.classList.add('ai-card-selected');
|
||||
card.style.border = '2px solid #2563eb';
|
||||
card.style.boxShadow = '0 2px 8px rgba(37,99,235,0.2)';
|
||||
|
||||
if (s.datetime && slotDatetimeInput) {
|
||||
if (selectedSlotBtn) {
|
||||
selectedSlotBtn.classList.remove('btn-primary');
|
||||
selectedSlotBtn.classList.add('btn-outline-primary');
|
||||
}
|
||||
slotDatetimeInput.value = s.datetime;
|
||||
if (slotDurationInput) slotDurationInput.value = s.duration || '1.0';
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
|
||||
var btns = slotsGrid ? slotsGrid.querySelectorAll('.slot-btn') : [];
|
||||
btns.forEach(function (btn) {
|
||||
if (btn.dataset.datetime === s.datetime) {
|
||||
btn.classList.remove('btn-outline-primary');
|
||||
btn.classList.add('btn-primary');
|
||||
selectedSlotBtn = btn;
|
||||
btn.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
grid.appendChild(card);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
if (myRequestId !== aiRequestCounter) return;
|
||||
loading.style.display = 'none';
|
||||
section.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
var aiSuggestBtn = document.getElementById('btnAiSuggest');
|
||||
if (aiSuggestBtn) {
|
||||
aiSuggestBtn.addEventListener('click', function () {
|
||||
if (dateInput && dateInput.value) fetchAiSuggestions(dateInput.value);
|
||||
});
|
||||
}
|
||||
|
||||
if (dateInput) {
|
||||
dateInput.addEventListener('change', function () {
|
||||
var val = this.value;
|
||||
fetchWeekEvents(val, val);
|
||||
fetchSlots(val);
|
||||
});
|
||||
}
|
||||
|
||||
if (typeSelect) {
|
||||
typeSelect.addEventListener('change', function () {
|
||||
if (dateInput && dateInput.value) {
|
||||
fetchSlots(dateInput.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var btnPrevWeek = document.getElementById('btnPrevWeek');
|
||||
var btnNextWeek = document.getElementById('btnNextWeek');
|
||||
if (btnPrevWeek) {
|
||||
btnPrevWeek.addEventListener('click', function () { navigateWeek(-1); });
|
||||
}
|
||||
if (btnNextWeek) {
|
||||
btnNextWeek.addEventListener('click', function () { navigateWeek(1); });
|
||||
}
|
||||
|
||||
if (dateInput && weekContainer) {
|
||||
var today = formatDateStr(new Date());
|
||||
dateInput.value = today;
|
||||
fetchWeekEvents(today, today);
|
||||
fetchSlots(today);
|
||||
}
|
||||
|
||||
var bookingForm = document.getElementById('bookingForm');
|
||||
if (bookingForm) {
|
||||
bookingForm.addEventListener('submit', function (e) {
|
||||
if (!slotDatetimeInput || !slotDatetimeInput.value) {
|
||||
e.preventDefault();
|
||||
if (typeof fusionToast === 'function') { fusionToast('Please select a time slot before booking.', 'danger'); }
|
||||
else { window.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();
|
||||
if (typeof fusionToast === 'function') { fusionToast('Please enter the client name.', 'danger'); }
|
||||
else { window.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...';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setupAddressAutocomplete() {
|
||||
var streetInput = document.getElementById('clientStreet');
|
||||
if (!streetInput || typeof google === 'undefined') 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;
|
||||
|
||||
if (place.geometry && place.geometry.location) {
|
||||
var latInput = document.getElementById('clientLat');
|
||||
var lngInput = document.getElementById('clientLng');
|
||||
if (latInput) latInput.value = place.geometry.location.lat();
|
||||
if (lngInput) lngInput.value = place.geometry.location.lng();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (window._googleMapsReady) {
|
||||
setupAddressAutocomplete();
|
||||
} else {
|
||||
window._scheduleAutocompleteInit = setupAddressAutocomplete;
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,74 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { AttendeeCalendarController } from "@calendar/views/attendee_calendar/attendee_calendar_controller";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { useState, onWillStart } from "@odoo/owl";
|
||||
|
||||
patch(AttendeeCalendarController.prototype, {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.orm = useService("orm");
|
||||
this.notification = useService("notification");
|
||||
this.fusionState = useState({
|
||||
accounts: [],
|
||||
syncing: false,
|
||||
});
|
||||
onWillStart(async () => {
|
||||
await this._loadFusionAccounts();
|
||||
});
|
||||
},
|
||||
|
||||
get fusionAccounts() {
|
||||
return this.fusionState.accounts;
|
||||
},
|
||||
|
||||
get fusionSyncing() {
|
||||
return this.fusionState.syncing;
|
||||
},
|
||||
|
||||
async _loadFusionAccounts() {
|
||||
try {
|
||||
const accounts = await this.orm.call(
|
||||
"fusion.calendar.account",
|
||||
"get_user_accounts_status",
|
||||
[],
|
||||
);
|
||||
this.fusionState.accounts = accounts;
|
||||
} catch {
|
||||
this.fusionState.accounts = [];
|
||||
}
|
||||
},
|
||||
|
||||
async onFusionSyncNow() {
|
||||
this.fusionState.syncing = true;
|
||||
try {
|
||||
const result = await this.orm.call(
|
||||
"fusion.calendar.account",
|
||||
"sync_current_user",
|
||||
[],
|
||||
);
|
||||
if (result.success) {
|
||||
this.notification.add(
|
||||
result.message || "Calendar synced successfully.",
|
||||
{ type: "success" },
|
||||
);
|
||||
await this._loadFusionAccounts();
|
||||
await this.model.load();
|
||||
this.render(true);
|
||||
} else {
|
||||
this.notification.add(
|
||||
result.error || "Sync failed.",
|
||||
{ type: "danger" },
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
this.notification.add(
|
||||
"Sync error: " + (e.message || "Unknown error"),
|
||||
{ type: "danger" },
|
||||
);
|
||||
} finally {
|
||||
this.fusionState.syncing = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
|
||||
<!-- Hide the native sync area and add Fusion Schedule sync UI alongside -->
|
||||
<t t-name="fusion_schedule.FusionCalendarController"
|
||||
t-inherit="calendar.AttendeeCalendarController"
|
||||
t-inherit-mode="extension">
|
||||
|
||||
<!-- Hide native sync buttons but keep the div so other modules' xpaths still work -->
|
||||
<xpath expr="//div[@id='header_synchronization_settings']" position="attributes">
|
||||
<attribute name="style">display: none !important;</attribute>
|
||||
</xpath>
|
||||
|
||||
<!-- Add our own sync UI right after the hidden native one -->
|
||||
<xpath expr="//div[@id='header_synchronization_settings']" position="after">
|
||||
<div id="fusion_calendar_sync" class="mx-2 ms-lg-auto d-inline-flex align-items-center gap-2">
|
||||
<t t-if="fusionAccounts and fusionAccounts.length">
|
||||
<t t-foreach="fusionAccounts" t-as="acct" t-key="acct.id">
|
||||
<span class="o_tag d-inline-flex align-items-center gap-1 rounded-pill px-2 py-1 small"
|
||||
t-att-class="acct.status === 'active' ? 'bg-success-subtle text-success border border-success-subtle' : acct.status === 'error' ? 'bg-danger-subtle text-danger border border-danger-subtle' : 'bg-warning-subtle text-warning border border-warning-subtle'"
|
||||
t-att-title="acct.email + ' (' + (acct.provider === 'google' ? 'Google' : 'Outlook') + ')' + (acct.last_sync ? ' — Last sync: ' + acct.last_sync : '')"
|
||||
t-att-data-tooltip="acct.email + ' (' + (acct.provider === 'google' ? 'Google' : 'Outlook') + ')'"
|
||||
data-tooltip-position="bottom">
|
||||
<i t-att-class="acct.provider === 'google' ? 'fa fa-google' : 'fa fa-windows'" style="font-size: .85em;"/>
|
||||
<t t-esc="acct.email.split('@')[1].split('.')[0]"/>
|
||||
</span>
|
||||
</t>
|
||||
<button type="button" class="btn btn-sm o_button_icon text-nowrap"
|
||||
t-on-click="onFusionSyncNow"
|
||||
t-att-disabled="fusionSyncing"
|
||||
title="Sync all calendars now"
|
||||
data-tooltip="Sync all calendars now"
|
||||
data-tooltip-position="bottom">
|
||||
<i t-att-class="fusionSyncing ? 'fa fa-refresh fa-spin' : 'fa fa-refresh'"/>
|
||||
</button>
|
||||
</t>
|
||||
<a t-att-href="'/my/schedule'"
|
||||
class="btn btn-sm o_button_icon text-nowrap"
|
||||
title="Manage connected calendars"
|
||||
data-tooltip="Manage connected calendars"
|
||||
data-tooltip-position="bottom">
|
||||
<i class="fa fa-cog"/>
|
||||
</a>
|
||||
</div>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
1
fusion_schedule/utils/__init__.py
Normal file
1
fusion_schedule/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
69
fusion_schedule/views/fusion_calendar_account_views.xml
Normal file
69
fusion_schedule/views/fusion_calendar_account_views.xml
Normal file
@@ -0,0 +1,69 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Tree View -->
|
||||
<record id="fusion_calendar_account_view_tree" model="ir.ui.view">
|
||||
<field name="name">fusion.calendar.account.tree</field>
|
||||
<field name="model">fusion.calendar.account</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="x_fc_user_id"/>
|
||||
<field name="x_fc_provider"/>
|
||||
<field name="x_fc_email"/>
|
||||
<field name="x_fc_sync_status" widget="badge"
|
||||
decoration-success="x_fc_sync_status == 'active'"
|
||||
decoration-danger="x_fc_sync_status == 'error'"
|
||||
decoration-warning="x_fc_sync_status == 'paused'"/>
|
||||
<field name="x_fc_last_sync"/>
|
||||
<field name="x_fc_active"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="fusion_calendar_account_view_form" model="ir.ui.view">
|
||||
<field name="name">fusion.calendar.account.form</field>
|
||||
<field name="model">fusion.calendar.account</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<field name="x_fc_sync_status" widget="statusbar"
|
||||
statusbar_visible="active,error,paused"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="x_fc_name" readonly="True"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="x_fc_user_id"/>
|
||||
<field name="x_fc_provider"/>
|
||||
<field name="x_fc_email"/>
|
||||
<field name="x_fc_calendar_id"/>
|
||||
<field name="x_fc_active"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_last_sync"/>
|
||||
<field name="x_fc_error_message" invisible="x_fc_sync_status != 'error'"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_fusion_calendar_account" model="ir.actions.act_window">
|
||||
<field name="name">Connected Calendar Accounts</field>
|
||||
<field name="res_model">fusion.calendar.account</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu under Settings > Technical -->
|
||||
<menuitem id="menu_fusion_calendar_account"
|
||||
name="Calendar Accounts"
|
||||
parent="base.menu_custom"
|
||||
action="action_fusion_calendar_account"
|
||||
sequence="99"/>
|
||||
|
||||
</odoo>
|
||||
832
fusion_schedule/views/portal_schedule.xml
Normal file
832
fusion_schedule/views/portal_schedule.xml
Normal file
@@ -0,0 +1,832 @@
|
||||
<?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>
|
||||
<t t-if="request.params.get('error')">
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-exclamation-circle me-2"/><t t-out="request.params.get('error')"/>
|
||||
<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 d-none d-md-block">View your appointments and book new ones</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap align-items-center">
|
||||
<button class="btn btn-outline-info px-3 py-2"
|
||||
id="btnOptimizeSchedule"
|
||||
style="font-size: 15px;"
|
||||
title="AI-powered schedule optimization">
|
||||
<i class="fa fa-magic me-1"/>
|
||||
<span class="d-none d-md-inline">Optimize</span>
|
||||
</button>
|
||||
<t t-if="public_booking_url">
|
||||
<button class="btn btn-outline-secondary px-3 py-2 js-share-booking"
|
||||
t-att-data-url="public_booking_url"
|
||||
style="font-size: 15px;"
|
||||
title="Copy public booking link">
|
||||
<i class="fa fa-share-alt me-1"/>
|
||||
<span class="d-none d-md-inline">Share Calendar</span>
|
||||
</button>
|
||||
</t>
|
||||
<a href="/my/schedule/book" class="btn btn-primary px-3 py-2"
|
||||
style="font-size: 15px;">
|
||||
<i class="fa fa-plus me-1"/>
|
||||
<span class="d-none d-sm-inline">Book Appointment</span>
|
||||
<span class="d-sm-none">Book</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connected Calendars (Collapsible) -->
|
||||
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
|
||||
<div class="card-header bg-white border-bottom-0 py-2 px-3 px-md-4"
|
||||
style="border-radius: 12px; cursor: pointer;"
|
||||
data-bs-toggle="collapse" data-bs-target="#calendarAccountsBody"
|
||||
aria-expanded="false" aria-controls="calendarAccountsBody">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<h6 class="mb-0"><i class="fa fa-link me-2 text-info"/>Connected Calendars</h6>
|
||||
<!-- Compact status badges -->
|
||||
<t t-if="calendar_accounts">
|
||||
<t t-foreach="calendar_accounts" t-as="account">
|
||||
<span class="d-none d-sm-inline-block"
|
||||
style="font-size: 11px; border-radius: 8px; padding: 4px 10px; color: #fff; display: inline-block;"
|
||||
t-att-style="'font-size: 11px; border-radius: 8px; padding: 4px 10px; color: #fff; background-color: ' + ('var(--bs-success)' if account.x_fc_sync_status == 'active' else ('var(--bs-danger)' if account.x_fc_sync_status == 'error' else 'var(--bs-warning)'))"
|
||||
t-att-title="account.x_fc_email">
|
||||
<i t-att-class="'fa fa-google' if account.x_fc_provider == 'google' else 'fa fa-windows'" style="font-size: 10px;"/>
|
||||
<span style="margin-left: 5px;"><t t-out="account.x_fc_email.split('@')[0][:12]"/></span>
|
||||
</span>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span style="font-size: 11px; border-radius: 8px; padding: 4px 10px; color: #fff; background-color: var(--bs-secondary);">None</span>
|
||||
</t>
|
||||
</div>
|
||||
<i class="fa fa-chevron-down text-muted" style="font-size: 12px; transition: transform 0.2s;"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse" id="calendarAccountsBody">
|
||||
<div class="card-body px-3 px-md-4 pb-3 pt-0">
|
||||
<t t-if="calendar_accounts">
|
||||
<t t-foreach="calendar_accounts" t-as="account">
|
||||
<div class="d-flex justify-content-between align-items-center py-2 border-bottom">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-2">
|
||||
<t t-if="account.x_fc_provider == 'google'">
|
||||
<i class="fa fa-google text-danger" style="font-size: 18px;"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<i class="fa fa-windows text-primary" style="font-size: 18px;"/>
|
||||
</t>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold" style="font-size: 14px;">
|
||||
<t t-out="account.x_fc_email"/>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
<t t-if="account.x_fc_last_sync">
|
||||
Synced <t t-out="account.x_fc_last_sync.astimezone(user_tz).strftime('%b %d, %I:%M %p')"/>
|
||||
</t>
|
||||
<t t-else="">Never synced</t>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<t t-if="account.x_fc_sync_status == 'active'">
|
||||
<span class="badge text-bg-success">Active</span>
|
||||
</t>
|
||||
<t t-elif="account.x_fc_sync_status == 'error'">
|
||||
<span class="badge text-bg-danger" t-att-title="account.x_fc_error_message or ''">Error</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="badge text-bg-warning">Paused</span>
|
||||
</t>
|
||||
<button class="btn btn-sm btn-outline-secondary js-sync-account"
|
||||
t-att-data-account-id="account.id" title="Sync Now">
|
||||
<i class="fa fa-refresh"/>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger js-disconnect-account"
|
||||
t-att-data-account-id="account.id"
|
||||
t-att-data-account-email="account.x_fc_email" title="Disconnect">
|
||||
<i class="fa fa-times"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<div class="d-flex gap-2 mt-3 flex-wrap">
|
||||
<a href="/my/schedule/connect/google" class="btn btn-sm btn-outline-danger">
|
||||
<i class="fa fa-google me-1"/> Connect Google
|
||||
</a>
|
||||
<a href="/my/schedule/connect/microsoft" class="btn btn-sm btn-outline-primary">
|
||||
<i class="fa fa-windows me-1"/> Connect Outlook
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Share Appointment Link -->
|
||||
<t t-if="public_booking_url">
|
||||
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
|
||||
<div class="card-body px-3 px-md-4 py-3">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2">
|
||||
<div>
|
||||
<h6 class="mb-0"><i class="fa fa-share-alt me-2 text-primary"/>Share Booking Link</h6>
|
||||
<small class="text-muted">Share this link with clients so they can book your available time</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2 w-100 w-md-auto" style="max-width: 450px;">
|
||||
<input type="text" class="form-control form-control-sm" t-att-value="public_booking_url"
|
||||
id="shareBookingUrlInput" readonly="readonly" style="font-size: 13px;"/>
|
||||
<button class="btn btn-primary btn-sm px-3 flex-shrink-0 js-share-booking"
|
||||
t-att-data-url="public_booking_url">
|
||||
<i class="fa fa-copy me-1"/><span class="d-none d-sm-inline">Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Schedule Preferences (Collapsible) -->
|
||||
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
|
||||
<div class="card-header bg-white border-bottom-0 py-2 px-3 px-md-4"
|
||||
style="border-radius: 12px; cursor: pointer;"
|
||||
data-bs-toggle="collapse" data-bs-target="#schedulePrefsBody"
|
||||
aria-expanded="false" aria-controls="schedulePrefsBody">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<h6 class="mb-0"><i class="fa fa-sliders me-2 text-secondary"/>Schedule Preferences</h6>
|
||||
<span style="font-size: 11px; border-radius: 8px; padding: 4px 10px; color: #fff; background-color: var(--bs-secondary);">
|
||||
<t t-out="'%d:%02d' % (int(user_prefs.get('work_start', 9)), int((user_prefs.get('work_start', 9) % 1) * 60))"/> -
|
||||
<t t-out="'%d:%02d' % (int(user_prefs.get('work_end', 17)), int((user_prefs.get('work_end', 17) % 1) * 60))"/>
|
||||
</span>
|
||||
</div>
|
||||
<i class="fa fa-chevron-down text-muted" style="font-size: 12px; transition: transform 0.2s;"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse" id="schedulePrefsBody">
|
||||
<div class="card-body px-3 px-md-4 pb-3 pt-2">
|
||||
<form id="schedulePrefsForm">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold" style="font-size: 13px;">Work Day Start</label>
|
||||
<input type="time" class="form-control form-control-sm" name="work_start"
|
||||
t-att-value="'%02d:%02d' % (int(user_prefs.get('work_start', 9)), int((user_prefs.get('work_start', 9) % 1) * 60))"/>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold" style="font-size: 13px;">Work Day End</label>
|
||||
<input type="time" class="form-control form-control-sm" name="work_end"
|
||||
t-att-value="'%02d:%02d' % (int(user_prefs.get('work_end', 17)), int((user_prefs.get('work_end', 17) % 1) * 60))"/>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold" style="font-size: 13px;">Break Start</label>
|
||||
<input type="time" class="form-control form-control-sm" name="break_start"
|
||||
t-att-value="'%02d:%02d' % (int(user_prefs.get('break_start', 12)), int((user_prefs.get('break_start', 12) % 1) * 60))"/>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold" style="font-size: 13px;">Break Duration (min)</label>
|
||||
<input type="number" class="form-control form-control-sm" name="break_duration_min"
|
||||
t-att-value="int(user_prefs.get('break_duration', 0.5) * 60)"
|
||||
min="0" max="120" step="5"/>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold" style="font-size: 13px;">Travel Buffer (min)</label>
|
||||
<input type="number" class="form-control form-control-sm" name="travel_buffer"
|
||||
t-att-value="user_prefs.get('travel_buffer', 30)"
|
||||
min="0" max="120" step="5"/>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold" style="font-size: 13px;">Base/Office Address</label>
|
||||
<input type="text" class="form-control form-control-sm" name="home_address"
|
||||
t-att-value="user_prefs.get('home_address', '')"
|
||||
placeholder="e.g. 123 Main St, Toronto, ON"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button type="button" class="btn btn-primary btn-sm px-3 js-save-prefs" id="btnSavePrefs">
|
||||
<i class="fa fa-save me-1"/> Save Preferences
|
||||
</button>
|
||||
<span class="text-success ms-2" id="prefsSavedMsg" style="display: none; font-size: 13px;">
|
||||
<i class="fa fa-check me-1"/> Saved
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</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-3 px-md-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-3 px-md-4 pb-4 pt-2">
|
||||
<t t-if="today_events">
|
||||
<t t-foreach="today_events" t-as="event">
|
||||
<div class="d-flex justify-content-between align-items-start py-3 border-bottom">
|
||||
<div class="d-flex align-items-start flex-grow-1 min-width-0">
|
||||
<div class="rounded-3 text-center px-2 py-1 me-3 flex-shrink-0"
|
||||
t-attf-style="background: #{portal_gradient}; min-width: 58px;">
|
||||
<div class="text-white fw-bold" style="font-size: 13px;">
|
||||
<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 class="min-width-0">
|
||||
<h6 class="mb-0 text-truncate"><t t-out="event.name"/></h6>
|
||||
<div class="d-flex flex-wrap gap-2 mt-1">
|
||||
<t t-if="event.location">
|
||||
<small class="text-muted text-truncate" style="max-width: 200px;">
|
||||
<i class="fa fa-map-marker me-1"/><t t-out="event.location"/>
|
||||
</small>
|
||||
</t>
|
||||
<small class="text-muted">
|
||||
<t t-out="'%.0f' % (event.duration * 60)"/> min
|
||||
</small>
|
||||
<!-- Source badge -->
|
||||
<t t-set="src" t-value="event_sources.get(event.id, {})"/>
|
||||
<t t-if="src.get('provider') == 'google'">
|
||||
<small class="badge bg-light text-dark border" title="Google Calendar">
|
||||
<i class="fa fa-google text-danger" style="font-size: 10px;"/>
|
||||
<span class="d-none d-lg-inline ms-1"><t t-out="src.get('email','').split('@')[0][:10]"/></span>
|
||||
</small>
|
||||
</t>
|
||||
<t t-elif="src.get('provider') == 'microsoft'">
|
||||
<small class="badge bg-light text-dark border" title="Outlook Calendar">
|
||||
<i class="fa fa-windows text-primary" style="font-size: 10px;"/>
|
||||
<span class="d-none d-lg-inline ms-1"><t t-out="src.get('email','').split('@')[0][:10]"/></span>
|
||||
</small>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<small class="badge bg-light text-dark border">
|
||||
<i class="fa fa-calendar" style="font-size: 10px;"/> Booked
|
||||
</small>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Actions -->
|
||||
<div class="d-flex gap-1 flex-shrink-0 ms-2">
|
||||
<button class="btn btn-sm btn-outline-primary js-reschedule-event"
|
||||
t-att-data-event-id="event.id"
|
||||
t-att-data-event-name="event.name"
|
||||
t-att-data-event-duration="event.duration"
|
||||
title="Reschedule">
|
||||
<i class="fa fa-clock-o"/>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger js-cancel-event"
|
||||
t-att-data-event-id="event.id"
|
||||
t-att-data-event-name="event.name"
|
||||
title="Cancel">
|
||||
<i class="fa fa-trash-o"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</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-3 px-md-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-3 px-md-4 pb-4 pt-2">
|
||||
<t t-if="upcoming_events">
|
||||
<!-- Desktop table view -->
|
||||
<div class="table-responsive d-none d-md-block">
|
||||
<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;">Source</th>
|
||||
<th style="border-top:none;">Duration</th>
|
||||
<th style="border-top:none; width: 80px;"></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 class="text-truncate" style="max-width: 200px;">
|
||||
<t t-out="event.name"/>
|
||||
</td>
|
||||
<td>
|
||||
<t t-if="event.location">
|
||||
<small class="text-truncate d-inline-block" style="max-width: 150px;"><t t-out="event.location"/></small>
|
||||
</t>
|
||||
<t t-else=""><small class="text-muted">-</small></t>
|
||||
</td>
|
||||
<td>
|
||||
<t t-set="src" t-value="event_sources.get(event.id, {})"/>
|
||||
<t t-if="src.get('provider') == 'google'">
|
||||
<span class="badge bg-light text-dark border">
|
||||
<i class="fa fa-google text-danger" style="font-size: 10px;"/>
|
||||
<t t-out="src.get('email','').split('@')[0][:12]"/>
|
||||
</span>
|
||||
</t>
|
||||
<t t-elif="src.get('provider') == 'microsoft'">
|
||||
<span class="badge bg-light text-dark border">
|
||||
<i class="fa fa-windows text-primary" style="font-size: 10px;"/>
|
||||
<t t-out="src.get('email','').split('@')[0][:12]"/>
|
||||
</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="badge bg-light text-dark border">
|
||||
<i class="fa fa-calendar" style="font-size: 10px;"/> Booked
|
||||
</span>
|
||||
</t>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark">
|
||||
<t t-out="'%.0f' % (event.duration * 60)"/> min
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-sm btn-outline-primary js-reschedule-event"
|
||||
t-att-data-event-id="event.id"
|
||||
t-att-data-event-name="event.name"
|
||||
t-att-data-event-duration="event.duration"
|
||||
title="Reschedule">
|
||||
<i class="fa fa-clock-o"/>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger js-cancel-event"
|
||||
t-att-data-event-id="event.id"
|
||||
t-att-data-event-name="event.name"
|
||||
title="Cancel">
|
||||
<i class="fa fa-trash-o"/>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile card view -->
|
||||
<div class="d-md-none">
|
||||
<t t-foreach="upcoming_events" t-as="event">
|
||||
<div class="d-flex justify-content-between align-items-start py-3 border-bottom">
|
||||
<div class="d-flex align-items-start flex-grow-1 min-width-0">
|
||||
<div class="rounded-3 text-center px-2 py-1 me-3 flex-shrink-0"
|
||||
t-attf-style="background: #{portal_gradient}; min-width: 50px;">
|
||||
<div class="text-white fw-bold" style="font-size: 11px;">
|
||||
<t t-out="event.start.astimezone(user_tz).strftime('%b')"/>
|
||||
</div>
|
||||
<div class="text-white fw-bold" style="font-size: 16px;">
|
||||
<t t-out="event.start.astimezone(user_tz).strftime('%d')"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-width-0">
|
||||
<h6 class="mb-0 text-truncate" style="font-size: 14px;"><t t-out="event.name"/></h6>
|
||||
<small class="text-muted">
|
||||
<t t-out="event.start.astimezone(user_tz).strftime('%I:%M %p')"/>
|
||||
<t t-if="event.location">
|
||||
&middot; <t t-out="event.location[:30]"/>
|
||||
</t>
|
||||
</small>
|
||||
<div class="mt-1">
|
||||
<t t-set="src" t-value="event_sources.get(event.id, {})"/>
|
||||
<t t-if="src.get('provider') == 'google'">
|
||||
<span class="badge bg-light text-dark border" style="font-size: 10px;">
|
||||
<i class="fa fa-google text-danger"/>
|
||||
</span>
|
||||
</t>
|
||||
<t t-elif="src.get('provider') == 'microsoft'">
|
||||
<span class="badge bg-light text-dark border" style="font-size: 10px;">
|
||||
<i class="fa fa-windows text-primary"/>
|
||||
</span>
|
||||
</t>
|
||||
<span class="badge bg-light text-dark" style="font-size: 10px;">
|
||||
<t t-out="'%.0f' % (event.duration * 60)"/> min
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-1 flex-shrink-0 ms-2">
|
||||
<button class="btn btn-sm btn-outline-primary js-reschedule-event"
|
||||
t-att-data-event-id="event.id"
|
||||
t-att-data-event-name="event.name"
|
||||
t-att-data-event-duration="event.duration"
|
||||
title="Reschedule">
|
||||
<i class="fa fa-clock-o"/>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger js-cancel-event"
|
||||
t-att-data-event-id="event.id"
|
||||
t-att-data-event-name="event.name"
|
||||
title="Cancel">
|
||||
<i class="fa fa-trash-o"/>
|
||||
</button>
|
||||
</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 upcoming appointments.
|
||||
<a href="/my/schedule/book">Book one now</a>
|
||||
</p>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Modal (reusable) -->
|
||||
<div class="modal fade" id="fusionConfirmModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-sm">
|
||||
<div class="modal-content" style="border-radius: 12px; border: none;">
|
||||
<div class="modal-header border-bottom-0 pb-0">
|
||||
<h5 class="modal-title" id="fusionConfirmTitle">Confirm</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"/>
|
||||
</div>
|
||||
<div class="modal-body pt-2">
|
||||
<p id="fusionConfirmMessage" class="text-muted mb-0"/>
|
||||
</div>
|
||||
<div class="modal-footer border-top-0 pt-0">
|
||||
<button type="button" class="btn btn-light" data-bs-dismiss="modal">No, keep it</button>
|
||||
<button type="button" class="btn btn-danger" id="fusionConfirmOk">
|
||||
<i class="fa fa-check me-1"/>Yes, proceed
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast notification -->
|
||||
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 9999;">
|
||||
<div id="fusionToast" class="toast align-items-center border-0" role="alert"
|
||||
aria-live="assertive" aria-atomic="true" data-bs-delay="4000">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body" id="fusionToastMessage"/>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto"
|
||||
data-bs-dismiss="toast" aria-label="Close"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reschedule Modal -->
|
||||
<div class="modal fade" id="rescheduleModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content" style="border-radius: 12px; border: none;">
|
||||
<div class="modal-header border-bottom-0 pb-0">
|
||||
<h5 class="modal-title"><i class="fa fa-clock-o me-2"/>Reschedule</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"/>
|
||||
</div>
|
||||
<div class="modal-body pt-2">
|
||||
<p class="text-muted mb-3">
|
||||
<span id="rescheduleEventName" class="fw-semibold text-dark"></span>
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">New Date</label>
|
||||
<input type="date" class="form-control" id="rescheduleDate"/>
|
||||
</div>
|
||||
<div id="rescheduleSlotsContainer" style="display: none;">
|
||||
<label class="form-label fw-semibold">Available Time Slots</label>
|
||||
<div id="rescheduleSlotsLoading" class="text-center py-2" style="display: none;">
|
||||
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"/>
|
||||
Loading...
|
||||
</div>
|
||||
<div id="rescheduleSlotsGrid" class="d-flex flex-wrap gap-2 mb-2"></div>
|
||||
<div id="rescheduleNoSlots" class="text-muted py-2" style="display: none;">
|
||||
No slots available for this date.
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" id="rescheduleEventId"/>
|
||||
<input type="hidden" id="rescheduleSlotDatetime"/>
|
||||
<input type="hidden" id="rescheduleEventDuration"/>
|
||||
<t t-if="appointment_types">
|
||||
<input type="hidden" id="rescheduleAppTypeId"
|
||||
t-att-value="appointment_types[0].id"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="modal-footer border-top-0">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="btnConfirmReschedule" disabled="disabled">
|
||||
<i class="fa fa-check me-1"/> Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Optimize Schedule Modal -->
|
||||
<div class="modal fade" id="optimizeModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content" style="border-radius: 12px; border: none;">
|
||||
<div class="modal-header border-bottom-0 pb-0">
|
||||
<h5 class="modal-title"><i class="fa fa-magic me-2 text-info"/>Optimize Schedule</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"/>
|
||||
</div>
|
||||
<div class="modal-body pt-2">
|
||||
<p class="text-muted mb-3">AI analyzes your appointments and travel times to suggest the optimal order.</p>
|
||||
<div id="optimizeLoading" class="text-center py-4">
|
||||
<div class="spinner-border text-info me-2" role="status"/>
|
||||
<div class="mt-2">Analyzing travel routes and schedule...</div>
|
||||
</div>
|
||||
<div id="optimizeResult" style="display: none;">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 bg-light">
|
||||
<div class="card-body py-2 px-3">
|
||||
<small class="text-muted">Current Travel</small>
|
||||
<div class="fw-bold" id="optimizeCurrentTravel">--</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0" style="background-color: #e8f5e9;">
|
||||
<div class="card-body py-2 px-3">
|
||||
<small class="text-muted">Optimized Travel</small>
|
||||
<div class="fw-bold text-success" id="optimizeNewTravel">--</div>
|
||||
<small class="text-success" id="optimizeSavings"></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="optimizeScheduleList"></div>
|
||||
</div>
|
||||
<div id="optimizeError" class="text-danger py-3" style="display: none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer border-top-0">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</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-3 px-md-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-3 px-md-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;">
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<label class="form-label fw-semibold mb-0">
|
||||
<i class="fa fa-calendar me-1"/>Your Week
|
||||
</label>
|
||||
<div id="weekCalendarNav" class="d-flex align-items-center gap-2" style="display: none !important;">
|
||||
<button type="button" id="btnPrevWeek" class="btn btn-sm btn-outline-secondary px-2 py-0"
|
||||
style="font-size: 14px; line-height: 1.6; border-radius: 6px;">
|
||||
<i class="fa fa-chevron-left"/>
|
||||
</button>
|
||||
<span id="weekRangeLabel" class="text-muted fw-semibold" style="font-size: 13px; min-width: 140px; text-align: center;"></span>
|
||||
<button type="button" id="btnNextWeek" class="btn btn-sm btn-outline-secondary px-2 py-0"
|
||||
style="font-size: 14px; line-height: 1.6; border-radius: 6px;">
|
||||
<i class="fa fa-chevron-right"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<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" style="display: none;">
|
||||
<div id="weekCalendarHeader"></div>
|
||||
<div id="weekCalendarBody"></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"/>
|
||||
|
||||
<!-- AI Suggestions -->
|
||||
<div id="aiSuggestSection" class="mt-3" style="display: none;">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<label class="form-label fw-semibold mb-0">
|
||||
<i class="fa fa-magic me-1 text-info"/> AI Suggested Times
|
||||
</label>
|
||||
<button type="button" class="btn btn-outline-info btn-sm px-2 py-0"
|
||||
id="btnAiSuggest" style="font-size: 12px;">
|
||||
<i class="fa fa-refresh me-1"/> Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div id="aiSuggestLoading" class="text-center py-2" style="display: none;">
|
||||
<div class="spinner-border spinner-border-sm text-info me-2" role="status"/>
|
||||
Analyzing schedule...
|
||||
</div>
|
||||
<div id="aiSuggestGrid"></div>
|
||||
</div>
|
||||
</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-3 px-md-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-3 px-md-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="row g-2 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Email</label>
|
||||
<input type="email" name="client_email" class="form-control"
|
||||
placeholder="client@email.com (optional)"/>
|
||||
<small class="text-muted">If provided, a calendar invitation will be sent</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Phone</label>
|
||||
<input type="tel" name="client_phone" class="form-control"
|
||||
placeholder="(optional)"/>
|
||||
</div>
|
||||
</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>
|
||||
<input type="hidden" name="client_lat" id="clientLat" value="0"/>
|
||||
<input type="hidden" name="client_lng" id="clientLng" value="0"/>
|
||||
|
||||
<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-end gap-3">
|
||||
<a href="/my/schedule" class="btn btn-outline-secondary px-3 py-2">
|
||||
<i class="fa fa-arrow-left me-1"/> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary px-4 py-2" 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>
|
||||
function initScheduleAddressAutocomplete() {
|
||||
if (window._scheduleAutocompleteInit) {
|
||||
window._scheduleAutocompleteInit();
|
||||
} else {
|
||||
window._googleMapsReady = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script t-attf-src="https://maps.googleapis.com/maps/api/js?key=#{google_maps_api_key}&libraries=places&callback=initScheduleAddressAutocomplete"
|
||||
defer="defer"></script>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
27
fusion_schedule/views/portal_schedule_tile.xml
Normal file
27
fusion_schedule/views/portal_schedule_tile.xml
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Add "My Schedule" tile inside the existing portal card grid -->
|
||||
<template id="portal_my_home_schedule" name="Portal My Home: Schedule"
|
||||
inherit_id="fusion_authorizer_portal.portal_my_home_authorizer" priority="45">
|
||||
<!-- Navigate up from a known card to the ROW div, then append inside (not inside any t-if) -->
|
||||
<xpath expr="//a[@href='/my/funding-claims']/ancestor::div[hasclass('row') and hasclass('g-3') and hasclass('mb-4')]" position="inside">
|
||||
<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-att-style="'width: 50px; height: 50px; background: ' + (fc_gradient or 'linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%)')">
|
||||
<i class="fa fa-calendar-check-o fa-lg text-white" title="Schedule"/>
|
||||
</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>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
585
fusion_schedule/views/public_booking.xml
Normal file
585
fusion_schedule/views/public_booking.xml
Normal file
@@ -0,0 +1,585 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ==================== PUBLIC BOOKING PAGE ==================== -->
|
||||
|
||||
<template id="public_booking_page" name="Public Booking Page">
|
||||
<t t-call="website.layout">
|
||||
<div class="container py-5" style="max-width: 700px;">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-4">
|
||||
<div class="d-inline-flex align-items-center justify-content-center rounded-circle mb-3"
|
||||
style="width: 64px; height: 64px; background: linear-gradient(135deg, #5ba848, #3a8fb7);">
|
||||
<i class="fa fa-calendar-check-o text-white" style="font-size: 28px;"/>
|
||||
</div>
|
||||
<h2 class="mb-1">Book a Time with <t t-out="staff_user.name"/></h2>
|
||||
<p class="text-muted">Select a date and time that works for you</p>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<t t-if="success">
|
||||
<div class="card border-0 shadow-sm text-center p-5" style="border-radius: 12px;">
|
||||
<div class="mb-3">
|
||||
<i class="fa fa-check-circle text-success" style="font-size: 48px;"/>
|
||||
</div>
|
||||
<h4 class="mb-2">Appointment Booked!</h4>
|
||||
<p class="text-muted mb-0"><t t-out="success"/></p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Error Message -->
|
||||
<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>
|
||||
|
||||
<t t-if="not success">
|
||||
<form t-att-action="'/schedule/%s/book' % booking_slug" method="post" id="publicBookingForm">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
|
||||
<!-- Step 1: 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"
|
||||
style="background: linear-gradient(135deg, #5ba848, #3a8fb7);">1</span>
|
||||
Select Date & Time
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body px-4 pb-4">
|
||||
<!-- Appointment Type -->
|
||||
<t t-if="appointment_types and 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="publicAppointmentType">
|
||||
<t t-foreach="appointment_types" t-as="atype">
|
||||
<option t-att-value="atype.id">
|
||||
<t t-out="atype.name"/>
|
||||
(<t t-out="'%.0f' % (atype.appointment_duration * 60)"/> min)
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="appointment_types">
|
||||
<input type="hidden" name="appointment_type_id"
|
||||
t-att-value="appointment_types[0].id"/>
|
||||
</t>
|
||||
|
||||
<!-- Date -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Select Date</label>
|
||||
<input type="date" class="form-control" id="publicBookingDate"
|
||||
name="date" required="required"
|
||||
t-att-min="today"/>
|
||||
</div>
|
||||
|
||||
<!-- Available Slots -->
|
||||
<div id="publicSlotsContainer" style="display: none;">
|
||||
<label class="form-label fw-semibold">Available Time Slots</label>
|
||||
<div id="publicSlotsLoading" 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="publicSlotsGrid" class="d-flex flex-wrap gap-2 mb-2"></div>
|
||||
<div id="publicNoSlots" class="text-muted py-2" style="display: none;">
|
||||
<i class="fa fa-info-circle me-1"/> No available slots for this date.
|
||||
</div>
|
||||
<input type="hidden" name="slot_datetime" id="publicSlotDatetime"/>
|
||||
<input type="hidden" name="slot_duration" id="publicSlotDuration"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Your 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"
|
||||
style="background: linear-gradient(135deg, #5ba848, #3a8fb7);">2</span>
|
||||
Your Details
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body px-4 pb-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Your Name <span class="text-danger">*</span></label>
|
||||
<input type="text" name="visitor_name" class="form-control"
|
||||
placeholder="Full name" required="required"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Email <span class="text-danger">*</span></label>
|
||||
<input type="email" name="visitor_email" class="form-control"
|
||||
placeholder="your@email.com" required="required"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Phone</label>
|
||||
<input type="tel" name="visitor_phone" class="form-control"
|
||||
placeholder="(optional)"/>
|
||||
</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="publicClientStreet"
|
||||
placeholder="Start typing an address..."/>
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<input type="text" name="client_city" class="form-control"
|
||||
id="publicClientCity" placeholder="City"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="text" name="client_province" class="form-control"
|
||||
id="publicClientProvince" placeholder="Province"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="text" name="client_postal" class="form-control"
|
||||
id="publicClientPostal" placeholder="Postal Code"/>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="client_lat" id="publicClientLat" value="0"/>
|
||||
<input type="hidden" name="client_lng" id="publicClientLng" value="0"/>
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label class="form-label fw-semibold">Notes</label>
|
||||
<textarea name="visitor_notes" class="form-control" rows="3"
|
||||
placeholder="Anything you'd like us to know..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="text-end">
|
||||
<button type="submit" class="btn btn-primary btn-lg px-4" id="publicBtnSubmit"
|
||||
disabled="disabled">
|
||||
<i class="fa fa-calendar-check-o me-1"/> Confirm Booking
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</t>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="text-center mt-4">
|
||||
<small class="text-muted">Powered by Fusion Schedule</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Public Booking JS -->
|
||||
<script type="text/javascript">
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var dateInput = document.getElementById('publicBookingDate');
|
||||
var slotsContainer = document.getElementById('publicSlotsContainer');
|
||||
var slotsGrid = document.getElementById('publicSlotsGrid');
|
||||
var slotsLoading = document.getElementById('publicSlotsLoading');
|
||||
var noSlots = document.getElementById('publicNoSlots');
|
||||
var slotDatetime = document.getElementById('publicSlotDatetime');
|
||||
var slotDuration = document.getElementById('publicSlotDuration');
|
||||
var submitBtn = document.getElementById('publicBtnSubmit');
|
||||
var typeSelect = document.getElementById('publicAppointmentType');
|
||||
var selectedSlotBtn = null;
|
||||
|
||||
var slug = '<t t-out="booking_slug"/>';
|
||||
|
||||
function getTypeId() {
|
||||
if (typeSelect) return typeSelect.value;
|
||||
var hidden = document.querySelector('input[name="appointment_type_id"]');
|
||||
return hidden ? hidden.value : null;
|
||||
}
|
||||
|
||||
function fetchSlots(date) {
|
||||
var typeId = getTypeId();
|
||||
if (!typeId || !date) return;
|
||||
|
||||
slotsContainer.style.display = 'block';
|
||||
slotsLoading.style.display = 'block';
|
||||
slotsGrid.innerHTML = '';
|
||||
noSlots.style.display = 'none';
|
||||
slotDatetime.value = '';
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
selectedSlotBtn = null;
|
||||
|
||||
fetch('/schedule/' + slug + '/available-slots', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'call',
|
||||
params: {
|
||||
selected_date: date,
|
||||
appointment_type_id: parseInt(typeId),
|
||||
},
|
||||
}),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
slotsLoading.style.display = 'none';
|
||||
var result = data.result || {};
|
||||
var slots = result.slots || [];
|
||||
|
||||
if (!slots.length) {
|
||||
noSlots.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
var morningSlots = [];
|
||||
var afternoonSlots = [];
|
||||
slots.forEach(function (s) {
|
||||
var hour = parseInt(s.start_hour);
|
||||
if (isNaN(hour)) {
|
||||
var match = s.start_hour.match(/(\d+)/);
|
||||
hour = match ? parseInt(match[1]) : 0;
|
||||
if (s.start_hour.toLowerCase().indexOf('pm') > -1 && hour !== 12) hour += 12;
|
||||
if (s.start_hour.toLowerCase().indexOf('am') > -1 && hour === 12) hour = 0;
|
||||
}
|
||||
(hour < 12 ? morningSlots : afternoonSlots).push(s);
|
||||
});
|
||||
|
||||
function renderGroup(label, icon, group) {
|
||||
if (!group.length) return;
|
||||
var h = document.createElement('div');
|
||||
h.className = 'w-100 mt-2 mb-1';
|
||||
h.innerHTML = '<small class="text-muted fw-semibold"><i class="fa ' + icon + ' me-1"></i>' + label + '</small>';
|
||||
slotsGrid.appendChild(h);
|
||||
group.forEach(function (s) {
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-outline-primary btn-sm';
|
||||
btn.style.cssText = 'min-width: 100px; border-radius: 8px; padding: 8px 14px;';
|
||||
btn.textContent = s.start_hour;
|
||||
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;
|
||||
slotDatetime.value = s.datetime;
|
||||
slotDuration.value = s.duration;
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
});
|
||||
slotsGrid.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
renderGroup('Morning', 'fa-sun-o', morningSlots);
|
||||
renderGroup('Afternoon', 'fa-cloud', afternoonSlots);
|
||||
})
|
||||
.catch(function () {
|
||||
slotsLoading.style.display = 'none';
|
||||
noSlots.textContent = 'Failed to load slots. Please try again.';
|
||||
noSlots.style.display = 'block';
|
||||
});
|
||||
}
|
||||
|
||||
if (dateInput) {
|
||||
dateInput.addEventListener('change', function () { fetchSlots(this.value); });
|
||||
}
|
||||
if (typeSelect) {
|
||||
typeSelect.addEventListener('change', function () {
|
||||
if (dateInput && dateInput.value) fetchSlots(dateInput.value);
|
||||
});
|
||||
}
|
||||
|
||||
var form = document.getElementById('publicBookingForm');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function (e) {
|
||||
if (!slotDatetime.value) { e.preventDefault(); alert('Please select a time slot.'); return; }
|
||||
if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Booking...'; }
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<t t-if="google_maps_api_key">
|
||||
<script>
|
||||
function initPublicAddressAutocomplete() {
|
||||
var streetInput = document.getElementById('publicClientStreet');
|
||||
if (!streetInput || typeof google === 'undefined') return;
|
||||
var autocomplete = new google.maps.places.Autocomplete(streetInput, {
|
||||
types: ['address'],
|
||||
componentRestrictions: { country: 'ca' },
|
||||
fields: ['address_components', 'geometry'],
|
||||
});
|
||||
autocomplete.addListener('place_changed', function () {
|
||||
var place = autocomplete.getPlace();
|
||||
if (!place.address_components) return;
|
||||
var streetNumber = '', streetName = '', city = '', province = '', postalCode = '';
|
||||
place.address_components.forEach(function (c) {
|
||||
var t = c.types;
|
||||
if (t.indexOf('street_number') > -1) streetNumber = c.long_name;
|
||||
if (t.indexOf('route') > -1) streetName = c.long_name;
|
||||
if (t.indexOf('locality') > -1) city = c.long_name;
|
||||
if (t.indexOf('administrative_area_level_1') > -1) province = c.short_name;
|
||||
if (t.indexOf('postal_code') > -1) postalCode = c.long_name;
|
||||
});
|
||||
streetInput.value = (streetNumber + ' ' + streetName).trim();
|
||||
var ci = document.getElementById('publicClientCity');
|
||||
if (ci) ci.value = city;
|
||||
var pr = document.getElementById('publicClientProvince');
|
||||
if (pr) pr.value = province;
|
||||
var po = document.getElementById('publicClientPostal');
|
||||
if (po) po.value = postalCode;
|
||||
if (place.geometry && place.geometry.location) {
|
||||
var la = document.getElementById('publicClientLat');
|
||||
var ln = document.getElementById('publicClientLng');
|
||||
if (la) la.value = place.geometry.location.lat();
|
||||
if (ln) ln.value = place.geometry.location.lng();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script t-attf-src="https://maps.googleapis.com/maps/api/js?key=#{google_maps_api_key}&libraries=places&callback=initPublicAddressAutocomplete"
|
||||
async="async" defer="defer"></script>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ==================== PUBLIC MANAGE PAGE ==================== -->
|
||||
|
||||
<template id="public_manage_page" name="Manage Your Appointment">
|
||||
<t t-call="website.layout">
|
||||
<div class="container py-5" style="max-width: 600px;">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-4">
|
||||
<div class="d-inline-flex align-items-center justify-content-center rounded-circle mb-3"
|
||||
style="width: 64px; height: 64px; background: linear-gradient(135deg, #5ba848, #3a8fb7);">
|
||||
<i class="fa fa-calendar-check-o text-white" style="font-size: 28px;"/>
|
||||
</div>
|
||||
<h2 class="mb-1">Your Appointment</h2>
|
||||
<p class="text-muted">Manage your booking below</p>
|
||||
</div>
|
||||
|
||||
<!-- Cancelled state -->
|
||||
<t t-if="cancelled">
|
||||
<div class="card border-0 shadow-sm text-center p-5" style="border-radius: 12px;">
|
||||
<div class="mb-3">
|
||||
<i class="fa fa-times-circle text-danger" style="font-size: 48px;"/>
|
||||
</div>
|
||||
<h4 class="mb-2">Appointment Cancelled</h4>
|
||||
<p class="text-muted mb-0">Your appointment has been cancelled.</p>
|
||||
<t t-if="booking_slug">
|
||||
<a t-attf-href="/schedule/#{booking_slug}" class="btn btn-primary mt-3">Book a New Appointment</a>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Rescheduled state -->
|
||||
<t t-elif="rescheduled">
|
||||
<div class="card border-0 shadow-sm text-center p-5" style="border-radius: 12px;">
|
||||
<div class="mb-3">
|
||||
<i class="fa fa-check-circle text-success" style="font-size: 48px;"/>
|
||||
</div>
|
||||
<h4 class="mb-2">Appointment Rescheduled</h4>
|
||||
<p class="text-muted mb-3">Your appointment has been moved to the new time.</p>
|
||||
<t t-if="event">
|
||||
<div class="bg-light rounded-3 p-3 d-inline-block mx-auto">
|
||||
<strong><t t-out="event.start.astimezone(user_tz).strftime('%A, %B %d, %Y')"/></strong>
|
||||
<br/>
|
||||
<t t-out="event.start.astimezone(user_tz).strftime('%I:%M %p')"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Active appointment -->
|
||||
<t t-elif="event">
|
||||
<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>
|
||||
|
||||
<!-- Appointment details card -->
|
||||
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="mb-3"><t t-out="event.name"/></h5>
|
||||
<div class="row g-3">
|
||||
<div class="col-6">
|
||||
<small class="text-muted d-block">Date</small>
|
||||
<strong><t t-out="event.start.astimezone(user_tz).strftime('%A, %b %d, %Y')"/></strong>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<small class="text-muted d-block">Time</small>
|
||||
<strong><t t-out="event.start.astimezone(user_tz).strftime('%I:%M %p')"/></strong>
|
||||
</div>
|
||||
<t t-if="event.location">
|
||||
<div class="col-12">
|
||||
<small class="text-muted d-block">Location</small>
|
||||
<span><t t-out="event.location"/></span>
|
||||
</div>
|
||||
</t>
|
||||
<div class="col-6">
|
||||
<small class="text-muted d-block">Duration</small>
|
||||
<span><t t-out="'%.0f' % (event.duration * 60)"/> minutes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reschedule section -->
|
||||
<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; cursor: pointer;"
|
||||
data-bs-toggle="collapse" data-bs-target="#rescheduleSection"
|
||||
aria-expanded="false">
|
||||
<h6 class="mb-0">
|
||||
<i class="fa fa-clock-o me-2 text-primary"/>Reschedule
|
||||
<i class="fa fa-chevron-down float-end text-muted" style="font-size: 12px;"/>
|
||||
</h6>
|
||||
</div>
|
||||
<div class="collapse" id="rescheduleSection">
|
||||
<div class="card-body px-4 pb-4">
|
||||
<form t-attf-action="/schedule/manage/#{token}/reschedule" method="post"
|
||||
id="publicRescheduleForm">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">New Date</label>
|
||||
<input type="date" class="form-control" id="publicRescheduleDate"
|
||||
required="required"/>
|
||||
</div>
|
||||
<div id="publicRescheduleSlotsContainer" style="display: none;">
|
||||
<label class="form-label fw-semibold">Available Slots</label>
|
||||
<div id="publicRescheduleSlotsLoading" class="text-center py-2"
|
||||
style="display: none;">
|
||||
<div class="spinner-border spinner-border-sm text-primary me-2"
|
||||
role="status"/>
|
||||
Loading...
|
||||
</div>
|
||||
<div id="publicRescheduleSlotsGrid"
|
||||
class="d-flex flex-wrap gap-2 mb-2"></div>
|
||||
<div id="publicRescheduleNoSlots" class="text-muted py-2"
|
||||
style="display: none;">
|
||||
No slots available for this date.
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="slot_datetime"
|
||||
id="publicRescheduleSlotDatetime"/>
|
||||
<button type="submit" class="btn btn-primary mt-2"
|
||||
id="publicRescheduleSubmit" disabled="disabled">
|
||||
<i class="fa fa-check me-1"/> Confirm New Time
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancel section -->
|
||||
<div class="card border-0 shadow-sm" style="border-radius: 12px;">
|
||||
<div class="card-body p-4">
|
||||
<form t-attf-action="/schedule/manage/#{token}/cancel" method="post"
|
||||
id="publicCancelForm"
|
||||
onsubmit="return confirm('Are you sure you want to cancel this appointment?');">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<button type="submit" class="btn btn-outline-danger w-100">
|
||||
<i class="fa fa-times me-1"/> Cancel Appointment
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Event not found (already cancelled) -->
|
||||
<t t-else="">
|
||||
<div class="card border-0 shadow-sm text-center p-5" style="border-radius: 12px;">
|
||||
<div class="mb-3">
|
||||
<i class="fa fa-calendar-times-o text-muted" style="font-size: 48px;"/>
|
||||
</div>
|
||||
<h4 class="mb-2">Appointment Not Found</h4>
|
||||
<p class="text-muted mb-0">This appointment may have been cancelled or the link is invalid.</p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<small class="text-muted">Powered by Fusion Schedule</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Public Reschedule JS -->
|
||||
<t t-if="event and not cancelled and not rescheduled">
|
||||
<script type="text/javascript">
|
||||
(function () {
|
||||
'use strict';
|
||||
var token = '<t t-out="token"/>';
|
||||
var dateInput = document.getElementById('publicRescheduleDate');
|
||||
var container = document.getElementById('publicRescheduleSlotsContainer');
|
||||
var grid = document.getElementById('publicRescheduleSlotsGrid');
|
||||
var loading = document.getElementById('publicRescheduleSlotsLoading');
|
||||
var noSlots = document.getElementById('publicRescheduleNoSlots');
|
||||
var slotInput = document.getElementById('publicRescheduleSlotDatetime');
|
||||
var submitBtn = document.getElementById('publicRescheduleSubmit');
|
||||
var selectedBtn = null;
|
||||
|
||||
if (!dateInput) return;
|
||||
|
||||
dateInput.addEventListener('change', function () {
|
||||
var date = this.value;
|
||||
if (!date) return;
|
||||
container.style.display = 'block';
|
||||
loading.style.display = 'block';
|
||||
grid.innerHTML = '';
|
||||
noSlots.style.display = 'none';
|
||||
slotInput.value = '';
|
||||
submitBtn.disabled = true;
|
||||
selectedBtn = null;
|
||||
|
||||
fetch('/schedule/manage/' + token + '/available-slots', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0', method: 'call',
|
||||
params: { selected_date: date },
|
||||
}),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
loading.style.display = 'none';
|
||||
var slots = (data.result || {}).slots || [];
|
||||
if (!slots.length) { noSlots.style.display = 'block'; return; }
|
||||
slots.forEach(function (s) {
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-outline-primary btn-sm';
|
||||
btn.style.cssText = 'min-width: 90px; border-radius: 8px; padding: 8px 12px;';
|
||||
btn.textContent = s.start_hour;
|
||||
btn.addEventListener('click', function () {
|
||||
if (selectedBtn) {
|
||||
selectedBtn.classList.remove('btn-primary');
|
||||
selectedBtn.classList.add('btn-outline-primary');
|
||||
}
|
||||
btn.classList.remove('btn-outline-primary');
|
||||
btn.classList.add('btn-primary');
|
||||
selectedBtn = btn;
|
||||
slotInput.value = s.datetime;
|
||||
submitBtn.disabled = false;
|
||||
});
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
loading.style.display = 'none';
|
||||
noSlots.textContent = 'Failed to load slots.';
|
||||
noSlots.style.display = 'block';
|
||||
});
|
||||
});
|
||||
|
||||
var form = document.getElementById('publicRescheduleForm');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function (e) {
|
||||
if (!slotInput.value) { e.preventDefault(); alert('Please select a time slot.'); }
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
161
fusion_schedule/views/res_config_settings_views.xml
Normal file
161
fusion_schedule/views/res_config_settings_views.xml
Normal file
@@ -0,0 +1,161 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="res_config_settings_view_form_fusion_schedule" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.fusion.schedule</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||
<field name="priority">90</field>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//form" position="inside">
|
||||
<app data-string="Fusion Schedule" string="Fusion Schedule" name="fusion_schedule"
|
||||
logo="/fusion_schedule/static/description/icon.png">
|
||||
|
||||
<!-- ===== CALENDAR SYNC ===== -->
|
||||
<h2>Calendar Sync</h2>
|
||||
|
||||
<div class="row mt-4 o_settings_container">
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="o_form_label">Sync Interval</span>
|
||||
<div class="text-muted">
|
||||
How often connected calendars are synchronised automatically
|
||||
</div>
|
||||
<div class="mt-2 row">
|
||||
<div class="col-4">
|
||||
<field name="x_fc_sync_interval_minutes"/>
|
||||
</div>
|
||||
<div class="col-8 pt-2 text-muted">minutes (default: 5)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== GOOGLE CALENDAR ===== -->
|
||||
<h2>Google Calendar</h2>
|
||||
|
||||
<div class="row mt-4 o_settings_container">
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="o_form_label">Google OAuth Credentials</span>
|
||||
<div class="text-muted">
|
||||
Required to connect Google Calendar accounts.
|
||||
Get these from Google Cloud Console.
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<div class="row mb-2">
|
||||
<label for="x_fc_google_client_id" class="col-5 col-form-label">Client ID</label>
|
||||
<div class="col-7">
|
||||
<field name="x_fc_google_client_id"
|
||||
placeholder="Leave empty to use Odoo default"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="x_fc_google_client_secret" class="col-5 col-form-label">Client Secret</label>
|
||||
<div class="col-7">
|
||||
<field name="x_fc_google_client_secret" password="True"
|
||||
placeholder="Leave empty to use Odoo default"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<field name="x_fc_google_has_fallback" invisible="True"/>
|
||||
<div class="alert alert-info mt-2 py-2 px-3" role="alert"
|
||||
invisible="not x_fc_google_has_fallback">
|
||||
<i class="fa fa-info-circle me-1"/>
|
||||
Using Odoo's default Google credentials as fallback
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== MICROSOFT OUTLOOK ===== -->
|
||||
<h2>Microsoft Outlook</h2>
|
||||
|
||||
<div class="row mt-4 o_settings_container">
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="o_form_label">Microsoft OAuth Credentials</span>
|
||||
<div class="text-muted">
|
||||
Required to connect Outlook / Microsoft 365 accounts.
|
||||
Get these from Azure Portal.
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<div class="row mb-2">
|
||||
<label for="x_fc_microsoft_client_id" class="col-5 col-form-label">Client ID</label>
|
||||
<div class="col-7">
|
||||
<field name="x_fc_microsoft_client_id"
|
||||
placeholder="Leave empty to use Odoo default"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="x_fc_microsoft_client_secret" class="col-5 col-form-label">Client Secret</label>
|
||||
<div class="col-7">
|
||||
<field name="x_fc_microsoft_client_secret" password="True"
|
||||
placeholder="Leave empty to use Odoo default"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<field name="x_fc_microsoft_has_fallback" invisible="True"/>
|
||||
<div class="alert alert-info mt-2 py-2 px-3" role="alert"
|
||||
invisible="not x_fc_microsoft_has_fallback">
|
||||
<i class="fa fa-info-circle me-1"/>
|
||||
Using Odoo's default Microsoft credentials as fallback
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== SCHEDULE DEFAULTS ===== -->
|
||||
<h2>Schedule Defaults</h2>
|
||||
|
||||
<div class="row mt-4 o_settings_container">
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="o_form_label">Work Hours</span>
|
||||
<div class="text-muted">
|
||||
Default work day start and end times for staff scheduling
|
||||
</div>
|
||||
<div class="mt-2 d-flex align-items-center gap-2">
|
||||
<field name="x_fc_default_work_start" widget="float_time" style="max-width: 90px;"/>
|
||||
<span class="text-muted">to</span>
|
||||
<field name="x_fc_default_work_end" widget="float_time" style="max-width: 90px;"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="o_form_label">Break / Lunch</span>
|
||||
<div class="text-muted">
|
||||
Default fixed break time for staff
|
||||
</div>
|
||||
<div class="mt-2 d-flex align-items-center gap-2">
|
||||
<field name="x_fc_default_break_start" widget="float_time" style="max-width: 90px;"/>
|
||||
<span class="text-muted">for</span>
|
||||
<field name="x_fc_default_break_duration" widget="float_time" style="max-width: 90px;"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="o_form_label">Travel Buffer</span>
|
||||
<div class="text-muted">
|
||||
Minimum travel time buffer between consecutive appointments
|
||||
</div>
|
||||
<div class="mt-2 row">
|
||||
<div class="col-4">
|
||||
<field name="x_fc_default_travel_buffer"/>
|
||||
</div>
|
||||
<div class="col-8 pt-2 text-muted">minutes (default: 30)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</app>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user