diff --git a/car_inventory/__pycache__/settings.cpython-311.pyc b/car_inventory/__pycache__/settings.cpython-311.pyc index 398c131a..165fdde1 100644 Binary files a/car_inventory/__pycache__/settings.cpython-311.pyc and b/car_inventory/__pycache__/settings.cpython-311.pyc differ diff --git a/inventory/__pycache__/forms.cpython-311.pyc b/inventory/__pycache__/forms.cpython-311.pyc index b7d0f1a6..eb66006b 100644 Binary files a/inventory/__pycache__/forms.cpython-311.pyc and b/inventory/__pycache__/forms.cpython-311.pyc differ diff --git a/inventory/__pycache__/mixins.cpython-311.pyc b/inventory/__pycache__/mixins.cpython-311.pyc index a098233c..6bcef338 100644 Binary files a/inventory/__pycache__/mixins.cpython-311.pyc and b/inventory/__pycache__/mixins.cpython-311.pyc differ diff --git a/inventory/__pycache__/models.cpython-311.pyc b/inventory/__pycache__/models.cpython-311.pyc index 17a6662e..68d5f230 100644 Binary files a/inventory/__pycache__/models.cpython-311.pyc and b/inventory/__pycache__/models.cpython-311.pyc differ diff --git a/inventory/__pycache__/urls.cpython-311.pyc b/inventory/__pycache__/urls.cpython-311.pyc index df997e16..2f40588c 100644 Binary files a/inventory/__pycache__/urls.cpython-311.pyc and b/inventory/__pycache__/urls.cpython-311.pyc differ diff --git a/inventory/__pycache__/utils.cpython-311.pyc b/inventory/__pycache__/utils.cpython-311.pyc index 6aa2a8a8..24d1001e 100644 Binary files a/inventory/__pycache__/utils.cpython-311.pyc and b/inventory/__pycache__/utils.cpython-311.pyc differ diff --git a/inventory/__pycache__/views.cpython-311.pyc b/inventory/__pycache__/views.cpython-311.pyc index e52deaf8..04480929 100644 Binary files a/inventory/__pycache__/views.cpython-311.pyc and b/inventory/__pycache__/views.cpython-311.pyc differ diff --git a/inventory/mixins.py b/inventory/mixins.py index b7762382..966a8666 100644 --- a/inventory/mixins.py +++ b/inventory/mixins.py @@ -2,17 +2,16 @@ from django import forms from django.utils.translation import get_language - class AddClassMixin: """ - Mixin for adding classes to form fields and wrapping them in a div with class 'form-floating'. + Mixin for adding classes to a model. """ def add_class_to_fields(self): """ - Adds the class to the fields of the form and wraps them in a div with class 'form-floating'. + Adds the class to the fields of the model. + :return: class names form-control or form-select """ for field_name, field in self.fields.items(): - # Add classes to the field if isinstance(field.widget, forms.Select): existing_classes = field.widget.attrs.get('class', '') field.widget.attrs['class'] = f"{existing_classes} form-select form-select-sm".strip() @@ -20,23 +19,6 @@ class AddClassMixin: existing_classes = field.widget.attrs.get('class', '') field.widget.attrs['class'] = f"{existing_classes} form-control form-control-sm".strip() - # Wrap the field in a div with class 'form-floating' - field.widget.attrs['wrapper_class'] = 'form-floating' - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.add_class_to_fields() - - def __getitem__(self, name): - """ - Overrides the __getitem__ method to wrap the field in a div with class 'form-floating'. - """ - field = super().__getitem__(name) - wrapper_class = field.field.widget.attrs.pop('wrapper_class', None) - if wrapper_class: - field = forms.utils.safety.mark_safe(f'
{field}
') - return field - class LocalizedNameMixin: """ diff --git a/inventory/signals.py b/inventory/signals.py index 4422d3ae..f550dff8 100644 --- a/inventory/signals.py +++ b/inventory/signals.py @@ -689,7 +689,8 @@ def create_item_model(sender, instance, created, **kwargs): item_type=ItemModel.ITEM_TYPE_MATERIAL, uom_model=uom, coa_model=coa, - additional_info={} + + # additional_info={} ) product = entity.get_items_all().filter(name=instance.vin).first() diff --git a/inventory/utils.py b/inventory/utils.py index 2abf4681..c0b28e3b 100644 --- a/inventory/utils.py +++ b/inventory/utils.py @@ -481,4 +481,6 @@ def to_dict(obj): obj_dict[key] = str(value) else: obj_dict[key] = str(value) - return obj_dict \ No newline at end of file + return obj_dict + + diff --git a/requirements.txt b/requirements.txt index ae0ef94c..4deb40fc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ aiohappyeyeballs==2.4.4 aiohttp==3.11.11 -aiohttp-retry==2.8.3 +aiohttp-retry==2.9.1 aiosignal==1.3.2 alabaster==1.0.0 albucore==0.0.23 -albumentations==2.0.0 +albumentations==2.0.1 annotated-types==0.7.0 anyio==4.8.0 arabic-reshaper==3.0.0 @@ -33,7 +33,7 @@ ctranslate2==4.5.0 cycler==0.12.1 Cython==3.0.11 decorator==5.1.1 -desert==2020.11.18 +desert==2022.9.22 dill==0.3.9 distro==1.9.0 dj-rest-auth==7.0.1 @@ -53,13 +53,17 @@ django-filter==24.3 django-formtools==2.5.1 django-ledger==0.7.3 django-money==3.5.3 +django-next-url-mixin==0.4.0 django-nine==0.2.7 django-nonefield==0.4 +django-ordered-model==3.7.4 django-phonenumber-field==8.0.0 django-picklefield==3.2 +django-plans==1.2.0 django-prometheus==2.3.1 django-q2==1.7.6 django-sekizai==4.1.0 +django-sequences==3.0 django-silk==5.3.2 django-sms==0.7.0 django-sslserver==0.22 @@ -72,12 +76,13 @@ djangorestframework_simplejwt==5.4.0 djangoviz==0.1.1 docutils==0.21.2 easy-thumbnails==2.10 +emoji==2.14.1 et_xmlfile==2.0.0 -Faker==33.3.1 -filelock==3.16.1 +Faker==35.0.0 +filelock==3.17.0 fire==0.7.0 Flask==3.1.0 -fonttools==4.55.3 +fonttools==4.55.6 frozenlist==1.5.0 fsspec==2024.12.0 gprof2dot==2024.6.6 @@ -85,11 +90,11 @@ graphqlclient==0.2.4 greenlet==3.1.1 h11==0.14.0 h2==4.1.0 -hpack==4.0.0 +hpack==4.1.0 hstspreload==2025.1.1 httpcore==1.0.7 httpx==0.28.1 -hyperframe==6.0.1 +hyperframe==6.1.0 idna==3.10 imageio==2.37.0 imagesize==1.4.1 @@ -110,7 +115,7 @@ lxml==5.3.0 Markdown==3.7 markdown-it-py==3.0.0 MarkupSafe==3.0.2 -marshmallow==3.25.1 +marshmallow==3.26.0 matplotlib==3.10.0 mccabe==0.7.0 mdurl==0.1.2 @@ -121,10 +126,10 @@ mypy-extensions==1.0.0 networkx==3.4.2 newrelic==10.4.0 nltk==3.9.1 -libquadmath==2.2.2 +numpy==2.2.2 oauthlib==3.2.2 ofxtools==0.9.5 -openai==1.59.8 +openai==1.60.0 opencv-contrib-python==4.11.0.86 opencv-python==4.11.0.86 opencv-python-headless==4.11.0.86 @@ -135,8 +140,8 @@ packaging==24.2 pandas==2.2.3 pango==0.0.1 pdfkit==1.0.0 -phonenumbers==8.13.53 -pillow==11.1.0 +phonenumbers==8.13.42 +pillow==10.4.0 platformdirs==4.3.6 prometheus_client==0.21.1 propcache==0.2.1 @@ -164,7 +169,7 @@ pyobjc-framework-Cocoa==11.0 pyobjc-framework-Quartz==11.0 pyparsing==3.2.1 pyperclip==1.9.0 -pyphen==0.17.0 +pyphen==0.17.2 pypng==0.20220715.0 PyRect==0.2.0 PyScreeze==1.0.1 @@ -174,6 +179,7 @@ python-bidi==0.6.3 python-dateutil==2.9.0.post0 python-docx==1.1.2 python-openid==2.2.5 +python-stdnum==1.20 python3-saml==1.16.0 pytweening==1.2.0 pytz==2024.2 @@ -194,11 +200,12 @@ rich==13.9.4 rubicon-objc==0.5.0 sacremoses==0.1.1 scikit-image==0.25.0 -libomp runtime library==1.6.1 -libquadmath==1.15.1 -selenium==4.27.1 +scikit-learn==1.6.1 +scipy==1.15.1 +selenium==4.28.1 sentencepiece==0.2.0 shapely==2.0.6 +simsimd==6.2.1 six==1.17.0 sniffio==1.3.1 snowballstemmer==2.2.0 @@ -207,8 +214,11 @@ soupsieve==2.6 SQLAlchemy==2.0.37 sqlparse==0.5.3 stanza==1.10.1 +stringzilla==3.11.3 +suds==1.2.0 +swapper==1.3.0 sympy==1.13.1 -tablib==3.7.0 +tablib==3.8.0 termcolor==2.5.0 threadpoolctl==3.5.0 tifffile==2025.1.10 @@ -223,7 +233,7 @@ trio-websocket==0.11.1 twilio==9.4.3 typing-inspect==0.9.0 typing_extensions==4.12.2 -tzdata==2024.2 +tzdata==2025.1 Unidecode==1.3.8 upgrade-requirements==1.7.0 urllib3==2.3.0 diff --git a/static/css/appt-common.css b/static/css/appt-common.css new file mode 100644 index 00000000..c0a616fc --- /dev/null +++ b/static/css/appt-common.css @@ -0,0 +1,452 @@ +.djangoAppt_main-container { + + max-width: 1200px; + + background-color: rgba(248, 249, 250, 0.4); + border-radius: 5px; + box-shadow: 0 0 2px rgba(0, 0, 0, 0.1); +} + +.djangoAppt_body-container { + margin: 0 auto; + max-width: 1120px; + padding: 0 15px; +} + +.djangoAppt_page-body { + display: flex; + flex-direction: row; + margin-top: 50px; +} + +.djangoAppt_appointment-calendar { + flex: 3; + padding: 20px; + + border-radius: 5px; + box-shadow: 0 0 2px rgba(0, 0, 0, 0.1); +} + +.djangoAppt_service-description { + flex: 1; + margin-left: 20px; + padding: 20px; + background-color: #fff; + border-radius: 5px; + box-shadow: 0 0 2px rgba(0, 0, 0, 0.1); +} + +.djangoAppt_second-part { + margin-top: 10px; +} + +.djangoAppt_calendar-and-slot { + margin-top: 20px; + display: flex; +} + +.djangoAppt_service-description-content { + margin-top: 20px; + color: black; +} + +.djangoAppt_item-name { + color: black; + font-weight: bold; + font-size: 20px; + margin-bottom: 10px; +} + +.djangoAppt_calendar { + flex: 3; +} + +.djangoAppt_slot { + flex: 2; +} + +.djangoAppt_appointment-calendar-title-timezone { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.djangoAppt_title { + flex: 1; + font-weight: bold; + font-size: 20px; +} + +.djangoAppt_timezone-details { + flex: 1; + text-align: right; + font-size: 16px; + color: #333; +} + +.fc-day { + font-size: 12px; +} + +.fc-daygrid-day-frame { + height: 20px; +} + +a { + color: #0c042c; +} + +.djangoAppt_slot { + margin-left: 20px; +} + +.djangoAppt_slot-list { + columns: 2; + -webkit-columns: 2; + -moz-columns: 2; + margin-top: 10px; + padding-left: 10px !important; +} + +#slot-list li { + list-style-type: none; + text-align: center; +} + +.djangoAppt_appointment-slot { + border: 1px solid #ccc; + background-color: rgba(0, 48, 124, 0.95); + color: #fff; + padding: 7px; + margin-bottom: 6px; + cursor: pointer; + border-radius: 4px; +} + +.djangoAppt_appointment-slot:hover { + background-color: #fff; + color: rgba(42, 42, 42, 0.82); +} + +.selected { + background-color: #fff; + color: rgba(42, 42, 42, 0.82); +} + +.djangoAppt_next-available-date { + font-size: 16px; + font-weight: bold; + color: #333; + margin-top: 10px; + margin-left: 10px; + padding: 8px; + background-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 5px; + width: fit-content; +} + +/* Change the color of the buttons for the calendar */ +.fc-button { + background-color: rgba(0, 48, 124, 0.95) !important; + border-color: rgba(2, 76, 157, 0.85) !important; + color: #fff !important; +} + +/* Change the color of the buttons when hovered */ +.fc-button:hover { + background-color: #025bbb !important; + border-color: #0056b3; +} + +/* Change the color of the buttons when active or focused */ +.fc-button:active, .fc-button:focus { + background-color: #0759b2 !important; + border-color: #145294; +} + +.djangoAppt_date_chosen { + margin-left: 5px; + padding-left: 5px; + font-size: 18px; + color: #333; + font-weight: bold; +} + +.djangoAppt_btn-request-next-slot { + margin-left: 10px; + padding: 8px !important; + margin-top: -30px; +} + +.djangoAppt_no-availability-text { + margin-left: 5px; + padding-left: 5px; + color: #f00; + font-weight: bold; +} + +.disabled-day { + background-color: #ECECEC; /* light gray */ + opacity: 0.5; + pointer-events: none; /* makes it unclickable */ +} + +/* responsive */ + +/* CSS for screens larger than 1200px */ +@media (min-width: 1200px) { + .djangoAppt_page-body { + flex-direction: row; + } + + .djangoAppt_appointment-calendar { + flex: 3; + padding: 20px; + } + + .djangoAppt_service-description { + flex: 1; + margin-left: 20px; + } + + .djangoAppt_calendar { + flex: 3; + } + + .djangoAppt_slot { + flex: 2; + } + + .djangoAppt_slot { + margin-left: 20px; + } +} + +/* Select design */ + +#staff_id { + width: 100%; + padding: 2px 4px; + border: 1px solid rgba(255, 227, 227, 0.89); + border-radius: 5px; + background-color: #d4eaf5; + color: #333; + appearance: none; /* Remove default arrow icon in some browsers */ + -webkit-appearance: none; /* For Webkit browsers */ + -moz-appearance: none; /* For Firefox */ + cursor: pointer; + outline: none; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + transition: background-color 0.3s; + position: relative; + font-size: 16px; +} + +#staff_id:hover { + background-color: #f6c6c6; +} + +#staff_id:focus { + background-color: #d1dbff; +} + +/* Add a custom arrow icon using pseudo-elements */ +#staff_id::-ms-expand { + display: none; +} + +#staff_id::after { + content: '\25BC'; /* Unicode arrow character */ + position: absolute; + top: 50%; + right: 10px; + transform: translateY(-50%); + pointer-events: none; /* Make sure clicks pass through */ + color: #333; +} + +/* Styling the options when hovered */ +#staff_id option:hover { + background-color: #f0f0f0; + color: #333; +} + +/* Styling the options when they are active (clicked or selected with keyboard) */ +#staff_id option:active, #staff_id option:checked { + background-color: #e0e0e0; + color: #333; +} + +/* This changes the background color of the option elements when the select is open */ +#staff_id option { + background-color: #f8f8f8; + color: #333; +} + +/* For Firefox - to change the background color of the dropdown */ +#staff_id:-moz-focusring { + color: transparent; + text-shadow: 0 0 0 #333; +} + + +/* CSS for screens smaller than 1200px */ +@media (max-width: 1199px) { + .djangoAppt_page-body { + flex-direction: column; + } + + .djangoAppt_appointment-calendar { + flex: 1; + padding: 10px; + } + + .djangoAppt_service-description { + flex: 1; + margin-left: 0; + margin-top: 20px; + } + + .djangoAppt_calendar { + flex: 1; + } + + .djangoAppt_slot { + flex: 1; + } + + .djangoAppt_slot { + margin-left: 10px; + } +} + +/* CSS for screens smaller or equal to 768px */ +@media (max-width: 768px) { + .djangoAppt_main-container { + padding: 8px; + } + + .djangoAppt_body-container { + padding: 8px; + } + + .djangoAppt_appointment-calendar { + flex: 1; + padding: 10px; + } + + .djangoAppt_service-description { + flex: 1; + margin-left: 0; + margin-top: 20px; + } + + .djangoAppt_calendar-and-slot { + display: grid; + } + + .djangoAppt_slot { + margin-top: 40px; + } + + .djangoAppt_slot-list, .djangoAppt_date_chosen, .djangoAppt_no-availability-text { + margin-left: 0; + padding-left: 0; + } + + .djangoAppt_btn-request-next-slot { + margin-left: 0; + } + + /* Reduce font size for smaller screens */ + .djangoAppt_title, .djangoAppt_item-name, .djangoAppt_date_chosen, .djangoAppt_next-available-date { + font-size: 16px; + } + + .djangoAppt_timezone-details { + font-size: 14px; + } + + .fc-day { + font-size: 10px; + } +} + +/* CSS for screens smaller or equal to 768px */ +@media (max-width: 450px) { + .djangoAppt_main-container { + padding: 3px; + } + + .djangoAppt_body-container { + padding: 3px; + } + + .djangoAppt_appointment-calendar { + flex: 1; + padding: 5px; + } + + .djangoAppt_service-description { + flex: 1; + margin-left: 0; + margin-top: 20px; + } + + .djangoAppt_calendar-and-slot { + display: grid; + } + + .djangoAppt_slot { + margin-top: 40px; + } + + .djangoAppt_slot-list, .djangoAppt_date_chosen, .djangoAppt_no-availability-text { + margin-left: 0 !important; + padding-left: 0 !important; + } + + .djangoAppt_btn-request-next-slot { + margin-left: 0; + } + + /* Reduce font size for smaller screens */ + .djangoAppt_title, .djangoAppt_item-name, .djangoAppt_date_chosen, .djangoAppt_next-available-date { + font-size: 13px; + } + + .djangoAppt_timezone-details, .error-message { + font-size: 13px; + } + + .fc-day { + font-size: 11px; + } + + .fc-toolbar-title { + font-size: 14px !important; + } + + .fc { + font-size: 13px !important; + } + + .fc, .fc-button { + vertical-align: center !important; + } + + .djangoAppt_appointment-slot { + padding: 5px; + font-size: 13px; + } + + .djangoAppt_service-description { + font-size: 13px !important; + } +} + +.selected-cell { + background-color: #aaddff; /* or any color you prefer */ +} \ No newline at end of file diff --git a/static/images/logos/vendors/Aljumaih-Automotive-logo_kM7B61x_AROA7K7.png b/static/images/logos/vendors/Aljumaih-Automotive-logo_kM7B61x_AROA7K7.png new file mode 100644 index 00000000..04ee0afa Binary files /dev/null and b/static/images/logos/vendors/Aljumaih-Automotive-logo_kM7B61x_AROA7K7.png differ diff --git a/static/js/app_admin/staff_index.js b/static/js/app_admin/staff_index.js new file mode 100644 index 00000000..5173949d --- /dev/null +++ b/static/js/app_admin/staff_index.js @@ -0,0 +1,870 @@ +// Constants +const Constants = { + MOBILE_WIDTH_SMALL: 350, + MOBILE_WIDTH: 450, + SMALL_TABLET_WIDTH: 650, + TABLET_WIDTH: 767, + MEDIUM_WIDTH: 991, + DEFAULT_START_TIME: '09:00' +}; + +// Application State +const AppState = { + eventIdSelected: null, calendar: null, isEditingAppointment: false, isCreating: false, isUserStaffAdmin: true, +}; + +document.addEventListener("DOMContentLoaded", initializeCalendar); +window.addEventListener('resize', updateCalendarConfig); +document.getElementById('eventDetailsModal').addEventListener('keypress', function (event) { + if (event.key === 'Enter') { + event.preventDefault(); + document.getElementById('eventSubmitBtn').click(); + } +}); + +// Throttle resize events +let resizeTimeout; +window.addEventListener('resize', function () { + if (resizeTimeout) { + clearTimeout(resizeTimeout); + } + resizeTimeout = setTimeout(function () { + initializeCalendar() + }, 500); // Only rerender at most, every 100ms +}); + +document.addEventListener("DOMContentLoaded", function () { + // Wait for a 50ms after the DOM is ready before initializing the calendar + setUserStaffAdminFlag().then(() => { + setTimeout(initializeCalendar, 50); + }); +}); + +const AppStateProxy = new Proxy(AppState, { + set(target, property, value) { + console.log(`Setting ${property} to ${value}`) + // Check if the property being changed is 'isCreating' + if (value === true) { + attachEventListeners(); // Attach event listeners if isCreating becomes true + // (didn't check if property is isCreating, since AppStateProxy is only set with it) + } + target[property] = value; // Set the property value + return true; // Indicate successful setting + } +}); + +function attachEventListeners() { + // Checks if the DOM is already loaded + if (document.readyState === "complete" || document.readyState === "interactive") { + // DOM is already ready, attach event listeners directly + attachEventListenersToDropdown(); + } else { + // If the DOM is not yet ready, then wait for the DOMContentLoaded event + document.addEventListener('DOMContentLoaded', function () { + attachEventListenersToDropdown(); + }); + } +} + +function attachEventListenersToDropdown() { + const staffDropdown = document.getElementById('staffSelect'); + if (staffDropdown && !staffDropdown.getAttribute('listener-attached')) { + staffDropdown.setAttribute('listener-attached', 'true'); + staffDropdown.addEventListener('change', async function () { + const selectedStaffId = this.value; + const servicesDropdown = document.getElementById('serviceSelect'); + const services = await fetchServicesForStaffMember(selectedStaffId); + updateServicesDropdown(servicesDropdown, services); + }); + } +} + + +function initializeCalendar() { + const formattedAppointments = formatAppointmentsForCalendar(appointments); + const calendarEl = document.getElementById('calendar'); + AppState.calendar = new FullCalendar.Calendar(calendarEl, getCalendarConfig(formattedAppointments)); + AppState.calendar.setOption('locale', locale); + AppState.calendar.render(); +} + +function formatAppointmentsForCalendar(appointments) { + return appointments.map(appointment => ({ + id: appointment.id, + title: appointment.service_name, + start: appointment.start_time, + end: appointment.end_time, + client_name: appointment.client_name, + backgroundColor: appointment.background_color, + })); +} + +function updateCalendarConfig() { + AppState.calendar.setOption('headerToolbar', getHeaderToolbarConfig()); + AppState.calendar.setOption('height', getCalendarHeight()); +} + +function mobileCheck() { + return window.innerWidth < Constants.MOBILE_WIDTH; +} + +function tabletCheck() { + return window.innerWidth < Constants.TABLET_WIDTH; +} + +function getEventDisplayedStyle() { + if (mobileCheck()) { + return "list-item"; + } + return "block"; +} + +function getCalendarConfig(events) { + return { + initialView: 'dayGridMonth', + headerToolbar: getHeaderToolbarConfig(), + navLinks: true, + editable: true, + dayMaxEvents: true, + height: getCalendarHeight(), + aspectRatio: 1.0, + themeSystem: 'bootstrap5', + nowIndicator: true, + bootstrapFontAwesome: { + close: 'fa-times', + prev: 'fa-chevron-left', + next: 'fa-chevron-right', + prevYear: 'fa-angle-double-left', + nextYear: 'fa-angle-double-right' + }, + defaultView: mobileCheck() ? "basicDay" : "dayGridMonth", + selectable: true, + events: events, + eventDisplay: getEventDisplayedStyle(), + timeZone: timezone, + eventClick: async function (info) { + AppState.eventIdSelected = info.event.id; + await showEventModal(info.event.id, false, false); + }, + dateClick: function (info) { + // Retrieve events for the clicked date + const dateEvents = appointments + .filter(event => moment(info.date).isSame(event.start_time, 'day')) + .sort((a, b) => new Date(a.start_time) - new Date(b.start_time)); + + // Display events in a list below the calendar + displayEventList(dateEvents, info.date); + }, + + selectAllow: function (info) { + }, + dayCellClassNames: function (info) { + const day = info.date.getDay(); + if (day === 0 || day === 6) { // 0 = Sunday, 6 = Saturday + return 'highlight-weekend'; + } + return ''; // Return empty string for regular days + }, + eventDrop: async function (info) { + await validateAndUpdateAppointmentDate(info.event, info.revert); + }, + eventDidMount: function (info) { + // If it is a mobile view, we change the event to a dot + if (mobileCheck()) { + // Find the fc-daygrid-event-dot class within the event element + // and change its style to display as a dot + const dotEl = info.el.querySelector('.fc-daygrid-event-dot') || document.createElement('span'); + dotEl.classList.add('fc-daygrid-event-dot'); + dotEl.style.borderRadius = '50%'; + dotEl.style.backgroundColor = info.event.backgroundColor; + + // Clear the inner HTML of the event element and append the dot + info.el.innerHTML = ''; + info.el.appendChild(dotEl); + } + }, + dayCellDidMount: function (dayCell) { + // Check if the day is in the past + const currentDate = new Date(); + currentDate.setHours(0, 0, 0, 0); // Reset time part to compare only dates + + if (dayCell.date >= currentDate && !tabletCheck()) { + // Attach right-click event listener only if the day is not in the past + if (AppState.isUserStaffAdmin) { + dayCell.el.addEventListener('contextmenu', function (event) { + event.preventDefault(); + handleCalendarRightClick(event, dayCell.date); + }); + } + } + }, + }; +} + +function displayEventList(events, date) { + let eventListHtml = '

' + eventsOnTxt + ' ' + moment(date).format('MMMM Do, YYYY') + '

'; + eventListHtml += '
'; + + events.forEach(function (event) { + eventListHtml += `
${event.service_name}
`; + eventListHtml += `
${moment(event.start_time).format('h:mm a')} - ${moment(event.end_time).format('h:mm a')}
`; + eventListHtml += '
'; + }); + + const date_obj = new Date(date.toISOString()) + + if (events.length === 0) { + eventListHtml += `
` + noEventTxt + `
`; + } + + eventListHtml += ``; + + const eventListContainer = document.getElementById('event-list-container'); + eventListContainer.innerHTML = eventListHtml; + + // Add click event listeners to each event item + const eventItems = eventListContainer.getElementsByClassName('event-list-item-appt'); + for (let item of eventItems) { + item.addEventListener('click', function () { + const eventId = this.getAttribute('data-event-id'); + AppState.eventIdSelected = eventId; + showEventModal(eventId, false, false).then(r => r); + }); + } +} + + +function getHeaderToolbarConfig() { + if (window.matchMedia('(max-width: 767px)').matches) { + // Mobile configuration + return { + left: 'title', right: 'prev,next,dayGridMonth,timeGridDay' + }; + } else if (window.matchMedia('(max-width: 991px)').matches) { + // Tablet configuration + return { + left: 'prev,today,next', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' + }; + } else { + // Desktop configuration + return { + left: 'prevYear,prev,today,next,nextYear', + center: 'title', + right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek', + prevYear: 'prevYear', + nextYear: 'nextYear' + }; + } +} + +function getCalendarHeight() { + if (window.innerWidth <= Constants.MOBILE_WIDTH_SMALL) return '400px'; + if (window.innerWidth <= Constants.MOBILE_WIDTH) return '450px'; + if (window.innerWidth <= Constants.SMALL_TABLET_WIDTH) return '600px'; + if (window.innerWidth <= Constants.TABLET_WIDTH) return '650px'; + if (window.innerWidth <= Constants.MEDIUM_WIDTH) return '767px'; + return '850px'; +} + +function setUserStaffAdminFlag() { + return fetch(isUserStaffAdminURL) + .then(response => response.json()) + .then(data => { + if (data.is_staff_admin) { + AppState.isUserStaffAdmin = true; + } else { + console.error(data.message || "Error fetching user details."); + AppState.isUserStaffAdmin = false; + } + }) + .catch(error => { + console.error("Error checking user's staff admin status: ", error); + AppState.isUserStaffAdmin = false; + }); +} + +function handleCalendarRightClick(event, date) { + if (!AppState.isUserStaffAdmin) { + showErrorModal(notStaffMemberTxt) + return; + } + const contextMenu = document.getElementById("customContextMenu"); + contextMenu.style.top = `${event.pageY}px`; + contextMenu.style.left = `${event.pageX}px`; + contextMenu.style.display = 'block'; + + const newAppointmentOption = document.getElementById("newAppointmentOption"); + newAppointmentOption.onclick = () => createNewAppointment(date); + + // Hide context menu on any click + document.addEventListener('click', () => contextMenu.style.display = 'none', {once: true}); +} + +function goToEvent() { + // Get the event URL + const event = appointments.find(app => Number(app.id) === Number(AppState.eventIdSelected)); + if (event && event.url) { + closeModal() + window.location.href = event.url; + } else { + console.error("Event not found or doesn't have a URL."); + } +} + +function closeModal() { + const modal = document.getElementById("eventDetailsModal"); + const editButton = document.getElementById("eventEditBtn"); + const submitButton = document.getElementById("eventSubmitBtn"); + const closeButton = modal.querySelector(".btn-secondary[data-dismiss='modal']"); + const cancelButton = document.getElementById("eventCancelBtn"); + + // Reset the modal buttons to their default state + editButton.style.display = ""; + closeButton.style.display = ""; + submitButton.style.display = "none"; + cancelButton.style.display = "none"; + + // Reset the editing flag + AppStateProxy.isEditingAppointment = false; + + // Close the modal + $('#eventDetailsModal').modal('hide'); +} + + +// ################################################################ // +// Generic // +// ################################################################ // + + +async function cancelEdit() { + // Retrieve the appointment that matches the eventIdSelected + const appointment = appointments.find(app => Number(app.id) === Number(AppState.eventIdSelected)); + if (!appointment) { + return; + } + + // Extract only the time using Moment.js + const endTime = moment(appointment.end_time).format('HH:mm:ss'); + + // Find the modal, end time label, and end time input + const modal = document.getElementById("eventDetailsModal"); + const endTimeLabel = modal.querySelector("label[for='endTime']"); + const endTimeInput = modal.querySelector("input[name='endTime']"); + + // Set and display the end time label and input + endTimeInput.value = endTime; + endTimeLabel.style.display = ""; + endTimeInput.style.display = ""; + + // Re-show the event modal with the original data + await showEventModal(appointment.id, false, false); + toggleEditMode(); // Turn off edit mode +} + +function confirmDeleteAppointment(appointmentId) { + const deleteURL = deleteAppointmentURLTemplate + const data = {appointment_id: appointmentId}; + + fetch(deleteURL, { + method: 'POST', headers: { + 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRFToken': getCookie('csrftoken'), + }, body: JSON.stringify(data) + }) + .then(response => { + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response.json(); + }) + .then(data => { + $('#eventDetailsModal').modal('hide'); + let event = AppState.calendar.getEventById(appointmentId); + if (event) { + event.remove(); + } + showErrorModal(data.message, successTxt); + closeConfirmModal(); // Close the confirmation modal + + // Remove the deleted appointment from the global appointments array + appointments = appointments.filter(appointment => appointment.id !== appointmentId); + + // Refresh the event list for the current date + const currentDate = AppState.calendar.getDate(); + const dateEvents = appointments + .filter(event => moment(currentDate).isSame(event.start_time, 'day')) + .sort((a, b) => new Date(a.start_time) - new Date(b.start_time)); + displayEventList(dateEvents, currentDate); + }) + .catch(error => { + console.error('Error:', error); + showErrorModal(updateApptErrorTitleTxt); + }); +} + +function deleteAppointment() { + showModal(confirmDeletionTxt, confirmDeletionTxt, deleteBtnTxt, null, () => confirmDeleteAppointment(AppState.eventIdSelected)); +} + +function fetchServices(isEditMode = false) { + let url = isEditMode && AppState.eventIdSelected ? `${fetchServiceListForStaffURL}?appointmentId=${AppState.eventIdSelected}` : fetchServiceListForStaffURL; + return fetch(url) + .then(response => response.json()) + .then(data => data.services_offered) + .catch(error => console.error("Error fetching services: ", error)); +} + +function fetchStaffMembers(isEditMode = false) { + let url = isEditMode && AppState.eventIdSelected ? `${fetchStaffListURL}?appointmentId=${AppState.eventIdSelected}` : fetchStaffListURL; + return fetch(url) + .then(response => response.json()) + .then(data => data.staff_members) + .catch(error => console.error("Error fetching staff members: ", error)); + +} + +async function populateServices(selectedServiceId, isEditMode = false) { + const services = await fetchServices(isEditMode); + if (!services) { + showErrorModal(noServiceOfferedTxt) + } + const selectElement = document.createElement('select'); + services.forEach(service => { + const option = document.createElement('option'); + option.value = service.id; // Accessing the id + option.textContent = service.name; // Accessing the name + if (service.id === selectedServiceId) { + option.defaultSelected = true; + } + selectElement.appendChild(option); + }); + return selectElement; +} + +async function populateStaffMembers(selectedStaffId, isEditMode = false) { + const staffMembers = await fetchStaffMembers(isEditMode); + if (!staffMembers) { + showErrorModal(noStaffMemberTxt) + } + const selectElement = document.createElement('select'); + staffMembers.forEach(staff => { + const option = document.createElement('option'); + option.value = staff.id; // Accessing the id + option.textContent = staff.name; // Accessing the name + if (staff.id === selectedStaffId) { + option.defaultSelected = true; + } + selectElement.appendChild(option); + }); + return selectElement; +} + +// Function to fetch services for a specific staff member +async function fetchServicesForStaffMember(staffId) { + const url = `${fetchServiceListForStaffURL}?staff_member=${staffId}`; + try { + const response = await fetch(url); + if (!response.ok) throw new Error('Network response was not ok'); + const data = await response.json(); + return data.services_offered || []; + } catch (error) { + console.error("Error fetching services: ", error); + return []; // Return an empty array in case of error + } +} + +// Function to update services dropdown options +function updateServicesDropdown(dropdown, services) { + // Clear existing options + dropdown.innerHTML = ''; + + // Populate with new options + services.forEach(service => { + const option = new Option(service.name, service.id); // Assuming service object has id and name properties + dropdown.add(option); + }); +} +/* +function getCSRFToken() { + const metaTag = document.querySelector('meta[name="csrf-token"]'); + if (metaTag) { + return metaTag.getAttribute('content'); + } else { + console.error("CSRF token meta tag not found!"); + return null; + } +} +*/ +async function validateAndUpdateAppointmentDate(event, revertFunction) { + const updatedStartTime = event.start.toISOString(); + const updatedEndTime = event.end ? event.end.toISOString() : null; + + const data = { + appointment_id: event.id, start_time: updatedStartTime, date: event.start.toISOString().split('T')[0] + }; + + try { + const validationResponse = await fetch(validateApptDateURL, { + method: 'POST', headers: { + 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRFToken': getCookie('csrftoken'), + }, body: JSON.stringify(data) + }); + + if (validationResponse.ok) { + await updateAppointmentDate(event, revertFunction); + } else { + const responseData = await validationResponse.json(); + showErrorModal(responseData.message); + + revertFunction(); + } + } catch (error) { + console.error('Failed to validate data:', error); + revertFunction(); + } +} + +async function updateAppointmentDate(event, revertFunction) { + const updatedStartTime = event.start.toISOString().split('T')[1]; + const updatedDate = event.start.toISOString().split('T')[0]; + + const data = { + appointment_id: event.id, start_time: updatedStartTime, date: updatedDate, + }; + + try { + const response = await fetch(updateApptDateURL, { + method: 'POST', headers: { + 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRFToken': getCookie('csrftoken'), + }, body: JSON.stringify(data) + }); + + const responseData = await response.json(); + if (response.ok) { + showErrorModal(responseData.message, successTxt) + } else { + console.error('Failed to update appointment date. Server responded with:', response.statusText); + showErrorModal(responseData.message, updateApptErrorTitleTxt); + revertFunction(); + } + } catch (error) { + console.error('Failed to send data:', error); + revertFunction(); + } +} + +// ################################################################ // +// Create new Appt // +// ################################################################ // +function createNewAppointment(dateInput) { + let date; + if (typeof dateInput === 'string' || dateInput instanceof String) { + date = new Date(dateInput); + } else { + date = dateInput; + } + + const day = date.getDate().toString().padStart(2, '0'); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); // getMonth() returns 0-11 + const year = date.getFullYear(); + const formattedDate = `${year}-${month}-${day}`; + const defaultStartTime = `${formattedDate}T09:00:00`; + + showCreateAppointmentModal(defaultStartTime, formattedDate).then(() => { + }); +} + +async function showCreateAppointmentModal(defaultStartTime, formattedDate) { + const servicesDropdown = await populateServices(null, false); + let staffDropdown = null; + if (isUserSuperUser) { + staffDropdown = await populateStaffMembers(null, false); + staffDropdown.id = "staffSelect"; + staffDropdown.disabled = false; // Enable dropdown + attachEventListenersToDropdown(); // Attach event listener + } + servicesDropdown.id = "serviceSelect"; + servicesDropdown.disabled = false; // Enable dropdown + + document.getElementById('eventModalBody').innerHTML = prepareCreateAppointmentModalContent(servicesDropdown, staffDropdown, defaultStartTime, formattedDate); + + adjustCreateAppointmentModalButtons(); + AppStateProxy.isCreating = true; + $('#eventDetailsModal').modal('show'); +} + +function adjustCreateAppointmentModalButtons() { + document.getElementById("eventSubmitBtn").style.display = ""; + document.getElementById("eventCancelBtn").style.display = "none"; + document.getElementById("eventEditBtn").style.display = "none"; + document.getElementById("eventDeleteBtn").style.display = "none"; + document.getElementById("eventGoBtn").style.display = "none"; +} + +// ################################################################ // +// Show Event Modal // +// ################################################################ // + +// Extract Appointment Data +async function getAppointmentData(eventId, isCreatingMode, defaultStartTime) { + if (eventId && !isCreatingMode) { + const appointment = appointments.find(app => Number(app.id) === Number(eventId)); + if (!appointment) { + showErrorModal(apptNotFoundTxt, errorTxt); + return null; + } + return appointment; + } + return { + id: null, + service_name: '', + start_time: defaultStartTime, + end_time: '', + client_name: '', + client_email: '', + client_phone: '', + client_address: '', + additional_info: '', + want_reminder: false, + background_color: '', + timezone: '', + }; +} + +// Populate Services Dropdown +async function getServiceDropdown(serviceId, isEditMode) { + const servicesDropdown = await populateServices(serviceId, !isEditMode); + servicesDropdown.id = "serviceSelect"; + servicesDropdown.disabled = !isEditMode; + return servicesDropdown; +} + +// Populate Staff Dropdown +async function getStaffDropdown(staffId, isEditMode) { + const staffDropdown = await populateStaffMembers(staffId, !isEditMode); + staffDropdown.id = "staffSelect"; + staffDropdown.disabled = !isEditMode; + return staffDropdown; +} + +// Show Event Modal +async function showEventModal(eventId = null, isEditMode, isCreatingMode = false, defaultStartTime = '') { + const appointment = await getAppointmentData(eventId, isCreatingMode, defaultStartTime); + if (!appointment) return; + + const servicesDropdown = await getServiceDropdown(appointment.service_id, isEditMode); + let staffDropdown = null; + if (isUserSuperUser) { + staffDropdown = await getStaffDropdown(appointment.staff_id, isEditMode); + attachEventListenersToDropdown(); // Attach event listener + } + + document.getElementById('eventModalBody').innerHTML = generateModalContent(appointment, servicesDropdown, isEditMode, staffDropdown); + adjustModalButtonsVisibility(isEditMode, isCreatingMode); + $('#eventDetailsModal').modal('show'); +} + +// Adjust Modal Buttons Visibility +function adjustModalButtonsVisibility(isEditMode, isCreatingMode) { + const editButton = document.getElementById("eventEditBtn"); + const submitButton = document.getElementById("eventSubmitBtn"); + const deleteButton = document.getElementById("eventDeleteBtn"); + const goButton = document.getElementById("eventGoBtn"); + + editButton.style.display = !isEditMode && !isCreatingMode ? "" : "none"; + submitButton.style.display = isCreatingMode || isEditMode ? "" : "none"; + deleteButton.style.display = !isEditMode && !isCreatingMode ? "" : "none"; + goButton.style.display = isCreatingMode ? "none" : ""; +} + +// ################################################################ // +// Edit Logic // +// ################################################################ // + +function toggleEditMode() { + const modal = document.getElementById("eventDetailsModal"); + const appointment = appointments.find(app => Number(app.id) === Number(AppState.eventIdSelected)); + AppStateProxy.isCreating = false; // Turn off creating mode + + // Proceed only if an appointment is found + if (appointment) { + AppStateProxy.isEditingAppointment = !AppState.isEditingAppointment; // Toggle the editing state + updateModalUIForEditMode(modal, AppState.isEditingAppointment); + } else { + console.error("Appointment not found!"); + } +} + +function updateModalUIForEditMode(modal, isEditingAppointment) { + const inputs = modal.querySelectorAll("input"); + const staffDropdown = document.getElementById("staffSelect"); + const servicesDropdown = document.getElementById("serviceSelect"); + const editButton = document.getElementById("eventEditBtn"); + const submitButton = document.getElementById("eventSubmitBtn"); + const closeButton = modal.querySelector(".btn-secondary[data-dismiss='modal']"); + const cancelButton = document.getElementById("eventCancelBtn"); + const deleteButton = document.getElementById("eventDeleteBtn"); + const goButton = document.getElementById("eventGoBtn"); + const endTimeLabel = modal.querySelector("label[for='endTime']"); + const endTimeInput = modal.querySelector("input[name='endTime']"); + + // Toggle input and dropdown enable/disable state + inputs.forEach(input => input.disabled = !isEditingAppointment); + staffDropdown.disabled = !isEditingAppointment; + servicesDropdown.disabled = !isEditingAppointment; + + // Toggle visibility of UI elements + toggleElementVisibility(editButton, !isEditingAppointment); + toggleElementVisibility(submitButton, isEditingAppointment); + toggleElementVisibility(cancelButton, isEditingAppointment); + toggleElementVisibility(deleteButton, !isEditingAppointment); + toggleElementVisibility(closeButton, !isEditingAppointment); + toggleElementVisibility(endTimeLabel, !isEditingAppointment); // Show end time in view mode + toggleElementVisibility(endTimeInput, !isEditingAppointment); // Show end time in view mode + toggleElementVisibility(goButton, !isEditingAppointment); +} + +function toggleElementVisibility(element, isVisible) { + if (element) { + element.style.display = isVisible ? "" : "none"; + } +} + +// ################################################################ // +// Submit Logic // +// ################################################################ // + +async function submitChanges() { + const modal = document.getElementById("eventDetailsModal"); + const formData = collectFormDataFromModal(modal); + + if (!validateFormData(formData)) return; + + const response = await sendAppointmentData(formData); + if (response.ok) { + const responseData = await response.json(); + if (AppState.isCreating) { + addNewAppointmentToCalendar(responseData.appt[0]); + } else { + updateExistingAppointmentInCalendar(responseData.appt); + } + + AppState.calendar.render(); + } else { + const responseData = await response.json(); + showErrorModal(responseData.message); + } + closeModal(); + +} + +// Collect form data from modal +function collectFormDataFromModal(modal) { + const inputs = modal.querySelectorAll("input"); + const serviceId = modal.querySelector("#serviceSelect").value; + let staffId = null; + + if (isUserSuperUser) { + // If the user is a superuser, get the staff ID from the dropdown + const staffDropdown = modal.querySelector("#staffSelect"); + if (staffDropdown) { + staffId = staffDropdown.value; + } + } + + const data = { + isCreating: AppState.isCreating, + service_id: serviceId, + appointment_id: AppState.eventIdSelected + }; + + if (staffId) { + data.staff_member = staffId; + } + + inputs.forEach(input => { + if (input.name !== "date") { + let key = input.name.replace(/([A-Z])/g, '_$1').toLowerCase(); + data[key] = input.value; + } + }); + + if (AppState.isCreating) { + data["date"] = modal.querySelector('input[name="date"]').value; + } + + // Special handling for checkbox + const wantReminderCheckbox = modal.querySelector('input[name="want_reminder"]'); + if (!wantReminderCheckbox.checked) { + data['want_reminder'] = 'false'; + } else { + data['want_reminder'] = 'true'; + } + + return data; +} + +// Validate form data +function validateFormData(data) { + return validateEmail(data["client_email"]); +} + +// Validate email +function validateEmail(email) { + const emailInput = document.querySelector('input[name="clientEmail"]'); + const emailError = document.getElementById("emailError"); + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(emailInput.value)) { + emailInput.style.border = "1px solid red"; + emailError.textContent = "Invalid email address, yeah."; + emailError.style.color = "red"; + emailError.style.display = "inline"; + return false; + } else { + emailInput.style.border = ""; + emailError.textContent = ""; + emailError.style.display = "none"; + return true; + } +} + +// Send appointment data to server +async function sendAppointmentData(data) { + const headers = { + 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRFToken': getCookie('csrftoken'), + }; + + return fetch(updateApptMinInfoURL, { + method: 'POST', headers: headers, body: JSON.stringify(data) + }); +} + +// Add new appointment to calendar +function addNewAppointmentToCalendar(newAppointment) { + const newEvent = formatAppointmentsForCalendar([newAppointment])[0]; + appointments.push(newAppointment); + AppState.calendar.addEvent(newEvent); +} + +// Update existing appointment in calendar +function updateExistingAppointmentInCalendar(appointment) { + let eventToUpdate = AppState.calendar.getEventById(AppState.eventIdSelected); + if (eventToUpdate) { + updateEventProperties(eventToUpdate, appointment); + } + // update appointment in appointments array + const index = appointments.findIndex(app => Number(app.id) === Number(AppState.eventIdSelected)); + if (index !== -1) { + appointments[index] = appointment; + } +} + +// Update event properties +function updateEventProperties(event, appointment) { + event.setProp('title', appointment.service_name); + event.setStart(moment(appointment.start_time).format('YYYY-MM-DDTHH:mm:ss')); + event.setEnd(appointment.end_time); + event.setExtendedProp('client_name', appointment.client_name); + event.setProp('backgroundColor', appointment.background_color); +} diff --git a/static/js/appointments.js b/static/js/appointments.js new file mode 100644 index 00000000..1df6b5d3 --- /dev/null +++ b/static/js/appointments.js @@ -0,0 +1,370 @@ +const calendarEl = document.getElementById('calendar'); +let nextAvailableDateSelector = $('.djangoAppt_next-available-date') +const body = $('body'); +let nonWorkingDays = []; +let selectedDate = rescheduledDate || null; +let staffId = $('#staff_id').val() || null; +let previouslySelectedCell = null; +let isRequestInProgress = false; + +const calendar = new FullCalendar.Calendar(calendarEl, { + initialView: 'dayGridMonth', + initialDate: selectedDate, + timeZone: timezone, + headerToolbar: { + left: 'title', + right: 'prev,today,next', + }, + height: '400px', + themeSystem: 'bootstrap', + nowIndicator: true, + bootstrapFontAwesome: { + close: 'fa-times', + prev: 'fa-chevron-left', + next: 'fa-chevron-right', + prevYear: 'fa-angle-double-left', + nextYear: 'fa-angle-double-right' + }, + selectable: true, + dateClick: function (info) { + const day = info.date.getDay(); // Get the day of the week (0 for Sunday, 6 for Saturday) + if (nonWorkingDays.includes(day)) { + return; + } + + // If there's a previously selected cell, remove the class + if (previouslySelectedCell) { + previouslySelectedCell.classList.remove('selected-cell'); + } + + // Add the class to the currently clicked cell + info.dayEl.classList.add('selected-cell'); + + // Store the currently clicked cell + previouslySelectedCell = info.dayEl; + + selectedDate = info.dateStr; + getAvailableSlots(info.dateStr, staffId); + }, + datesSet: function (info) { + highlightSelectedDate(); + }, + selectAllow: function (info) { + const day = info.start.getDay(); // Get the day of the week (0 for Sunday, 6 for Saturday) + if (nonWorkingDays.includes(day)) { + return false; // Disallow selection for non-working days + } + return (info.start >= getDateWithoutTime(new Date())); + }, + dayCellClassNames: function (info) { + const day = info.date.getDay(); + if (nonWorkingDays.includes(day)) { + return ['disabled-day']; + } + return []; + }, +}); + +calendar.setOption('locale', locale); + +$(document).ready(function () { + staffId = $('#staff_id').val() || null; + calendar.render(); + const currentDate = rescheduledDate || moment.tz(timezone).format('YYYY-MM-DD'); + getAvailableSlots(currentDate, staffId); +}); + +function highlightSelectedDate() { + setTimeout(function () { + const dateCell = document.querySelector(`.fc-daygrid-day[data-date='${selectedDate}']`); + if (dateCell) { + dateCell.classList.add('selected-cell'); + previouslySelectedCell = dateCell; + } + }, 10); +} + +body.on('click', '.djangoAppt_btn-request-next-slot', function () { + const serviceId = $(this).data('service-id'); + requestNextAvailableSlot(serviceId); +}) + +body.on('click', '.btn-submit-appointment', function () { + const selectedSlot = $('.djangoAppt_appointment-slot.selected').text(); + const selectedDate = $('.djangoAppt_date_chosen').text(); + if (!selectedSlot || !selectedDate) { + alert(selectDateAndTimeAlertTxt); + return; + } + if (selectedSlot && selectedDate) { + const startTime = convertTo24Hour(selectedSlot); + const APPOINTMENT_BASE_TEMPLATE = localStorage.getItem('APPOINTMENT_BASE_TEMPLATE'); + // Convert the selectedDate string to a valid format + const dateParts = selectedDate.split(', '); + const monthDayYear = dateParts[1] + "," + dateParts[2]; + const formattedDate = new Date(monthDayYear + " " + startTime); + + const date = formattedDate.toISOString().slice(0, 10); + const endTimeDate = new Date(formattedDate.getTime() + serviceDuration * 60000); + const endTime = formatTime(endTimeDate); + const reasonForRescheduling = $('#reason_for_rescheduling').val(); + const form = $('.appointment-form'); + let formAction = rescheduledDate ? appointmentRescheduleURL : appointmentRequestSubmitURL; + form.attr('action', formAction); + if (!form.find('input[name="appointment_request_id"]').length) { + form.append($('', { + type: 'hidden', + name: 'appointment_request_id', + value: appointmentRequestId + })); + } + form.append($('', {type: 'hidden', name: 'date', value: date})); + form.append($('', {type: 'hidden', name: 'start_time', value: startTime})); + form.append($('', {type: 'hidden', name: 'end_time', value: endTime})); + form.append($('', {type: 'hidden', name: 'service', value: serviceId})); + form.append($('', {type: 'hidden', name: 'reason_for_rescheduling', value: reasonForRescheduling})); + form.submit(); + } else { + const warningContainer = $('.warning-message'); + if (warningContainer.find('submit-warning') === 0) { + warningContainer.append('

' + selectTimeSlotWarningTxt + '

'); + } + } +}); + +$('#staff_id').on('change', function () { + staffId = $(this).val() || null; // If staffId is an empty string, set it to null + let currentDate = null + if (selectedDate == null) { + currentDate = moment.tz(timezone).format('YYYY-MM-DD'); + } else { + currentDate = selectedDate; + } + fetchNonWorkingDays(staffId, function (newNonWorkingDays) { + nonWorkingDays = newNonWorkingDays; // Update the nonWorkingDays array + calendar.render(); // Re-render the calendar to apply changes + + // Fetch available slots for the current date + getAvailableSlots(currentDate, staffId); + }); +}); + + +function fetchNonWorkingDays(staffId, callback) { + if (!staffId || staffId === 'none') { + nonWorkingDays = []; // Reset nonWorkingDays + calendar.render(); // Re-render the calendar + callback([]); + return; // Exit the function early + } + let ajaxData = { + 'staff_member': staffId, + }; + + $.ajax({ + url: getNonWorkingDaysURL, + data: ajaxData, + dataType: 'json', + success: function (data) { + if (data.error) { + console.error('Error fetching non-working days:', data.message); + callback([]); + } else { + nonWorkingDays = data.non_working_days; + calendar.render(); + callback(data.non_working_days); + } + } + }); +} + +function getDateWithoutTime(dt) { + dt.setHours(0, 0, 0, 0); + return dt; +} + +function convertTo24Hour(time12h) { + const [time, modifier] = time12h.split(' '); + let [hours, minutes] = time.split(':'); + + if (hours === '12') { + hours = '00'; + } + + if (modifier.toUpperCase() === 'PM') { + hours = parseInt(hours, 10) + 12; + } + + return `${hours}:${minutes}`; +} + +function formatTime(date) { + const hours = date.getHours(); + const minutes = date.getMinutes(); + return (hours < 10 ? '0' + hours : hours) + ':' + (minutes < 10 ? '0' + minutes : minutes); +} + +function getAvailableSlots(selectedDate, staffId = null) { + // Update the slot list with the available slots for the selected date + const slotList = $('#slot-list'); + const slotContainer = $('.slot-container'); + const errorMessageContainer = $('.error-message'); + + // Clear previous error messages and slots + slotList.empty(); + errorMessageContainer.find('.djangoAppt_no-availability-text').remove(); + + // Remove the "Next available date" message + nextAvailableDateSelector = $('.djangoAppt_next-available-date'); // Update the selector + nextAvailableDateSelector.remove(); + + // Correctly check if staffId is 'none', null, or undefined and exit the function if true + // Check if 'staffId' is 'none', null, or undefined and display an error message + if (staffId === 'none' || staffId === null || staffId === undefined) { + console.log('No staff ID provided, displaying error message.'); + const errorMessage = $('

' + noStaffMemberSelectedTxt + '

'); + errorMessageContainer.append(errorMessage); + // Optionally disable the "submit" button here + $('.btn-submit-appointment').attr('disabled', 'disabled'); + return; // Exit the function early + } + + let ajaxData = { + 'selected_date': selectedDate, + 'staff_member': staffId, + }; + fetchNonWorkingDays(staffId, function (nonWorkingDays) { + // Check if nonWorkingDays is an array + if (Array.isArray(nonWorkingDays)) { + // Update the FullCalendar configuration + // calendar.setOption('hiddenDays', nonWorkingDays); + } else { + // Handle the case where there's an error or no data + // For now, we'll just log it, but you can handle it more gracefully if needed + console.error('Failed to get non-working days:', nonWorkingDays); + } + }); + + // Send an AJAX request to get the available slots for the selected date + if (isRequestInProgress) { + return; // Exit the function if a request is already in progress + } + isRequestInProgress = true; + $.ajax({ + url: availableSlotsAjaxURL, + data: ajaxData, + dataType: 'json', + success: function (data) { + if (data.available_slots.length === 0) { + const selectedDateObj = moment.tz(selectedDate, timezone); + const selectedD = selectedDateObj.toDate(); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + if (selectedD < today) { + // Show an error message + errorMessageContainer.append('

' + dateInPastErrorTxt + '

'); + if (slotContainer.find('.djangoAppt_btn-request-next-slot').length === 0) { + slotContainer.append(``); + } + // Disable the 'submit' button + $('.btn-submit-appointment').attr('disabled', 'disabled'); + } else { + errorMessageContainer.find('.djangoAppt_no-availability-text').remove(); + if (errorMessageContainer.find('.djangoAppt_no-availability-text').length === 0) { + errorMessageContainer.append(`

${data.message}

`); + } + // Check if the returned message is 'No availability' + if (data.message.toLowerCase() === 'no availability') { + if (slotContainer.find('.djangoAppt_btn-request-next-slot').length === 0) { + slotContainer.append(``); + } + } else { + $('.djangoAppt_btn-request-next-slot').remove(); + } + } + } else { + // remove the button to request for next available slot + $('.djangoAppt_no-availability-text').remove(); + $('.djangoAppt_btn-request-next-slot').remove(); + const uniqueSlots = [...new Set(data.available_slots)]; // remove duplicates + for (let i = 0; i < uniqueSlots.length; i++) { + slotList.append('
  • ' + uniqueSlots[i] + '
  • '); + } + + // Attach click event to the slots + $('.djangoAppt_appointment-slot').on('click', function () { + // Remove the 'selected' class from all other appointment slots + $('.djangoAppt_appointment-slot').removeClass('selected'); + + // Add the 'selected' class to the clicked appointment slot + $(this).addClass('selected'); + + // Enable the submit button + $('.btn-submit-appointment').removeAttr('disabled'); + + // Continue with the existing logic + const selectedSlot = $(this).text(); + $('#service-datetime-chosen').text(data.date_chosen + ' ' + selectedSlot); + }); + } + // Update the date chosen + $('.djangoAppt_date_chosen').text(data.date_chosen); + $('#service-datetime-chosen').text(data.date_chosen); + isRequestInProgress = false; + }, + error: function() { + isRequestInProgress = false; // Ensure the flag is reset even if the request fails + } + }); +} + +function requestNextAvailableSlot(serviceId) { + const requestNextAvailableSlotURL = requestNextAvailableSlotURLTemplate.replace('0', serviceId); + if (staffId === null) { + return; + } + let ajaxData = { + 'staff_member': staffId, + }; + $.ajax({ + url: requestNextAvailableSlotURL, + data: ajaxData, + dataType: 'json', + success: function (data) { + // If there's an error, just log it and return + let nextAvailableDateResponse = null; + let formattedDate = null; + if (data.error) { + nextAvailableDateResponse = data.message; + } else { + // Set the date in the calendar to the next available date + nextAvailableDateResponse = data.next_available_date; + const selectedDateObj = moment.tz(nextAvailableDateResponse, timezone); + const nextAvailableDate = selectedDateObj.toDate(); + formattedDate = new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }).format(nextAvailableDate); + } + + // Check if the .next-available-date element already exists + nextAvailableDateSelector = $('.djangoAppt_next-available-date'); // Update the selector + let nextAvailableDateText = null; + if (data.error) { + nextAvailableDateText = nextAvailableDateResponse; + } else { + nextAvailableDateText = `Next available date: ${formattedDate}`; + } + if (nextAvailableDateSelector.length > 0) { + // Update the content of the existing .next-available-date element + nextAvailableDateSelector.text(nextAvailableDateText); + } else { + // If the .next-available-date element doesn't exist, create and append it + const nextDateText = `

    ${nextAvailableDateText}

    `; + $('.djangoAppt_btn-request-next-slot').after(nextDateText); + } + } + }); +} diff --git a/static/js/js-utils.js b/static/js/js-utils.js new file mode 100644 index 00000000..ba791270 --- /dev/null +++ b/static/js/js-utils.js @@ -0,0 +1,8 @@ +document.addEventListener('DOMContentLoaded', function () { + const messageElements = document.querySelectorAll('.alert-dismissible'); + setTimeout(function () { + messageElements.forEach(function (element) { + element.style.display = 'none'; + }); + }, 5000); +}); diff --git a/static/js/modal/error_modal.js b/static/js/modal/error_modal.js new file mode 100644 index 00000000..a64887f9 --- /dev/null +++ b/static/js/modal/error_modal.js @@ -0,0 +1,19 @@ +let errorModalInstance = null; + +function showErrorModal(message, title = errorTxt) { + // Insert the error message into the modal + document.getElementById('errorModalLabel').textContent = title; + document.getElementById('errorModalMessage').textContent = message; + + // Show the modal + if (!errorModalInstance) { + errorModalInstance = new bootstrap.Modal(document.getElementById('errorModal')); + } + errorModalInstance.show(); +} + +function closeErrorModal() { + if (errorModalInstance) { + errorModalInstance.hide(); + } +} \ No newline at end of file diff --git a/static/js/modal/show_modal.js b/static/js/modal/show_modal.js new file mode 100644 index 00000000..7dac8b7c --- /dev/null +++ b/static/js/modal/show_modal.js @@ -0,0 +1,25 @@ +function showModal(title, body, actionText, actionUrl, actionCallback) { + // Set the content of the modal + document.getElementById('modalLabel').innerText = title; + document.getElementById('modalBody').innerText = body; + const actionBtn = document.getElementById('modalActionBtn'); + actionBtn.innerText = actionText; + + // Determine the type of action: callback function or URL + if (actionCallback) { + actionBtn.onclick = () => { + actionCallback(); + closeModal(); // Close the modal after action + }; + } else if (actionUrl) { + actionBtn.href = actionUrl; + } + + // Display the modal + $('#confirmModal').modal('show'); +} + + +function closeConfirmModal() { + $('#confirmModal').modal('hide'); +} \ No newline at end of file diff --git a/templates/administration/display_appointment.html b/templates/administration/display_appointment.html new file mode 100644 index 00000000..0071e007 --- /dev/null +++ b/templates/administration/display_appointment.html @@ -0,0 +1,98 @@ +{% extends BASE_TEMPLATE %} +{% load i18n %} +{% load static %} +{% block customCSS %} + + +{% endblock %} +{% block title %} + {{ page_title }} +{% endblock %} +{% block description %} + {{ page_description }} +{% endblock %} +{% block body %} +
    +
    +
    +
    +

    {{ page_title }}

    +
    +
    + + {% trans 'Date' %}: {{ appointment.get_date }} +
    +
    + + {% trans 'Start time' %}: {{ appointment.get_start_time|time:"g:i A" }} +
    +
    + + {% trans 'End time' %}: {{ appointment.get_end_time|time:"g:i A" }} +
    +
    + + {% trans 'Service' %}: {{ appointment.get_service_name }} +
    +
    + + {% trans 'Client' %}: {{ appointment.get_client_name }} +
    +
    + + {% trans 'Email' %}: {{ appointment.client.email }} +
    +
    + + {% trans 'Phone' %}: {{ appointment.phone }} +
    +
    + + {% trans 'Wants reminder' %}: {{ appointment.want_reminder }} +
    +
    + + {% trans 'Client address' %}: {{ appointment.address }} +
    +
    + + {% trans 'Additional Information' %}: {{ appointment.additional_info }} +
    +
    + + {% trans 'Is paid' %}: {{ appointment.is_paid_text }} +
    +
    + + {% trans 'Service price' %}: {{ appointment.get_appointment_amount_to_pay_text }} +
    +
    +
    +
    + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} +
    +
    +
    +
    +{% endblock %} +{% block customJS %} + + + + + + +{% endblock %} diff --git a/templates/administration/email_change_verification_code.html b/templates/administration/email_change_verification_code.html new file mode 100644 index 00000000..d080e5a8 --- /dev/null +++ b/templates/administration/email_change_verification_code.html @@ -0,0 +1,41 @@ +{% extends BASE_TEMPLATE %} +{% load i18n %} +{% load static %} +{% block customCSS %} + +{% endblock %} +{% block title %} + {% trans 'Enter Verification Code' %} +{% endblock %} +{% block description %} + {% trans 'Enter Verification Code' %} +{% endblock %} +{% block body %} +
    +
    +
    +

    {% trans 'Enter Verification Code' %}

    +

    {% trans "We've sent a verification code to your email. Please enter it below" %}:

    +
    + {% csrf_token %} + + +
    +
    +
    +
    + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} +
    +
    +{% endblock %} +{% block customJS %} + +{% endblock %} diff --git a/templates/administration/manage_day_off.html b/templates/administration/manage_day_off.html new file mode 100644 index 00000000..0e8c7411 --- /dev/null +++ b/templates/administration/manage_day_off.html @@ -0,0 +1,152 @@ +{% extends BASE_TEMPLATE %} +{% load i18n %} +{% load static %} + +{% block customCSS %} + + + +{% endblock %} + +{% block body %} +
    +
    +
    +

    {% trans "Manage Days Off" %}

    + +
    + {% csrf_token %} + + {% if error_message %} + + {% endif %} + + {% if days_off_form.staff_member %} +
    + + {{ days_off_form.staff_member }} +
    + {% endif %} + + +
    + + + + +
    + + +
    + + + + +
    + + +
    + + +
    + + + +
    +
    + {% if days_off_form.errors %} +
    + {{ days_off_form.errors }} +
    + {% endif %} +
    + +
    + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} +
    + {% include 'modal/error_modal.html' %} +
    +
    +
    +{% endblock %} + +{% block customJS %} + + + + + + +{% endblock %} diff --git a/templates/administration/manage_service.html b/templates/administration/manage_service.html new file mode 100644 index 00000000..187cc410 --- /dev/null +++ b/templates/administration/manage_service.html @@ -0,0 +1,51 @@ +{% extends BASE_TEMPLATE %} +{% load i18n %} +{% load static %} + +{% block customCSS %} + + +{% endblock %} + +{% block body %} +
    +
    +
    +

    {{ page_title }}

    +
    + {% csrf_token %} + {{ form.as_p }} + {% if btn_text %} + + {% else %} + {% if request.user.is_superuser and service.id %} + + {% endif %} + {% endif %} +
    +
    + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} +
    +
    +
    +
    +{% endblock %} + +{% block customJS %} + +{% endblock %} diff --git a/templates/administration/manage_staff_member.html b/templates/administration/manage_staff_member.html new file mode 100644 index 00000000..4c7ce226 --- /dev/null +++ b/templates/administration/manage_staff_member.html @@ -0,0 +1,87 @@ +{% extends BASE_TEMPLATE %} +{% load i18n %} +{% load static %} + +{% block customCSS %} + +{% endblock %} + +{% block body %} +
    +
    +
    +

    {% trans 'Staff Appointment Information' %}

    +
    + {% csrf_token %} + {% if form.user %} +
    + {{ form.user.label_tag }} + {{ form.user.errors }} + {{ form.user }} +
    +
    + + {% translate 'User not found' %} ? {% translate 'Create staff member manually' %} + +
    + {% endif %} + +
    + {{ form.services_offered.label_tag }} + {{ form.services_offered.errors }} + {{ form.services_offered }} +
    {% trans 'Hold down “Control”, or “Command” on a Mac, to select more than one.' %} +
    + +
    + {{ form.slot_duration.label_tag }} + {{ form.slot_duration }} + {{ form.slot_duration.help_text }} +
    + +
    + {{ form.lead_time.label_tag }} + {{ form.lead_time }} + {{ form.lead_time.help_text }} +
    + +
    + {{ form.finish_time.label_tag }} + {{ form.finish_time }} + {{ form.finish_time.help_text }} +
    + +
    + {{ form.appointment_buffer_time.label_tag }} + {{ form.appointment_buffer_time }} + {{ form.appointment_buffer_time.help_text }} +
    + +
    + {{ form.work_on_saturday }} + {{ form.work_on_saturday.label_tag }} +
    + +
    + {{ form.work_on_sunday }} + {{ form.work_on_sunday.label_tag }} +
    + + +
    +
    + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} +
    +
    +
    +
    +{% endblock %} + +{% block customJS %} + +{% endblock %} diff --git a/templates/administration/manage_staff_personal_info.html b/templates/administration/manage_staff_personal_info.html new file mode 100644 index 00000000..a9c07358 --- /dev/null +++ b/templates/administration/manage_staff_personal_info.html @@ -0,0 +1,49 @@ +{% extends BASE_TEMPLATE %} +{% load i18n %} +{% load static %} + +{% block customCSS %} + +{% endblock %} + +{% block body %} +
    +
    +
    +

    {% trans 'Staff Personal Information' %}

    +
    + {% csrf_token %} + +
    + {{ form.first_name.label_tag }} + {{ form.first_name }} +
    + +
    + {{ form.last_name.label_tag }} + {{ form.last_name }} +
    + +
    + {{ form.email.label_tag }} + {{ form.email }} +
    + + +
    +
    + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} +
    +
    +
    +
    +{% endblock %} + +{% block customJS %} + +{% endblock %} diff --git a/templates/administration/manage_working_hours.html b/templates/administration/manage_working_hours.html new file mode 100644 index 00000000..819f383d --- /dev/null +++ b/templates/administration/manage_working_hours.html @@ -0,0 +1,183 @@ +{% extends BASE_TEMPLATE %} +{% load i18n %} +{% load static %} + +{% block customCSS %} + + + + + +{% endblock %} + +{% block body %} +
    +
    +
    +

    {% trans "Manage Working Hours" %}

    +
    + {% csrf_token %} + + {% if working_hours_form.staff_member %} +
    + + {{ working_hours_form.staff_member }} +
    + {% endif %} + +
    + + {{ working_hours_form.day_of_week }} +
    + +
    + +
    + +
    +
    +
    +
    +
    + +
    + +
    + +
    +
    +
    +
    +
    + + + + + +
    + {% include 'modal/error_modal.html' %} +
    + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} +
    +
    +
    +
    +{% endblock %} +{% block customJS %} + + + + + + + + + +{% endblock %} + diff --git a/templates/administration/service_list.html b/templates/administration/service_list.html new file mode 100644 index 00000000..4251649c --- /dev/null +++ b/templates/administration/service_list.html @@ -0,0 +1,78 @@ +{% extends 'base.html' %} +{% load i18n %} +{% load static %} + +{% block title %} + {% trans 'Service List' %} +{% endblock %} +{% block description %} + {% trans 'Service List' %}. +{% endblock %} +{% block body %} + +
    +
    +
    +

    {% trans 'Service List' %}

    +
    +
    + + + + + + + + + + + {% for service in services %} + + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans 'Name' %}{% trans 'Duration' %}{% trans 'Price' %}{% trans 'Action' %}
    {{ service.name }}{{ service.get_duration }}{{ service.get_price_text }} +
    + + + + {% translate "Are you sure you want to delete this service?" as d_modal_message %} + {% if request.user.is_superuser %} + + + + + + + {% endif %} +
    +
    {% trans 'No service found' %}.
    +
    +
    +
    + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} +
    +
    + +{% endblock %} + +{% block customJS %} + +{% endblock %} diff --git a/templates/administration/staff_index.html b/templates/administration/staff_index.html new file mode 100644 index 00000000..e1683d2d --- /dev/null +++ b/templates/administration/staff_index.html @@ -0,0 +1,409 @@ +{% extends BASE_TEMPLATE %} +{% load i18n %} +{% load static %} +{% block customMetaTag %} + +{% endblock %} +{% block customCSS %} + + + +{% endblock %} +{% block title %} + {{ page_title }} +{% endblock %} +{% block description %} + {{ page_description }} +{% endblock %} +{% block body %} +
    +
    +
    +
    +
    +
    + + {% include 'modal/event_details_modal.html' %} + {% include 'modal/error_modal.html' %} + +
    + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} +
    +
    +
    + + + {% include 'modal/confirm_modal.html' %} +{% endblock %} + + +{% block customJS %} + + + + + + + + + + + + +{% endblock %} diff --git a/templates/administration/staff_list.html b/templates/administration/staff_list.html new file mode 100644 index 00000000..70fb7142 --- /dev/null +++ b/templates/administration/staff_list.html @@ -0,0 +1,81 @@ +{% extends 'base.html' %} +{% load i18n %} +{% load static %} +{% block customCSS %} + + + +{% endblock %} +{% block title %} + Staff Members List +{% endblock %} +{% block description %} + {% trans 'List of all staff members' %}. +{% endblock %} +{% block body %} + +
    +
    +
    +

    {% trans 'Staff Members' %}

    + +
    +
    + + + + + + + + + + {% for staff_member in staff_members %} + + + + + + {% empty %} + + + + {% endfor %} + + {% trans "PS: Remove means, deleting the staff status of the user. The user account is still active." %} + + +
    {% trans 'Name' %}{% trans 'Email' %}{% trans 'Details' %}
    {{ staff_member.get_staff_member_name }}{{ staff_member.user.email|default:"N/A" }} + {% trans 'View Profile' %} + {% trans 'Remove' %} +
    {% trans 'No staff members found' %}.
    +
    +
    +
    + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} +
    +
    + +{% endblock %} + +{% block customJS %} + +{% endblock %} diff --git a/templates/administration/user_profile.html b/templates/administration/user_profile.html new file mode 100644 index 00000000..c762c671 --- /dev/null +++ b/templates/administration/user_profile.html @@ -0,0 +1,281 @@ +{% extends BASE_TEMPLATE %} +{% load i18n %} +{% load static %} +{% block customCSS %} + + + + +{% endblock %} +{% block title %} + {{ page_title }} +{% endblock %} +{% block description %} + {{ page_description }} +{% endblock %} +{% block body %} +
    +
    +
    + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} +
    +
    + {% translate "Confirm Deletion" as modal_title %} + {% translate "Delete" as delete_btn_modal %} + +

    {% trans 'Personal Information' %}

    + +
    +

    {% trans 'First name' %}: {{ user.first_name|default:user.username }}

    +

    {% trans 'Last name' %}: {{ user.last_name|default:"N/A" }}

    +

    {% trans 'Email' %}: {{ user.email|default:"N/A" }}

    +
    + + + +
    + + +
    +

    {% trans 'Appointment Information' %}

    + + {{ service_msg }} + + {% if staff_member %} +
    +

    + {% trans 'Slot duration' %}: {{ staff_member.get_slot_duration_text }} + +

    +

    {% trans 'General start time' %}: {{ staff_member.get_lead_time }}

    +

    {% trans 'General end time' %}: {{ staff_member.get_finish_time }}

    +

    + {% trans 'Weekend days you work' %}: {{ staff_member.get_weekend_days_worked_text }} +

    + +

    + {% trans 'Appointment buffer time' %}: {{ staff_member.get_appointment_buffer_time_text }} + +

    + +
    + + + + {% else %} +
    +

    {% trans 'No staff member information yet for this user' %}.

    +
    + + + + {% endif %} + +
    + + +
    +

    {% trans 'Days Off' %}

    + + + + + {% trans "Days off are days you're not working, you need to set them for holidays as well so clients don't book you those days." %} + +
    + + + + + + + + + + + {% for day_off in days_off %} + + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans 'Start date' %}{% trans 'End date' %}{% trans 'Description' %}{% trans 'Action' %}
    {{ day_off.start_date }}{{ day_off.end_date }}{{ day_off.description }} +
    + {% if superuser %} + + + + {% else %} + + + + {% endif %} + {% translate "Are you sure you want to delete this working hours?" as d_modal_message %} + {% if superuser %} + + + + {% else %} + + + + {% endif %} +
    +
    {% trans 'No days off have been set' %}.
    +
    +
    + + +
    +

    {% trans 'Working Hours' %}

    + + + + + {% trans "Note: If you are a staff member, your working hours will be used to determine when you are available for appointments." %} + +
    + + + + + + + + + + + {% for working_hour in working_hours %} + + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans 'Day' %}{% trans 'Start time' %}{% trans 'End time' %}{% trans 'Action' %}
    {{ working_hour.get_day_of_week_str }}{{ working_hour.start_time|time:"g:i A" }}{{ working_hour.end_time|time:"g:i A" }} +
    + {% if superuser %} + + + + {% else %} + + + + {% endif %} + {% translate "Are you sure you want to delete this working hours?" as w_modal_message %} + {% if superuser %} + + + + {% else %} + + + + {% endif %} +
    +
    {% trans 'No working hours have been set' %}.
    +
    +
    + + +
    +

    {% trans 'Service Offered' %}

    + + {% if not superuser %} + {% trans "To add/modify a new service, make a request to an admin." %} + {% trans "Changes made in one service will change it for every staff member." %} + {% endif %} + +
    + + + + + + + + + + + + {% for service in services_offered %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans 'Name' %}{% trans 'Description' %}{% trans 'Duration' %}{% trans 'Price' %}{% trans 'Down payment' %}
    {{ service.name }}{{ service.description|default:"N/A" }}{{ service.get_duration }}{{ service.get_price_text }}{{ service.get_down_payment_text }}
    {% trans 'No service offered yet' %}.
    +
    +
    +
    + + {% include 'modal/confirm_modal.html' %} +
    +{% endblock %} +{% block customJS %} + + + + + + + + + +{% endblock %} + diff --git a/templates/appointment/appointment_client_information.html b/templates/appointment/appointment_client_information.html new file mode 100644 index 00000000..acac34a0 --- /dev/null +++ b/templates/appointment/appointment_client_information.html @@ -0,0 +1,165 @@ +{% extends BASE_TEMPLATE %} +{% load i18n %} +{% load static %} +{% block customCSS %} + +{% endblock %} +{% block title %} + {% translate 'Client Information' %} - {{ ar.get_service_name }} +{% endblock %} +{% block description %} + {% blocktranslate with service_name=ar.get_service_name %} + Your appointment request for {{ service_name }} has been submitted. + Please provide your information to create an account and complete the payment process. + {% endblocktranslate %} +{% endblock %} +{% block body %} +
    +
    + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + +
    + {% csrf_token %} + +
    +
    {% trans "Service Details" %}
    + +
    + +
    +
    {{ ar.get_service_name }}
    +
    + {{ ar.date }} {% trans "at" %} {{ ar.start_time }} +
    +
    {{ ar.service.get_duration }}
    +
    + +
    + {% if ar.is_a_paid_service %} + {% if APPOINTMENT_PAYMENT_URL %} +
    +
    {% trans "Payment Details" %}
    +
    +
    {% trans "Total" %}
    +
    ${{ ar.get_service_price }}
    +
    +
    + + {% if ar.accepts_down_payment %} + + {% endif %} +
    +
    + {% else %} + + {% endif %} + {% else %} + + {% endif %} +
    +
    +
    +
    +{% endblock %} +{% block customJS %} + +{% endblock %} diff --git a/templates/appointment/appointments.html b/templates/appointment/appointments.html new file mode 100644 index 00000000..10cb6359 --- /dev/null +++ b/templates/appointment/appointments.html @@ -0,0 +1,134 @@ +{% extends BASE_TEMPLATE %} +{% load i18n %} +{% load static %} +{% block customCSS %} + + +{% endblock %} +{% block title %} + {{ page_title }} +{% endblock %} +{% block description %} + {{ page_description }} +{% endblock %} +{% block body %} +
    +
    +
    +

    + {% if page_header %}{{ page_header }}{% else %}{{ service.name }}{% endif %}

    + + {% trans "Check out our availability and book the date and time that works for you" %} + +
    + +
    +
    +
    +
    + {% trans "Select a date and time" %} +
    +
    + {% trans "Timezone" %}: {{ timezoneTxt }} +
    +
    +
    +
    +
    +
    +
    +
    {{ date_chosen }}
    +
    +
    +
      + +
    +
    + +
    +
    + {% if rescheduled_date %} +
    + + +
    + {% endif %} +
    +
    +
    + {% csrf_token %} +
    + + +
    + +
    {% trans "Service Details" %}
    +
    +
    +

    {{ service.name }}

    +

    {{ date_chosen }}

    +

    {{ service.get_duration }}

    +

    {{ service.get_price_text }}

    + +
    +
    +
    +
    + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} +
    +
    +
    +{% endblock %} + +{% block customJS %} + + + + + + + + +{% endblock %} diff --git a/templates/appointment/default_thank_you.html b/templates/appointment/default_thank_you.html new file mode 100644 index 00000000..7d066b6c --- /dev/null +++ b/templates/appointment/default_thank_you.html @@ -0,0 +1,36 @@ +{% extends BASE_TEMPLATE %} +{% load i18n %} +{% load static %} +{% block customCSS %} + +{% endblock %} +{% block title %} + {{ page_title }} +{% endblock %} +{% block description %} + {{ page_description }} +{% endblock %} +{% block body %} +
    +
    +

    {% trans "See you soon" %} !

    +

    {% trans "We've successfully scheduled your appointment! Please check your email for all the details" %}.

    +

    {% trans "Appointment details" %}:

    + + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} +
    +
    +{% endblock %} +{% block customJS %} + +{% endblock %} \ No newline at end of file diff --git a/templates/appointment/enter_verification_code.html b/templates/appointment/enter_verification_code.html new file mode 100644 index 00000000..6b157a11 --- /dev/null +++ b/templates/appointment/enter_verification_code.html @@ -0,0 +1,39 @@ +{% extends BASE_TEMPLATE %} +{% load i18n %} +{% load static %} + +{% block customCSS %} + +{% endblock %} + +{% block title %}{% trans 'Enter Verification Code' %}{% endblock %} +{% block description %}{% trans 'Enter Verification Code' %}{% endblock %} + +{% block body %} +
    +
    +

    {% trans 'Enter Verification Code' %}

    +

    {% trans "We've sent a verification code to your email. Please enter it below" %}:

    + +
    + {% csrf_token %} +
    + + +
    + +
    + + {% if messages %} + {% for message in messages %} +
    + {{ message }} +
    + {% endfor %} + {% endif %} +
    +
    +{% endblock %} + +{% block customJS %} +{% endblock %} \ No newline at end of file diff --git a/templates/appointment/rescheduling_thank_you.html b/templates/appointment/rescheduling_thank_you.html new file mode 100644 index 00000000..715c917a --- /dev/null +++ b/templates/appointment/rescheduling_thank_you.html @@ -0,0 +1,79 @@ +{% extends BASE_TEMPLATE %} +{% load i18n %} +{% load static %} +{% block customCSS %} + +{% endblock %} +{% block title %} + {% trans "Rescheduling Successful" %} +{% endblock %} +{% block description %} + {% trans "Your appointment rescheduling was successful. Please confirm via email." %} +{% endblock %} +{% block body %} +
    +
    +
    +

    {% trans "Rescheduling Successful" %}

    +

    {% trans "Your appointment rescheduling request has been successfully submitted. Please check your email and click on the confirmation link to finalize the rescheduling process." %}

    + {% trans "Go to Homepage" %} +
    + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} +
    +
    +{% endblock %} +{% block customJS %} + +{% endblock %} \ No newline at end of file diff --git a/templates/appointment/set_password.html b/templates/appointment/set_password.html new file mode 100644 index 00000000..8c39db2e --- /dev/null +++ b/templates/appointment/set_password.html @@ -0,0 +1,104 @@ +{% extends BASE_TEMPLATE %} +{% load i18n %} +{% load static %} +{% block customCSS %} + +{% endblock %} +{% block title %} + {% trans 'Reset Your Password' %} +{% endblock %} +{% block description %} + {% trans 'Reset Your Password' %} +{% endblock %} +{% block body %} +
    +

    {% trans 'Reset Your Password' %}

    + + {% if messages %} + + {% endif %} + + +
    + {% csrf_token %} + {{ form.as_p }} + +
    +{% endblock %} +{% block customJS %} + +{% endblock %} \ No newline at end of file diff --git a/templates/appointment/thank_you.html b/templates/appointment/thank_you.html new file mode 100644 index 00000000..335760b8 --- /dev/null +++ b/templates/appointment/thank_you.html @@ -0,0 +1,53 @@ +{% extends BASE_TEMPLATE %} +{% load i18n %} +{% load static %} +{% block customCSS %} + +{% endblock %} +{% block title %}{{ page_title }}{% endblock %} +{% block description %}{{ page_description }}{% endblock %} +{% block body %} +
    +

    {{ page_title }}

    +

    + {{ page_message }} +

    + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} +
    +{% endblock %} +{% block customJS %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html index 7599a954..f0adc4ae 100644 --- a/templates/base.html +++ b/templates/base.html @@ -46,7 +46,9 @@ {% endif %} - + {% block customCSS %} {% endblock %} @@ -56,13 +58,13 @@
    {% include 'header.html' %}
    -
    + {% block content %} {% endblock content%} {% block body %} {% endblock body%} -
    + {% include 'footer.html' %}
    diff --git a/templates/email_sender/admin_new_appointment_email.html b/templates/email_sender/admin_new_appointment_email.html new file mode 100644 index 00000000..4d75d8a1 --- /dev/null +++ b/templates/email_sender/admin_new_appointment_email.html @@ -0,0 +1,74 @@ +{% load i18n %} + + + + + + {% translate 'Appointment Request Notification' %} + + + +
    +

    {% translate 'New Appointment Request' %}

    +

    {% translate 'Dear Admin,' %}

    +

    {% translate 'You have received a new appointment request. Here are the details:' %}

    + +
    +

    {% translate 'Client Name' %}: {{ client_name }}

    +

    {% translate 'Service Requested' %}: {{ appointment.get_service_name }}

    +

    {% translate 'Appointment Date' %}: {{ appointment.appointment_request.date }}

    +

    {% translate 'Time' %}: {{ appointment.appointment_request.start_time }} + - {{ appointment.appointment_request.end_time }}

    +

    {% translate 'Contact Details' %}: {{ appointment.phone }} | {{ appointment.client.email }}

    +

    {% translate 'Additional Info' %}: {{ appointment.additional_info|default:"N/A" }}

    +
    + +

    {% translate 'Please review the appointment request and take the necessary action.' %}

    + + +
    + + diff --git a/templates/email_sender/reminder_email.html b/templates/email_sender/reminder_email.html new file mode 100644 index 00000000..0c58b6c7 --- /dev/null +++ b/templates/email_sender/reminder_email.html @@ -0,0 +1,97 @@ +{% load i18n %} + + + + + + {% translate 'Appointment Reminder' %} + + + +
    + + + +
    + + diff --git a/templates/email_sender/reschedule_email.html b/templates/email_sender/reschedule_email.html new file mode 100644 index 00000000..00d3b08f --- /dev/null +++ b/templates/email_sender/reschedule_email.html @@ -0,0 +1,93 @@ +{% load i18n %} + + + + + {% trans "Appointment Reschedule Confirmation" %} + + + + +
    +

    {% trans "Appointment Reschedule" %}

    + + +
    + + diff --git a/templates/email_sender/thank_you_email.html b/templates/email_sender/thank_you_email.html new file mode 100644 index 00000000..1f9b23db --- /dev/null +++ b/templates/email_sender/thank_you_email.html @@ -0,0 +1,290 @@ +{% load i18n %} +{% load static %} + + + + + + + + + +
    + {% if pre_header %} + {{ pre_header }} + {% endif %} +
    + + + + + + + + + + + +
    + + + + + + + + + + + + + + + +
    + {% comment %}{% endcomment %} +
    +

    {{ main_title }}

    +
    + + + + + + + +
    + {{ month_year }} +
    + {{ day }} +
    +
    + +

    + {% trans 'Thank you for choosing us.' %} +

    +
    + +
    + + + {% if account_details %} + + + + + + + {% if activation_link %} + + + + {% endif %} + + + + + {% endif %} + {% if more_details %} + + + + {% endif %} + {% if reschedule_link %} + + + + {% endif %} + + + + +
    +

    Hi {{ first_name }},

    +

    {{ message_1 }}

    +
    +

    {{ message_2 }}

    +
    +

    {% trans 'Account Activation' %}

    +

    + {% blocktranslate with link=activation_link %} + To activate your account and set your password, please use the following secure + link: Set Your Password. Please + note that this link will expire in 2 days for your security. + {% endblocktranslate %} +

    +
    +

    {% trans "Account Information" %}

    +
    +
      + {% for key, value in account_details.items %} +
    • {{ key }}: {{ value }}
    • + {% endfor %} +
    +
    + +
    +

    {% trans "Appointment Details" %}

    +
    +
      + {% for key, value in more_details.items %} +
    • {{ key }}: {{ value }}
    • + {% endfor %} +
    +
    + +
    +

    {% trans 'Rescheduling' %}

    +

    + {% translate 'If your plans change and you need to reschedule your appointment, you can easily do so by following this link: ' %} + + {% translate 'Reschedule Appointment' %} + +

    +
    +

    {% trans 'Support' %}

    +

    + {% blocktranslate %} + Should you have any inquiries or require further assistance, our support team is here to + help. You can reach us anytime. + {% endblocktranslate %} +

    +

    + {% trans "We look forward to serving you and ensuring that your experience with us is both rewarding and satisfactory." %} +

    +

    {% trans "Warm regards" %},

    +

    {% trans "The Team" %}

    +
    + +
    + + + + + +
    + © {{ current_year }} {{ company }}. {% trans "All rights reserved" %}. +
    + +
    + + + diff --git a/templates/inventory/car_inventory.html b/templates/inventory/car_inventory.html index 8355d1bf..c6a7bbc5 100644 --- a/templates/inventory/car_inventory.html +++ b/templates/inventory/car_inventory.html @@ -34,7 +34,7 @@
    -

    {{ cars.first.id_car_make.get_local_name }}{{ cars.first.id_car_model.get_local_name }}

    +

    {{ cars.first.id_car_make.get_local_name }} {{ cars.first.id_car_model.get_local_name }}

    {{ cars.first.id_car_serie.name }}, {{ cars.first.id_car_trim.name }}

    diff --git a/templates/modal/confirm_modal.html b/templates/modal/confirm_modal.html new file mode 100644 index 00000000..da3daab8 --- /dev/null +++ b/templates/modal/confirm_modal.html @@ -0,0 +1,23 @@ +{% load i18n %} + + diff --git a/templates/modal/error_modal.html b/templates/modal/error_modal.html new file mode 100644 index 00000000..0213a1bd --- /dev/null +++ b/templates/modal/error_modal.html @@ -0,0 +1,21 @@ +{% load i18n %} + + diff --git a/templates/modal/event_details_modal.html b/templates/modal/event_details_modal.html new file mode 100644 index 00000000..5f141122 --- /dev/null +++ b/templates/modal/event_details_modal.html @@ -0,0 +1,38 @@ +{% load i18n %} + + diff --git a/templates/sales/invoices/invoice_detail.html b/templates/sales/invoices/invoice_detail.html index d65994a9..b17f5159 100644 --- a/templates/sales/invoices/invoice_detail.html +++ b/templates/sales/invoices/invoice_detail.html @@ -55,7 +55,7 @@ -
    +

    {% trans 'Invoice' %}

    @@ -82,8 +82,8 @@

    {% trans 'Paid Amount' %}

    -

    ${{invoice.amount_paid}}

    -
    Owned ${{invoice.get_amount_open}}
    +

    {{invoice.amount_paid}} {{ _("SAR") }}

    +
    {{ _("Owned") }} {{invoice.get_amount_open|floatformat}} {{ _("SAR") }}
    {{invoice.get_progress_percent}}%
    @@ -135,9 +135,9 @@

    {% trans 'Due Amount' %}

    {% if invoice.is_paid %} -

    ${{invoice.amount_due}}

    +

    {{invoice.amount_due}} {{ _("SAR") }}

    {% else %} -

    ${{invoice.amount_due}}

    +

    {{invoice.amount_due}} {{ _("SAR") }}

    {% endif %}
    @@ -259,7 +259,7 @@
    - +
    {% endblock %} {% block customJS %} diff --git a/templates/sales/invoices/invoice_preview.html b/templates/sales/invoices/invoice_preview.html index b077abc9..55facc0a 100644 --- a/templates/sales/invoices/invoice_preview.html +++ b/templates/sales/invoices/invoice_preview.html @@ -1,3 +1,4 @@ +{% load i18n static%} @@ -44,6 +45,13 @@ .invoice-details { margin-bottom: 2.5rem; } + .invoice-details-en { + text-align: left; + } + .invoice-details-ar { + text-align: right; + + } .invoice-details p { margin: 0.75rem 0; color: #555; @@ -119,80 +127,84 @@
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    invoice

    -

    Thank you for choosing us. We appreciate your business!

    + +

    Invoice / فاتورة

    +

    -
    -

    invoice Number: #{{invoice.invoice_number}}

    -

    Date: {{invoice.date_in_review}}

    -

    Customer: {{invoice.customer.customer_name}}

    -

    Email: {{invoice.customer.email}}

    -

    Terms: {{invoice.terms|title}}

    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Invoice Number + + {{invoice.invoice_number}} + + رقم الفاتورة +
    + Date + + {{invoice.date_in_review}} + + التاريخ +
    + Customer + + {{invoice.customer.customer_name}} + + العميل +
    + Email + + {{invoice.customer.email}} + + البريد الالكتروني +
    + Terms + + {{invoice.get_terms_display}} + + طريقة الدفع +
    + +
    -
    +
    - - - - + + + + @@ -201,7 +213,7 @@ - + {% endfor %} @@ -210,28 +222,29 @@
    -

    VAT ({{vat}}%): ${{vat_amount}}

    -

    Additional Services: +

    VAT/ضريبة القيمة المضافة ({{vat}}%): {{vat_amount}} {{ _("SAR") }}

    +

    Additional Services/ الخدمات الإضافية
    {% for service in additional_services %} - {{service.name}} - ${{service.price}}
    + {{service.name}} - {{service.price}} {{ _("SAR") }}
    {% endfor %}

    -

    Total Amount: ${{total}}

    +

    Total/الإجمالي {{total}} {{ _("SAR") }}

    - + @@ -245,9 +258,9 @@ // Options for html2pdf.js const options = { margin: 0, // No margin - filename: 'invoice.pdf', // Name of the downloaded file + filename: '{{invoice.invoice_number}}.pdf', // Name of the downloaded file image: { type: 'jpeg', quality: 0.98 }, // Image quality - html2canvas: { + html2canvas: { scale: 2, // Increase scale for better quality scrollX: 0, // Ensure no horizontal scroll offset scrollY: 0, // Ensure no vertical scroll offset diff --git a/test.txt b/test.txt new file mode 100644 index 00000000..929c2e93 --- /dev/null +++ b/test.txt @@ -0,0 +1,12 @@ + + + +from django.core.mail import send_mail + +send_mail( + 'Test Email Subject', + 'This is a test email message.', + 'info@tenhal.sa', # From email + ['recipient@example.com'], # To email + fail_silently=False, +) \ No newline at end of file
    ItemQuantityUnit PriceTotalItem
    الصنف
    Quantity
    العدد
    Unit Price
    سعر الوحدة
    Total
    الإجمالي
    {{item.info.make}} {{item.quantity}} {{item.finances.selling_price}}{{item.total}}{{item.total}}