fix: rename appointment booking route (#19886)
* rename appoinment booking route
* fix: replace all references to book-appointment route
diff --git a/erpnext/www/book_appointment/__init__.py b/erpnext/www/book_appointment/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/erpnext/www/book_appointment/__init__.py
diff --git a/erpnext/www/book_appointment/index.css b/erpnext/www/book_appointment/index.css
new file mode 100644
index 0000000..6c49fde
--- /dev/null
+++ b/erpnext/www/book_appointment/index.css
@@ -0,0 +1,53 @@
+.time-slot {
+ margin-bottom: 2em;
+ margin-left: 0.5em;
+ margin-right: 0.5em;
+ border-radius: 0.4em;
+ cursor: pointer;
+ border: 0.5px solid #cccccc;
+ min-height: 75px;
+ padding: 0.5em 1em;
+}
+
+@media (max-width: 768px) {
+ #submit-button-area {
+ display: grid;
+ grid-template-areas:
+ "submit"
+ "back";
+ }
+}
+#customer-form{
+ border-color: black;
+}
+#customer-form ::placeholder{
+ color: #ddd;
+}
+#timeslot-container{
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.time-slot:hover {
+ background: #ddd;
+}
+
+.time-slot.unavailable {
+ background: #CBD5E0;
+ cursor: not-allowed;
+ color: #718096
+}
+
+.time-slot.unavailable .text-muted {
+ color: #718096
+}
+
+.time-slot.selected {
+ color: white;
+ background: #5e64ff;
+}
+
+.time-slot.selected .text-muted {
+ color: #EDF2F7 !important;
+}
diff --git a/erpnext/www/book_appointment/index.html b/erpnext/www/book_appointment/index.html
new file mode 100644
index 0000000..f242f43
--- /dev/null
+++ b/erpnext/www/book_appointment/index.html
@@ -0,0 +1,66 @@
+{% extends "templates/web.html" %}
+
+{% block title %}{{ _("Book Appointment") }}{% endblock %}
+
+{% block script %}
+<script src="assets/js/moment-bundle.min.js"></script>
+<script src="book_appointment/index.js"></script>
+{% endblock %}
+
+{% block page_content %}
+<div class="container">
+ <!-- title: Book an appointment -->
+ <div id="select-date-time">
+ <div class="text-center mt-5">
+ <h3>Book an appointment</h3>
+ <p class="lead text-muted" id="lead-text">Select the date and your timezone</p>
+ </div>
+ <div class="row justify-content-center mt-3">
+ <div class="col-md-6 align-self-center ">
+ <div class="row">
+ <input type="date" oninput="on_date_or_timezone_select()" name="appointment-date"
+ id="appointment-date" class="form-control mt-3 col-md m-3">
+ <select name="appointment-timezone" oninput="on_date_or_timezone_select()" id="appointment-timezone"
+ class="form-control m-3 col-md">
+ </select>
+ </div>
+ </div>
+ </div>
+ <div class="mt-3" id="timeslot-container">
+
+ </div>
+ <div class="row justify-content-center mt-3">
+ <div class="col-md-4 mb-3">
+ <button class="btn btn-primary form-control" id="next-button">Next</button>
+ </div>
+ </div>
+ </div>
+</div>
+<!--Enter Details-->
+<div id="enter-details" class="mb-5">
+ <div class="text-center mt-5">
+ <h3>Add details</h3>
+ <p class="lead text-muted">Selected date is <span class="date-span"></span> at <span class="time-span">
+ </span></p>
+ </div>
+ <div class="row justify-content-center mt-3">
+ <div class="col-md-4 align-items-center">
+ <form id="customer-form" action='#'>
+ <input class="form-control mt-3" type="text" name="customer_name" id="customer_name" placeholder="Your Name (required)" required>
+ <input class="form-control mt-3" type="tel" name="customer_number" id="customer_number" placeholder="+910000000000">
+ <input class="form-control mt-3" type="text" name="customer_skype" id="customer_skype" placeholder="Skype">
+ <input class="form-control mt-3"type="email" name="customer_email" id="customer_email" placeholder="Email Address (required)" required>
+
+ <textarea class="form-control mt-3" name="customer_notes" id="customer_notes" cols="30" rows="10"
+ placeholder="Notes"></textarea>
+ </form>
+ <div class="row mt-3 " id="submit-button-area">
+ <div class="col-md mt-3" style="grid-area: back;"><button class="btn btn-dark form-control" onclick="initialise_select_date()">Go back</button></div>
+ <div class="col-md mt-3" style="grid-area: submit;"><button class="btn btn-primary form-control " onclick="submit()" id="submit-button">Submit</button></div>
+ </div>
+ </div>
+ </div>
+</div>
+</div>
+
+{% endblock %}
\ No newline at end of file
diff --git a/erpnext/www/book_appointment/index.js b/erpnext/www/book_appointment/index.js
new file mode 100644
index 0000000..c8dd501
--- /dev/null
+++ b/erpnext/www/book_appointment/index.js
@@ -0,0 +1,236 @@
+frappe.ready(async () => {
+ initialise_select_date();
+})
+
+window.holiday_list = [];
+
+async function initialise_select_date() {
+ navigate_to_page(1);
+ await get_global_variables();
+ setup_date_picker();
+ setup_timezone_selector();
+ hide_next_button();
+}
+
+async function get_global_variables() {
+ // Using await through this file instead of then.
+ window.appointment_settings = (await frappe.call({
+ method: 'erpnext.www.book_appointment.index.get_appointment_settings'
+ })).message;
+ window.timezones = (await frappe.call({
+ method:'erpnext.www.book_appointment.index.get_timezones'
+ })).message;
+ window.holiday_list = window.appointment_settings.holiday_list;
+}
+
+function setup_timezone_selector() {
+ /**
+ * window.timezones is a dictionary with the following structure
+ * { IANA name: Pretty name}
+ * For example : { Asia/Kolkata : "India Time - Asia/Kolkata"}
+ */
+ let timezones_element = document.getElementById('appointment-timezone');
+ let offset = new Date().getTimezoneOffset();
+ Object.keys(window.timezones).forEach((timezone) => {
+ let opt = document.createElement('option');
+ opt.value = timezone;
+ if (timezone == moment.tz.guess()) {
+ opt.selected = true;
+ }
+ opt.innerHTML = window.timezones[timezone]
+ timezones_element.appendChild(opt)
+ });
+}
+
+function setup_date_picker() {
+ let date_picker = document.getElementById('appointment-date');
+ let today = new Date();
+ date_picker.min = today.toISOString().substr(0, 10);
+ today.setDate(today.getDate() + window.appointment_settings.advance_booking_days);
+ date_picker.max = today.toISOString().substr(0, 10);
+}
+
+function hide_next_button() {
+ let next_button = document.getElementById('next-button');
+ next_button.disabled = true;
+ next_button.onclick = () => frappe.msgprint("Please select a date and time");
+}
+
+function show_next_button() {
+ let next_button = document.getElementById('next-button');
+ next_button.disabled = false;
+ next_button.onclick = setup_details_page;
+}
+
+function on_date_or_timezone_select() {
+ let date_picker = document.getElementById('appointment-date');
+ let timezone = document.getElementById('appointment-timezone');
+ if (date_picker.value === '') {
+ clear_time_slots();
+ hide_next_button();
+ frappe.throw('Please select a date');
+ }
+ window.selected_date = date_picker.value;
+ window.selected_timezone = timezone.value;
+ update_time_slots(date_picker.value, timezone.value);
+ let lead_text = document.getElementById('lead-text');
+ lead_text.innerHTML = "Select Time"
+}
+
+async function get_time_slots(date, timezone) {
+ let slots = (await frappe.call({
+ method: 'erpnext.www.book_appointment.index.get_appointment_slots',
+ args: {
+ date: date,
+ timezone: timezone
+ }
+ })).message;
+ return slots;
+}
+
+async function update_time_slots(selected_date, selected_timezone) {
+ let timeslot_container = document.getElementById('timeslot-container');
+ window.slots = await get_time_slots(selected_date, selected_timezone);
+ clear_time_slots();
+ if (window.slots.length <= 0) {
+ let message_div = document.createElement('p');
+ message_div.innerHTML = "There are no slots available on this date";
+ timeslot_container.appendChild(message_div);
+ return
+ }
+ window.slots.forEach((slot, index) => {
+ // Get and append timeslot div
+ let timeslot_div = get_timeslot_div_layout(slot)
+ timeslot_container.appendChild(timeslot_div);
+ });
+ set_default_timeslot();
+}
+
+function get_timeslot_div_layout(timeslot) {
+ let start_time = new Date(timeslot.time)
+ let timeslot_div = document.createElement('div');
+ timeslot_div.classList.add('time-slot');
+ if (!timeslot.availability) {
+ timeslot_div.classList.add('unavailable')
+ }
+ timeslot_div.innerHTML = get_slot_layout(start_time);
+ timeslot_div.id = timeslot.time.substr(11, 20);
+ timeslot_div.addEventListener('click', select_time);
+ return timeslot_div
+}
+
+function clear_time_slots() {
+ // Clear any existing divs in timeslot container
+ let timeslot_container = document.getElementById('timeslot-container');
+ while (timeslot_container.firstChild) {
+ timeslot_container.removeChild(timeslot_container.firstChild);
+ }
+}
+
+function get_slot_layout(time) {
+ let timezone = document.getElementById("appointment-timezone").value;
+ time = new Date(time);
+ let start_time_string = moment(time).tz(timezone).format("LT");
+ let end_time = moment(time).tz(timezone).add(window.appointment_settings.appointment_duration, 'minutes');
+ let end_time_string = end_time.format("LT");
+ return `<span style="font-size: 1.2em;">${start_time_string}</span><br><span class="text-muted small">to ${end_time_string}</span>`;
+}
+
+function select_time() {
+ if (this.classList.contains('unavailable')) {
+ return;
+ }
+ let selected_element = document.getElementsByClassName('selected');
+ if (!(selected_element.length > 0)) {
+ this.classList.add('selected');
+ show_next_button();
+ return;
+ }
+ selected_element = selected_element[0]
+ window.selected_time = this.id;
+ selected_element.classList.remove('selected');
+ this.classList.add('selected');
+ show_next_button();
+}
+
+function set_default_timeslot() {
+ let timeslots = document.getElementsByClassName('time-slot')
+ // Can't use a forEach here since, we need to break the loop after a timeslot is selected
+ for (let i = 0; i < timeslots.length; i++) {
+ const timeslot = timeslots[i];
+ if (!timeslot.classList.contains('unavailable')) {
+ timeslot.classList.add('selected');
+ break;
+ }
+ }
+}
+
+function navigate_to_page(page_number) {
+ let page1 = document.getElementById('select-date-time');
+ let page2 = document.getElementById('enter-details');
+ switch (page_number) {
+ case 1:
+ page1.style.display = 'block';
+ page2.style.display = 'none';
+ break;
+ case 2:
+ page1.style.display = 'none';
+ page2.style.display = 'block';
+ break;
+ default:
+ break;
+ }
+}
+
+function setup_details_page() {
+ navigate_to_page(2)
+ let date_container = document.getElementsByClassName('date-span')[0];
+ let time_container = document.getElementsByClassName('time-span')[0];
+ date_container.innerHTML = moment(window.selected_date).format("MMM Do YYYY");
+ time_container.innerHTML = moment(window.selected_time, "HH:mm:ss").format("LT");
+}
+
+async function submit() {
+ let button = document.getElementById('submit-button');
+ button.disabled = true;
+ let form = document.querySelector('#customer-form');
+ if (!form.checkValidity()) {
+ form.reportValidity();
+ button.disabled = false;
+ return;
+ }
+ let contact = get_form_data();
+ let appointment = frappe.call({
+ method: 'erpnext.www.book_appointment.index.create_appointment',
+ args: {
+ 'date': window.selected_date,
+ 'time': window.selected_time,
+ 'contact': contact,
+ 'tz':window.selected_timezone
+ },
+ callback: (response)=>{
+ if (response.message.status == "Unverified") {
+ frappe.show_alert("Please check your email to confirm the appointment")
+ } else {
+ frappe.show_alert("Appointment Created Successfully");
+ }
+ setTimeout(()=>{
+ let redirect_url = "/";
+ if (window.appointment_settings.success_redirect_url){
+ redirect_url += window.appointment_settings.success_redirect_url;
+ }
+ window.location.href = redirect_url;},5000)
+ },
+ error: (err)=>{
+ frappe.show_alert("Something went wrong please try again");
+ button.disabled = false;
+ }
+ });
+}
+
+function get_form_data() {
+ contact = {};
+ let inputs = ['name', 'skype', 'number', 'notes', 'email'];
+ inputs.forEach((id) => contact[id] = document.getElementById(`customer_${id}`).value)
+ return contact
+}
diff --git a/erpnext/www/book_appointment/index.py b/erpnext/www/book_appointment/index.py
new file mode 100644
index 0000000..5b60dd5
--- /dev/null
+++ b/erpnext/www/book_appointment/index.py
@@ -0,0 +1,159 @@
+import frappe
+import datetime
+import json
+import pytz
+
+
+WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
+
+no_cache = 1
+
+
+def get_context(context):
+ is_enabled = frappe.db.get_single_value('Appointment Booking Settings', 'enable_scheduling')
+ if is_enabled:
+ return context
+ else:
+ frappe.local.flags.redirect_location = '/404'
+ raise frappe.Redirect
+
+@frappe.whitelist(allow_guest=True)
+def get_appointment_settings():
+ settings = frappe.get_doc('Appointment Booking Settings')
+ settings.holiday_list = frappe.get_doc('Holiday List', settings.holiday_list)
+ return settings
+
+@frappe.whitelist(allow_guest=True)
+def get_timezones():
+ from babel.dates import get_timezone, get_timezone_name, Locale
+ from frappe.utils.momentjs import get_all_timezones
+
+ translated_dict = {}
+ locale = Locale.parse(frappe.local.lang, sep="-")
+
+ for tz in get_all_timezones():
+ timezone_name = get_timezone_name(get_timezone(tz), locale=locale, width='short')
+ if timezone_name:
+ translated_dict[tz] = timezone_name + ' - ' + tz
+
+ return translated_dict
+
+@frappe.whitelist(allow_guest=True)
+def get_appointment_slots(date, timezone):
+ # Convert query to local timezones
+ format_string = '%Y-%m-%d %H:%M:%S'
+ query_start_time = datetime.datetime.strptime(date + ' 00:00:00', format_string)
+ query_end_time = datetime.datetime.strptime(date + ' 23:59:59', format_string)
+ query_start_time = convert_to_system_timezone(timezone, query_start_time)
+ query_end_time = convert_to_system_timezone(timezone, query_end_time)
+ now = convert_to_guest_timezone(timezone, datetime.datetime.now())
+
+ # Database queries
+ settings = frappe.get_doc('Appointment Booking Settings')
+ holiday_list = frappe.get_doc('Holiday List', settings.holiday_list)
+ timeslots = get_available_slots_between(query_start_time, query_end_time, settings)
+
+ # Filter and convert timeslots
+ converted_timeslots = []
+ for timeslot in timeslots:
+ converted_timeslot = convert_to_guest_timezone(timezone, timeslot)
+ # Check if holiday
+ if _is_holiday(converted_timeslot.date(), holiday_list):
+ converted_timeslots.append(dict(time=converted_timeslot, availability=False))
+ continue
+ # Check availability
+ if check_availabilty(timeslot, settings) and converted_timeslot >= now:
+ converted_timeslots.append(dict(time=converted_timeslot, availability=True))
+ else:
+ converted_timeslots.append(dict(time=converted_timeslot, availability=False))
+ date_required = datetime.datetime.strptime(date + ' 00:00:00', format_string).date()
+ converted_timeslots = filter_timeslots(date_required, converted_timeslots)
+ return converted_timeslots
+
+def get_available_slots_between(query_start_time, query_end_time, settings):
+ records = _get_records(query_start_time, query_end_time, settings)
+ timeslots = []
+ appointment_duration = datetime.timedelta(
+ minutes=settings.appointment_duration)
+ for record in records:
+ if record.day_of_week == WEEKDAYS[query_start_time.weekday()]:
+ current_time = _deltatime_to_datetime(query_start_time, record.from_time)
+ end_time = _deltatime_to_datetime(query_start_time, record.to_time)
+ else:
+ current_time = _deltatime_to_datetime(query_end_time, record.from_time)
+ end_time = _deltatime_to_datetime(query_end_time, record.to_time)
+ while current_time + appointment_duration <= end_time:
+ timeslots.append(current_time)
+ current_time += appointment_duration
+ return timeslots
+
+
+@frappe.whitelist(allow_guest=True)
+def create_appointment(date, time, tz, contact):
+ format_string = '%Y-%m-%d %H:%M:%S%z'
+ scheduled_time = datetime.datetime.strptime(date + " " + time, format_string)
+ # Strip tzinfo from datetime objects since it's handled by the doctype
+ scheduled_time = scheduled_time.replace(tzinfo = None)
+ scheduled_time = convert_to_system_timezone(tz, scheduled_time)
+ scheduled_time = scheduled_time.replace(tzinfo = None)
+ # Create a appointment document from form
+ appointment = frappe.new_doc('Appointment')
+ appointment.scheduled_time = scheduled_time
+ contact = json.loads(contact)
+ appointment.customer_name = contact.get('name', None)
+ appointment.customer_phone_number = contact.get('number', None)
+ appointment.customer_skype = contact.get('skype', None)
+ appointment.customer_details = contact.get('notes', None)
+ appointment.customer_email = contact.get('email', None)
+ appointment.status = 'Open'
+ appointment.insert()
+ return appointment
+
+# Helper Functions
+def filter_timeslots(date, timeslots):
+ filtered_timeslots = []
+ for timeslot in timeslots:
+ if(timeslot['time'].date() == date):
+ filtered_timeslots.append(timeslot)
+ return filtered_timeslots
+
+def convert_to_guest_timezone(guest_tz, datetimeobject):
+ guest_tz = pytz.timezone(guest_tz)
+ local_timezone = pytz.timezone(frappe.utils.get_time_zone())
+ datetimeobject = local_timezone.localize(datetimeobject)
+ datetimeobject = datetimeobject.astimezone(guest_tz)
+ return datetimeobject
+
+def convert_to_system_timezone(guest_tz,datetimeobject):
+ guest_tz = pytz.timezone(guest_tz)
+ datetimeobject = guest_tz.localize(datetimeobject)
+ system_tz = pytz.timezone(frappe.utils.get_time_zone())
+ datetimeobject = datetimeobject.astimezone(system_tz)
+ return datetimeobject
+
+def check_availabilty(timeslot, settings):
+ return frappe.db.count('Appointment', {'scheduled_time': timeslot}) < settings.number_of_agents
+
+def _is_holiday(date, holiday_list):
+ for holiday in holiday_list.holidays:
+ if holiday.holiday_date == date:
+ return True
+ return False
+
+
+def _get_records(start_time, end_time, settings):
+ records = []
+ for record in settings.availability_of_slots:
+ if record.day_of_week == WEEKDAYS[start_time.weekday()] or record.day_of_week == WEEKDAYS[end_time.weekday()]:
+ records.append(record)
+ return records
+
+
+def _deltatime_to_datetime(date, deltatime):
+ time = (datetime.datetime.min + deltatime).time()
+ return datetime.datetime.combine(date.date(), time)
+
+
+def _datetime_to_deltatime(date_time):
+ midnight = datetime.datetime.combine(date_time.date(), datetime.time.min)
+ return (date_time-midnight)
\ No newline at end of file
diff --git a/erpnext/www/book_appointment/verify/__init__.py b/erpnext/www/book_appointment/verify/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/erpnext/www/book_appointment/verify/__init__.py
diff --git a/erpnext/www/book_appointment/verify/index.html b/erpnext/www/book_appointment/verify/index.html
new file mode 100644
index 0000000..ebb65b1
--- /dev/null
+++ b/erpnext/www/book_appointment/verify/index.html
@@ -0,0 +1,18 @@
+{% extends "templates/web.html" %}
+
+{% block title %}
+{{ _("Verify Email") }}
+{% endblock%}
+
+{% block page_content %}
+
+ {% if success==True %}
+ <div class="alert alert-success">
+ Your email has been verified and your appointment has been scheduled
+ </div>
+ {% else %}
+ <div class="alert alert-danger">
+ Verification failed please check the link
+ </div>
+ {% endif %}
+{% endblock%}
\ No newline at end of file
diff --git a/erpnext/www/book_appointment/verify/index.py b/erpnext/www/book_appointment/verify/index.py
new file mode 100644
index 0000000..d4478ae
--- /dev/null
+++ b/erpnext/www/book_appointment/verify/index.py
@@ -0,0 +1,20 @@
+import frappe
+
+from frappe.utils.verified_command import verify_request
+@frappe.whitelist(allow_guest=True)
+def get_context(context):
+ if not verify_request():
+ context.success = False
+ return context
+
+ email = frappe.form_dict['email']
+ appointment_name = frappe.form_dict['appointment']
+
+ if email and appointment_name:
+ appointment = frappe.get_doc('Appointment',appointment_name)
+ appointment.set_verified(email)
+ context.success = True
+ return context
+ else:
+ context.success = False
+ return context
\ No newline at end of file