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

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import controllers
from . import models

View 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,
}

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import portal_schedule

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,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>

View 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;">
&#10003; 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;">&#128197;</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'}"/>
&#8211;
<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;">&#128205;</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;">&#128100;</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;">&#160;</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>

View 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

View 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

File diff suppressed because it is too large Load Diff

View 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.',
)

View 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)

View 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)

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_calendar_account_user fusion.calendar.account.user model_fusion_calendar_account base.group_user 1 1 1 1
3 access_fusion_calendar_account_public fusion.calendar.account.public model_fusion_calendar_account base.group_public 0 0 0 0
4 access_fusion_calendar_event_link_user fusion.calendar.event.link.user model_fusion_calendar_event_link base.group_user 1 1 1 0
5 access_fusion_calendar_event_link_system fusion.calendar.event.link.system model_fusion_calendar_event_link base.group_system 1 1 1 1

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View 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;
}

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

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

View File

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

View File

@@ -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>

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View 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>

View 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">
&amp;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 &amp; 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}&amp;libraries=places&amp;callback=initScheduleAddressAutocomplete"
defer="defer"></script>
</t>
</t>
</template>
</odoo>

View 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>

View 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 &amp; 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 &amp;&amp; hour !== 12) hour += 12;
if (s.start_hour.toLowerCase().indexOf('am') > -1 &amp;&amp; hour === 12) hour = 0;
}
(hour &lt; 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 = '&lt;small class="text-muted fw-semibold">&lt;i class="fa ' + icon + ' me-1">&lt;/i>' + label + '&lt;/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 &amp;&amp; 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 = '&lt;i class="fa fa-spinner fa-spin me-1">&lt;/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 &amp;&amp; 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}&amp;libraries=places&amp;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>

View 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>