update
This commit is contained in:
56
fusion_schedule/static/src/css/portal_schedule.css
Normal file
56
fusion_schedule/static/src/css/portal_schedule.css
Normal file
@@ -0,0 +1,56 @@
|
||||
/* Fusion Schedule - Portal responsive styles */
|
||||
|
||||
/* Collapse chevron rotation */
|
||||
[data-bs-toggle="collapse"][aria-expanded="true"] .fa-chevron-down {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Min-width utility for text truncation */
|
||||
.min-width-0 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Mobile-friendly button sizing */
|
||||
@media (max-width: 575.98px) {
|
||||
.js-reschedule-event,
|
||||
.js-cancel-event {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-header h5,
|
||||
.card-header h6 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
font-size: 13px;
|
||||
padding: 0.5rem 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Slot buttons responsive grid */
|
||||
@media (max-width: 575.98px) {
|
||||
#slotsGrid .btn,
|
||||
#rescheduleSlotsGrid .btn,
|
||||
#publicSlotsGrid .btn,
|
||||
#publicRescheduleSlotsGrid .btn {
|
||||
min-width: 80px !important;
|
||||
padding: 6px 10px !important;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Modal responsive */
|
||||
@media (max-width: 575.98px) {
|
||||
.modal-dialog {
|
||||
margin: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Connected calendar compact badges */
|
||||
.badge i.fa-google,
|
||||
.badge i.fa-windows {
|
||||
vertical-align: middle;
|
||||
}
|
||||
489
fusion_schedule/static/src/js/portal_schedule_accounts.js
Normal file
489
fusion_schedule/static/src/js/portal_schedule_accounts.js
Normal file
@@ -0,0 +1,489 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function localDateStr(d) {
|
||||
d = d || new Date();
|
||||
var y = d.getFullYear();
|
||||
var m = ('0' + (d.getMonth() + 1)).slice(-2);
|
||||
var day = ('0' + d.getDate()).slice(-2);
|
||||
return y + '-' + m + '-' + day;
|
||||
}
|
||||
|
||||
(function setTzCookie() {
|
||||
try {
|
||||
var tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (tz && document.cookie.indexOf('tz=' + tz) === -1) {
|
||||
document.cookie = 'tz=' + tz + ';path=/;max-age=31536000;SameSite=Lax';
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
|
||||
function jsonRpc(url, params) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: params }),
|
||||
}).then(function (r) { return r.json(); });
|
||||
}
|
||||
|
||||
// ---- Reusable confirmation modal ----
|
||||
function fusionConfirm(opts) {
|
||||
return new Promise(function (resolve) {
|
||||
var modal = document.getElementById('fusionConfirmModal');
|
||||
if (!modal) { resolve(window.confirm(opts.message)); return; }
|
||||
var titleEl = document.getElementById('fusionConfirmTitle');
|
||||
var msgEl = document.getElementById('fusionConfirmMessage');
|
||||
var okBtn = document.getElementById('fusionConfirmOk');
|
||||
|
||||
titleEl.textContent = opts.title || 'Confirm';
|
||||
msgEl.textContent = opts.message || 'Are you sure?';
|
||||
|
||||
okBtn.className = 'btn ' + (opts.btnClass || 'btn-danger');
|
||||
okBtn.innerHTML = '<i class="fa ' + (opts.icon || 'fa-check') + ' me-1"></i>' + (opts.okLabel || 'Yes, proceed');
|
||||
|
||||
function openModal() {
|
||||
modal.classList.add('show');
|
||||
modal.style.display = 'block';
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('modal-open');
|
||||
var bd = document.createElement('div');
|
||||
bd.className = 'modal-backdrop fade show';
|
||||
bd.id = 'fusionConfirmBackdrop';
|
||||
document.body.appendChild(bd);
|
||||
bd.addEventListener('click', onDismiss);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.classList.remove('show');
|
||||
modal.style.display = 'none';
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
document.body.classList.remove('modal-open');
|
||||
var bd = document.getElementById('fusionConfirmBackdrop');
|
||||
if (bd) bd.remove();
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
okBtn.removeEventListener('click', onOk);
|
||||
modal.querySelectorAll('[data-bs-dismiss="modal"]').forEach(function (el) {
|
||||
el.removeEventListener('click', onDismiss);
|
||||
});
|
||||
modal.removeEventListener('click', onBackdrop);
|
||||
}
|
||||
|
||||
function onOk() { cleanup(); closeModal(); resolve(true); }
|
||||
function onDismiss() { cleanup(); closeModal(); resolve(false); }
|
||||
function onBackdrop(e) { if (e.target === modal) onDismiss(); }
|
||||
|
||||
okBtn.addEventListener('click', onOk);
|
||||
modal.querySelectorAll('[data-bs-dismiss="modal"]').forEach(function (el) {
|
||||
el.addEventListener('click', onDismiss);
|
||||
});
|
||||
modal.addEventListener('click', onBackdrop);
|
||||
openModal();
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Reusable toast notification ----
|
||||
function fusionToast(message, type) {
|
||||
var existing = document.getElementById('fusionToastLive');
|
||||
if (existing) existing.remove();
|
||||
|
||||
var colors = { success: '#16a34a', danger: '#dc2626' };
|
||||
var bg = colors[type] || '#374151';
|
||||
|
||||
var toast = document.createElement('div');
|
||||
toast.id = 'fusionToastLive';
|
||||
toast.style.cssText = 'position:fixed;bottom:24px;right:24px;z-index:9999;background:' + bg +
|
||||
';color:#fff;padding:14px 22px;border-radius:10px;font-size:14px;font-weight:500;' +
|
||||
'box-shadow:0 4px 16px rgba(0,0,0,0.18);opacity:0;transition:opacity .3s ease;max-width:380px;';
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
toast.style.opacity = '1';
|
||||
});
|
||||
setTimeout(function () {
|
||||
toast.style.opacity = '0';
|
||||
setTimeout(function () { toast.remove(); }, 350);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
// ---- Disconnect account ----
|
||||
document.querySelectorAll('.js-disconnect-account').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var accountId = btn.dataset.accountId;
|
||||
var accountEmail = btn.dataset.accountEmail || 'this account';
|
||||
|
||||
fusionConfirm({
|
||||
title: 'Disconnect Calendar',
|
||||
message: 'Disconnect ' + accountEmail + '? Events synced from this account will remain in Odoo.',
|
||||
okLabel: 'Disconnect',
|
||||
icon: 'fa-unlink',
|
||||
btnClass: 'btn-warning',
|
||||
}).then(function (yes) {
|
||||
if (!yes) return;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i>';
|
||||
|
||||
jsonRpc('/my/schedule/disconnect', { account_id: parseInt(accountId) })
|
||||
.then(function (data) {
|
||||
if ((data.result || {}).success) { window.location.reload(); }
|
||||
else { fusionToast((data.result || {}).error || 'Failed to disconnect.', 'danger'); btn.disabled = false; btn.innerHTML = '<i class="fa fa-times"></i>'; }
|
||||
})
|
||||
.catch(function () { fusionToast('Network error.', 'danger'); btn.disabled = false; btn.innerHTML = '<i class="fa fa-times"></i>'; });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Sync Now ----
|
||||
document.querySelectorAll('.js-sync-account').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var accountId = this.dataset.accountId;
|
||||
btn.disabled = true;
|
||||
var origHtml = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i>';
|
||||
|
||||
jsonRpc('/my/schedule/sync-now', { account_id: parseInt(accountId) })
|
||||
.then(function (data) {
|
||||
if ((data.result || {}).success) { window.location.reload(); }
|
||||
else { fusionToast((data.result || {}).error || 'Sync failed.', 'danger'); btn.disabled = false; btn.innerHTML = origHtml; }
|
||||
})
|
||||
.catch(function () { fusionToast('Network error.', 'danger'); btn.disabled = false; btn.innerHTML = origHtml; });
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Share Booking Link ----
|
||||
document.querySelectorAll('.js-share-booking').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
var url = this.dataset.url;
|
||||
if (!url) return;
|
||||
|
||||
if (navigator.share) {
|
||||
navigator.share({ title: 'Book an Appointment', url: url }).catch(function () {});
|
||||
} else {
|
||||
navigator.clipboard.writeText(url).then(function () {
|
||||
var orig = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="fa fa-check me-1"></i> Copied!';
|
||||
btn.classList.add('btn-success');
|
||||
btn.classList.remove('btn-outline-secondary', 'btn-primary');
|
||||
setTimeout(function () {
|
||||
btn.innerHTML = orig;
|
||||
btn.classList.remove('btn-success');
|
||||
btn.classList.add('btn-primary');
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Save Schedule Preferences ----
|
||||
var savePrefsBtn = document.getElementById('btnSavePrefs');
|
||||
if (savePrefsBtn) {
|
||||
savePrefsBtn.addEventListener('click', function () {
|
||||
var form = document.getElementById('schedulePrefsForm');
|
||||
if (!form) return;
|
||||
|
||||
var workStartParts = (form.querySelector('[name="work_start"]').value || '09:00').split(':');
|
||||
var workEndParts = (form.querySelector('[name="work_end"]').value || '17:00').split(':');
|
||||
var breakStartParts = (form.querySelector('[name="break_start"]').value || '12:00').split(':');
|
||||
var breakDurMin = parseInt(form.querySelector('[name="break_duration_min"]').value || '30');
|
||||
|
||||
var params = {
|
||||
work_start: parseInt(workStartParts[0]) + parseInt(workStartParts[1] || 0) / 60,
|
||||
work_end: parseInt(workEndParts[0]) + parseInt(workEndParts[1] || 0) / 60,
|
||||
break_start: parseInt(breakStartParts[0]) + parseInt(breakStartParts[1] || 0) / 60,
|
||||
break_duration: breakDurMin / 60,
|
||||
travel_buffer: parseInt(form.querySelector('[name="travel_buffer"]').value || '30'),
|
||||
home_address: form.querySelector('[name="home_address"]').value || '',
|
||||
};
|
||||
|
||||
savePrefsBtn.disabled = true;
|
||||
savePrefsBtn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Saving...';
|
||||
|
||||
jsonRpc('/my/schedule/preferences', params)
|
||||
.then(function (data) {
|
||||
savePrefsBtn.disabled = false;
|
||||
savePrefsBtn.innerHTML = '<i class="fa fa-save me-1"></i> Save Preferences';
|
||||
if ((data.result || {}).success) {
|
||||
var msg = document.getElementById('prefsSavedMsg');
|
||||
if (msg) {
|
||||
msg.style.display = 'inline';
|
||||
setTimeout(function () { msg.style.display = 'none'; }, 3000);
|
||||
}
|
||||
} else {
|
||||
fusionToast('Failed to save preferences.', 'danger');
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
savePrefsBtn.disabled = false;
|
||||
savePrefsBtn.innerHTML = '<i class="fa fa-save me-1"></i> Save Preferences';
|
||||
fusionToast('Network error.', 'danger');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Cancel Event ----
|
||||
document.querySelectorAll('.js-cancel-event').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
var eventId = btn.dataset.eventId;
|
||||
var eventName = btn.dataset.eventName || 'this appointment';
|
||||
|
||||
fusionConfirm({
|
||||
title: 'Cancel Appointment',
|
||||
message: 'Cancel "' + eventName + '"? This action cannot be undone.',
|
||||
okLabel: 'Cancel appointment',
|
||||
icon: 'fa-trash-o',
|
||||
}).then(function (yes) {
|
||||
if (!yes) return;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i>';
|
||||
|
||||
jsonRpc('/my/schedule/event/cancel', { event_id: parseInt(eventId) })
|
||||
.then(function (data) {
|
||||
if ((data.result || {}).success) { window.location.reload(); }
|
||||
else { fusionToast((data.result || {}).error || 'Failed to cancel.', 'danger'); btn.disabled = false; btn.innerHTML = '<i class="fa fa-trash-o"></i>'; }
|
||||
})
|
||||
.catch(function () { fusionToast('Network error.', 'danger'); btn.disabled = false; btn.innerHTML = '<i class="fa fa-trash-o"></i>'; });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Reschedule Event (Modal) ----
|
||||
var rescheduleModal = document.getElementById('rescheduleModal');
|
||||
if (!rescheduleModal) return;
|
||||
|
||||
var rescheduleDateInput = document.getElementById('rescheduleDate');
|
||||
var rescheduleSlotsContainer = document.getElementById('rescheduleSlotsContainer');
|
||||
var rescheduleSlotsGrid = document.getElementById('rescheduleSlotsGrid');
|
||||
var rescheduleSlotsLoading = document.getElementById('rescheduleSlotsLoading');
|
||||
var rescheduleNoSlots = document.getElementById('rescheduleNoSlots');
|
||||
var rescheduleEventIdInput = document.getElementById('rescheduleEventId');
|
||||
var rescheduleSlotDatetimeInput = document.getElementById('rescheduleSlotDatetime');
|
||||
var rescheduleEventDurationInput = document.getElementById('rescheduleEventDuration');
|
||||
var rescheduleEventNameEl = document.getElementById('rescheduleEventName');
|
||||
var confirmRescheduleBtn = document.getElementById('btnConfirmReschedule');
|
||||
var rescheduleAppTypeInput = document.getElementById('rescheduleAppTypeId');
|
||||
var rescheduleSelectedBtn = null;
|
||||
|
||||
document.querySelectorAll('.js-reschedule-event').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
rescheduleEventIdInput.value = this.dataset.eventId;
|
||||
rescheduleEventDurationInput.value = this.dataset.eventDuration || '';
|
||||
rescheduleEventNameEl.textContent = this.dataset.eventName || '';
|
||||
|
||||
rescheduleDateInput.value = '';
|
||||
rescheduleSlotsContainer.style.display = 'none';
|
||||
rescheduleSlotsGrid.innerHTML = '';
|
||||
rescheduleSlotDatetimeInput.value = '';
|
||||
confirmRescheduleBtn.disabled = true;
|
||||
rescheduleSelectedBtn = null;
|
||||
|
||||
var today = new Date();
|
||||
rescheduleDateInput.min = localDateStr(today);
|
||||
|
||||
rescheduleModal.classList.add('show');
|
||||
rescheduleModal.style.display = 'block';
|
||||
rescheduleModal.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('modal-open');
|
||||
|
||||
var backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop fade show';
|
||||
backdrop.id = 'rescheduleBackdrop';
|
||||
document.body.appendChild(backdrop);
|
||||
});
|
||||
});
|
||||
|
||||
function closeRescheduleModal() {
|
||||
rescheduleModal.classList.remove('show');
|
||||
rescheduleModal.style.display = 'none';
|
||||
rescheduleModal.setAttribute('aria-hidden', 'true');
|
||||
document.body.classList.remove('modal-open');
|
||||
var backdrop = document.getElementById('rescheduleBackdrop');
|
||||
if (backdrop) backdrop.remove();
|
||||
}
|
||||
|
||||
rescheduleModal.querySelectorAll('[data-bs-dismiss="modal"]').forEach(function (el) {
|
||||
el.addEventListener('click', closeRescheduleModal);
|
||||
});
|
||||
|
||||
rescheduleModal.addEventListener('click', function (e) {
|
||||
if (e.target === rescheduleModal) closeRescheduleModal();
|
||||
});
|
||||
|
||||
if (rescheduleDateInput) {
|
||||
rescheduleDateInput.addEventListener('change', function () {
|
||||
var date = this.value;
|
||||
if (!date) return;
|
||||
|
||||
rescheduleSlotsContainer.style.display = 'block';
|
||||
rescheduleSlotsLoading.style.display = 'block';
|
||||
rescheduleSlotsGrid.innerHTML = '';
|
||||
rescheduleNoSlots.style.display = 'none';
|
||||
rescheduleSlotDatetimeInput.value = '';
|
||||
confirmRescheduleBtn.disabled = true;
|
||||
rescheduleSelectedBtn = null;
|
||||
|
||||
var appTypeId = rescheduleAppTypeInput ? parseInt(rescheduleAppTypeInput.value) : 0;
|
||||
|
||||
jsonRpc('/my/schedule/available-slots', {
|
||||
selected_date: date,
|
||||
appointment_type_id: appTypeId,
|
||||
})
|
||||
.then(function (data) {
|
||||
rescheduleSlotsLoading.style.display = 'none';
|
||||
var slots = (data.result || {}).slots || [];
|
||||
if (!slots.length) {
|
||||
rescheduleNoSlots.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
slots.forEach(function (s) {
|
||||
var slotBtn = document.createElement('button');
|
||||
slotBtn.type = 'button';
|
||||
slotBtn.className = 'btn btn-outline-primary btn-sm';
|
||||
slotBtn.style.cssText = 'min-width: 90px; border-radius: 8px; padding: 8px 12px;';
|
||||
slotBtn.textContent = s.start_hour;
|
||||
slotBtn.addEventListener('click', function () {
|
||||
if (rescheduleSelectedBtn) {
|
||||
rescheduleSelectedBtn.classList.remove('btn-primary');
|
||||
rescheduleSelectedBtn.classList.add('btn-outline-primary');
|
||||
}
|
||||
slotBtn.classList.remove('btn-outline-primary');
|
||||
slotBtn.classList.add('btn-primary');
|
||||
rescheduleSelectedBtn = slotBtn;
|
||||
rescheduleSlotDatetimeInput.value = s.datetime;
|
||||
confirmRescheduleBtn.disabled = false;
|
||||
});
|
||||
rescheduleSlotsGrid.appendChild(slotBtn);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
rescheduleSlotsLoading.style.display = 'none';
|
||||
rescheduleNoSlots.textContent = 'Failed to load slots.';
|
||||
rescheduleNoSlots.style.display = 'block';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (confirmRescheduleBtn) {
|
||||
confirmRescheduleBtn.addEventListener('click', function () {
|
||||
var eventId = rescheduleEventIdInput.value;
|
||||
var newDatetime = rescheduleSlotDatetimeInput.value;
|
||||
var duration = rescheduleEventDurationInput.value;
|
||||
if (!eventId || !newDatetime) return;
|
||||
|
||||
confirmRescheduleBtn.disabled = true;
|
||||
confirmRescheduleBtn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Saving...';
|
||||
|
||||
jsonRpc('/my/schedule/event/reschedule', {
|
||||
event_id: parseInt(eventId),
|
||||
new_datetime: newDatetime,
|
||||
new_duration: duration || null,
|
||||
})
|
||||
.then(function (data) {
|
||||
var result = data.result || {};
|
||||
if (result.success) {
|
||||
closeRescheduleModal();
|
||||
window.location.reload();
|
||||
} else {
|
||||
fusionToast(result.error || 'Failed to reschedule.', 'danger');
|
||||
confirmRescheduleBtn.disabled = false;
|
||||
confirmRescheduleBtn.innerHTML = '<i class="fa fa-check me-1"></i> Confirm';
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
fusionToast('Network error.', 'danger');
|
||||
confirmRescheduleBtn.disabled = false;
|
||||
confirmRescheduleBtn.innerHTML = '<i class="fa fa-check me-1"></i> Confirm';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Optimize Schedule (AI) ----
|
||||
var optimizeBtn = document.getElementById('btnOptimizeSchedule');
|
||||
var optimizeModal = document.getElementById('optimizeModal');
|
||||
if (optimizeBtn && optimizeModal) {
|
||||
optimizeBtn.addEventListener('click', function () {
|
||||
optimizeModal.classList.add('show');
|
||||
optimizeModal.style.display = 'block';
|
||||
optimizeModal.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('modal-open');
|
||||
var backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop fade show';
|
||||
backdrop.id = 'optimizeBackdrop';
|
||||
document.body.appendChild(backdrop);
|
||||
|
||||
var loading = document.getElementById('optimizeLoading');
|
||||
var result = document.getElementById('optimizeResult');
|
||||
var errDiv = document.getElementById('optimizeError');
|
||||
loading.style.display = 'block';
|
||||
result.style.display = 'none';
|
||||
errDiv.style.display = 'none';
|
||||
|
||||
var today = localDateStr();
|
||||
jsonRpc('/my/schedule/ai/optimize', { selected_date: today })
|
||||
.then(function (data) {
|
||||
loading.style.display = 'none';
|
||||
var r = data.result || {};
|
||||
if (r.error) {
|
||||
errDiv.textContent = r.error;
|
||||
errDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
var opt = r.optimization;
|
||||
if (!opt) {
|
||||
errDiv.textContent = 'No optimization data returned.';
|
||||
errDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
document.getElementById('optimizeCurrentTravel').textContent =
|
||||
(opt.current_travel_total_min || 0) + ' min';
|
||||
document.getElementById('optimizeNewTravel').textContent =
|
||||
(opt.optimized_travel_total_min || 0) + ' min';
|
||||
var savings = opt.savings_min || 0;
|
||||
document.getElementById('optimizeSavings').textContent =
|
||||
savings > 0 ? 'Save ' + savings + ' min' : '';
|
||||
|
||||
var listEl = document.getElementById('optimizeScheduleList');
|
||||
listEl.innerHTML = '';
|
||||
(opt.schedule || []).forEach(function (item) {
|
||||
var div = document.createElement('div');
|
||||
div.className = 'd-flex justify-content-between align-items-center py-2 border-bottom';
|
||||
div.innerHTML =
|
||||
'<div><strong>' + (item.name || '') + '</strong>' +
|
||||
'<br/><small class="text-muted">' + (item.reason || '') + '</small></div>' +
|
||||
'<span class="badge text-bg-info">' + (item.suggested_time || '') + '</span>';
|
||||
listEl.appendChild(div);
|
||||
});
|
||||
result.style.display = 'block';
|
||||
})
|
||||
.catch(function () {
|
||||
loading.style.display = 'none';
|
||||
errDiv.textContent = 'Network error.';
|
||||
errDiv.style.display = 'block';
|
||||
});
|
||||
});
|
||||
|
||||
function closeOptimizeModal() {
|
||||
optimizeModal.classList.remove('show');
|
||||
optimizeModal.style.display = 'none';
|
||||
optimizeModal.setAttribute('aria-hidden', 'true');
|
||||
document.body.classList.remove('modal-open');
|
||||
var b = document.getElementById('optimizeBackdrop');
|
||||
if (b) b.remove();
|
||||
}
|
||||
optimizeModal.querySelectorAll('[data-bs-dismiss="modal"]').forEach(function (el) {
|
||||
el.addEventListener('click', closeOptimizeModal);
|
||||
});
|
||||
optimizeModal.addEventListener('click', function (e) {
|
||||
if (e.target === optimizeModal) closeOptimizeModal();
|
||||
});
|
||||
}
|
||||
})();
|
||||
575
fusion_schedule/static/src/js/portal_schedule_booking.js
Normal file
575
fusion_schedule/static/src/js/portal_schedule_booking.js
Normal file
@@ -0,0 +1,575 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
(function setTzCookie() {
|
||||
try {
|
||||
var tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (tz && document.cookie.indexOf('tz=' + tz) === -1) {
|
||||
document.cookie = 'tz=' + tz + ';path=/;max-age=31536000;SameSite=Lax';
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
|
||||
var dateInput = document.getElementById('bookingDate');
|
||||
var slotsContainer = document.getElementById('slotsContainer');
|
||||
var slotsGrid = document.getElementById('slotsGrid');
|
||||
var slotsLoading = document.getElementById('slotsLoading');
|
||||
var noSlots = document.getElementById('noSlots');
|
||||
var slotDatetimeInput = document.getElementById('slotDatetime');
|
||||
var slotDurationInput = document.getElementById('slotDuration');
|
||||
var submitBtn = document.getElementById('btnSubmitBooking');
|
||||
var typeSelect = document.getElementById('appointmentTypeSelect');
|
||||
var selectedSlotBtn = null;
|
||||
|
||||
var weekContainer = document.getElementById('weekCalendarContainer');
|
||||
var weekLoading = document.getElementById('weekCalendarLoading');
|
||||
var weekGrid = document.getElementById('weekCalendarGrid');
|
||||
var weekHeader = document.getElementById('weekCalendarHeader');
|
||||
var weekBody = document.getElementById('weekCalendarBody');
|
||||
var weekEmpty = document.getElementById('weekCalendarEmpty');
|
||||
var weekNav = document.getElementById('weekCalendarNav');
|
||||
|
||||
var currentWeekDays = [];
|
||||
var currentWeekEvents = [];
|
||||
|
||||
function getAppointmentTypeId() {
|
||||
if (typeSelect) return typeSelect.value;
|
||||
var hidden = document.querySelector('input[name="appointment_type_id"]');
|
||||
return hidden ? hidden.value : null;
|
||||
}
|
||||
|
||||
function truncate(str, max) {
|
||||
if (!str) return '';
|
||||
return str.length > max ? str.substring(0, max) + '...' : str;
|
||||
}
|
||||
|
||||
function formatDateStr(d) {
|
||||
var y = d.getFullYear();
|
||||
var m = ('0' + (d.getMonth() + 1)).slice(-2);
|
||||
var day = ('0' + d.getDate()).slice(-2);
|
||||
return y + '-' + m + '-' + day;
|
||||
}
|
||||
|
||||
function addDays(dateStr, n) {
|
||||
var d = new Date(dateStr + 'T12:00:00');
|
||||
d.setDate(d.getDate() + n);
|
||||
return formatDateStr(d);
|
||||
}
|
||||
|
||||
function getMonday(dateStr) {
|
||||
var d = new Date(dateStr + 'T12:00:00');
|
||||
var dow = d.getDay();
|
||||
var diff = dow === 0 ? -6 : 1 - dow;
|
||||
d.setDate(d.getDate() + diff);
|
||||
return formatDateStr(d);
|
||||
}
|
||||
|
||||
function selectDay(dateStr) {
|
||||
if (dateInput) {
|
||||
dateInput.value = dateStr;
|
||||
}
|
||||
fetchSlots(dateStr);
|
||||
if (currentWeekDays.length) {
|
||||
renderWeekCalendar(currentWeekDays, currentWeekEvents, dateStr);
|
||||
}
|
||||
}
|
||||
|
||||
function fetchWeekEvents(date, selectDate) {
|
||||
if (!weekContainer || !date) return;
|
||||
|
||||
weekContainer.style.display = 'block';
|
||||
weekLoading.style.display = 'block';
|
||||
weekGrid.style.display = 'none';
|
||||
weekEmpty.style.display = 'none';
|
||||
if (weekNav) weekNav.style.display = 'none';
|
||||
|
||||
fetch('/my/schedule/week-events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'call',
|
||||
params: { selected_date: date },
|
||||
}),
|
||||
})
|
||||
.then(function (resp) { return resp.json(); })
|
||||
.then(function (data) {
|
||||
weekLoading.style.display = 'none';
|
||||
var result = data.result || {};
|
||||
var events = result.events || [];
|
||||
var weekDays = result.week_days || [];
|
||||
|
||||
if (result.error || !weekDays.length) {
|
||||
weekEmpty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
currentWeekDays = weekDays;
|
||||
currentWeekEvents = events;
|
||||
|
||||
var sel = selectDate || date;
|
||||
renderWeekCalendar(weekDays, events, sel);
|
||||
})
|
||||
.catch(function () {
|
||||
weekLoading.style.display = 'none';
|
||||
weekEmpty.textContent = 'Failed to load calendar. Please try again.';
|
||||
weekEmpty.style.display = 'block';
|
||||
});
|
||||
}
|
||||
|
||||
function navigateWeek(direction) {
|
||||
var workDays = currentWeekDays.filter(function (d) {
|
||||
return d.label !== 'Sat' && d.label !== 'Sun';
|
||||
});
|
||||
if (!workDays.length) return;
|
||||
|
||||
var refDate = direction > 0 ? workDays[workDays.length - 1].date : workDays[0].date;
|
||||
var newDate = addDays(refDate, direction > 0 ? 7 : -7);
|
||||
var monday = getMonday(newDate);
|
||||
|
||||
var today = formatDateStr(new Date());
|
||||
var targetSelect = monday;
|
||||
if (today >= monday && today <= addDays(monday, 4)) {
|
||||
targetSelect = today;
|
||||
}
|
||||
|
||||
fetchWeekEvents(monday, targetSelect);
|
||||
selectDay(targetSelect);
|
||||
}
|
||||
|
||||
function renderWeekCalendar(weekDays, events, selectedDate) {
|
||||
weekHeader.innerHTML = '';
|
||||
weekBody.innerHTML = '';
|
||||
|
||||
var eventsByDate = {};
|
||||
events.forEach(function (ev) {
|
||||
if (!eventsByDate[ev.date]) eventsByDate[ev.date] = [];
|
||||
eventsByDate[ev.date].push(ev);
|
||||
});
|
||||
|
||||
var workDays = weekDays.filter(function (d) {
|
||||
return d.label !== 'Sat' && d.label !== 'Sun';
|
||||
});
|
||||
|
||||
if (!workDays.length) {
|
||||
weekGrid.style.display = 'none';
|
||||
weekEmpty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
var monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
var firstD = new Date(workDays[0].date + 'T12:00:00');
|
||||
var lastD = new Date(workDays[workDays.length - 1].date + 'T12:00:00');
|
||||
var rangeLabel = monthNames[firstD.getMonth()] + ' ' + firstD.getDate();
|
||||
if (firstD.getMonth() !== lastD.getMonth()) {
|
||||
rangeLabel += ' - ' + monthNames[lastD.getMonth()] + ' ' + lastD.getDate();
|
||||
} else {
|
||||
rangeLabel += ' - ' + lastD.getDate();
|
||||
}
|
||||
rangeLabel += ', ' + firstD.getFullYear();
|
||||
|
||||
if (weekNav) {
|
||||
weekNav.style.display = 'flex';
|
||||
var rangeEl = weekNav.querySelector('#weekRangeLabel');
|
||||
if (rangeEl) rangeEl.textContent = rangeLabel;
|
||||
}
|
||||
|
||||
weekGrid.style.cssText = 'display: grid; grid-template-columns: repeat(' + workDays.length + ', 1fr); border-radius: 10px; overflow: hidden; border: 1px solid #e5e7eb;';
|
||||
weekHeader.style.cssText = 'display: contents;';
|
||||
weekBody.style.cssText = 'display: contents;';
|
||||
|
||||
workDays.forEach(function (day) {
|
||||
var isSelected = day.date === selectedDate;
|
||||
var isToday = day.date === formatDateStr(new Date());
|
||||
var dayEvents = eventsByDate[day.date] || [];
|
||||
|
||||
var col = document.createElement('div');
|
||||
col.style.cssText = 'cursor: pointer; user-select: none;';
|
||||
col.dataset.date = day.date;
|
||||
col.addEventListener('click', function () {
|
||||
selectDay(day.date);
|
||||
});
|
||||
|
||||
var headerCell = document.createElement('div');
|
||||
headerCell.style.cssText = 'text-align: center; padding: 10px 4px 8px; border-right: 1px solid #f0f0f0; border-bottom: 1px solid #e5e7eb; transition: background 0.15s;';
|
||||
if (isSelected) {
|
||||
headerCell.style.background = 'linear-gradient(180deg, #eff6ff 0%, #dbeafe 100%)';
|
||||
} else {
|
||||
headerCell.style.background = '#fafbfc';
|
||||
}
|
||||
|
||||
var labelEl = document.createElement('div');
|
||||
labelEl.style.cssText = 'font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: ' + (isSelected ? '#2563eb' : '#9ca3af') + ';';
|
||||
labelEl.textContent = day.label;
|
||||
|
||||
var numEl = document.createElement('div');
|
||||
if (isSelected) {
|
||||
numEl.innerHTML = '<span style="display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #2563eb; color: #fff; font-size: 16px; font-weight: 700;">' + day.day_num + '</span>';
|
||||
} else if (isToday) {
|
||||
numEl.innerHTML = '<span style="display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #059669; color: #fff; font-size: 16px; font-weight: 700;">' + day.day_num + '</span>';
|
||||
} else {
|
||||
numEl.style.cssText = 'font-size: 18px; font-weight: 700; line-height: 1.2; color: #374151;';
|
||||
numEl.textContent = day.day_num;
|
||||
}
|
||||
|
||||
headerCell.appendChild(labelEl);
|
||||
headerCell.appendChild(numEl);
|
||||
weekHeader.appendChild(col);
|
||||
col.appendChild(headerCell);
|
||||
|
||||
var bodyCell = document.createElement('div');
|
||||
bodyCell.style.cssText = 'padding: 6px; min-height: 90px; border-right: 1px solid #f0f0f0; overflow-y: auto; transition: background 0.15s;';
|
||||
bodyCell.style.background = isSelected ? '#f0f7ff' : '#fff';
|
||||
|
||||
if (dayEvents.length) {
|
||||
dayEvents.forEach(function (ev) {
|
||||
var card = document.createElement('div');
|
||||
card.style.cssText = 'margin-bottom: 4px; padding: 5px 7px; border-radius: 6px; background: linear-gradient(135deg, #eff6ff 0%, #f0f7ff 100%); border-left: 3px solid #3b82f6; cursor: pointer; transition: box-shadow 0.15s;';
|
||||
card.title = ev.start_time + ' - ' + ev.end_time + '\n' + ev.name + (ev.location ? '\n' + ev.location : '');
|
||||
card.onmouseenter = function () { card.style.boxShadow = '0 2px 8px rgba(59,130,246,0.15)'; };
|
||||
card.onmouseleave = function () { card.style.boxShadow = 'none'; };
|
||||
|
||||
var timeEl = document.createElement('div');
|
||||
timeEl.style.cssText = 'font-size: 10px; font-weight: 600; color: #3b82f6;';
|
||||
timeEl.textContent = ev.start_time;
|
||||
|
||||
var nameEl = document.createElement('div');
|
||||
nameEl.style.cssText = 'font-size: 11px; color: #374151; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;';
|
||||
nameEl.textContent = truncate(ev.name, 20);
|
||||
|
||||
card.appendChild(timeEl);
|
||||
card.appendChild(nameEl);
|
||||
bodyCell.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
var bodyCol = document.createElement('div');
|
||||
bodyCol.style.cssText = 'cursor: pointer;';
|
||||
bodyCol.dataset.date = day.date;
|
||||
bodyCol.addEventListener('click', function () {
|
||||
selectDay(day.date);
|
||||
});
|
||||
bodyCol.appendChild(bodyCell);
|
||||
weekBody.appendChild(bodyCol);
|
||||
});
|
||||
|
||||
weekGrid.style.display = 'grid';
|
||||
weekEmpty.style.display = 'none';
|
||||
}
|
||||
|
||||
function fetchSlots(date) {
|
||||
var typeId = getAppointmentTypeId();
|
||||
if (!typeId || !date) return;
|
||||
|
||||
slotsContainer.style.display = 'block';
|
||||
slotsLoading.style.display = 'block';
|
||||
slotsGrid.innerHTML = '';
|
||||
noSlots.style.display = 'none';
|
||||
slotDatetimeInput.value = '';
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
selectedSlotBtn = null;
|
||||
|
||||
fetch('/my/schedule/available-slots', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'call',
|
||||
params: {
|
||||
appointment_type_id: parseInt(typeId),
|
||||
selected_date: date,
|
||||
},
|
||||
}),
|
||||
})
|
||||
.then(function (resp) { return resp.json(); })
|
||||
.then(function (data) {
|
||||
slotsLoading.style.display = 'none';
|
||||
slotsGrid.innerHTML = '';
|
||||
var result = data.result || {};
|
||||
var slots = result.slots || [];
|
||||
|
||||
if (result.error) {
|
||||
noSlots.textContent = result.error;
|
||||
noSlots.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!slots.length) {
|
||||
noSlots.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
var morningSlots = [];
|
||||
var afternoonSlots = [];
|
||||
slots.forEach(function (slot) {
|
||||
var text = slot.start_hour.toLowerCase();
|
||||
var match = text.match(/(\d+)/);
|
||||
var hour = match ? parseInt(match[1]) : 0;
|
||||
if (text.indexOf('pm') > -1 && hour !== 12) hour += 12;
|
||||
if (text.indexOf('am') > -1 && hour === 12) hour = 0;
|
||||
if (hour < 12) {
|
||||
morningSlots.push(slot);
|
||||
} else {
|
||||
afternoonSlots.push(slot);
|
||||
}
|
||||
});
|
||||
|
||||
function renderGroup(label, icon, groupSlots) {
|
||||
if (!groupSlots.length) return;
|
||||
var header = document.createElement('div');
|
||||
header.className = 'w-100 mt-2 mb-1';
|
||||
header.innerHTML = '<small class="text-muted fw-semibold"><i class="fa ' + icon + ' me-1"></i>' + label + '</small>';
|
||||
slotsGrid.appendChild(header);
|
||||
|
||||
groupSlots.forEach(function (slot) {
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-outline-primary btn-sm slot-btn';
|
||||
btn.style.cssText = 'min-width: 100px; border-radius: 8px; padding: 8px 14px;';
|
||||
btn.textContent = slot.start_hour;
|
||||
btn.dataset.datetime = slot.datetime;
|
||||
btn.dataset.duration = slot.duration;
|
||||
btn.addEventListener('click', function () {
|
||||
if (selectedSlotBtn) {
|
||||
selectedSlotBtn.classList.remove('btn-primary');
|
||||
selectedSlotBtn.classList.add('btn-outline-primary');
|
||||
}
|
||||
btn.classList.remove('btn-outline-primary');
|
||||
btn.classList.add('btn-primary');
|
||||
selectedSlotBtn = btn;
|
||||
slotDatetimeInput.value = slot.datetime;
|
||||
slotDurationInput.value = slot.duration;
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
});
|
||||
slotsGrid.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
renderGroup('Morning', 'fa-sun-o', morningSlots);
|
||||
renderGroup('Afternoon', 'fa-cloud', afternoonSlots);
|
||||
|
||||
fetchAiSuggestions(date);
|
||||
})
|
||||
.catch(function (err) {
|
||||
slotsLoading.style.display = 'none';
|
||||
noSlots.textContent = 'Failed to load slots. Please try again.';
|
||||
noSlots.style.display = 'block';
|
||||
});
|
||||
}
|
||||
|
||||
var aiRequestCounter = 0;
|
||||
|
||||
function fetchAiSuggestions(date) {
|
||||
var section = document.getElementById('aiSuggestSection');
|
||||
var loading = document.getElementById('aiSuggestLoading');
|
||||
var grid = document.getElementById('aiSuggestGrid');
|
||||
if (!section || !grid) return;
|
||||
|
||||
var myRequestId = ++aiRequestCounter;
|
||||
|
||||
section.style.display = 'block';
|
||||
loading.style.display = 'block';
|
||||
grid.innerHTML = '';
|
||||
|
||||
var streetInput = document.getElementById('clientStreet');
|
||||
var latInput = document.getElementById('clientLat');
|
||||
var lngInput = document.getElementById('clientLng');
|
||||
var durationInput = document.getElementById('slotDuration');
|
||||
|
||||
var params = {
|
||||
selected_date: date,
|
||||
appointment_type_id: getAppointmentTypeId() || 0,
|
||||
location: streetInput ? streetInput.value : '',
|
||||
lat: latInput ? parseFloat(latInput.value) || 0 : 0,
|
||||
lng: lngInput ? parseFloat(lngInput.value) || 0 : 0,
|
||||
duration: durationInput ? parseFloat(durationInput.value) || 1.0 : 1.0,
|
||||
};
|
||||
|
||||
fetch('/my/schedule/ai/suggest', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: params }),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (myRequestId !== aiRequestCounter) return;
|
||||
loading.style.display = 'none';
|
||||
grid.innerHTML = '';
|
||||
var result = data.result || {};
|
||||
var suggestions = result.suggestions || [];
|
||||
if (!suggestions.length) {
|
||||
section.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
suggestions.forEach(function (s) {
|
||||
var card = document.createElement('div');
|
||||
card.className = 'border rounded-3 p-2 mb-2 d-flex justify-content-between align-items-center';
|
||||
card.style.cssText = 'background: linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%); cursor: pointer; transition: all 0.15s;';
|
||||
card.innerHTML =
|
||||
'<div><strong class="text-primary">' + (s.time || '') + '</strong>' +
|
||||
'<br/><small class="text-muted">' + (s.reason || '') + '</small></div>' +
|
||||
'<i class="fa fa-magic text-info"></i>';
|
||||
card.addEventListener('click', function () {
|
||||
grid.querySelectorAll('.ai-card-selected').forEach(function (el) {
|
||||
el.classList.remove('ai-card-selected');
|
||||
el.style.border = '';
|
||||
el.style.boxShadow = '';
|
||||
});
|
||||
card.classList.add('ai-card-selected');
|
||||
card.style.border = '2px solid #2563eb';
|
||||
card.style.boxShadow = '0 2px 8px rgba(37,99,235,0.2)';
|
||||
|
||||
if (s.datetime && slotDatetimeInput) {
|
||||
if (selectedSlotBtn) {
|
||||
selectedSlotBtn.classList.remove('btn-primary');
|
||||
selectedSlotBtn.classList.add('btn-outline-primary');
|
||||
}
|
||||
slotDatetimeInput.value = s.datetime;
|
||||
if (slotDurationInput) slotDurationInput.value = s.duration || '1.0';
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
|
||||
var btns = slotsGrid ? slotsGrid.querySelectorAll('.slot-btn') : [];
|
||||
btns.forEach(function (btn) {
|
||||
if (btn.dataset.datetime === s.datetime) {
|
||||
btn.classList.remove('btn-outline-primary');
|
||||
btn.classList.add('btn-primary');
|
||||
selectedSlotBtn = btn;
|
||||
btn.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
grid.appendChild(card);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
if (myRequestId !== aiRequestCounter) return;
|
||||
loading.style.display = 'none';
|
||||
section.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
var aiSuggestBtn = document.getElementById('btnAiSuggest');
|
||||
if (aiSuggestBtn) {
|
||||
aiSuggestBtn.addEventListener('click', function () {
|
||||
if (dateInput && dateInput.value) fetchAiSuggestions(dateInput.value);
|
||||
});
|
||||
}
|
||||
|
||||
if (dateInput) {
|
||||
dateInput.addEventListener('change', function () {
|
||||
var val = this.value;
|
||||
fetchWeekEvents(val, val);
|
||||
fetchSlots(val);
|
||||
});
|
||||
}
|
||||
|
||||
if (typeSelect) {
|
||||
typeSelect.addEventListener('change', function () {
|
||||
if (dateInput && dateInput.value) {
|
||||
fetchSlots(dateInput.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var btnPrevWeek = document.getElementById('btnPrevWeek');
|
||||
var btnNextWeek = document.getElementById('btnNextWeek');
|
||||
if (btnPrevWeek) {
|
||||
btnPrevWeek.addEventListener('click', function () { navigateWeek(-1); });
|
||||
}
|
||||
if (btnNextWeek) {
|
||||
btnNextWeek.addEventListener('click', function () { navigateWeek(1); });
|
||||
}
|
||||
|
||||
if (dateInput && weekContainer) {
|
||||
var today = formatDateStr(new Date());
|
||||
dateInput.value = today;
|
||||
fetchWeekEvents(today, today);
|
||||
fetchSlots(today);
|
||||
}
|
||||
|
||||
var bookingForm = document.getElementById('bookingForm');
|
||||
if (bookingForm) {
|
||||
bookingForm.addEventListener('submit', function (e) {
|
||||
if (!slotDatetimeInput || !slotDatetimeInput.value) {
|
||||
e.preventDefault();
|
||||
if (typeof fusionToast === 'function') { fusionToast('Please select a time slot before booking.', 'danger'); }
|
||||
else { window.alert('Please select a time slot before booking.'); }
|
||||
return false;
|
||||
}
|
||||
var clientName = bookingForm.querySelector('input[name="client_name"]');
|
||||
if (!clientName || !clientName.value.trim()) {
|
||||
e.preventDefault();
|
||||
if (typeof fusionToast === 'function') { fusionToast('Please enter the client name.', 'danger'); }
|
||||
else { window.alert('Please enter the client name.'); }
|
||||
return false;
|
||||
}
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Booking...';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setupAddressAutocomplete() {
|
||||
var streetInput = document.getElementById('clientStreet');
|
||||
if (!streetInput || typeof google === 'undefined') return;
|
||||
|
||||
var autocomplete = new google.maps.places.Autocomplete(streetInput, {
|
||||
componentRestrictions: { country: 'ca' },
|
||||
types: ['address'],
|
||||
});
|
||||
|
||||
autocomplete.addListener('place_changed', function () {
|
||||
var place = autocomplete.getPlace();
|
||||
if (!place.address_components) return;
|
||||
|
||||
var streetNumber = '';
|
||||
var streetName = '';
|
||||
var city = '';
|
||||
var province = '';
|
||||
var postalCode = '';
|
||||
|
||||
for (var i = 0; i < place.address_components.length; i++) {
|
||||
var component = place.address_components[i];
|
||||
var types = component.types;
|
||||
|
||||
if (types.indexOf('street_number') > -1) {
|
||||
streetNumber = component.long_name;
|
||||
} else if (types.indexOf('route') > -1) {
|
||||
streetName = component.long_name;
|
||||
} else if (types.indexOf('locality') > -1) {
|
||||
city = component.long_name;
|
||||
} else if (types.indexOf('administrative_area_level_1') > -1) {
|
||||
province = component.long_name;
|
||||
} else if (types.indexOf('postal_code') > -1) {
|
||||
postalCode = component.long_name;
|
||||
}
|
||||
}
|
||||
|
||||
streetInput.value = (streetNumber + ' ' + streetName).trim();
|
||||
var cityInput = document.getElementById('clientCity');
|
||||
if (cityInput) cityInput.value = city;
|
||||
var provInput = document.getElementById('clientProvince');
|
||||
if (provInput) provInput.value = province;
|
||||
var postalInput = document.getElementById('clientPostal');
|
||||
if (postalInput) postalInput.value = postalCode;
|
||||
|
||||
if (place.geometry && place.geometry.location) {
|
||||
var latInput = document.getElementById('clientLat');
|
||||
var lngInput = document.getElementById('clientLng');
|
||||
if (latInput) latInput.value = place.geometry.location.lat();
|
||||
if (lngInput) lngInput.value = place.geometry.location.lng();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (window._googleMapsReady) {
|
||||
setupAddressAutocomplete();
|
||||
} else {
|
||||
window._scheduleAutocompleteInit = setupAddressAutocomplete;
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,74 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { AttendeeCalendarController } from "@calendar/views/attendee_calendar/attendee_calendar_controller";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { useState, onWillStart } from "@odoo/owl";
|
||||
|
||||
patch(AttendeeCalendarController.prototype, {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.orm = useService("orm");
|
||||
this.notification = useService("notification");
|
||||
this.fusionState = useState({
|
||||
accounts: [],
|
||||
syncing: false,
|
||||
});
|
||||
onWillStart(async () => {
|
||||
await this._loadFusionAccounts();
|
||||
});
|
||||
},
|
||||
|
||||
get fusionAccounts() {
|
||||
return this.fusionState.accounts;
|
||||
},
|
||||
|
||||
get fusionSyncing() {
|
||||
return this.fusionState.syncing;
|
||||
},
|
||||
|
||||
async _loadFusionAccounts() {
|
||||
try {
|
||||
const accounts = await this.orm.call(
|
||||
"fusion.calendar.account",
|
||||
"get_user_accounts_status",
|
||||
[],
|
||||
);
|
||||
this.fusionState.accounts = accounts;
|
||||
} catch {
|
||||
this.fusionState.accounts = [];
|
||||
}
|
||||
},
|
||||
|
||||
async onFusionSyncNow() {
|
||||
this.fusionState.syncing = true;
|
||||
try {
|
||||
const result = await this.orm.call(
|
||||
"fusion.calendar.account",
|
||||
"sync_current_user",
|
||||
[],
|
||||
);
|
||||
if (result.success) {
|
||||
this.notification.add(
|
||||
result.message || "Calendar synced successfully.",
|
||||
{ type: "success" },
|
||||
);
|
||||
await this._loadFusionAccounts();
|
||||
await this.model.load();
|
||||
this.render(true);
|
||||
} else {
|
||||
this.notification.add(
|
||||
result.error || "Sync failed.",
|
||||
{ type: "danger" },
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
this.notification.add(
|
||||
"Sync error: " + (e.message || "Unknown error"),
|
||||
{ type: "danger" },
|
||||
);
|
||||
} finally {
|
||||
this.fusionState.syncing = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
|
||||
<!-- Hide the native sync area and add Fusion Schedule sync UI alongside -->
|
||||
<t t-name="fusion_schedule.FusionCalendarController"
|
||||
t-inherit="calendar.AttendeeCalendarController"
|
||||
t-inherit-mode="extension">
|
||||
|
||||
<!-- Hide native sync buttons but keep the div so other modules' xpaths still work -->
|
||||
<xpath expr="//div[@id='header_synchronization_settings']" position="attributes">
|
||||
<attribute name="style">display: none !important;</attribute>
|
||||
</xpath>
|
||||
|
||||
<!-- Add our own sync UI right after the hidden native one -->
|
||||
<xpath expr="//div[@id='header_synchronization_settings']" position="after">
|
||||
<div id="fusion_calendar_sync" class="mx-2 ms-lg-auto d-inline-flex align-items-center gap-2">
|
||||
<t t-if="fusionAccounts and fusionAccounts.length">
|
||||
<t t-foreach="fusionAccounts" t-as="acct" t-key="acct.id">
|
||||
<span class="o_tag d-inline-flex align-items-center gap-1 rounded-pill px-2 py-1 small"
|
||||
t-att-class="acct.status === 'active' ? 'bg-success-subtle text-success border border-success-subtle' : acct.status === 'error' ? 'bg-danger-subtle text-danger border border-danger-subtle' : 'bg-warning-subtle text-warning border border-warning-subtle'"
|
||||
t-att-title="acct.email + ' (' + (acct.provider === 'google' ? 'Google' : 'Outlook') + ')' + (acct.last_sync ? ' — Last sync: ' + acct.last_sync : '')"
|
||||
t-att-data-tooltip="acct.email + ' (' + (acct.provider === 'google' ? 'Google' : 'Outlook') + ')'"
|
||||
data-tooltip-position="bottom">
|
||||
<i t-att-class="acct.provider === 'google' ? 'fa fa-google' : 'fa fa-windows'" style="font-size: .85em;"/>
|
||||
<t t-esc="acct.email.split('@')[1].split('.')[0]"/>
|
||||
</span>
|
||||
</t>
|
||||
<button type="button" class="btn btn-sm o_button_icon text-nowrap"
|
||||
t-on-click="onFusionSyncNow"
|
||||
t-att-disabled="fusionSyncing"
|
||||
title="Sync all calendars now"
|
||||
data-tooltip="Sync all calendars now"
|
||||
data-tooltip-position="bottom">
|
||||
<i t-att-class="fusionSyncing ? 'fa fa-refresh fa-spin' : 'fa fa-refresh'"/>
|
||||
</button>
|
||||
</t>
|
||||
<a t-att-href="'/my/schedule'"
|
||||
class="btn btn-sm o_button_icon text-nowrap"
|
||||
title="Manage connected calendars"
|
||||
data-tooltip="Manage connected calendars"
|
||||
data-tooltip-position="bottom">
|
||||
<i class="fa fa-cog"/>
|
||||
</a>
|
||||
</div>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
Reference in New Issue
Block a user