update
This commit is contained in:
69
fusion_schedule/views/fusion_calendar_account_views.xml
Normal file
69
fusion_schedule/views/fusion_calendar_account_views.xml
Normal file
@@ -0,0 +1,69 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Tree View -->
|
||||
<record id="fusion_calendar_account_view_tree" model="ir.ui.view">
|
||||
<field name="name">fusion.calendar.account.tree</field>
|
||||
<field name="model">fusion.calendar.account</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="x_fc_user_id"/>
|
||||
<field name="x_fc_provider"/>
|
||||
<field name="x_fc_email"/>
|
||||
<field name="x_fc_sync_status" widget="badge"
|
||||
decoration-success="x_fc_sync_status == 'active'"
|
||||
decoration-danger="x_fc_sync_status == 'error'"
|
||||
decoration-warning="x_fc_sync_status == 'paused'"/>
|
||||
<field name="x_fc_last_sync"/>
|
||||
<field name="x_fc_active"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="fusion_calendar_account_view_form" model="ir.ui.view">
|
||||
<field name="name">fusion.calendar.account.form</field>
|
||||
<field name="model">fusion.calendar.account</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<field name="x_fc_sync_status" widget="statusbar"
|
||||
statusbar_visible="active,error,paused"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="x_fc_name" readonly="True"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="x_fc_user_id"/>
|
||||
<field name="x_fc_provider"/>
|
||||
<field name="x_fc_email"/>
|
||||
<field name="x_fc_calendar_id"/>
|
||||
<field name="x_fc_active"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_last_sync"/>
|
||||
<field name="x_fc_error_message" invisible="x_fc_sync_status != 'error'"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_fusion_calendar_account" model="ir.actions.act_window">
|
||||
<field name="name">Connected Calendar Accounts</field>
|
||||
<field name="res_model">fusion.calendar.account</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu under Settings > Technical -->
|
||||
<menuitem id="menu_fusion_calendar_account"
|
||||
name="Calendar Accounts"
|
||||
parent="base.menu_custom"
|
||||
action="action_fusion_calendar_account"
|
||||
sequence="99"/>
|
||||
|
||||
</odoo>
|
||||
832
fusion_schedule/views/portal_schedule.xml
Normal file
832
fusion_schedule/views/portal_schedule.xml
Normal file
@@ -0,0 +1,832 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ==================== SCHEDULE OVERVIEW PAGE ==================== -->
|
||||
|
||||
<template id="portal_schedule_page" name="My Schedule">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
|
||||
<div class="container py-4">
|
||||
<!-- Success/Error Messages -->
|
||||
<t t-if="request.params.get('success')">
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-check-circle me-2"/><t t-out="request.params.get('success')"/>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="request.params.get('error')">
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-exclamation-circle me-2"/><t t-out="request.params.get('error')"/>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<h3 class="mb-1"><i class="fa fa-calendar-check-o me-2"/>My Schedule</h3>
|
||||
<p class="text-muted mb-0 d-none d-md-block">View your appointments and book new ones</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap align-items-center">
|
||||
<button class="btn btn-outline-info px-3 py-2"
|
||||
id="btnOptimizeSchedule"
|
||||
style="font-size: 15px;"
|
||||
title="AI-powered schedule optimization">
|
||||
<i class="fa fa-magic me-1"/>
|
||||
<span class="d-none d-md-inline">Optimize</span>
|
||||
</button>
|
||||
<t t-if="public_booking_url">
|
||||
<button class="btn btn-outline-secondary px-3 py-2 js-share-booking"
|
||||
t-att-data-url="public_booking_url"
|
||||
style="font-size: 15px;"
|
||||
title="Copy public booking link">
|
||||
<i class="fa fa-share-alt me-1"/>
|
||||
<span class="d-none d-md-inline">Share Calendar</span>
|
||||
</button>
|
||||
</t>
|
||||
<a href="/my/schedule/book" class="btn btn-primary px-3 py-2"
|
||||
style="font-size: 15px;">
|
||||
<i class="fa fa-plus me-1"/>
|
||||
<span class="d-none d-sm-inline">Book Appointment</span>
|
||||
<span class="d-sm-none">Book</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connected Calendars (Collapsible) -->
|
||||
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
|
||||
<div class="card-header bg-white border-bottom-0 py-2 px-3 px-md-4"
|
||||
style="border-radius: 12px; cursor: pointer;"
|
||||
data-bs-toggle="collapse" data-bs-target="#calendarAccountsBody"
|
||||
aria-expanded="false" aria-controls="calendarAccountsBody">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<h6 class="mb-0"><i class="fa fa-link me-2 text-info"/>Connected Calendars</h6>
|
||||
<!-- Compact status badges -->
|
||||
<t t-if="calendar_accounts">
|
||||
<t t-foreach="calendar_accounts" t-as="account">
|
||||
<span class="d-none d-sm-inline-block"
|
||||
style="font-size: 11px; border-radius: 8px; padding: 4px 10px; color: #fff; display: inline-block;"
|
||||
t-att-style="'font-size: 11px; border-radius: 8px; padding: 4px 10px; color: #fff; background-color: ' + ('var(--bs-success)' if account.x_fc_sync_status == 'active' else ('var(--bs-danger)' if account.x_fc_sync_status == 'error' else 'var(--bs-warning)'))"
|
||||
t-att-title="account.x_fc_email">
|
||||
<i t-att-class="'fa fa-google' if account.x_fc_provider == 'google' else 'fa fa-windows'" style="font-size: 10px;"/>
|
||||
<span style="margin-left: 5px;"><t t-out="account.x_fc_email.split('@')[0][:12]"/></span>
|
||||
</span>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span style="font-size: 11px; border-radius: 8px; padding: 4px 10px; color: #fff; background-color: var(--bs-secondary);">None</span>
|
||||
</t>
|
||||
</div>
|
||||
<i class="fa fa-chevron-down text-muted" style="font-size: 12px; transition: transform 0.2s;"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse" id="calendarAccountsBody">
|
||||
<div class="card-body px-3 px-md-4 pb-3 pt-0">
|
||||
<t t-if="calendar_accounts">
|
||||
<t t-foreach="calendar_accounts" t-as="account">
|
||||
<div class="d-flex justify-content-between align-items-center py-2 border-bottom">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-2">
|
||||
<t t-if="account.x_fc_provider == 'google'">
|
||||
<i class="fa fa-google text-danger" style="font-size: 18px;"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<i class="fa fa-windows text-primary" style="font-size: 18px;"/>
|
||||
</t>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold" style="font-size: 14px;">
|
||||
<t t-out="account.x_fc_email"/>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
<t t-if="account.x_fc_last_sync">
|
||||
Synced <t t-out="account.x_fc_last_sync.astimezone(user_tz).strftime('%b %d, %I:%M %p')"/>
|
||||
</t>
|
||||
<t t-else="">Never synced</t>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<t t-if="account.x_fc_sync_status == 'active'">
|
||||
<span class="badge text-bg-success">Active</span>
|
||||
</t>
|
||||
<t t-elif="account.x_fc_sync_status == 'error'">
|
||||
<span class="badge text-bg-danger" t-att-title="account.x_fc_error_message or ''">Error</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="badge text-bg-warning">Paused</span>
|
||||
</t>
|
||||
<button class="btn btn-sm btn-outline-secondary js-sync-account"
|
||||
t-att-data-account-id="account.id" title="Sync Now">
|
||||
<i class="fa fa-refresh"/>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger js-disconnect-account"
|
||||
t-att-data-account-id="account.id"
|
||||
t-att-data-account-email="account.x_fc_email" title="Disconnect">
|
||||
<i class="fa fa-times"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<div class="d-flex gap-2 mt-3 flex-wrap">
|
||||
<a href="/my/schedule/connect/google" class="btn btn-sm btn-outline-danger">
|
||||
<i class="fa fa-google me-1"/> Connect Google
|
||||
</a>
|
||||
<a href="/my/schedule/connect/microsoft" class="btn btn-sm btn-outline-primary">
|
||||
<i class="fa fa-windows me-1"/> Connect Outlook
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Share Appointment Link -->
|
||||
<t t-if="public_booking_url">
|
||||
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
|
||||
<div class="card-body px-3 px-md-4 py-3">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2">
|
||||
<div>
|
||||
<h6 class="mb-0"><i class="fa fa-share-alt me-2 text-primary"/>Share Booking Link</h6>
|
||||
<small class="text-muted">Share this link with clients so they can book your available time</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2 w-100 w-md-auto" style="max-width: 450px;">
|
||||
<input type="text" class="form-control form-control-sm" t-att-value="public_booking_url"
|
||||
id="shareBookingUrlInput" readonly="readonly" style="font-size: 13px;"/>
|
||||
<button class="btn btn-primary btn-sm px-3 flex-shrink-0 js-share-booking"
|
||||
t-att-data-url="public_booking_url">
|
||||
<i class="fa fa-copy me-1"/><span class="d-none d-sm-inline">Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Schedule Preferences (Collapsible) -->
|
||||
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
|
||||
<div class="card-header bg-white border-bottom-0 py-2 px-3 px-md-4"
|
||||
style="border-radius: 12px; cursor: pointer;"
|
||||
data-bs-toggle="collapse" data-bs-target="#schedulePrefsBody"
|
||||
aria-expanded="false" aria-controls="schedulePrefsBody">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<h6 class="mb-0"><i class="fa fa-sliders me-2 text-secondary"/>Schedule Preferences</h6>
|
||||
<span style="font-size: 11px; border-radius: 8px; padding: 4px 10px; color: #fff; background-color: var(--bs-secondary);">
|
||||
<t t-out="'%d:%02d' % (int(user_prefs.get('work_start', 9)), int((user_prefs.get('work_start', 9) % 1) * 60))"/> -
|
||||
<t t-out="'%d:%02d' % (int(user_prefs.get('work_end', 17)), int((user_prefs.get('work_end', 17) % 1) * 60))"/>
|
||||
</span>
|
||||
</div>
|
||||
<i class="fa fa-chevron-down text-muted" style="font-size: 12px; transition: transform 0.2s;"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse" id="schedulePrefsBody">
|
||||
<div class="card-body px-3 px-md-4 pb-3 pt-2">
|
||||
<form id="schedulePrefsForm">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold" style="font-size: 13px;">Work Day Start</label>
|
||||
<input type="time" class="form-control form-control-sm" name="work_start"
|
||||
t-att-value="'%02d:%02d' % (int(user_prefs.get('work_start', 9)), int((user_prefs.get('work_start', 9) % 1) * 60))"/>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold" style="font-size: 13px;">Work Day End</label>
|
||||
<input type="time" class="form-control form-control-sm" name="work_end"
|
||||
t-att-value="'%02d:%02d' % (int(user_prefs.get('work_end', 17)), int((user_prefs.get('work_end', 17) % 1) * 60))"/>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold" style="font-size: 13px;">Break Start</label>
|
||||
<input type="time" class="form-control form-control-sm" name="break_start"
|
||||
t-att-value="'%02d:%02d' % (int(user_prefs.get('break_start', 12)), int((user_prefs.get('break_start', 12) % 1) * 60))"/>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold" style="font-size: 13px;">Break Duration (min)</label>
|
||||
<input type="number" class="form-control form-control-sm" name="break_duration_min"
|
||||
t-att-value="int(user_prefs.get('break_duration', 0.5) * 60)"
|
||||
min="0" max="120" step="5"/>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold" style="font-size: 13px;">Travel Buffer (min)</label>
|
||||
<input type="number" class="form-control form-control-sm" name="travel_buffer"
|
||||
t-att-value="user_prefs.get('travel_buffer', 30)"
|
||||
min="0" max="120" step="5"/>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold" style="font-size: 13px;">Base/Office Address</label>
|
||||
<input type="text" class="form-control form-control-sm" name="home_address"
|
||||
t-att-value="user_prefs.get('home_address', '')"
|
||||
placeholder="e.g. 123 Main St, Toronto, ON"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button type="button" class="btn btn-primary btn-sm px-3 js-save-prefs" id="btnSavePrefs">
|
||||
<i class="fa fa-save me-1"/> Save Preferences
|
||||
</button>
|
||||
<span class="text-success ms-2" id="prefsSavedMsg" style="display: none; font-size: 13px;">
|
||||
<i class="fa fa-check me-1"/> Saved
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today's Appointments -->
|
||||
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
|
||||
<div class="card-header bg-white border-bottom-0 pt-3 pb-2 px-3 px-md-4"
|
||||
style="border-radius: 12px 12px 0 0;">
|
||||
<h5 class="mb-0"><i class="fa fa-sun-o me-2 text-warning"/>Today's Appointments</h5>
|
||||
</div>
|
||||
<div class="card-body px-3 px-md-4 pb-4 pt-2">
|
||||
<t t-if="today_events">
|
||||
<t t-foreach="today_events" t-as="event">
|
||||
<div class="d-flex justify-content-between align-items-start py-3 border-bottom">
|
||||
<div class="d-flex align-items-start flex-grow-1 min-width-0">
|
||||
<div class="rounded-3 text-center px-2 py-1 me-3 flex-shrink-0"
|
||||
t-attf-style="background: #{portal_gradient}; min-width: 58px;">
|
||||
<div class="text-white fw-bold" style="font-size: 13px;">
|
||||
<t t-out="event.start.astimezone(user_tz).strftime('%I:%M')"/>
|
||||
</div>
|
||||
<div class="text-white" style="font-size: 10px;">
|
||||
<t t-out="event.start.astimezone(user_tz).strftime('%p')"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-width-0">
|
||||
<h6 class="mb-0 text-truncate"><t t-out="event.name"/></h6>
|
||||
<div class="d-flex flex-wrap gap-2 mt-1">
|
||||
<t t-if="event.location">
|
||||
<small class="text-muted text-truncate" style="max-width: 200px;">
|
||||
<i class="fa fa-map-marker me-1"/><t t-out="event.location"/>
|
||||
</small>
|
||||
</t>
|
||||
<small class="text-muted">
|
||||
<t t-out="'%.0f' % (event.duration * 60)"/> min
|
||||
</small>
|
||||
<!-- Source badge -->
|
||||
<t t-set="src" t-value="event_sources.get(event.id, {})"/>
|
||||
<t t-if="src.get('provider') == 'google'">
|
||||
<small class="badge bg-light text-dark border" title="Google Calendar">
|
||||
<i class="fa fa-google text-danger" style="font-size: 10px;"/>
|
||||
<span class="d-none d-lg-inline ms-1"><t t-out="src.get('email','').split('@')[0][:10]"/></span>
|
||||
</small>
|
||||
</t>
|
||||
<t t-elif="src.get('provider') == 'microsoft'">
|
||||
<small class="badge bg-light text-dark border" title="Outlook Calendar">
|
||||
<i class="fa fa-windows text-primary" style="font-size: 10px;"/>
|
||||
<span class="d-none d-lg-inline ms-1"><t t-out="src.get('email','').split('@')[0][:10]"/></span>
|
||||
</small>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<small class="badge bg-light text-dark border">
|
||||
<i class="fa fa-calendar" style="font-size: 10px;"/> Booked
|
||||
</small>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Actions -->
|
||||
<div class="d-flex gap-1 flex-shrink-0 ms-2">
|
||||
<button class="btn btn-sm btn-outline-primary js-reschedule-event"
|
||||
t-att-data-event-id="event.id"
|
||||
t-att-data-event-name="event.name"
|
||||
t-att-data-event-duration="event.duration"
|
||||
title="Reschedule">
|
||||
<i class="fa fa-clock-o"/>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger js-cancel-event"
|
||||
t-att-data-event-id="event.id"
|
||||
t-att-data-event-name="event.name"
|
||||
title="Cancel">
|
||||
<i class="fa fa-trash-o"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<p class="text-muted mb-0 py-3 text-center">
|
||||
<i class="fa fa-calendar-o me-1"/> No appointments scheduled for today.
|
||||
</p>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upcoming Appointments -->
|
||||
<div class="card border-0 shadow-sm" style="border-radius: 12px;">
|
||||
<div class="card-header bg-white border-bottom-0 pt-3 pb-2 px-3 px-md-4"
|
||||
style="border-radius: 12px 12px 0 0;">
|
||||
<h5 class="mb-0"><i class="fa fa-calendar me-2 text-primary"/>Upcoming Appointments</h5>
|
||||
</div>
|
||||
<div class="card-body px-3 px-md-4 pb-4 pt-2">
|
||||
<t t-if="upcoming_events">
|
||||
<!-- Desktop table view -->
|
||||
<div class="table-responsive d-none d-md-block">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="border-top:none;">Date</th>
|
||||
<th style="border-top:none;">Time</th>
|
||||
<th style="border-top:none;">Appointment</th>
|
||||
<th style="border-top:none;">Location</th>
|
||||
<th style="border-top:none;">Source</th>
|
||||
<th style="border-top:none;">Duration</th>
|
||||
<th style="border-top:none; width: 80px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="upcoming_events" t-as="event">
|
||||
<tr>
|
||||
<td>
|
||||
<strong><t t-out="event.start.astimezone(user_tz).strftime('%b %d')"/></strong>
|
||||
<br/>
|
||||
<small class="text-muted">
|
||||
<t t-out="event.start.astimezone(user_tz).strftime('%A')"/>
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<t t-out="event.start.astimezone(user_tz).strftime('%I:%M %p')"/>
|
||||
</td>
|
||||
<td class="text-truncate" style="max-width: 200px;">
|
||||
<t t-out="event.name"/>
|
||||
</td>
|
||||
<td>
|
||||
<t t-if="event.location">
|
||||
<small class="text-truncate d-inline-block" style="max-width: 150px;"><t t-out="event.location"/></small>
|
||||
</t>
|
||||
<t t-else=""><small class="text-muted">-</small></t>
|
||||
</td>
|
||||
<td>
|
||||
<t t-set="src" t-value="event_sources.get(event.id, {})"/>
|
||||
<t t-if="src.get('provider') == 'google'">
|
||||
<span class="badge bg-light text-dark border">
|
||||
<i class="fa fa-google text-danger" style="font-size: 10px;"/>
|
||||
<t t-out="src.get('email','').split('@')[0][:12]"/>
|
||||
</span>
|
||||
</t>
|
||||
<t t-elif="src.get('provider') == 'microsoft'">
|
||||
<span class="badge bg-light text-dark border">
|
||||
<i class="fa fa-windows text-primary" style="font-size: 10px;"/>
|
||||
<t t-out="src.get('email','').split('@')[0][:12]"/>
|
||||
</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="badge bg-light text-dark border">
|
||||
<i class="fa fa-calendar" style="font-size: 10px;"/> Booked
|
||||
</span>
|
||||
</t>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark">
|
||||
<t t-out="'%.0f' % (event.duration * 60)"/> min
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-sm btn-outline-primary js-reschedule-event"
|
||||
t-att-data-event-id="event.id"
|
||||
t-att-data-event-name="event.name"
|
||||
t-att-data-event-duration="event.duration"
|
||||
title="Reschedule">
|
||||
<i class="fa fa-clock-o"/>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger js-cancel-event"
|
||||
t-att-data-event-id="event.id"
|
||||
t-att-data-event-name="event.name"
|
||||
title="Cancel">
|
||||
<i class="fa fa-trash-o"/>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile card view -->
|
||||
<div class="d-md-none">
|
||||
<t t-foreach="upcoming_events" t-as="event">
|
||||
<div class="d-flex justify-content-between align-items-start py-3 border-bottom">
|
||||
<div class="d-flex align-items-start flex-grow-1 min-width-0">
|
||||
<div class="rounded-3 text-center px-2 py-1 me-3 flex-shrink-0"
|
||||
t-attf-style="background: #{portal_gradient}; min-width: 50px;">
|
||||
<div class="text-white fw-bold" style="font-size: 11px;">
|
||||
<t t-out="event.start.astimezone(user_tz).strftime('%b')"/>
|
||||
</div>
|
||||
<div class="text-white fw-bold" style="font-size: 16px;">
|
||||
<t t-out="event.start.astimezone(user_tz).strftime('%d')"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-width-0">
|
||||
<h6 class="mb-0 text-truncate" style="font-size: 14px;"><t t-out="event.name"/></h6>
|
||||
<small class="text-muted">
|
||||
<t t-out="event.start.astimezone(user_tz).strftime('%I:%M %p')"/>
|
||||
<t t-if="event.location">
|
||||
&middot; <t t-out="event.location[:30]"/>
|
||||
</t>
|
||||
</small>
|
||||
<div class="mt-1">
|
||||
<t t-set="src" t-value="event_sources.get(event.id, {})"/>
|
||||
<t t-if="src.get('provider') == 'google'">
|
||||
<span class="badge bg-light text-dark border" style="font-size: 10px;">
|
||||
<i class="fa fa-google text-danger"/>
|
||||
</span>
|
||||
</t>
|
||||
<t t-elif="src.get('provider') == 'microsoft'">
|
||||
<span class="badge bg-light text-dark border" style="font-size: 10px;">
|
||||
<i class="fa fa-windows text-primary"/>
|
||||
</span>
|
||||
</t>
|
||||
<span class="badge bg-light text-dark" style="font-size: 10px;">
|
||||
<t t-out="'%.0f' % (event.duration * 60)"/> min
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-1 flex-shrink-0 ms-2">
|
||||
<button class="btn btn-sm btn-outline-primary js-reschedule-event"
|
||||
t-att-data-event-id="event.id"
|
||||
t-att-data-event-name="event.name"
|
||||
t-att-data-event-duration="event.duration"
|
||||
title="Reschedule">
|
||||
<i class="fa fa-clock-o"/>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger js-cancel-event"
|
||||
t-att-data-event-id="event.id"
|
||||
t-att-data-event-name="event.name"
|
||||
title="Cancel">
|
||||
<i class="fa fa-trash-o"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<p class="text-muted mb-0 py-3 text-center">
|
||||
<i class="fa fa-calendar-o me-1"/> No upcoming appointments.
|
||||
<a href="/my/schedule/book">Book one now</a>
|
||||
</p>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Modal (reusable) -->
|
||||
<div class="modal fade" id="fusionConfirmModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-sm">
|
||||
<div class="modal-content" style="border-radius: 12px; border: none;">
|
||||
<div class="modal-header border-bottom-0 pb-0">
|
||||
<h5 class="modal-title" id="fusionConfirmTitle">Confirm</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"/>
|
||||
</div>
|
||||
<div class="modal-body pt-2">
|
||||
<p id="fusionConfirmMessage" class="text-muted mb-0"/>
|
||||
</div>
|
||||
<div class="modal-footer border-top-0 pt-0">
|
||||
<button type="button" class="btn btn-light" data-bs-dismiss="modal">No, keep it</button>
|
||||
<button type="button" class="btn btn-danger" id="fusionConfirmOk">
|
||||
<i class="fa fa-check me-1"/>Yes, proceed
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast notification -->
|
||||
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 9999;">
|
||||
<div id="fusionToast" class="toast align-items-center border-0" role="alert"
|
||||
aria-live="assertive" aria-atomic="true" data-bs-delay="4000">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body" id="fusionToastMessage"/>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto"
|
||||
data-bs-dismiss="toast" aria-label="Close"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reschedule Modal -->
|
||||
<div class="modal fade" id="rescheduleModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content" style="border-radius: 12px; border: none;">
|
||||
<div class="modal-header border-bottom-0 pb-0">
|
||||
<h5 class="modal-title"><i class="fa fa-clock-o me-2"/>Reschedule</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"/>
|
||||
</div>
|
||||
<div class="modal-body pt-2">
|
||||
<p class="text-muted mb-3">
|
||||
<span id="rescheduleEventName" class="fw-semibold text-dark"></span>
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">New Date</label>
|
||||
<input type="date" class="form-control" id="rescheduleDate"/>
|
||||
</div>
|
||||
<div id="rescheduleSlotsContainer" style="display: none;">
|
||||
<label class="form-label fw-semibold">Available Time Slots</label>
|
||||
<div id="rescheduleSlotsLoading" class="text-center py-2" style="display: none;">
|
||||
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"/>
|
||||
Loading...
|
||||
</div>
|
||||
<div id="rescheduleSlotsGrid" class="d-flex flex-wrap gap-2 mb-2"></div>
|
||||
<div id="rescheduleNoSlots" class="text-muted py-2" style="display: none;">
|
||||
No slots available for this date.
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" id="rescheduleEventId"/>
|
||||
<input type="hidden" id="rescheduleSlotDatetime"/>
|
||||
<input type="hidden" id="rescheduleEventDuration"/>
|
||||
<t t-if="appointment_types">
|
||||
<input type="hidden" id="rescheduleAppTypeId"
|
||||
t-att-value="appointment_types[0].id"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="modal-footer border-top-0">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="btnConfirmReschedule" disabled="disabled">
|
||||
<i class="fa fa-check me-1"/> Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Optimize Schedule Modal -->
|
||||
<div class="modal fade" id="optimizeModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content" style="border-radius: 12px; border: none;">
|
||||
<div class="modal-header border-bottom-0 pb-0">
|
||||
<h5 class="modal-title"><i class="fa fa-magic me-2 text-info"/>Optimize Schedule</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"/>
|
||||
</div>
|
||||
<div class="modal-body pt-2">
|
||||
<p class="text-muted mb-3">AI analyzes your appointments and travel times to suggest the optimal order.</p>
|
||||
<div id="optimizeLoading" class="text-center py-4">
|
||||
<div class="spinner-border text-info me-2" role="status"/>
|
||||
<div class="mt-2">Analyzing travel routes and schedule...</div>
|
||||
</div>
|
||||
<div id="optimizeResult" style="display: none;">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 bg-light">
|
||||
<div class="card-body py-2 px-3">
|
||||
<small class="text-muted">Current Travel</small>
|
||||
<div class="fw-bold" id="optimizeCurrentTravel">--</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0" style="background-color: #e8f5e9;">
|
||||
<div class="card-body py-2 px-3">
|
||||
<small class="text-muted">Optimized Travel</small>
|
||||
<div class="fw-bold text-success" id="optimizeNewTravel">--</div>
|
||||
<small class="text-success" id="optimizeSavings"></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="optimizeScheduleList"></div>
|
||||
</div>
|
||||
<div id="optimizeError" class="text-danger py-3" style="display: none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer border-top-0">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ==================== BOOKING FORM ==================== -->
|
||||
|
||||
<template id="portal_schedule_book" name="Book Appointment">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
|
||||
<div class="container py-4" style="max-width: 800px;">
|
||||
<!-- Error Messages -->
|
||||
<t t-if="error">
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-exclamation-circle me-2"/><t t-out="error"/>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-4">
|
||||
<a href="/my/schedule" class="text-muted text-decoration-none mb-2 d-inline-block">
|
||||
<i class="fa fa-arrow-left me-1"/> Back to Schedule
|
||||
</a>
|
||||
<h3 class="mb-1"><i class="fa fa-plus-circle me-2"/>Book Appointment</h3>
|
||||
<p class="text-muted mb-0">Select a time slot and enter client details</p>
|
||||
</div>
|
||||
|
||||
<form action="/my/schedule/book/submit" method="post" id="bookingForm">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
|
||||
<!-- Step 1: Appointment Type + Date/Time -->
|
||||
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
|
||||
<div class="card-header bg-white border-bottom pt-3 pb-2 px-3 px-md-4"
|
||||
style="border-radius: 12px 12px 0 0;">
|
||||
<h5 class="mb-0">
|
||||
<span class="badge rounded-pill me-2"
|
||||
t-attf-style="background: #{portal_gradient};">1</span>
|
||||
Date & Time
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body px-3 px-md-4 pb-4">
|
||||
<!-- Appointment Type (if multiple) -->
|
||||
<t t-if="len(appointment_types) > 1">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Appointment Type</label>
|
||||
<select name="appointment_type_id" class="form-select"
|
||||
id="appointmentTypeSelect">
|
||||
<t t-foreach="appointment_types" t-as="atype">
|
||||
<option t-att-value="atype.id"
|
||||
t-att-selected="atype.id == selected_type.id"
|
||||
t-att-data-duration="atype.appointment_duration">
|
||||
<t t-out="atype.name"/>
|
||||
(<t t-out="'%.0f' % (atype.appointment_duration * 60)"/> min)
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<input type="hidden" name="appointment_type_id"
|
||||
t-att-value="selected_type.id"/>
|
||||
</t>
|
||||
|
||||
<!-- Date Picker -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Select Date</label>
|
||||
<input type="date" class="form-control" id="bookingDate"
|
||||
required="required"
|
||||
t-att-min="now.strftime('%Y-%m-%d')"/>
|
||||
</div>
|
||||
|
||||
<!-- Week Calendar Preview -->
|
||||
<div id="weekCalendarContainer" class="mb-3" style="display: none;">
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<label class="form-label fw-semibold mb-0">
|
||||
<i class="fa fa-calendar me-1"/>Your Week
|
||||
</label>
|
||||
<div id="weekCalendarNav" class="d-flex align-items-center gap-2" style="display: none !important;">
|
||||
<button type="button" id="btnPrevWeek" class="btn btn-sm btn-outline-secondary px-2 py-0"
|
||||
style="font-size: 14px; line-height: 1.6; border-radius: 6px;">
|
||||
<i class="fa fa-chevron-left"/>
|
||||
</button>
|
||||
<span id="weekRangeLabel" class="text-muted fw-semibold" style="font-size: 13px; min-width: 140px; text-align: center;"></span>
|
||||
<button type="button" id="btnNextWeek" class="btn btn-sm btn-outline-secondary px-2 py-0"
|
||||
style="font-size: 14px; line-height: 1.6; border-radius: 6px;">
|
||||
<i class="fa fa-chevron-right"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="weekCalendarLoading" class="text-center py-3" style="display: none;">
|
||||
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"/>
|
||||
Loading calendar...
|
||||
</div>
|
||||
<div id="weekCalendarGrid" style="display: none;">
|
||||
<div id="weekCalendarHeader"></div>
|
||||
<div id="weekCalendarBody"></div>
|
||||
</div>
|
||||
<div id="weekCalendarEmpty" class="text-muted py-2 text-center" style="display: none;">
|
||||
<i class="fa fa-calendar-o me-1"/> No events this week -- your schedule is open.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Available Slots -->
|
||||
<div id="slotsContainer" style="display: none;">
|
||||
<label class="form-label fw-semibold">Available Time Slots</label>
|
||||
<div id="slotsLoading" class="text-center py-3" style="display: none;">
|
||||
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"/>
|
||||
Loading available slots...
|
||||
</div>
|
||||
<div id="slotsGrid" class="d-flex flex-wrap gap-2 mb-2"></div>
|
||||
<div id="noSlots" class="text-muted py-2" style="display: none;">
|
||||
<i class="fa fa-info-circle me-1"/> No available slots for this date.
|
||||
Try another date.
|
||||
</div>
|
||||
<input type="hidden" name="slot_datetime" id="slotDatetime"/>
|
||||
<input type="hidden" name="slot_duration" id="slotDuration"
|
||||
t-att-value="selected_type.appointment_duration"/>
|
||||
|
||||
<!-- AI Suggestions -->
|
||||
<div id="aiSuggestSection" class="mt-3" style="display: none;">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<label class="form-label fw-semibold mb-0">
|
||||
<i class="fa fa-magic me-1 text-info"/> AI Suggested Times
|
||||
</label>
|
||||
<button type="button" class="btn btn-outline-info btn-sm px-2 py-0"
|
||||
id="btnAiSuggest" style="font-size: 12px;">
|
||||
<i class="fa fa-refresh me-1"/> Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div id="aiSuggestLoading" class="text-center py-2" style="display: none;">
|
||||
<div class="spinner-border spinner-border-sm text-info me-2" role="status"/>
|
||||
Analyzing schedule...
|
||||
</div>
|
||||
<div id="aiSuggestGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Client Details -->
|
||||
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
|
||||
<div class="card-header bg-white border-bottom pt-3 pb-2 px-3 px-md-4"
|
||||
style="border-radius: 12px 12px 0 0;">
|
||||
<h5 class="mb-0">
|
||||
<span class="badge rounded-pill me-2"
|
||||
t-attf-style="background: #{portal_gradient};">2</span>
|
||||
Client Details
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body px-3 px-md-4 pb-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Client Name <span class="text-danger">*</span></label>
|
||||
<input type="text" name="client_name" class="form-control"
|
||||
placeholder="Enter client's full name" required="required"/>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Email</label>
|
||||
<input type="email" name="client_email" class="form-control"
|
||||
placeholder="client@email.com (optional)"/>
|
||||
<small class="text-muted">If provided, a calendar invitation will be sent</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Phone</label>
|
||||
<input type="tel" name="client_phone" class="form-control"
|
||||
placeholder="(optional)"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Address</label>
|
||||
<input type="text" name="client_street" class="form-control mb-2"
|
||||
id="clientStreet"
|
||||
placeholder="Start typing address..."/>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-4">
|
||||
<input type="text" name="client_city" class="form-control"
|
||||
id="clientCity" placeholder="City"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="text" name="client_province" class="form-control"
|
||||
id="clientProvince" placeholder="Province"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="text" name="client_postal" class="form-control"
|
||||
id="clientPostal" placeholder="Postal Code"/>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="client_lat" id="clientLat" value="0"/>
|
||||
<input type="hidden" name="client_lng" id="clientLng" value="0"/>
|
||||
|
||||
<div class="mb-0">
|
||||
<label class="form-label fw-semibold">Notes</label>
|
||||
<textarea name="notes" class="form-control" rows="3"
|
||||
placeholder="e.g. Equipment to bring, special instructions, reason for visit..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="d-flex justify-content-end gap-3">
|
||||
<a href="/my/schedule" class="btn btn-outline-secondary px-3 py-2">
|
||||
<i class="fa fa-arrow-left me-1"/> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary px-4 py-2" id="btnSubmitBooking"
|
||||
disabled="disabled">
|
||||
<i class="fa fa-calendar-check-o me-1"/> Book Appointment
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Google Maps Places API -->
|
||||
<t t-if="google_maps_api_key">
|
||||
<script>
|
||||
function initScheduleAddressAutocomplete() {
|
||||
if (window._scheduleAutocompleteInit) {
|
||||
window._scheduleAutocompleteInit();
|
||||
} else {
|
||||
window._googleMapsReady = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script t-attf-src="https://maps.googleapis.com/maps/api/js?key=#{google_maps_api_key}&libraries=places&callback=initScheduleAddressAutocomplete"
|
||||
defer="defer"></script>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
27
fusion_schedule/views/portal_schedule_tile.xml
Normal file
27
fusion_schedule/views/portal_schedule_tile.xml
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Add "My Schedule" tile inside the existing portal card grid -->
|
||||
<template id="portal_my_home_schedule" name="Portal My Home: Schedule"
|
||||
inherit_id="fusion_authorizer_portal.portal_my_home_authorizer" priority="45">
|
||||
<!-- Navigate up from a known card to the ROW div, then append inside (not inside any t-if) -->
|
||||
<xpath expr="//a[@href='/my/funding-claims']/ancestor::div[hasclass('row') and hasclass('g-3') and hasclass('mb-4')]" position="inside">
|
||||
<div class="col-md-6">
|
||||
<a href="/my/schedule" class="card h-100 border-0 shadow-sm text-decoration-none" style="border-radius: 12px; min-height: 100px;">
|
||||
<div class="card-body d-flex align-items-center p-4">
|
||||
<div class="me-3">
|
||||
<div class="rounded-circle d-flex align-items-center justify-content-center" t-att-style="'width: 50px; height: 50px; background: ' + (fc_gradient or 'linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%)')">
|
||||
<i class="fa fa-calendar-check-o fa-lg text-white" title="Schedule"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="mb-1 text-dark">My Schedule</h5>
|
||||
<small class="text-muted">View and book appointments</small>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
585
fusion_schedule/views/public_booking.xml
Normal file
585
fusion_schedule/views/public_booking.xml
Normal file
@@ -0,0 +1,585 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ==================== PUBLIC BOOKING PAGE ==================== -->
|
||||
|
||||
<template id="public_booking_page" name="Public Booking Page">
|
||||
<t t-call="website.layout">
|
||||
<div class="container py-5" style="max-width: 700px;">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-4">
|
||||
<div class="d-inline-flex align-items-center justify-content-center rounded-circle mb-3"
|
||||
style="width: 64px; height: 64px; background: linear-gradient(135deg, #5ba848, #3a8fb7);">
|
||||
<i class="fa fa-calendar-check-o text-white" style="font-size: 28px;"/>
|
||||
</div>
|
||||
<h2 class="mb-1">Book a Time with <t t-out="staff_user.name"/></h2>
|
||||
<p class="text-muted">Select a date and time that works for you</p>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<t t-if="success">
|
||||
<div class="card border-0 shadow-sm text-center p-5" style="border-radius: 12px;">
|
||||
<div class="mb-3">
|
||||
<i class="fa fa-check-circle text-success" style="font-size: 48px;"/>
|
||||
</div>
|
||||
<h4 class="mb-2">Appointment Booked!</h4>
|
||||
<p class="text-muted mb-0"><t t-out="success"/></p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Error Message -->
|
||||
<t t-if="error">
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-exclamation-circle me-2"/><t t-out="error"/>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="not success">
|
||||
<form t-att-action="'/schedule/%s/book' % booking_slug" method="post" id="publicBookingForm">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
|
||||
<!-- Step 1: Date & Time -->
|
||||
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
|
||||
<div class="card-header bg-white border-bottom pt-3 pb-2 px-4"
|
||||
style="border-radius: 12px 12px 0 0;">
|
||||
<h5 class="mb-0">
|
||||
<span class="badge rounded-pill me-2"
|
||||
style="background: linear-gradient(135deg, #5ba848, #3a8fb7);">1</span>
|
||||
Select Date & Time
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body px-4 pb-4">
|
||||
<!-- Appointment Type -->
|
||||
<t t-if="appointment_types and len(appointment_types) > 1">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Appointment Type</label>
|
||||
<select name="appointment_type_id" class="form-select"
|
||||
id="publicAppointmentType">
|
||||
<t t-foreach="appointment_types" t-as="atype">
|
||||
<option t-att-value="atype.id">
|
||||
<t t-out="atype.name"/>
|
||||
(<t t-out="'%.0f' % (atype.appointment_duration * 60)"/> min)
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="appointment_types">
|
||||
<input type="hidden" name="appointment_type_id"
|
||||
t-att-value="appointment_types[0].id"/>
|
||||
</t>
|
||||
|
||||
<!-- Date -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Select Date</label>
|
||||
<input type="date" class="form-control" id="publicBookingDate"
|
||||
name="date" required="required"
|
||||
t-att-min="today"/>
|
||||
</div>
|
||||
|
||||
<!-- Available Slots -->
|
||||
<div id="publicSlotsContainer" style="display: none;">
|
||||
<label class="form-label fw-semibold">Available Time Slots</label>
|
||||
<div id="publicSlotsLoading" class="text-center py-3" style="display: none;">
|
||||
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"/>
|
||||
Loading available slots...
|
||||
</div>
|
||||
<div id="publicSlotsGrid" class="d-flex flex-wrap gap-2 mb-2"></div>
|
||||
<div id="publicNoSlots" class="text-muted py-2" style="display: none;">
|
||||
<i class="fa fa-info-circle me-1"/> No available slots for this date.
|
||||
</div>
|
||||
<input type="hidden" name="slot_datetime" id="publicSlotDatetime"/>
|
||||
<input type="hidden" name="slot_duration" id="publicSlotDuration"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Your Details -->
|
||||
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
|
||||
<div class="card-header bg-white border-bottom pt-3 pb-2 px-4"
|
||||
style="border-radius: 12px 12px 0 0;">
|
||||
<h5 class="mb-0">
|
||||
<span class="badge rounded-pill me-2"
|
||||
style="background: linear-gradient(135deg, #5ba848, #3a8fb7);">2</span>
|
||||
Your Details
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body px-4 pb-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Your Name <span class="text-danger">*</span></label>
|
||||
<input type="text" name="visitor_name" class="form-control"
|
||||
placeholder="Full name" required="required"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Email <span class="text-danger">*</span></label>
|
||||
<input type="email" name="visitor_email" class="form-control"
|
||||
placeholder="your@email.com" required="required"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Phone</label>
|
||||
<input type="tel" name="visitor_phone" class="form-control"
|
||||
placeholder="(optional)"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Address</label>
|
||||
<input type="text" name="client_street" class="form-control mb-2"
|
||||
id="publicClientStreet"
|
||||
placeholder="Start typing an address..."/>
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<input type="text" name="client_city" class="form-control"
|
||||
id="publicClientCity" placeholder="City"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="text" name="client_province" class="form-control"
|
||||
id="publicClientProvince" placeholder="Province"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="text" name="client_postal" class="form-control"
|
||||
id="publicClientPostal" placeholder="Postal Code"/>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="client_lat" id="publicClientLat" value="0"/>
|
||||
<input type="hidden" name="client_lng" id="publicClientLng" value="0"/>
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label class="form-label fw-semibold">Notes</label>
|
||||
<textarea name="visitor_notes" class="form-control" rows="3"
|
||||
placeholder="Anything you'd like us to know..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="text-end">
|
||||
<button type="submit" class="btn btn-primary btn-lg px-4" id="publicBtnSubmit"
|
||||
disabled="disabled">
|
||||
<i class="fa fa-calendar-check-o me-1"/> Confirm Booking
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</t>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="text-center mt-4">
|
||||
<small class="text-muted">Powered by Fusion Schedule</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Public Booking JS -->
|
||||
<script type="text/javascript">
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var dateInput = document.getElementById('publicBookingDate');
|
||||
var slotsContainer = document.getElementById('publicSlotsContainer');
|
||||
var slotsGrid = document.getElementById('publicSlotsGrid');
|
||||
var slotsLoading = document.getElementById('publicSlotsLoading');
|
||||
var noSlots = document.getElementById('publicNoSlots');
|
||||
var slotDatetime = document.getElementById('publicSlotDatetime');
|
||||
var slotDuration = document.getElementById('publicSlotDuration');
|
||||
var submitBtn = document.getElementById('publicBtnSubmit');
|
||||
var typeSelect = document.getElementById('publicAppointmentType');
|
||||
var selectedSlotBtn = null;
|
||||
|
||||
var slug = '<t t-out="booking_slug"/>';
|
||||
|
||||
function getTypeId() {
|
||||
if (typeSelect) return typeSelect.value;
|
||||
var hidden = document.querySelector('input[name="appointment_type_id"]');
|
||||
return hidden ? hidden.value : null;
|
||||
}
|
||||
|
||||
function fetchSlots(date) {
|
||||
var typeId = getTypeId();
|
||||
if (!typeId || !date) return;
|
||||
|
||||
slotsContainer.style.display = 'block';
|
||||
slotsLoading.style.display = 'block';
|
||||
slotsGrid.innerHTML = '';
|
||||
noSlots.style.display = 'none';
|
||||
slotDatetime.value = '';
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
selectedSlotBtn = null;
|
||||
|
||||
fetch('/schedule/' + slug + '/available-slots', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'call',
|
||||
params: {
|
||||
selected_date: date,
|
||||
appointment_type_id: parseInt(typeId),
|
||||
},
|
||||
}),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
slotsLoading.style.display = 'none';
|
||||
var result = data.result || {};
|
||||
var slots = result.slots || [];
|
||||
|
||||
if (!slots.length) {
|
||||
noSlots.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
var morningSlots = [];
|
||||
var afternoonSlots = [];
|
||||
slots.forEach(function (s) {
|
||||
var hour = parseInt(s.start_hour);
|
||||
if (isNaN(hour)) {
|
||||
var match = s.start_hour.match(/(\d+)/);
|
||||
hour = match ? parseInt(match[1]) : 0;
|
||||
if (s.start_hour.toLowerCase().indexOf('pm') > -1 && hour !== 12) hour += 12;
|
||||
if (s.start_hour.toLowerCase().indexOf('am') > -1 && hour === 12) hour = 0;
|
||||
}
|
||||
(hour < 12 ? morningSlots : afternoonSlots).push(s);
|
||||
});
|
||||
|
||||
function renderGroup(label, icon, group) {
|
||||
if (!group.length) return;
|
||||
var h = document.createElement('div');
|
||||
h.className = 'w-100 mt-2 mb-1';
|
||||
h.innerHTML = '<small class="text-muted fw-semibold"><i class="fa ' + icon + ' me-1"></i>' + label + '</small>';
|
||||
slotsGrid.appendChild(h);
|
||||
group.forEach(function (s) {
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-outline-primary btn-sm';
|
||||
btn.style.cssText = 'min-width: 100px; border-radius: 8px; padding: 8px 14px;';
|
||||
btn.textContent = s.start_hour;
|
||||
btn.addEventListener('click', function () {
|
||||
if (selectedSlotBtn) {
|
||||
selectedSlotBtn.classList.remove('btn-primary');
|
||||
selectedSlotBtn.classList.add('btn-outline-primary');
|
||||
}
|
||||
btn.classList.remove('btn-outline-primary');
|
||||
btn.classList.add('btn-primary');
|
||||
selectedSlotBtn = btn;
|
||||
slotDatetime.value = s.datetime;
|
||||
slotDuration.value = s.duration;
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
});
|
||||
slotsGrid.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
renderGroup('Morning', 'fa-sun-o', morningSlots);
|
||||
renderGroup('Afternoon', 'fa-cloud', afternoonSlots);
|
||||
})
|
||||
.catch(function () {
|
||||
slotsLoading.style.display = 'none';
|
||||
noSlots.textContent = 'Failed to load slots. Please try again.';
|
||||
noSlots.style.display = 'block';
|
||||
});
|
||||
}
|
||||
|
||||
if (dateInput) {
|
||||
dateInput.addEventListener('change', function () { fetchSlots(this.value); });
|
||||
}
|
||||
if (typeSelect) {
|
||||
typeSelect.addEventListener('change', function () {
|
||||
if (dateInput && dateInput.value) fetchSlots(dateInput.value);
|
||||
});
|
||||
}
|
||||
|
||||
var form = document.getElementById('publicBookingForm');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function (e) {
|
||||
if (!slotDatetime.value) { e.preventDefault(); alert('Please select a time slot.'); return; }
|
||||
if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Booking...'; }
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<t t-if="google_maps_api_key">
|
||||
<script>
|
||||
function initPublicAddressAutocomplete() {
|
||||
var streetInput = document.getElementById('publicClientStreet');
|
||||
if (!streetInput || typeof google === 'undefined') return;
|
||||
var autocomplete = new google.maps.places.Autocomplete(streetInput, {
|
||||
types: ['address'],
|
||||
componentRestrictions: { country: 'ca' },
|
||||
fields: ['address_components', 'geometry'],
|
||||
});
|
||||
autocomplete.addListener('place_changed', function () {
|
||||
var place = autocomplete.getPlace();
|
||||
if (!place.address_components) return;
|
||||
var streetNumber = '', streetName = '', city = '', province = '', postalCode = '';
|
||||
place.address_components.forEach(function (c) {
|
||||
var t = c.types;
|
||||
if (t.indexOf('street_number') > -1) streetNumber = c.long_name;
|
||||
if (t.indexOf('route') > -1) streetName = c.long_name;
|
||||
if (t.indexOf('locality') > -1) city = c.long_name;
|
||||
if (t.indexOf('administrative_area_level_1') > -1) province = c.short_name;
|
||||
if (t.indexOf('postal_code') > -1) postalCode = c.long_name;
|
||||
});
|
||||
streetInput.value = (streetNumber + ' ' + streetName).trim();
|
||||
var ci = document.getElementById('publicClientCity');
|
||||
if (ci) ci.value = city;
|
||||
var pr = document.getElementById('publicClientProvince');
|
||||
if (pr) pr.value = province;
|
||||
var po = document.getElementById('publicClientPostal');
|
||||
if (po) po.value = postalCode;
|
||||
if (place.geometry && place.geometry.location) {
|
||||
var la = document.getElementById('publicClientLat');
|
||||
var ln = document.getElementById('publicClientLng');
|
||||
if (la) la.value = place.geometry.location.lat();
|
||||
if (ln) ln.value = place.geometry.location.lng();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script t-attf-src="https://maps.googleapis.com/maps/api/js?key=#{google_maps_api_key}&libraries=places&callback=initPublicAddressAutocomplete"
|
||||
async="async" defer="defer"></script>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ==================== PUBLIC MANAGE PAGE ==================== -->
|
||||
|
||||
<template id="public_manage_page" name="Manage Your Appointment">
|
||||
<t t-call="website.layout">
|
||||
<div class="container py-5" style="max-width: 600px;">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-4">
|
||||
<div class="d-inline-flex align-items-center justify-content-center rounded-circle mb-3"
|
||||
style="width: 64px; height: 64px; background: linear-gradient(135deg, #5ba848, #3a8fb7);">
|
||||
<i class="fa fa-calendar-check-o text-white" style="font-size: 28px;"/>
|
||||
</div>
|
||||
<h2 class="mb-1">Your Appointment</h2>
|
||||
<p class="text-muted">Manage your booking below</p>
|
||||
</div>
|
||||
|
||||
<!-- Cancelled state -->
|
||||
<t t-if="cancelled">
|
||||
<div class="card border-0 shadow-sm text-center p-5" style="border-radius: 12px;">
|
||||
<div class="mb-3">
|
||||
<i class="fa fa-times-circle text-danger" style="font-size: 48px;"/>
|
||||
</div>
|
||||
<h4 class="mb-2">Appointment Cancelled</h4>
|
||||
<p class="text-muted mb-0">Your appointment has been cancelled.</p>
|
||||
<t t-if="booking_slug">
|
||||
<a t-attf-href="/schedule/#{booking_slug}" class="btn btn-primary mt-3">Book a New Appointment</a>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Rescheduled state -->
|
||||
<t t-elif="rescheduled">
|
||||
<div class="card border-0 shadow-sm text-center p-5" style="border-radius: 12px;">
|
||||
<div class="mb-3">
|
||||
<i class="fa fa-check-circle text-success" style="font-size: 48px;"/>
|
||||
</div>
|
||||
<h4 class="mb-2">Appointment Rescheduled</h4>
|
||||
<p class="text-muted mb-3">Your appointment has been moved to the new time.</p>
|
||||
<t t-if="event">
|
||||
<div class="bg-light rounded-3 p-3 d-inline-block mx-auto">
|
||||
<strong><t t-out="event.start.astimezone(user_tz).strftime('%A, %B %d, %Y')"/></strong>
|
||||
<br/>
|
||||
<t t-out="event.start.astimezone(user_tz).strftime('%I:%M %p')"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Active appointment -->
|
||||
<t t-elif="event">
|
||||
<t t-if="error">
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-exclamation-circle me-2"/><t t-out="error"/>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Appointment details card -->
|
||||
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="mb-3"><t t-out="event.name"/></h5>
|
||||
<div class="row g-3">
|
||||
<div class="col-6">
|
||||
<small class="text-muted d-block">Date</small>
|
||||
<strong><t t-out="event.start.astimezone(user_tz).strftime('%A, %b %d, %Y')"/></strong>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<small class="text-muted d-block">Time</small>
|
||||
<strong><t t-out="event.start.astimezone(user_tz).strftime('%I:%M %p')"/></strong>
|
||||
</div>
|
||||
<t t-if="event.location">
|
||||
<div class="col-12">
|
||||
<small class="text-muted d-block">Location</small>
|
||||
<span><t t-out="event.location"/></span>
|
||||
</div>
|
||||
</t>
|
||||
<div class="col-6">
|
||||
<small class="text-muted d-block">Duration</small>
|
||||
<span><t t-out="'%.0f' % (event.duration * 60)"/> minutes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reschedule section -->
|
||||
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
|
||||
<div class="card-header bg-white border-bottom pt-3 pb-2 px-4"
|
||||
style="border-radius: 12px 12px 0 0; cursor: pointer;"
|
||||
data-bs-toggle="collapse" data-bs-target="#rescheduleSection"
|
||||
aria-expanded="false">
|
||||
<h6 class="mb-0">
|
||||
<i class="fa fa-clock-o me-2 text-primary"/>Reschedule
|
||||
<i class="fa fa-chevron-down float-end text-muted" style="font-size: 12px;"/>
|
||||
</h6>
|
||||
</div>
|
||||
<div class="collapse" id="rescheduleSection">
|
||||
<div class="card-body px-4 pb-4">
|
||||
<form t-attf-action="/schedule/manage/#{token}/reschedule" method="post"
|
||||
id="publicRescheduleForm">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">New Date</label>
|
||||
<input type="date" class="form-control" id="publicRescheduleDate"
|
||||
required="required"/>
|
||||
</div>
|
||||
<div id="publicRescheduleSlotsContainer" style="display: none;">
|
||||
<label class="form-label fw-semibold">Available Slots</label>
|
||||
<div id="publicRescheduleSlotsLoading" class="text-center py-2"
|
||||
style="display: none;">
|
||||
<div class="spinner-border spinner-border-sm text-primary me-2"
|
||||
role="status"/>
|
||||
Loading...
|
||||
</div>
|
||||
<div id="publicRescheduleSlotsGrid"
|
||||
class="d-flex flex-wrap gap-2 mb-2"></div>
|
||||
<div id="publicRescheduleNoSlots" class="text-muted py-2"
|
||||
style="display: none;">
|
||||
No slots available for this date.
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="slot_datetime"
|
||||
id="publicRescheduleSlotDatetime"/>
|
||||
<button type="submit" class="btn btn-primary mt-2"
|
||||
id="publicRescheduleSubmit" disabled="disabled">
|
||||
<i class="fa fa-check me-1"/> Confirm New Time
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancel section -->
|
||||
<div class="card border-0 shadow-sm" style="border-radius: 12px;">
|
||||
<div class="card-body p-4">
|
||||
<form t-attf-action="/schedule/manage/#{token}/cancel" method="post"
|
||||
id="publicCancelForm"
|
||||
onsubmit="return confirm('Are you sure you want to cancel this appointment?');">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<button type="submit" class="btn btn-outline-danger w-100">
|
||||
<i class="fa fa-times me-1"/> Cancel Appointment
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Event not found (already cancelled) -->
|
||||
<t t-else="">
|
||||
<div class="card border-0 shadow-sm text-center p-5" style="border-radius: 12px;">
|
||||
<div class="mb-3">
|
||||
<i class="fa fa-calendar-times-o text-muted" style="font-size: 48px;"/>
|
||||
</div>
|
||||
<h4 class="mb-2">Appointment Not Found</h4>
|
||||
<p class="text-muted mb-0">This appointment may have been cancelled or the link is invalid.</p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<small class="text-muted">Powered by Fusion Schedule</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Public Reschedule JS -->
|
||||
<t t-if="event and not cancelled and not rescheduled">
|
||||
<script type="text/javascript">
|
||||
(function () {
|
||||
'use strict';
|
||||
var token = '<t t-out="token"/>';
|
||||
var dateInput = document.getElementById('publicRescheduleDate');
|
||||
var container = document.getElementById('publicRescheduleSlotsContainer');
|
||||
var grid = document.getElementById('publicRescheduleSlotsGrid');
|
||||
var loading = document.getElementById('publicRescheduleSlotsLoading');
|
||||
var noSlots = document.getElementById('publicRescheduleNoSlots');
|
||||
var slotInput = document.getElementById('publicRescheduleSlotDatetime');
|
||||
var submitBtn = document.getElementById('publicRescheduleSubmit');
|
||||
var selectedBtn = null;
|
||||
|
||||
if (!dateInput) return;
|
||||
|
||||
dateInput.addEventListener('change', function () {
|
||||
var date = this.value;
|
||||
if (!date) return;
|
||||
container.style.display = 'block';
|
||||
loading.style.display = 'block';
|
||||
grid.innerHTML = '';
|
||||
noSlots.style.display = 'none';
|
||||
slotInput.value = '';
|
||||
submitBtn.disabled = true;
|
||||
selectedBtn = null;
|
||||
|
||||
fetch('/schedule/manage/' + token + '/available-slots', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0', method: 'call',
|
||||
params: { selected_date: date },
|
||||
}),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
loading.style.display = 'none';
|
||||
var slots = (data.result || {}).slots || [];
|
||||
if (!slots.length) { noSlots.style.display = 'block'; return; }
|
||||
slots.forEach(function (s) {
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-outline-primary btn-sm';
|
||||
btn.style.cssText = 'min-width: 90px; border-radius: 8px; padding: 8px 12px;';
|
||||
btn.textContent = s.start_hour;
|
||||
btn.addEventListener('click', function () {
|
||||
if (selectedBtn) {
|
||||
selectedBtn.classList.remove('btn-primary');
|
||||
selectedBtn.classList.add('btn-outline-primary');
|
||||
}
|
||||
btn.classList.remove('btn-outline-primary');
|
||||
btn.classList.add('btn-primary');
|
||||
selectedBtn = btn;
|
||||
slotInput.value = s.datetime;
|
||||
submitBtn.disabled = false;
|
||||
});
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
loading.style.display = 'none';
|
||||
noSlots.textContent = 'Failed to load slots.';
|
||||
noSlots.style.display = 'block';
|
||||
});
|
||||
});
|
||||
|
||||
var form = document.getElementById('publicRescheduleForm');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function (e) {
|
||||
if (!slotInput.value) { e.preventDefault(); alert('Please select a time slot.'); }
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
161
fusion_schedule/views/res_config_settings_views.xml
Normal file
161
fusion_schedule/views/res_config_settings_views.xml
Normal file
@@ -0,0 +1,161 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="res_config_settings_view_form_fusion_schedule" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.fusion.schedule</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||
<field name="priority">90</field>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//form" position="inside">
|
||||
<app data-string="Fusion Schedule" string="Fusion Schedule" name="fusion_schedule"
|
||||
logo="/fusion_schedule/static/description/icon.png">
|
||||
|
||||
<!-- ===== CALENDAR SYNC ===== -->
|
||||
<h2>Calendar Sync</h2>
|
||||
|
||||
<div class="row mt-4 o_settings_container">
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="o_form_label">Sync Interval</span>
|
||||
<div class="text-muted">
|
||||
How often connected calendars are synchronised automatically
|
||||
</div>
|
||||
<div class="mt-2 row">
|
||||
<div class="col-4">
|
||||
<field name="x_fc_sync_interval_minutes"/>
|
||||
</div>
|
||||
<div class="col-8 pt-2 text-muted">minutes (default: 5)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== GOOGLE CALENDAR ===== -->
|
||||
<h2>Google Calendar</h2>
|
||||
|
||||
<div class="row mt-4 o_settings_container">
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="o_form_label">Google OAuth Credentials</span>
|
||||
<div class="text-muted">
|
||||
Required to connect Google Calendar accounts.
|
||||
Get these from Google Cloud Console.
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<div class="row mb-2">
|
||||
<label for="x_fc_google_client_id" class="col-5 col-form-label">Client ID</label>
|
||||
<div class="col-7">
|
||||
<field name="x_fc_google_client_id"
|
||||
placeholder="Leave empty to use Odoo default"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="x_fc_google_client_secret" class="col-5 col-form-label">Client Secret</label>
|
||||
<div class="col-7">
|
||||
<field name="x_fc_google_client_secret" password="True"
|
||||
placeholder="Leave empty to use Odoo default"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<field name="x_fc_google_has_fallback" invisible="True"/>
|
||||
<div class="alert alert-info mt-2 py-2 px-3" role="alert"
|
||||
invisible="not x_fc_google_has_fallback">
|
||||
<i class="fa fa-info-circle me-1"/>
|
||||
Using Odoo's default Google credentials as fallback
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== MICROSOFT OUTLOOK ===== -->
|
||||
<h2>Microsoft Outlook</h2>
|
||||
|
||||
<div class="row mt-4 o_settings_container">
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="o_form_label">Microsoft OAuth Credentials</span>
|
||||
<div class="text-muted">
|
||||
Required to connect Outlook / Microsoft 365 accounts.
|
||||
Get these from Azure Portal.
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<div class="row mb-2">
|
||||
<label for="x_fc_microsoft_client_id" class="col-5 col-form-label">Client ID</label>
|
||||
<div class="col-7">
|
||||
<field name="x_fc_microsoft_client_id"
|
||||
placeholder="Leave empty to use Odoo default"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="x_fc_microsoft_client_secret" class="col-5 col-form-label">Client Secret</label>
|
||||
<div class="col-7">
|
||||
<field name="x_fc_microsoft_client_secret" password="True"
|
||||
placeholder="Leave empty to use Odoo default"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<field name="x_fc_microsoft_has_fallback" invisible="True"/>
|
||||
<div class="alert alert-info mt-2 py-2 px-3" role="alert"
|
||||
invisible="not x_fc_microsoft_has_fallback">
|
||||
<i class="fa fa-info-circle me-1"/>
|
||||
Using Odoo's default Microsoft credentials as fallback
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== SCHEDULE DEFAULTS ===== -->
|
||||
<h2>Schedule Defaults</h2>
|
||||
|
||||
<div class="row mt-4 o_settings_container">
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="o_form_label">Work Hours</span>
|
||||
<div class="text-muted">
|
||||
Default work day start and end times for staff scheduling
|
||||
</div>
|
||||
<div class="mt-2 d-flex align-items-center gap-2">
|
||||
<field name="x_fc_default_work_start" widget="float_time" style="max-width: 90px;"/>
|
||||
<span class="text-muted">to</span>
|
||||
<field name="x_fc_default_work_end" widget="float_time" style="max-width: 90px;"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="o_form_label">Break / Lunch</span>
|
||||
<div class="text-muted">
|
||||
Default fixed break time for staff
|
||||
</div>
|
||||
<div class="mt-2 d-flex align-items-center gap-2">
|
||||
<field name="x_fc_default_break_start" widget="float_time" style="max-width: 90px;"/>
|
||||
<span class="text-muted">for</span>
|
||||
<field name="x_fc_default_break_duration" widget="float_time" style="max-width: 90px;"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="o_form_label">Travel Buffer</span>
|
||||
<div class="text-muted">
|
||||
Minimum travel time buffer between consecutive appointments
|
||||
</div>
|
||||
<div class="mt-2 row">
|
||||
<div class="col-4">
|
||||
<field name="x_fc_default_travel_buffer"/>
|
||||
</div>
|
||||
<div class="col-8 pt-2 text-muted">minutes (default: 30)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</app>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user