Merge pull request #19475 from 0Pranav/scheduling-ui-rewrite
feat: Appointment Schedulling
This commit is contained in:
commit
a791170f29
@ -48,6 +48,11 @@ def get_data():
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "doctype",
|
"type": "doctype",
|
||||||
|
"name": "Appointment",
|
||||||
|
"description" : _("Helps you manage appointments with your leads"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "doctype",
|
||||||
"name": "Newsletter",
|
"name": "Newsletter",
|
||||||
"label": _("Newsletter"),
|
"label": _("Newsletter"),
|
||||||
}
|
}
|
||||||
|
0
erpnext/crm/doctype/appointment/__init__.py
Normal file
0
erpnext/crm/doctype/appointment/__init__.py
Normal file
17
erpnext/crm/doctype/appointment/appointment.js
Normal file
17
erpnext/crm/doctype/appointment/appointment.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
// Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
// For license information, please see license.txt
|
||||||
|
|
||||||
|
frappe.ui.form.on('Appointment', {
|
||||||
|
refresh: function(frm) {
|
||||||
|
if(frm.doc.lead){
|
||||||
|
frm.add_custom_button(frm.doc.lead,()=>{
|
||||||
|
frappe.set_route("Form", "Lead", frm.doc.lead);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if(frm.doc.calendar_event){
|
||||||
|
frm.add_custom_button(__(frm.doc.calendar_event),()=>{
|
||||||
|
frappe.set_route("Form", "Event", frm.doc.calendar_event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
153
erpnext/crm/doctype/appointment/appointment.json
Normal file
153
erpnext/crm/doctype/appointment/appointment.json
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
{
|
||||||
|
"autoname": "format:APMT-{customer_name}-{####}",
|
||||||
|
"creation": "2019-08-27 10:48:27.926283",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"editable_grid": 1,
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"scheduled_time",
|
||||||
|
"status",
|
||||||
|
"customer_details_section",
|
||||||
|
"customer_name",
|
||||||
|
"customer_phone_number",
|
||||||
|
"customer_skype",
|
||||||
|
"customer_email",
|
||||||
|
"col_br_2",
|
||||||
|
"customer_details",
|
||||||
|
"linked_docs_section",
|
||||||
|
"lead",
|
||||||
|
"col_br_3",
|
||||||
|
"calendar_event"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "customer_details_section",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "Customer Details"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "customer_name",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Name",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "customer_phone_number",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Phone Number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "customer_skype",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Skype ID"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "customer_details",
|
||||||
|
"fieldtype": "Long Text",
|
||||||
|
"label": "Details"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "scheduled_time",
|
||||||
|
"fieldtype": "Datetime",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Scheduled Time",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "status",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"label": "Status",
|
||||||
|
"options": "Open\nUnverified\nClosed",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "lead",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"label": "Lead",
|
||||||
|
"options": "Lead"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "calendar_event",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"label": "Calendar Event",
|
||||||
|
"options": "Event"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "col_br_2",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "customer_email",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Email",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "linked_docs_section",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "Linked Documents"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "col_br_3",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"modified": "2019-10-14 15:23:54.630731",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "CRM",
|
||||||
|
"name": "Appointment",
|
||||||
|
"name_case": "UPPER CASE",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "System Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "Guest",
|
||||||
|
"share": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "Sales Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "Sales User",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"quick_entry": 1,
|
||||||
|
"sort_field": "modified",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"track_changes": 1
|
||||||
|
}
|
223
erpnext/crm/doctype/appointment/appointment.py
Normal file
223
erpnext/crm/doctype/appointment/appointment.py
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import urllib
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe import _
|
||||||
|
from frappe.model.document import Document
|
||||||
|
from frappe.utils import get_url
|
||||||
|
from frappe.utils.verified_command import verify_request, get_signed_params
|
||||||
|
|
||||||
|
|
||||||
|
class Appointment(Document):
|
||||||
|
|
||||||
|
def find_lead_by_email(self):
|
||||||
|
lead_list = frappe.get_list(
|
||||||
|
'Lead', filters={'email_id': self.customer_email}, ignore_permissions=True)
|
||||||
|
if lead_list:
|
||||||
|
return lead_list[0].name
|
||||||
|
return None
|
||||||
|
|
||||||
|
def before_insert(self):
|
||||||
|
number_of_appointments_in_same_slot = frappe.db.count(
|
||||||
|
'Appointment', filters={'scheduled_time': self.scheduled_time})
|
||||||
|
number_of_agents = frappe.db.get_single_value('Appointment Booking Settings', 'number_of_agents')
|
||||||
|
if not number_of_agents == 0:
|
||||||
|
if (number_of_appointments_in_same_slot >= number_of_agents):
|
||||||
|
frappe.throw('Time slot is not available')
|
||||||
|
# Link lead
|
||||||
|
if not self.lead:
|
||||||
|
self.lead = self.find_lead_by_email()
|
||||||
|
|
||||||
|
def after_insert(self):
|
||||||
|
if self.lead:
|
||||||
|
# Create Calendar event
|
||||||
|
self.auto_assign()
|
||||||
|
self.create_calendar_event()
|
||||||
|
else:
|
||||||
|
# Set status to unverified
|
||||||
|
self.status = 'Unverified'
|
||||||
|
# Send email to confirm
|
||||||
|
self.send_confirmation_email()
|
||||||
|
|
||||||
|
def send_confirmation_email(self):
|
||||||
|
verify_url = self._get_verify_url()
|
||||||
|
template = 'confirm_appointment'
|
||||||
|
args = {
|
||||||
|
"link":verify_url,
|
||||||
|
"site_url":frappe.utils.get_url(),
|
||||||
|
"full_name":self.customer_name,
|
||||||
|
}
|
||||||
|
frappe.sendmail(recipients=[self.customer_email],
|
||||||
|
template=template,
|
||||||
|
args=args,
|
||||||
|
subject=_('Appointment Confirmation'))
|
||||||
|
if frappe.session.user == "Guest":
|
||||||
|
frappe.msgprint(
|
||||||
|
'Please check your email to confirm the appointment')
|
||||||
|
else :
|
||||||
|
frappe.msgprint(
|
||||||
|
'Appointment was created. But no lead was found. Please check the email to confirm')
|
||||||
|
|
||||||
|
def on_change(self):
|
||||||
|
# Sync Calendar
|
||||||
|
if not self.calendar_event:
|
||||||
|
return
|
||||||
|
cal_event = frappe.get_doc('Event', self.calendar_event)
|
||||||
|
cal_event.starts_on = self.scheduled_time
|
||||||
|
cal_event.save(ignore_permissions=True)
|
||||||
|
|
||||||
|
|
||||||
|
def set_verified(self, email):
|
||||||
|
if not email == self.customer_email:
|
||||||
|
frappe.throw('Email verification failed.')
|
||||||
|
# Create new lead
|
||||||
|
self.create_lead_and_link()
|
||||||
|
# Remove unverified status
|
||||||
|
self.status = 'Open'
|
||||||
|
# Create calender event
|
||||||
|
self.auto_assign()
|
||||||
|
self.create_calendar_event()
|
||||||
|
self.save(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
def create_lead_and_link(self):
|
||||||
|
# Return if already linked
|
||||||
|
if self.lead:
|
||||||
|
return
|
||||||
|
lead = frappe.get_doc({
|
||||||
|
'doctype': 'Lead',
|
||||||
|
'lead_name': self.customer_name,
|
||||||
|
'email_id': self.customer_email,
|
||||||
|
'notes': self.customer_details,
|
||||||
|
'phone': self.customer_phone_number,
|
||||||
|
})
|
||||||
|
lead.insert(ignore_permissions=True)
|
||||||
|
# Link lead
|
||||||
|
self.lead = lead.name
|
||||||
|
|
||||||
|
def auto_assign(self):
|
||||||
|
from frappe.desk.form.assign_to import add as add_assignemnt
|
||||||
|
existing_assignee = self.get_assignee_from_latest_opportunity()
|
||||||
|
if existing_assignee:
|
||||||
|
# If the latest opportunity is assigned to someone
|
||||||
|
# Assign the appointment to the same
|
||||||
|
add_assignemnt({
|
||||||
|
'doctype': self.doctype,
|
||||||
|
'name': self.name,
|
||||||
|
'assign_to': existing_assignee
|
||||||
|
})
|
||||||
|
return
|
||||||
|
if self._assign:
|
||||||
|
return
|
||||||
|
available_agents = _get_agents_sorted_by_asc_workload(
|
||||||
|
self.scheduled_time.date())
|
||||||
|
for agent in available_agents:
|
||||||
|
if(_check_agent_availability(agent, self.scheduled_time)):
|
||||||
|
agent = agent[0]
|
||||||
|
add_assignemnt({
|
||||||
|
'doctype': self.doctype,
|
||||||
|
'name': self.name,
|
||||||
|
'assign_to': agent
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
def get_assignee_from_latest_opportunity(self):
|
||||||
|
if not self.lead:
|
||||||
|
return None
|
||||||
|
if not frappe.db.exists('Lead', self.lead):
|
||||||
|
return None
|
||||||
|
opporutnities = frappe.get_list(
|
||||||
|
'Opportunity',
|
||||||
|
filters={
|
||||||
|
'party_name': self.lead,
|
||||||
|
},
|
||||||
|
ignore_permissions=True,
|
||||||
|
order_by='creation desc')
|
||||||
|
if not opporutnities:
|
||||||
|
return None
|
||||||
|
latest_opportunity = frappe.get_doc('Opportunity', opporutnities[0].name )
|
||||||
|
assignee = latest_opportunity._assign
|
||||||
|
if not assignee:
|
||||||
|
return None
|
||||||
|
assignee = frappe.parse_json(assignee)[0]
|
||||||
|
return assignee
|
||||||
|
|
||||||
|
def create_calendar_event(self):
|
||||||
|
if self.calendar_event:
|
||||||
|
return
|
||||||
|
appointment_event = frappe.get_doc({
|
||||||
|
'doctype': 'Event',
|
||||||
|
'subject': ' '.join(['Appointment with', self.customer_name]),
|
||||||
|
'starts_on': self.scheduled_time,
|
||||||
|
'status': 'Open',
|
||||||
|
'type': 'Public',
|
||||||
|
'send_reminder': frappe.db.get_single_value('Appointment Booking Settings', 'email_reminders'),
|
||||||
|
'event_participants': [dict(reference_doctype='Lead', reference_docname=self.lead)]
|
||||||
|
})
|
||||||
|
employee = _get_employee_from_user(self._assign)
|
||||||
|
if employee:
|
||||||
|
appointment_event.append('event_participants', dict(
|
||||||
|
reference_doctype='Employee',
|
||||||
|
reference_docname=employee.name))
|
||||||
|
appointment_event.insert(ignore_permissions=True)
|
||||||
|
self.calendar_event = appointment_event.name
|
||||||
|
self.save(ignore_permissions=True)
|
||||||
|
|
||||||
|
def _get_verify_url(self):
|
||||||
|
verify_route = '/book-appointment/verify'
|
||||||
|
params = {
|
||||||
|
'email': self.customer_email,
|
||||||
|
'appointment': self.name
|
||||||
|
}
|
||||||
|
return get_url(verify_route + '?' + get_signed_params(params))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_agents_sorted_by_asc_workload(date):
|
||||||
|
appointments = frappe.db.get_list('Appointment', fields='*')
|
||||||
|
agent_list = _get_agent_list_as_strings()
|
||||||
|
if not appointments:
|
||||||
|
return agent_list
|
||||||
|
appointment_counter = Counter(agent_list)
|
||||||
|
for appointment in appointments:
|
||||||
|
assigned_to = frappe.parse_json(appointment._assign)
|
||||||
|
if not assigned_to:
|
||||||
|
continue
|
||||||
|
if (assigned_to[0] in agent_list) and appointment.scheduled_time.date() == date:
|
||||||
|
appointment_counter[assigned_to[0]] += 1
|
||||||
|
sorted_agent_list = appointment_counter.most_common()
|
||||||
|
sorted_agent_list.reverse()
|
||||||
|
return sorted_agent_list
|
||||||
|
|
||||||
|
|
||||||
|
def _get_agent_list_as_strings():
|
||||||
|
agent_list_as_strings = []
|
||||||
|
agent_list = frappe.get_doc('Appointment Booking Settings').agent_list
|
||||||
|
for agent in agent_list:
|
||||||
|
agent_list_as_strings.append(agent.user)
|
||||||
|
return agent_list_as_strings
|
||||||
|
|
||||||
|
|
||||||
|
def _check_agent_availability(agent_email, scheduled_time):
|
||||||
|
appointemnts_at_scheduled_time = frappe.get_list(
|
||||||
|
'Appointment', filters={'scheduled_time': scheduled_time})
|
||||||
|
for appointment in appointemnts_at_scheduled_time:
|
||||||
|
if appointment._assign == agent_email:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _get_employee_from_user(user):
|
||||||
|
employee_docname = frappe.db.exists(
|
||||||
|
{'doctype': 'Employee', 'user_id': user})
|
||||||
|
if employee_docname:
|
||||||
|
# frappe.db.exists returns a tuple of a tuple
|
||||||
|
return frappe.get_doc('Employee', employee_docname[0][0])
|
||||||
|
return None
|
||||||
|
|
58
erpnext/crm/doctype/appointment/test_appointment.py
Normal file
58
erpnext/crm/doctype/appointment/test_appointment.py
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# See license.txt
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
import unittest
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_lead():
|
||||||
|
test_lead = frappe.db.exists({'doctype': 'Lead', 'lead_name': 'Test Lead'})
|
||||||
|
if test_lead:
|
||||||
|
return frappe.get_doc('Lead', test_lead[0][0])
|
||||||
|
test_lead = frappe.get_doc({
|
||||||
|
'doctype': 'Lead',
|
||||||
|
'lead_name': 'Test Lead',
|
||||||
|
'email_id': 'test@example.com'
|
||||||
|
})
|
||||||
|
test_lead.insert(ignore_permissions=True)
|
||||||
|
return test_lead
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_appointments():
|
||||||
|
test_appointment = frappe.db.exists(
|
||||||
|
{'doctype': 'Appointment', 'scheduled_time':datetime.datetime.now(),'email':'test@example.com'})
|
||||||
|
if test_appointment:
|
||||||
|
return frappe.get_doc('Appointment', test_appointment[0][0])
|
||||||
|
test_appointment = frappe.get_doc({
|
||||||
|
'doctype': 'Appointment',
|
||||||
|
'email': 'test@example.com',
|
||||||
|
'status': 'Open',
|
||||||
|
'customer_name': 'Test Lead',
|
||||||
|
'customer_phone_number': '666',
|
||||||
|
'customer_skype': 'test',
|
||||||
|
'customer_email': 'test@example.com',
|
||||||
|
'scheduled_time': datetime.datetime.now()
|
||||||
|
})
|
||||||
|
test_appointment.insert()
|
||||||
|
return test_appointment
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppointment(unittest.TestCase):
|
||||||
|
test_appointment = test_lead = None
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.test_lead = create_test_lead()
|
||||||
|
self.test_appointment = create_test_appointments()
|
||||||
|
|
||||||
|
def test_calendar_event_created(self):
|
||||||
|
cal_event = frappe.get_doc(
|
||||||
|
'Event', self.test_appointment.calendar_event)
|
||||||
|
self.assertEqual(cal_event.starts_on,
|
||||||
|
self.test_appointment.scheduled_time)
|
||||||
|
|
||||||
|
def test_lead_linked(self):
|
||||||
|
lead = frappe.get_doc('Lead', self.test_lead.name)
|
||||||
|
self.assertIsNotNone(lead)
|
@ -0,0 +1,10 @@
|
|||||||
|
frappe.ui.form.on('Appointment Booking Settings', 'validate',check_times);
|
||||||
|
function check_times(frm) {
|
||||||
|
$.each(frm.doc.availability_of_slots || [], function (i, d) {
|
||||||
|
let from_time = Date.parse('01/01/2019 ' + d.from_time);
|
||||||
|
let to_time = Date.parse('01/01/2019 ' + d.to_time);
|
||||||
|
if (from_time > to_time) {
|
||||||
|
frappe.throw(__(`In row ${i + 1} of Appointment Booking Slots : "To Time" must be later than "From Time"`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
@ -0,0 +1,151 @@
|
|||||||
|
{
|
||||||
|
"creation": "2019-08-27 10:56:48.309824",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"editable_grid": 1,
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"enable_scheduling",
|
||||||
|
"agent_detail_section",
|
||||||
|
"availability_of_slots",
|
||||||
|
"number_of_agents",
|
||||||
|
"agent_list",
|
||||||
|
"holiday_list",
|
||||||
|
"appointment_details_section",
|
||||||
|
"appointment_duration",
|
||||||
|
"email_reminders",
|
||||||
|
"advance_booking_days",
|
||||||
|
"success_details",
|
||||||
|
"success_redirect_url"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "availability_of_slots",
|
||||||
|
"fieldtype": "Table",
|
||||||
|
"label": "Availability Of Slots",
|
||||||
|
"options": "Appointment Booking Slots",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "1",
|
||||||
|
"fieldname": "number_of_agents",
|
||||||
|
"fieldtype": "Int",
|
||||||
|
"hidden": 1,
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Number of Concurrent Appointments",
|
||||||
|
"read_only": 1,
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "holiday_list",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Holiday List",
|
||||||
|
"options": "Holiday List",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "60",
|
||||||
|
"fieldname": "appointment_duration",
|
||||||
|
"fieldtype": "Int",
|
||||||
|
"label": "Appointment Duration (In Minutes)",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"description": "Notify customer and agent via email on the day of the appointment.",
|
||||||
|
"fieldname": "email_reminders",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Notify Via Email"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "7",
|
||||||
|
"fieldname": "advance_booking_days",
|
||||||
|
"fieldtype": "Int",
|
||||||
|
"label": "Number of days appointments can be booked in advance",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "agent_list",
|
||||||
|
"fieldtype": "Table MultiSelect",
|
||||||
|
"label": "Agents",
|
||||||
|
"options": "Assignment Rule User",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"fieldname": "enable_scheduling",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Enable Appointment Scheduling",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "agent_detail_section",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "Agent Details"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "appointment_details_section",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "Appointment Details"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "success_details",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "Success Settings"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Leave blank for home.\nThis is relative to site URL, for example \"about\" will redirect to \"https://yoursitename.com/about\"",
|
||||||
|
"fieldname": "success_redirect_url",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Success Redirect URL"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"issingle": 1,
|
||||||
|
"modified": "2019-11-26 12:14:17.669366",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "CRM",
|
||||||
|
"name": "Appointment Booking Settings",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"role": "System Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"role": "Guest",
|
||||||
|
"share": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"email": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"role": "HR Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"role": "Sales Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"quick_entry": 1,
|
||||||
|
"sort_field": "modified",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"track_changes": 1
|
||||||
|
}
|
@ -0,0 +1,43 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
import frappe
|
||||||
|
from frappe import _
|
||||||
|
import datetime
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
class AppointmentBookingSettings(Document):
|
||||||
|
agent_list = [] #Hack
|
||||||
|
min_date = '01/01/1970 '
|
||||||
|
format_string = "%d/%m/%Y %H:%M:%S"
|
||||||
|
|
||||||
|
def validate(self):
|
||||||
|
self.validate_availability_of_slots()
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
self.number_of_agents = len(self.agent_list)
|
||||||
|
super(AppointmentBookingSettings, self).save()
|
||||||
|
|
||||||
|
def validate_availability_of_slots(self):
|
||||||
|
for record in self.availability_of_slots:
|
||||||
|
from_time = datetime.datetime.strptime(
|
||||||
|
self.min_date+record.from_time, self.format_string)
|
||||||
|
to_time = datetime.datetime.strptime(
|
||||||
|
self.min_date+record.to_time, self.format_string)
|
||||||
|
timedelta = to_time-from_time
|
||||||
|
self.validate_from_and_to_time(from_time, to_time)
|
||||||
|
self.duration_is_divisible(from_time, to_time)
|
||||||
|
|
||||||
|
def validate_from_and_to_time(self, from_time, to_time):
|
||||||
|
if from_time > to_time:
|
||||||
|
err_msg = _('<b>From Time</b> cannot be later than <b>To Time</b> for {0}').format(record.day_of_week)
|
||||||
|
frappe.throw(_(err_msg))
|
||||||
|
|
||||||
|
def duration_is_divisible(self, from_time, to_time):
|
||||||
|
timedelta = to_time - from_time
|
||||||
|
if timedelta.total_seconds() % (self.appointment_duration * 60):
|
||||||
|
frappe.throw(
|
||||||
|
_('The difference between from time and To Time must be a multiple of Appointment'))
|
@ -0,0 +1,10 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# See license.txt
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
class TestAppointmentBookingSettings(unittest.TestCase):
|
||||||
|
pass
|
@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"creation": "2019-11-19 10:49:49.494927",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"editable_grid": 1,
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"day_of_week",
|
||||||
|
"from_time",
|
||||||
|
"to_time"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "day_of_week",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Day Of Week",
|
||||||
|
"options": "Sunday\nMonday\nTuesday\nWednesday\nThursday\nFriday\nSaturday",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "from_time",
|
||||||
|
"fieldtype": "Time",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "From Time ",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "to_time",
|
||||||
|
"fieldtype": "Time",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "To Time",
|
||||||
|
"reqd": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"istable": 1,
|
||||||
|
"modified": "2019-11-19 10:49:49.494927",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "CRM",
|
||||||
|
"name": "Appointment Booking Slots",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [],
|
||||||
|
"quick_entry": 1,
|
||||||
|
"sort_field": "modified",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"track_changes": 1
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
# import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
class AppointmentBookingSlots(Document):
|
||||||
|
pass
|
@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"creation": "2019-09-10 15:02:05.779434",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"editable_grid": 1,
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"day_of_week",
|
||||||
|
"from_time",
|
||||||
|
"to_time"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "day_of_week",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Day Of Week",
|
||||||
|
"options": "Sunday\nMonday\nTuesday\nWednesday\nThursday\nFriday\nSaturday",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "from_time",
|
||||||
|
"fieldtype": "Time",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "From Time",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "to_time",
|
||||||
|
"fieldtype": "Time",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "To Time",
|
||||||
|
"reqd": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"istable": 1,
|
||||||
|
"modified": "2019-09-10 15:05:20.406855",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "CRM",
|
||||||
|
"name": "Availability Of Slots",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [],
|
||||||
|
"quick_entry": 1,
|
||||||
|
"sort_field": "modified",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"track_changes": 1
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
# import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
class AvailabilityOfSlots(Document):
|
||||||
|
pass
|
File diff suppressed because it is too large
Load Diff
10
erpnext/templates/emails/confirm_appointment.html
Normal file
10
erpnext/templates/emails/confirm_appointment.html
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<p>{{_("Dear")}} {{ full_name }}{% if last_name %} {{ last_name}}{% endif %},</p>
|
||||||
|
<p>{{_("A new appointment has been created for you with {0}").format(site_url)}}.</p>
|
||||||
|
<p>{{_("Click on the link below to verify your email and confirm the appointment")}}.</p>
|
||||||
|
|
||||||
|
<p style="margin: 30px 0px;">
|
||||||
|
<a href="{{ link }}" rel="nofollow" style="padding: 8px 20px; background-color: #7575ff; color: #fff; border-radius: 4px; text-decoration: none; line-height: 1; border-bottom: 3px solid rgba(0, 0, 0, 0.2); font-size: 14px; font-weight: 200;">{{ _("Verify Email") }}</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
<p style="font-size: 85%;">{{_("You can also copy-paste this link in your browser")}} <a href="{{ link }}">{{ link }}</a></p>
|
53
erpnext/www/book-appointment/index.css
Normal file
53
erpnext/www/book-appointment/index.css
Normal file
@ -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;
|
||||||
|
}
|
66
erpnext/www/book-appointment/index.html
Normal file
66
erpnext/www/book-appointment/index.html
Normal file
@ -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 %}
|
236
erpnext/www/book-appointment/index.js
Normal file
236
erpnext/www/book-appointment/index.js
Normal file
@ -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
|
||||||
|
}
|
159
erpnext/www/book-appointment/index.py
Normal file
159
erpnext/www/book-appointment/index.py
Normal file
@ -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)
|
18
erpnext/www/book-appointment/verify/index.html
Normal file
18
erpnext/www/book-appointment/verify/index.html
Normal file
@ -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%}
|
20
erpnext/www/book-appointment/verify/index.py
Normal file
20
erpnext/www/book-appointment/verify/index.py
Normal file
@ -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
|
Loading…
x
Reference in New Issue
Block a user