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