From c526ec63e69a256ae847d3581394fa236accef99 Mon Sep 17 00:00:00 2001 From: aelassas Date: Tue, 28 Jan 2025 17:32:40 +0100 Subject: [PATCH] Add Property Scheduler --- api/src/controllers/bookingController.ts | 23 +- backend/package-lock.json | 11 +- backend/package.json | 1 + backend/src/App.tsx | 2 + .../assets/css/property-scheduler-filter.css | 14 + backend/src/assets/css/scheduler.css | 104 +++++ backend/src/common/helper.ts | 62 +++ backend/src/components/Header.tsx | 5 + backend/src/components/PropertyScheduler.tsx | 196 ++++++++++ .../components/PropertySchedulerFilter.tsx | 111 ++++++ backend/src/components/scheduler/README.md | 154 ++++++++ .../scheduler/SchedulerComponent.tsx | 75 ++++ .../scheduler/components/common/Cell.tsx | 53 +++ .../components/common/LocaleArrow.tsx | 38 ++ .../components/common/ResourceHeader.tsx | 74 ++++ .../scheduler/components/common/Tabs.tsx | 117 ++++++ .../scheduler/components/common/TodayTypo.tsx | 45 +++ .../components/common/WithResources.tsx | 95 +++++ .../scheduler/components/events/Actions.tsx | 66 ++++ .../components/events/AgendaEventsList.tsx | 109 ++++++ .../components/events/CurrentTimeBar.tsx | 49 +++ .../components/events/EmptyAgenda.tsx | 25 ++ .../scheduler/components/events/EventItem.tsx | 151 ++++++++ .../components/events/EventItemPopover.tsx | 175 +++++++++ .../components/events/MonthEvents.tsx | 141 +++++++ .../components/events/TodayEvents.tsx | 91 +++++ .../scheduler/components/hoc/DateProvider.tsx | 20 + .../components/inputs/DatePicker.tsx | 89 +++++ .../scheduler/components/inputs/Input.tsx | 108 ++++++ .../components/inputs/SelectInput.tsx | 158 ++++++++ .../scheduler/components/month/MonthTable.tsx | 206 ++++++++++ .../scheduler/components/nav/DayDateBtn.tsx | 68 ++++ .../scheduler/components/nav/MonthDateBtn.tsx | 68 ++++ .../scheduler/components/nav/Navigation.tsx | 195 ++++++++++ .../scheduler/components/nav/WeekDateBtn.tsx | 77 ++++ .../scheduler/components/week/WeekTable.tsx | 204 ++++++++++ .../components/scheduler/helpers/constants.ts | 4 + .../components/scheduler/helpers/generals.tsx | 304 +++++++++++++++ .../scheduler/hooks/useCellAttributes.ts | 67 ++++ .../scheduler/hooks/useDragAttributes.ts | 31 ++ .../scheduler/hooks/useEventPermissions.ts | 42 +++ .../components/scheduler/hooks/useStore.ts | 6 + .../scheduler/hooks/useSyncScroll.ts | 31 ++ .../scheduler/hooks/useWindowResize.ts | 37 ++ backend/src/components/scheduler/index.tsx | 12 + .../scheduler/positionManger/context.ts | 14 + .../scheduler/positionManger/provider.tsx | 102 +++++ .../scheduler/positionManger/usePosition.ts | 6 + .../src/components/scheduler/store/context.ts | 5 + .../src/components/scheduler/store/default.ts | 150 ++++++++ .../components/scheduler/store/provider.tsx | 203 ++++++++++ .../src/components/scheduler/store/types.ts | 32 ++ .../src/components/scheduler/styles/styles.ts | 240 ++++++++++++ backend/src/components/scheduler/types.ts | 356 ++++++++++++++++++ .../src/components/scheduler/views/Day.tsx | 216 +++++++++++ .../components/scheduler/views/DayAgenda.tsx | 46 +++ .../src/components/scheduler/views/Editor.tsx | 258 +++++++++++++ .../src/components/scheduler/views/Month.tsx | 92 +++++ .../scheduler/views/MonthAgenda.tsx | 79 ++++ .../src/components/scheduler/views/Week.tsx | 104 +++++ .../components/scheduler/views/WeekAgenda.tsx | 69 ++++ backend/src/lang/header.ts | 2 + backend/src/pages/Scheduler.tsx | 98 +++++ packages/movinin-types/index.ts | 1 + 64 files changed, 5784 insertions(+), 3 deletions(-) create mode 100644 backend/src/assets/css/property-scheduler-filter.css create mode 100644 backend/src/assets/css/scheduler.css create mode 100644 backend/src/components/PropertyScheduler.tsx create mode 100644 backend/src/components/PropertySchedulerFilter.tsx create mode 100644 backend/src/components/scheduler/README.md create mode 100644 backend/src/components/scheduler/SchedulerComponent.tsx create mode 100644 backend/src/components/scheduler/components/common/Cell.tsx create mode 100644 backend/src/components/scheduler/components/common/LocaleArrow.tsx create mode 100644 backend/src/components/scheduler/components/common/ResourceHeader.tsx create mode 100644 backend/src/components/scheduler/components/common/Tabs.tsx create mode 100644 backend/src/components/scheduler/components/common/TodayTypo.tsx create mode 100644 backend/src/components/scheduler/components/common/WithResources.tsx create mode 100644 backend/src/components/scheduler/components/events/Actions.tsx create mode 100644 backend/src/components/scheduler/components/events/AgendaEventsList.tsx create mode 100644 backend/src/components/scheduler/components/events/CurrentTimeBar.tsx create mode 100644 backend/src/components/scheduler/components/events/EmptyAgenda.tsx create mode 100644 backend/src/components/scheduler/components/events/EventItem.tsx create mode 100644 backend/src/components/scheduler/components/events/EventItemPopover.tsx create mode 100644 backend/src/components/scheduler/components/events/MonthEvents.tsx create mode 100644 backend/src/components/scheduler/components/events/TodayEvents.tsx create mode 100644 backend/src/components/scheduler/components/hoc/DateProvider.tsx create mode 100644 backend/src/components/scheduler/components/inputs/DatePicker.tsx create mode 100644 backend/src/components/scheduler/components/inputs/Input.tsx create mode 100644 backend/src/components/scheduler/components/inputs/SelectInput.tsx create mode 100644 backend/src/components/scheduler/components/month/MonthTable.tsx create mode 100644 backend/src/components/scheduler/components/nav/DayDateBtn.tsx create mode 100644 backend/src/components/scheduler/components/nav/MonthDateBtn.tsx create mode 100644 backend/src/components/scheduler/components/nav/Navigation.tsx create mode 100644 backend/src/components/scheduler/components/nav/WeekDateBtn.tsx create mode 100644 backend/src/components/scheduler/components/week/WeekTable.tsx create mode 100644 backend/src/components/scheduler/helpers/constants.ts create mode 100644 backend/src/components/scheduler/helpers/generals.tsx create mode 100644 backend/src/components/scheduler/hooks/useCellAttributes.ts create mode 100644 backend/src/components/scheduler/hooks/useDragAttributes.ts create mode 100644 backend/src/components/scheduler/hooks/useEventPermissions.ts create mode 100644 backend/src/components/scheduler/hooks/useStore.ts create mode 100644 backend/src/components/scheduler/hooks/useSyncScroll.ts create mode 100644 backend/src/components/scheduler/hooks/useWindowResize.ts create mode 100644 backend/src/components/scheduler/index.tsx create mode 100644 backend/src/components/scheduler/positionManger/context.ts create mode 100644 backend/src/components/scheduler/positionManger/provider.tsx create mode 100644 backend/src/components/scheduler/positionManger/usePosition.ts create mode 100644 backend/src/components/scheduler/store/context.ts create mode 100644 backend/src/components/scheduler/store/default.ts create mode 100644 backend/src/components/scheduler/store/provider.tsx create mode 100644 backend/src/components/scheduler/store/types.ts create mode 100644 backend/src/components/scheduler/styles/styles.ts create mode 100644 backend/src/components/scheduler/types.ts create mode 100644 backend/src/components/scheduler/views/Day.tsx create mode 100644 backend/src/components/scheduler/views/DayAgenda.tsx create mode 100644 backend/src/components/scheduler/views/Editor.tsx create mode 100644 backend/src/components/scheduler/views/Month.tsx create mode 100644 backend/src/components/scheduler/views/MonthAgenda.tsx create mode 100644 backend/src/components/scheduler/views/Week.tsx create mode 100644 backend/src/components/scheduler/views/WeekAgenda.tsx create mode 100644 backend/src/pages/Scheduler.tsx diff --git a/api/src/controllers/bookingController.ts b/api/src/controllers/bookingController.ts index a59140cc..9813fb4d 100644 --- a/api/src/controllers/bookingController.ts +++ b/api/src/controllers/bookingController.ts @@ -653,6 +653,7 @@ export const getBookings = async (req: Request, res: Response) => { } = body const location = (body.filter && body.filter.location) || null const from = (body.filter && body.filter.from && new Date(body.filter.from)) || null + const dateBetween = (body.filter && body.filter.dateBetween && new Date(body.filter.dateBetween)) || null const to = (body.filter && body.filter.to && new Date(body.filter.to)) || null let keyword = (body.filter && body.filter.keyword) || '' const options = 'i' @@ -676,10 +677,28 @@ export const getBookings = async (req: Request, res: Response) => { } if (from) { $match.$and!.push({ from: { $gte: from } }) - } // $from > from + } // $from >= from + + if (dateBetween) { + const dateBetweenStart = new Date(dateBetween) + dateBetweenStart.setHours(0, 0, 0, 0) + const dateBetweenEnd = new Date(dateBetween) + dateBetweenEnd.setHours(23, 59, 59, 999) + + $match.$and!.push({ + $and: [ + { from: { $lte: dateBetweenEnd } }, + { to: { $gte: dateBetweenStart } }, + ], + }) + } else if (from) { + $match.$and!.push({ from: { $gte: from } }) // $from >= from + } + if (to) { $match.$and!.push({ to: { $lte: to } }) - } // $to < to + } // $to <= to + if (keyword) { const isObjectId = helper.isValidObjectId(keyword) if (isObjectId) { diff --git a/backend/package-lock.json b/backend/package-lock.json index 4f949971..a50bdf54 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -41,6 +41,7 @@ "react-draft-wysiwyg": "^1.15.0", "react-router-dom": "^7.1.3", "react-toastify": "^11.0.3", + "rrule": "^2.8.1", "typescript": "^5.7.3", "validator": "^13.12.0", "vite": "^6.0.11" @@ -7153,6 +7154,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrule": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz", + "integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -7918,7 +7928,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/turbo-stream": { diff --git a/backend/package.json b/backend/package.json index c5247ef0..a07c25f4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -49,6 +49,7 @@ "react-draft-wysiwyg": "^1.15.0", "react-router-dom": "^7.1.3", "react-toastify": "^11.0.3", + "rrule": "^2.8.1", "typescript": "^5.7.3", "validator": "^13.12.0", "vite": "^6.0.11" diff --git a/backend/src/App.tsx b/backend/src/App.tsx index a2a89bda..0f231802 100644 --- a/backend/src/App.tsx +++ b/backend/src/App.tsx @@ -37,6 +37,7 @@ const NoMatch = lazy(() => import('@/pages/NoMatch')) const Countries = lazy(() => import('@/pages/Countries')) const CreateCountry = lazy(() => import('@/pages/CreateCountry')) const UpdateCountry = lazy(() => import('@/pages/UpdateCountry')) +const Scheduler = lazy(() => import('@/pages/Scheduler')) const App = () => ( @@ -79,6 +80,7 @@ const App = () => ( } /> {/* } /> */} {/* } /> */} + } /> } /> diff --git a/backend/src/assets/css/property-scheduler-filter.css b/backend/src/assets/css/property-scheduler-filter.css new file mode 100644 index 00000000..2d49e38e --- /dev/null +++ b/backend/src/assets/css/property-scheduler-filter.css @@ -0,0 +1,14 @@ +div.property-scheduler-filter { + background: #fafafa; + margin: 10px 10px 0 0; + border: 1px solid #dadada; + font-size: 13px; +} + +div.property-scheduler-filter .bf-search { + margin-top: 7px; +} + +div.property-scheduler-filter .btn-search { + margin: 20px 0; +} diff --git a/backend/src/assets/css/scheduler.css b/backend/src/assets/css/scheduler.css new file mode 100644 index 00000000..b1351ebb --- /dev/null +++ b/backend/src/assets/css/scheduler.css @@ -0,0 +1,104 @@ +div.scheduler { + position: absolute; + bottom: 0; + right: 0; + left: 0; +} + +@media only screen and (width <=960px) { + div.scheduler { + top: 56px; + overflow-y: auto; + } + + div.scheduler div.col-1 { + display: flex; + flex-direction: column; + align-items: center; + } + + div.scheduler div.col-2 { + display: flex; + } + + div.scheduler div.col-1 .cl-supplier-filter label.accordion, + div.scheduler div.col-1 .cl-status-filter label.accordion, + div.scheduler div.col-1 .cl-scheduler-filter label.accordion { + background: #fff; + } + + div.scheduler div.col-1 .cl-supplier-filter, + div.scheduler div.col-1 .cl-status-filter, + div.scheduler div.col-1 .cl-scheduler-filter { + margin: 5px 10px; + background-color: #fff; + max-width: 480px; + width: calc(100% - 20px); + } + + div.scheduler div.col-1 .cl-scheduler-filter div.panel, + div.scheduler div.col-1 .cl-scheduler-filter div.panel-collapse { + padding-right: 15px; + padding-left: 15px; + } + + div.scheduler div.col-1 .cl-new-booking { + width: calc(100% - 20px); + max-width: 480px; + margin: 15px 10px 5px; + } +} + +@media only screen and (width >=960px) { + div.scheduler { + top: 64px; + } + + div.scheduler div.col-1 { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 300px; + padding: 12px 0 0 12px; + background: #fefefe; + overflow: auto; + } + + div.scheduler div.col-2 { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 300px; + } + + div.scheduler div.col-1 .cl-supplier-filter label.accordion, + div.scheduler div.col-1 .cl-status-filter label.accordion, + div.scheduler div.col-1 .cl-scheduler-filter label.accordion { + background: #fafafa; + } + + div.scheduler div.col-1 .cl-supplier-filter, + div.scheduler div.col-1 .cl-status-filter, + div.scheduler div.col-1 .cl-scheduler-filter { + margin: 10px 10px 10px 0; + background-color: #fafafa; + } + + div.scheduler div.col-1 .cl-scheduler-filter div.panel, + div.scheduler div.col-1 .cl-scheduler-filter div.panel-collapse { + padding-right: 15px; + padding-left: 15px; + } + + div.scheduler div.col-1 .cl-status-filter, + div.scheduler div.col-1 .cl-scheduler-filter { + margin-bottom: 10px; + } + + div.scheduler div.col-1 .cl-new-booking { + width: 265px; + margin-left: 5px; + } +} diff --git a/backend/src/common/helper.ts b/backend/src/common/helper.ts index 4583c9f5..6b70be54 100644 --- a/backend/src/common/helper.ts +++ b/backend/src/common/helper.ts @@ -84,6 +84,68 @@ export const getPropertyType = (type: string) => { export const admin = (user?: movininTypes.User): boolean => (user && user.type === movininTypes.RecordType.Admin) ?? false +/** + * Get booking status background color. + * + * @param {string} status + * @returns {string} + */ +export const getBookingStatusBackgroundColor = (status?: movininTypes.BookingStatus) => { + switch (status) { + case movininTypes.BookingStatus.Void: + return '#D9D9D9' + + case movininTypes.BookingStatus.Pending: + return '#FBDCC2' + + case movininTypes.BookingStatus.Deposit: + return '#CDECDA' + + case movininTypes.BookingStatus.Paid: + return '#D1F9D1' + + case movininTypes.BookingStatus.Reserved: + return '#D9E7F4' + + case movininTypes.BookingStatus.Cancelled: + return '#FBDFDE' + + default: + return '' + } +} + +/** + * Get booking status text color. + * + * @param {string} status + * @returns {string} + */ +export const getBookingStatusTextColor = (status?: movininTypes.BookingStatus) => { + switch (status) { + case movininTypes.BookingStatus.Void: + return '#6E7C86' + + case movininTypes.BookingStatus.Pending: + return '#EF6C00' + + case movininTypes.BookingStatus.Deposit: + return '#3CB371' + + case movininTypes.BookingStatus.Paid: + return '#77BC23' + + case movininTypes.BookingStatus.Reserved: + return '#1E88E5' + + case movininTypes.BookingStatus.Cancelled: + return '#E53935' + + default: + return '' + } +} + /** * Get booking status label. * diff --git a/backend/src/components/Header.tsx b/backend/src/components/Header.tsx index 74cf7288..e8976b2b 100644 --- a/backend/src/components/Header.tsx +++ b/backend/src/components/Header.tsx @@ -31,6 +31,7 @@ import { DescriptionTwoTone as TosIcon, ExitToApp as SignoutIcon, Flag as CountriesIcon, + CalendarMonth as SchedulerIcon, } from '@mui/icons-material' import { useNavigate } from 'react-router-dom' import * as movininTypes from ':movinin-types' @@ -292,6 +293,10 @@ const Header = ({ + + + + diff --git a/backend/src/components/PropertyScheduler.tsx b/backend/src/components/PropertyScheduler.tsx new file mode 100644 index 00000000..1544f279 --- /dev/null +++ b/backend/src/components/PropertyScheduler.tsx @@ -0,0 +1,196 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { fr, enUS, es } from 'date-fns/locale' +import { Scheduler } from '@/components/scheduler/index' +import { + ProcessedEvent, + RemoteQuery, + SchedulerRef, +} from '@/components/scheduler/types' +import * as movininTypes from ':movinin-types' +import * as helper from '@/common/helper' +import * as BookingService from '@/services/BookingService' + +interface PropertySchedulerProps { + agencies: string[] + statuses: string[] + filter?: movininTypes.Filter + user?: movininTypes.User + language: string +} + +const PropertyScheduler = ( + { + agencies, + statuses, + filter: filterFromProps, + user, + language, + }: PropertySchedulerProps +) => { + const [filter, setFilter] = useState() + const [init, setInit] = useState(true) + + const schedulerRef = React.useRef(null) + + useEffect(() => { + setFilter(filterFromProps) + }, [filterFromProps]) + + const fetchBookings = useCallback(async (query: RemoteQuery): Promise => { + const emptyEvents: ProcessedEvent[] = [ + { + event_id: '1', + title: 'Dummy Event', + start: new Date(1970, 0, 1), + end: new Date(1970, 0, 2), + } + ] + + const dateBetween = new Date(query.end.getTime() - Math.ceil(query.end.getTime() - query.start.getTime()) / 2) + dateBetween.setHours(10, 0, 0, 0) + + const payload: movininTypes.GetBookingsPayload = { + agencies, + statuses, + filter: { + from: query.view !== 'day' ? new Date(query.start.getFullYear(), query.start.getMonth() - 1, 1) : undefined, + dateBetween: query.view === 'day' ? dateBetween : undefined, + to: query.view === 'month' ? new Date(query.end.getFullYear(), query.end.getMonth() + 1, 0) : new Date(query.end.getFullYear(), query.end.getMonth() + 2, 0), + location: filter?.location, + keyword: filter?.keyword, + }, + user: (user && user._id) || undefined, + } + + const data = await BookingService.getBookings(payload, 1, 10000) + const _data = data && data.length > 0 ? data[0] : { pageInfo: { totalRecord: 0 }, resultData: [] } + if (!_data) { + helper.error() + return emptyEvents + } + const bookings = _data.resultData + + const events = bookings.map((booking): ProcessedEvent => ({ + event_id: booking._id as string, + title: `${(booking.property as movininTypes.Property).name} / ${(booking.agency as movininTypes.User).fullName} / ${helper.getBookingStatus(booking.status)}`, + start: new Date(booking.from), + end: new Date(booking.to), + color: helper.getBookingStatusBackgroundColor(booking.status), + textColor: helper.getBookingStatusTextColor(booking.status), + })) + + setInit(false) + + if (events.length === 0) { + return emptyEvents + } + return events + }, [filter, statuses, agencies, user]) + + useEffect(() => { + const fetchEvents = async () => { + schedulerRef.current?.scheduler?.handleState(fetchBookings, 'getRemoteEvents') + } + + if (!init && statuses.length > 0 && agencies.length > 0) { + fetchEvents() + } + }, [statuses, agencies, filter]) // eslint-disable-line react-hooks/exhaustive-deps + + const getTranslations = (_language: string) => { + if (_language === 'fr') { + return { + navigation: { + month: 'Mois', + week: 'Semaine', + day: 'Jour', + today: "Aujourd'hui", + agenda: 'Agenda', + }, + form: { + addTitle: 'Ajouter un événement', + editTitle: 'Modifier un événement', + confirm: 'Confirmer', + delete: 'Supprimer', + cancel: 'Annuler', + }, + event: { + title: 'Titre', + subtitle: 'Sous-titre', + start: 'Début', + end: 'Fin', + allDay: 'Toute la journée', + }, + validation: { + required: 'Obligatoire', + invalidEmail: 'E-mail non valide', + onlyNumbers: 'Seuls les chiffres sont autorisés', + min: 'Minimum de {{min}} lettres', + max: 'Maximum de {{max}} lettres', + }, + moreEvents: 'Plus...', + noDataToDisplay: 'Aucune donnée à afficher', + loading: 'Chargement...', + } + } + + // default to english + return { + navigation: { + month: 'Month', + week: 'Week', + day: 'Day', + today: 'Today', + agenda: 'Agenda', + }, + form: { + addTitle: 'Add Event', + editTitle: 'Edit Event', + confirm: 'Confirm', + delete: 'Delete', + cancel: 'Cancel', + }, + event: { + title: 'Title', + subtitle: 'Subtitle', + start: 'Start', + end: 'End', + allDay: 'All Day', + }, + validation: { + required: 'Required', + invalidEmail: 'Invalid Email', + onlyNumbers: 'Only Numbers Allowed', + min: 'Minimum {{min}} letters', + max: 'Maximum {{max}} letters', + }, + moreEvents: 'More...', + noDataToDisplay: 'No data to display', + loading: 'Loading...', + } + } + + return ( + { + const url = `/update-booking?b=${event.event_id}` + window.open(url, '_blank')!.focus() + }} + getRemoteEvents={fetchBookings} + translations={getTranslations(language)} + height={window.innerHeight - (64 + 41 + 33 + 10)} + onClickMore={(date: Date, goToDay: (d: Date) => void) => { + goToDay(date) + }} + /> + ) +} + +export default PropertyScheduler diff --git a/backend/src/components/PropertySchedulerFilter.tsx b/backend/src/components/PropertySchedulerFilter.tsx new file mode 100644 index 00000000..eb8d22be --- /dev/null +++ b/backend/src/components/PropertySchedulerFilter.tsx @@ -0,0 +1,111 @@ +import React, { useState, useRef } from 'react' +import { + FormControl, + TextField, + Button, + IconButton +} from '@mui/material' +import { Search as SearchIcon, Clear as ClearIcon } from '@mui/icons-material' +import * as movininTypes from ':movinin-types' +import * as movininHelper from ':movinin-helper' +import { strings as commonStrings } from '@/lang/common' +import { strings } from '@/lang/booking-filter' +import LocationSelectList from './LocationSelectList' +import Accordion from '@/components/Accordion' + +import '@/assets/css/property-scheduler-filter.css' + +interface PropertySchedulerFilterProps { + collapse?: boolean + className?: string + onSubmit?: (filter: movininTypes.Filter | null) => void +} + +const PropertySchedulerFilter = ({ + collapse, + className, + onSubmit +}: PropertySchedulerFilterProps) => { + const [location, setLocation] = useState('') + const [keyword, setKeyword] = useState('') + + const inputRef = useRef(null) + + const handleSearchChange = (e: React.ChangeEvent) => { + setKeyword(e.target.value) + } + + const handleLocationChange = (locations: movininTypes.Option[]) => { + setLocation(locations.length > 0 ? locations[0]._id : '') + } + + const handleSubmit = (e: React.FormEvent | React.KeyboardEvent) => { + e.preventDefault() + + let filter: movininTypes.Filter | null = { + location, + keyword + } + + if (!location && !keyword) { + filter = null + } + if (onSubmit) { + onSubmit(movininHelper.clone(filter)) + } + } + + const handleSearchKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSubmit(e) + } + } + + return ( + +
+ + + + + + { + setKeyword('') + inputRef.current?.focus() + }} + > + + + ) : ( + + ), + } + }} + className="bf-search" + /> + + +
+
+ ) +} + +export default PropertySchedulerFilter diff --git a/backend/src/components/scheduler/README.md b/backend/src/components/scheduler/README.md new file mode 100644 index 00000000..2e2d2de6 --- /dev/null +++ b/backend/src/components/scheduler/README.md @@ -0,0 +1,154 @@ +# React Scheduler Component + +This component was forked from the following [project]()https://github.com/aldabil21/react-scheduler and was updated to fix bugs and meet specific needs. + +> :warning: **Notice**: This component uses `mui`/`emotion`/`date-fns`. if your project is not already using these libs, this component may not be suitable. + +## Installation + +If you plan to use `recurring` events in your scheduler, install `rrule` [package](https://www.npmjs.com/package/rrule) + +## Usage + +```jsx +import { Scheduler } from "@components/schduler/index"; +``` + +## Example + +```jsx + +``` + +### Scheduler Props + +All props are _optional_ +| Prop | Value | +|----------|-------------| +| height | number. Min height of table.
_Default_: 600 +| view | string. Initial view to load. options: "week", "month", "day".
_Default_: "week" (if it's not null) +| agenda | boolean. Activate agenda view +| alwaysShowAgendaDays | boolean. if true, day rows without events will be shown +| month | Object. Month view props.
_default_:
{
weekDays: [0, 1, 2, 3, 4, 5],
weekStartOn: 6,
startHour: 9,
endHour: 17,
cellRenderer?:(props: CellProps) => JSX.Element,
navigation: true,
disableGoToDay: false
}
+| week | Object. Week view props.
_default_:
{ 
weekDays: [0, 1, 2, 3, 4, 5],
weekStartOn: 6,
startHour: 9,
endHour: 17,
step: 60,
cellRenderer?:(props: CellProps) => JSX.Element,
navigation: true,
disableGoToDay: false
}
+| day | Object. Day view props.
_default_:
{
startHour: 9,
endHour: 17,
step: 60,
cellRenderer?:(props: CellProps) => JSX.Element,
hourRenderer?:(hour: string) => JSX.Element,
navigation: true
}
+| selectedDate | Date. Initial selected date.
_Default_: `new Date()` +| navigation | boolean. Show/Hide top bar date navigation.
_Default_: `true` +| navigationPickerProps | CalendarPickerProps for top bar date navigation. Ref [CalendarPicker API](https://mui.com/x/api/date-pickers/calendar-picker/#main-content) +| disableViewNavigator | boolean. Show/Hide top bar date View navigator.
_Default_: `false` +| events | Array of ProcessedEvent.
_Default_: []
type ProcessedEvent = {
event*id: number or string;
title: string;
subtitle?: string;
start: Date;
end: Date;
disabled?: boolean;
recurring: RRule;
color?: string or "palette.path";
textColor?: string or "palette.path";
editable?: boolean;
deletable?: boolean;
draggable?: boolean;
allDay?: boolean;
agendaAvatar?: React.ReactElement \| string
sx?: Mui sx prop
}
+| eventRenderer | Function(event:ProcessedEvent): JSX.Element.
A function that overrides the event item render function, see demo \_Custom Event Renderer* below +| editable | boolean. If `true`, the scheduler cell click will not open the editor, and the event item will not show the edit button, this is applied to all events, and can be overridden in each event property, see `ProcessedEvent` type. +| deletable | boolean. Whether the event item will show the delete button, this is applied to all events, and can be overridden in each event property, see `ProcessedEvent` type. +| draggable | boolean. Whether activate drag&drop for the events, this is applied to all events, and can be overridden in each event property, see `ProcessedEvent` type. +| getRemoteEvents | Function(RemoteQuery). Return promise of array of events. Can be used as a callback to fetch events by parent component or fetch.
type RemoteQuery = { 
start: Date;
end: Date;
view: "day" \| "week" \| "month";
}
+| fields | Array of extra fields with configurations.
Example:
 { 
name: "description",
type: "input" ,
config: { label: "Description", required: true, min: 3, email: true, variant: "outlined", ....
}
+| loading | boolean. Loading state of the calendar table +| loadingComponent | Custom component to override the default `CircularProgress` +| onConfirm | Function(event, action). Return promise with the new added/edited event use with remote data.
_action_: `add` | `edit` +| onDelete | Function(id) Return promise with the deleted event id to use with remote data. +| customEditor | Function(scheduler). Override editor modal.
Provided prop _scheduler_ object with helper props:
{
state: state obj,
close(): void
loading(status: boolean): void
edited?: ProcessedEvent
onConfirm(event: ProcessedEvent, action:EventActions): void
}
+| customViewer | Function(event: ProcessedEvent, close: () => void). Used to render fully customized content of the event popper. If used, `viewerExtraComponent` & `viewerTitleComponent` will be ignored +| viewerExtraComponent | Function(fields, event) OR Component. Additional component in event viewer popper +| viewerTitleComponent | Function(event). Helper function to render custom title in event popper +| viewerSubtitleComponent | Function(event). Helper function to render custom subtitle in event popper +| disableViewer | boolean. If true, the viewer popover will be disabled globally +| resources | Array. Resources array to split event views with resources
_Example_
{
assignee: 1,
text: "User One",
subtext: "Sales Manager",
avatar: "https://picsum.photos/200/300",
color: "#ab2d2d",
}
+| resourceFields | Object. Map the resources correct fields.
_Example_:
{
idField: "admin*id",
textField: "title",
subTextField: "mobile",
avatarField: "title",
colorField: "background",
}
+| resourceHeaderComponent | Function(resource). Override header component of resource +| resourceViewMode | Display resources mode.
\_Options*: `default` | `vertical` | `tabs` +| onResourceChange | Function(resource: Resource): void. Triggered when the resource tabs changes, only applicable when `resourceViewMode="tabs"` +| direction | string. Table direction. `rtl` | `ltr` +| dialogMaxWidth | Edito dialog maxWith. Ex: `lg` | `md` | `sm`... _Default_:`md` +| locale | Locale of date-fns. _Default_: `enUS` +| hourFormat | Hour format.
_Options_: `12` | `24`. _Default_: `12` +| timeZone| String, time zone [IANA ID](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) +| translations | Object. Translations view props.
_default_:
{
navigation: {
month: "Month",
week: "Week",
day: "Day",
today: "Today"
agenda: "Agenda"
},
form: {
addTitle: "Add Event",
editTitle: "Edit Event",
confirm: "Confirm",
delete: "Delete",
cancel: "Cancel"
},
event: {
title: "Title",
subtitle: "Subtitle",
start: "Start",
end: "End",
allDay: "All Day"
},
validation: {
required: "Required",
invalidEmail: "Invalid Email",
onlyNumbers: "Only Numbers Allowed",
min: "Minimum {{min}} letters",
max: "Maximum {{max}} letters"
},
moreEvents: "More...",
noDataToDisplay: "No data to display",
loading: "Loading..."
}
+| onEventDrop | Function(event: DragEvent, droppedOn: Date, updatedEvent: ProcessedEvent, originalEvent: ProcessedEvent). Return a promise, used to update remote data of the dropped event. Return an event to update state internally, or void if event state is managed within component +| onEventClick | Function(event: ProcessedEvent): void. Triggered when an event item is clicked +| onEventEdit | Function(event: ProcessedEvent): void. Triggered when an event item is being edited from the popover +| onCellClick | Function(start: Date, end: Date, resourceKey?: string, resourceVal?: string | number): void. Triggered when a cell in the grid is clicked +| onSelectedDateChange | Function(date: Date): void. Triggered when the `selectedDate` prop changes by navigation date picker or `today` button +| onViewChange | Function(view: View, agenda?: boolean): void. Triggered when navigation view changes +| stickyNavigation | If `true`, the navigation controller bar will be sticky +| onClickMore | Function(date: Date, goToDay: Function(date: Date): void): void. Triggered when the "More..." button is clicked, it receives the date and a `goToDay` function that shows a day view for a specfic date. + +### SchedulerRef + +Used to help manage and control the internal state of the `Scheduler` component from outside of `Scheduler` props, Example: + +```js +import { Scheduler } from "@aldabil/react-scheduler"; +import type { SchedulerRef } from "@aldabil/react-scheduler/types" + +const SomeComponent = () => { + const calendarRef = useRef(null); + + return +
+ + +
+ + +
+}; +``` + +The `calendarRef` holds the entire internal state of the Scheduler component. Perhaps the most useful method inside the `calendarRef` is `handleState`, example: + +``` +calendarRef.current.scheduler.handleState(value, key); +``` + +consider looking inside `SchedulerRef` type to see all fields & methods available. + +### Demos + +- [Basic](https://codesandbox.io/p/sandbox/standard-x24pqk) +- [Remote Data](https://codesandbox.io/s/remote-data-j13ei) +- [Custom Fields](https://codesandbox.io/s/custom-fields-b2kbv) +- [Editor/Viewer Override](https://codesandbox.io/s/customeditor-tt2pf) +- [Resources/View Mode](https://codesandbox.io/s/resources-7wlcy) +- [Custom Cell Action](https://codesandbox.io/s/custom-cell-action-n02dv) +- [Custom Event Renderer](https://codesandbox.io/s/custom-event-renderer-rkf4xw) + +### Todos + +- [ ] Tests +- [x] Drag&Drop - partially +- [ ] Resizable +- [x] Recurring events - partially +- [x] Localization +- [x] Hour format 12 | 24 diff --git a/backend/src/components/scheduler/SchedulerComponent.tsx b/backend/src/components/scheduler/SchedulerComponent.tsx new file mode 100644 index 00000000..2c58df59 --- /dev/null +++ b/backend/src/components/scheduler/SchedulerComponent.tsx @@ -0,0 +1,75 @@ +import React, { forwardRef, useMemo } from 'react' +import { CircularProgress, Typography } from '@mui/material' +import { Week } from './views/Week' +import { Navigation } from './components/nav/Navigation' +import Editor from './views/Editor' +import { Month } from './views/Month' +import { Day } from './views/Day' +import { Table, Wrapper } from './styles/styles' +import useStore from './hooks/useStore' +import { SchedulerRef } from './types' +import { PositionProvider } from './positionManger/provider' + +const SchedulerComponent = forwardRef((_, ref) => { + const store = useStore() + const { view, dialog, loading, loadingComponent, resourceViewMode, resources, translations } = store + + const Views = useMemo(() => { + switch (view) { + case 'month': + return + case 'week': + return + case 'day': + return + default: + return null + } + }, [view]) + + const LoadingComp = useMemo(() => ( +
+ {loadingComponent || ( +
+ + + {translations.loading} + +
+ )} +
+ ), [loadingComponent, translations.loading]) + + return ( + { + const calendarRef = ref as any + if (calendarRef) { + calendarRef.current = { + el, + scheduler: store, + } + } + }} + > + {loading ? LoadingComp : null} + + 1 ? 'auto' : undefined, + flexDirection: resourceViewMode === 'vertical' ? 'column' : undefined, + }} + data-testid="grid" + > + {Views} +
+ {dialog && } +
+ ) +}) + +export default SchedulerComponent diff --git a/backend/src/components/scheduler/components/common/Cell.tsx b/backend/src/components/scheduler/components/common/Cell.tsx new file mode 100644 index 00000000..82613a60 --- /dev/null +++ b/backend/src/components/scheduler/components/common/Cell.tsx @@ -0,0 +1,53 @@ +import React, { ReactNode } from 'react' +import { Button } from '@mui/material' +import { useCellAttributes } from '../../hooks/useCellAttributes' +import { CellRenderedProps } from '../../types' + +interface CellProps { + day: Date; + start: Date; + height: number; + end: Date; + resourceKey: string; + resourceVal: string | number; + cellRenderer?(props: CellRenderedProps): ReactNode; + children?: ReactNode; +} + +const Cell = ({ + day, + start, + end, + resourceKey, + resourceVal, + cellRenderer, + height, + children, +}: CellProps) => { + const props = useCellAttributes({ start, end, resourceKey, resourceVal }) + + if (cellRenderer) { + return cellRenderer({ + day, + start, + end, + height, + ...props, + }) + } + + return ( + + ) +} + +export default Cell diff --git a/backend/src/components/scheduler/components/common/LocaleArrow.tsx b/backend/src/components/scheduler/components/common/LocaleArrow.tsx new file mode 100644 index 00000000..d04c5be2 --- /dev/null +++ b/backend/src/components/scheduler/components/common/LocaleArrow.tsx @@ -0,0 +1,38 @@ +import React, { MouseEvent } from 'react' +import NavigateBeforeRoundedIcon from '@mui/icons-material/NavigateBeforeRounded' +import NavigateNextRoundedIcon from '@mui/icons-material/NavigateNextRounded' +import { IconButton, IconButtonProps } from '@mui/material' +import useStore from '../../hooks/useStore' + +interface LocaleArrowProps extends Omit { + type: 'prev' | 'next'; + onClick?(e?: MouseEvent): void; +} +const LocaleArrow = ({ type, onClick, ...props }: LocaleArrowProps) => { + const { direction } = useStore() + + let Arrow = NavigateNextRoundedIcon + if (type === 'prev') { + Arrow = direction === 'rtl' ? NavigateNextRoundedIcon : NavigateBeforeRoundedIcon + } else if (type === 'next') { + Arrow = direction === 'rtl' ? NavigateBeforeRoundedIcon : NavigateNextRoundedIcon + } + + return ( + { + e.preventDefault() + if (onClick) { + onClick() + } + }} + {...props} + > + + + ) +} + +export { LocaleArrow } diff --git a/backend/src/components/scheduler/components/common/ResourceHeader.tsx b/backend/src/components/scheduler/components/common/ResourceHeader.tsx new file mode 100644 index 00000000..0500c301 --- /dev/null +++ b/backend/src/components/scheduler/components/common/ResourceHeader.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import { + Avatar, + ListItem, + ListItemAvatar, + ListItemText, + Typography, + useTheme, +} from '@mui/material' +import { DefaultResource } from '../../types' +import useStore from '../../hooks/useStore' + +interface ResourceHeaderProps { + resource: DefaultResource; +} +const ResourceHeader = ({ resource }: ResourceHeaderProps) => { + const { resourceHeaderComponent, resourceFields, direction, resourceViewMode } = useStore() + const theme = useTheme() + + const text = resource[resourceFields.textField] + const subtext = resource[resourceFields.subTextField || ''] + const avatar = resource[resourceFields.avatarField || ''] + const color = resource[resourceFields.colorField || ''] + + if (resourceHeaderComponent instanceof Function) { + return resourceHeaderComponent(resource) + } + + return ( + + + + + + {text} + + )} + secondary={( + + {subtext} + + )} + /> + + ) +} + +export { ResourceHeader } diff --git a/backend/src/components/scheduler/components/common/Tabs.tsx b/backend/src/components/scheduler/components/common/Tabs.tsx new file mode 100644 index 00000000..41d0fda9 --- /dev/null +++ b/backend/src/components/scheduler/components/common/Tabs.tsx @@ -0,0 +1,117 @@ +import React, { CSSProperties, ReactNode } from 'react' +import { Tabs, Tab } from '@mui/material' +import { styled } from '@mui/material/styles' +import { Theme } from '@mui/system' + +interface TabPanelProps { + value: string | number; + index: string | number; + children: React.ReactNode; +} +const TabPanel = (props: TabPanelProps) => { + const { children, value, index } = props + return value === index ? <>{children} : <> +} + +function a11yProps(index: string | number) { + return { + id: `scrollable-auto-tab-${index}`, + 'aria-controls': `scrollable-auto-tabpanel-${index}`, + } +} + +const StyledTaps = styled('div')(({ theme }: { theme: Theme }) => ({ + flexGrow: 1, + width: '100%', + backgroundColor: theme.palette.background.paper, + alignSelf: 'center', + '& .tabs': { + borderColor: theme.palette.grey[300], + borderStyle: 'solid', + borderWidth: 1, + '& button.MuiTab-root': { + borderColor: theme.palette.grey[300], + borderRightStyle: 'solid', + borderWidth: 1, + }, + }, + '& .primary': { + background: theme.palette.primary.main, + }, + '& .secondary': { + background: theme.palette.secondary.main, + }, + '& .error': { + background: theme.palette.error.main, + }, + '& .info': { + background: theme.palette.info.dark, + }, + '& .text_primary': { + color: theme.palette.primary.main, + }, + '& .text_secondary': { + color: theme.palette.secondary.main, + }, + '& .text_error': { + color: theme.palette.error.main, + }, + '& .text_info': { + color: theme.palette.info.dark, + }, +})) + +export type ButtonTabProps = { + id: string | number; + label: string | ReactNode; + component: ReactNode; +}; +interface ButtonTabsProps { + tabs: ButtonTabProps[]; + tab: string | number; + setTab(tab: string | number): void; + variant?: 'scrollable' | 'standard' | 'fullWidth'; + indicator?: 'primary' | 'secondary' | 'info' | 'error'; + style?: CSSProperties; +} + +const ButtonTabs = ({ + tabs, + variant = 'scrollable', + tab, + setTab, + indicator = 'primary', + style, +}: ButtonTabsProps) => ( + + + {tabs.map((_tab: ButtonTabProps, i: number) => ( + setTab(_tab.id)} + onDragEnter={() => setTab(_tab.id)} + /> + ))} + + {tabs.map( + (t: ButtonTabProps) => + t.component && ( + + {t.component} + + ) + )} + + ) + +export { ButtonTabs } diff --git a/backend/src/components/scheduler/components/common/TodayTypo.tsx b/backend/src/components/scheduler/components/common/TodayTypo.tsx new file mode 100644 index 00000000..24dcb592 --- /dev/null +++ b/backend/src/components/scheduler/components/common/TodayTypo.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { Typography } from '@mui/material' +import { format, Locale } from 'date-fns' +import { isTimeZonedToday } from '../../helpers/generals' +import useStore from '../../hooks/useStore' + +interface TodayTypoProps { + date: Date; + onClick?(day: Date): void; + locale: Locale; +} + +const TodayTypo = ({ date, onClick, locale }: TodayTypoProps) => { + const { timeZone } = useStore() + const today = isTimeZonedToday({ dateLeft: date, timeZone }) + + return ( +
+ { + e.stopPropagation() + if (onClick) onClick(date) + }} + > + {format(date, 'dd', { locale })} + + + {format(date, 'eee', { locale })} + +
+ ) +} + +export default TodayTypo diff --git a/backend/src/components/scheduler/components/common/WithResources.tsx b/backend/src/components/scheduler/components/common/WithResources.tsx new file mode 100644 index 00000000..5a072751 --- /dev/null +++ b/backend/src/components/scheduler/components/common/WithResources.tsx @@ -0,0 +1,95 @@ +import React, { useMemo } from 'react' +import { Box, useTheme } from '@mui/material' +import { DefaultResource } from '../../types' +import { ResourceHeader } from './ResourceHeader' +import { ButtonTabProps, ButtonTabs } from './Tabs' +import useStore from '../../hooks/useStore' + +interface WithResourcesProps { + renderChildren(resource: DefaultResource): React.ReactNode; +} + +const ResourcesTabTables = ({ renderChildren }: WithResourcesProps) => { + const { resources, resourceFields, selectedResource, handleState, onResourceChange } = useStore() + + const tabs: ButtonTabProps[] = resources.map((res) => ({ + id: res[resourceFields.idField], + label: , + component: <>{renderChildren(res)}, + })) + + const setTab = (tab: DefaultResource['assignee']) => { + handleState(tab, 'selectedResource') + if (typeof onResourceChange === 'function') { + const selected = resources.find((re) => re[resourceFields.idField] === tab) + if (selected) { + onResourceChange(selected) + } + } + } + + const currentTabSafeId = useMemo(() => { + const firstId = resources[0][resourceFields.idField] + if (!selectedResource) { + return firstId + } + // Make sure current selected id is within the resources array + const idx = resources.findIndex((re) => re[resourceFields.idField] === selectedResource) + if (idx < 0) { + return firstId + } + + return selectedResource + }, [resources, selectedResource, resourceFields.idField]) + + return ( + + ) +} + +const WithResources = ({ renderChildren }: WithResourcesProps) => { + const { resources, resourceFields, resourceViewMode } = useStore() + const theme = useTheme() + + if (resourceViewMode === 'tabs') { + return + } if (resourceViewMode === 'vertical') { + return ( + <> + {resources.map((res: DefaultResource) => ( + + + + + + {renderChildren(res)} + + + ))} + + ) + } + return ( + <> + {resources.map((res: DefaultResource) => ( +
+ + {renderChildren(res)} +
+ ))} + + ) +} + +export { WithResources } diff --git a/backend/src/components/scheduler/components/events/Actions.tsx b/backend/src/components/scheduler/components/events/Actions.tsx new file mode 100644 index 00000000..8421b73a --- /dev/null +++ b/backend/src/components/scheduler/components/events/Actions.tsx @@ -0,0 +1,66 @@ +import React, { useState } from 'react' +import DeleteRounded from '@mui/icons-material/DeleteRounded' +import EditRounded from '@mui/icons-material/EditRounded' +import { Button, Grow, IconButton, Slide } from '@mui/material' +import { EventActions as Actions } from '../../styles/styles' +import { ProcessedEvent } from '../../types' +import useStore from '../../hooks/useStore' +import useEventPermissions from '../../hooks/useEventPermissions' + +interface Props { + event: ProcessedEvent; + onDelete(): void; + onEdit(): void; +} + +const EventActions = ({ event, onDelete, onEdit }: Props) => { + const { translations, direction } = useStore() + const [deleteConfirm, setDeleteConfirm] = useState(false) + + const handleDelete = () => { + if (!deleteConfirm) { + setDeleteConfirm(true) + return + } + onDelete() + } + + const { canEdit, canDelete } = useEventPermissions(event) + + return ( + + +
+ {canEdit && ( + + + + )} + {canDelete && ( + + + + )} +
+
+ +
+ + +
+
+
+ ) +} + +export default EventActions diff --git a/backend/src/components/scheduler/components/events/AgendaEventsList.tsx b/backend/src/components/scheduler/components/events/AgendaEventsList.tsx new file mode 100644 index 00000000..14802cf2 --- /dev/null +++ b/backend/src/components/scheduler/components/events/AgendaEventsList.tsx @@ -0,0 +1,109 @@ +import React, { Fragment, MouseEvent, useState } from 'react' +import { + useTheme, + List, + ListItemButton, + ListItemAvatar, + Avatar, + ListItemText, +} from '@mui/material' +import { format } from 'date-fns' +import { ProcessedEvent } from '../../types' +import { getHourFormat, isTimeZonedToday } from '../../helpers/generals' +import useStore from '../../hooks/useStore' +import EventItemPopover from './EventItemPopover' + +interface AgendaEventsListProps { + day: Date; + events: ProcessedEvent[]; +} + +const AgendaEventsList = ({ day, events }: AgendaEventsListProps) => { + const [anchorEl, setAnchorEl] = useState(null) + const [selectedEvent, setSelectedEvent] = useState() + const [deleteConfirm, setDeleteConfirm] = useState(false) + const { locale, hourFormat, eventRenderer, onEventClick, timeZone, disableViewer } = useStore() + const theme = useTheme() + const hFormat = getHourFormat(hourFormat) + + const triggerViewer = (el?: MouseEvent) => { + if (!el?.currentTarget && deleteConfirm) { + setDeleteConfirm(false) + } + setAnchorEl(el?.currentTarget || null) + } + + return ( + <> + + {events.map((event) => { + const startIsToday = isTimeZonedToday({ + dateLeft: event.start, + dateRight: day, + timeZone, + }) + const startFormat = startIsToday ? hFormat : `MMM d, ${hFormat}` + const startDate = format(event.start, startFormat, { + locale, + }) + const endIsToday = isTimeZonedToday({ dateLeft: event.end, dateRight: day, timeZone }) + + const endFormat = endIsToday ? hFormat : `MMM d, ${hFormat}` + const endDate = format(event.end, endFormat, { + locale, + }) + + if (typeof eventRenderer === 'function') { + return eventRenderer({ event, onClick: triggerViewer }) + } + + return ( + { + e.preventDefault() + e.stopPropagation() + if (!disableViewer) { + triggerViewer(e) + } + setSelectedEvent(event) + if (typeof onEventClick === 'function') { + onEventClick(event) + } + }} + > + + + {event.agendaAvatar || ' '} + + + + + ) + })} + + + {/* Viewer */} + {selectedEvent && ( + + )} + + ) +} + +export default AgendaEventsList diff --git a/backend/src/components/scheduler/components/events/CurrentTimeBar.tsx b/backend/src/components/scheduler/components/events/CurrentTimeBar.tsx new file mode 100644 index 00000000..6b0556ad --- /dev/null +++ b/backend/src/components/scheduler/components/events/CurrentTimeBar.tsx @@ -0,0 +1,49 @@ +import React, { useEffect, useState } from 'react' +import { differenceInMinutes, set } from 'date-fns' +import { BORDER_HEIGHT } from '../../helpers/constants' +import { getTimeZonedDate } from '../../helpers/generals' +import { TimeIndicatorBar } from '../../styles/styles' + +interface CurrentTimeBarProps { + startHour: number; + step: number; + minuteHeight: number; + timeZone?: string; + zIndex?: number; +} + +function calculateTop({ startHour, step, minuteHeight, timeZone }: CurrentTimeBarProps): number { + const now = getTimeZonedDate(new Date(), timeZone) + + const minutesFromTop = differenceInMinutes(now, set(now, { hours: startHour, minutes: 0 })) + const topSpace = minutesFromTop * minuteHeight + const slotsFromTop = minutesFromTop / step + const borderFactor = slotsFromTop + BORDER_HEIGHT + const top = topSpace + borderFactor + + return top +} + +const CurrentTimeBar = (props: CurrentTimeBarProps) => { + const [top, setTop] = useState(calculateTop(props)) + const { startHour, step, minuteHeight, timeZone, zIndex } = props + + useEffect(() => { + const calcProps = { startHour, step, minuteHeight, timeZone } + setTop(calculateTop(calcProps)) + const interval = setInterval(() => setTop(calculateTop(calcProps)), 60 * 1000) + return () => clearInterval(interval) + }, [startHour, step, minuteHeight, timeZone]) + + // Prevent showing bar on top of days/header + if (top < 0) return null + + return ( + +
+
+ + ) +} + +export default CurrentTimeBar diff --git a/backend/src/components/scheduler/components/events/EmptyAgenda.tsx b/backend/src/components/scheduler/components/events/EmptyAgenda.tsx new file mode 100644 index 00000000..e746230e --- /dev/null +++ b/backend/src/components/scheduler/components/events/EmptyAgenda.tsx @@ -0,0 +1,25 @@ +import React, { Typography } from '@mui/material' +import { AgendaDiv } from '../../styles/styles' +import useStore from '../../hooks/useStore' + +const EmptyAgenda = () => { + const { height, translations } = useStore() + return ( + +
+ {translations.noDataToDisplay} +
+
+ ) +} + +export default EmptyAgenda diff --git a/backend/src/components/scheduler/components/events/EventItem.tsx b/backend/src/components/scheduler/components/events/EventItem.tsx new file mode 100644 index 00000000..31f03228 --- /dev/null +++ b/backend/src/components/scheduler/components/events/EventItem.tsx @@ -0,0 +1,151 @@ +import React, { Fragment, MouseEvent, useMemo, useState } from 'react' +import { Typography, ButtonBase, useTheme } from '@mui/material' +import { format } from 'date-fns' +import ArrowRightRoundedIcon from '@mui/icons-material/ArrowRightRounded' +import ArrowLeftRoundedIcon from '@mui/icons-material/ArrowLeftRounded' +import { ProcessedEvent } from '../../types' +import { EventItemPaper } from '../../styles/styles' +import { differenceInDaysOmitTime, getHourFormat } from '../../helpers/generals' +import useStore from '../../hooks/useStore' +import useDragAttributes from '../../hooks/useDragAttributes' +import EventItemPopover from './EventItemPopover' +import useEventPermissions from '../../hooks/useEventPermissions' + +interface EventItemProps { + event: ProcessedEvent; + multiday?: boolean; + hasPrev?: boolean; + hasNext?: boolean; + showdate?: boolean; +} + +const EventItem = ({ event, multiday, hasPrev, hasNext, showdate = true }: EventItemProps) => { + const { direction, locale, hourFormat, eventRenderer, onEventClick, view, disableViewer } = useStore() + const dragProps = useDragAttributes(event) + const [anchorEl, setAnchorEl] = useState(null) + const [deleteConfirm, setDeleteConfirm] = useState(false) + const theme = useTheme() + const hFormat = getHourFormat(hourFormat) + + const NextArrow = direction === 'rtl' ? ArrowLeftRoundedIcon : ArrowRightRoundedIcon + const PrevArrow = direction === 'rtl' ? ArrowRightRoundedIcon : ArrowLeftRoundedIcon + const hideDates = differenceInDaysOmitTime(event.start, event.end) <= 0 && event.allDay + + const { canDrag } = useEventPermissions(event) + + const triggerViewer = (el?: MouseEvent) => { + if (!el?.currentTarget && deleteConfirm) { + setDeleteConfirm(false) + } + setAnchorEl(el?.currentTarget || null) + } + + const renderEvent = useMemo(() => { + // Check if has custom render event method + // only applicable to non-multiday events and not in month-view + if (typeof eventRenderer === 'function' && !multiday && view !== 'month') { + const custom = eventRenderer({ event, onClick: triggerViewer, ...dragProps }) + if (custom) { + return ( + + {custom} + + ) + } + } + + let item = ( +
+ + {event.title} + + {event.subtitle && ( + + {event.subtitle} + + )} + {showdate && ( + + {`${format(event.start, hFormat, { + locale, + })} - ${format(event.end, hFormat, { locale })}`} + + )} +
+ ) + if (multiday) { + item = ( +
+ + {hasPrev ? ( + + ) : ( + showdate && !hideDates && format(event.start, hFormat, { locale }) + )} + + + {event.title} + + + {hasNext ? ( + + ) : ( + showdate && !hideDates && format(event.end, hFormat, { locale }) + )} + +
+ ) + } + return ( + + { + e.preventDefault() + e.stopPropagation() + if (!disableViewer) { + triggerViewer(e) + } + if (typeof onEventClick === 'function') { + onEventClick(event) + } + }} + focusRipple + tabIndex={disableViewer ? -1 : 0} + disableRipple={disableViewer} + disabled={event.disabled} + > +
+ {item} +
+
+
+ ) + // eslint-disable-next-line + }, [hasPrev, hasNext, event, canDrag, locale, theme.palette]); + + return ( + <> + {renderEvent} + + {/* Viewer */} + + + ) +} + +export default EventItem diff --git a/backend/src/components/scheduler/components/events/EventItemPopover.tsx b/backend/src/components/scheduler/components/events/EventItemPopover.tsx new file mode 100644 index 00000000..3566446c --- /dev/null +++ b/backend/src/components/scheduler/components/events/EventItemPopover.tsx @@ -0,0 +1,175 @@ +import React, { MouseEvent } from 'react' +import { Box, IconButton, Popover, Typography, useTheme } from '@mui/material' +import EventNoteRoundedIcon from '@mui/icons-material/EventNoteRounded' +import ClearRoundedIcon from '@mui/icons-material/ClearRounded' +import SupervisorAccountRoundedIcon from '@mui/icons-material/SupervisorAccountRounded' +import { format } from 'date-fns' +import useStore from '../../hooks/useStore' +import { ProcessedEvent } from '../../types' +import { PopperInner } from '../../styles/styles' +import EventActions from './Actions' +import { differenceInDaysOmitTime, getHourFormat } from '../../helpers/generals' + +type Props = { + event: ProcessedEvent; + anchorEl: Element | null; + onTriggerViewer: (el?: MouseEvent) => void; +}; + +const EventItemPopover = ({ anchorEl, event, onTriggerViewer }: Props) => { + const { + triggerDialog, + onDelete, + events, + handleState, + triggerLoading, + customViewer, + viewerExtraComponent, + fields, + resources, + resourceFields, + locale, + viewerTitleComponent, + viewerSubtitleComponent, + hourFormat, + translations, + onEventEdit, + } = useStore() + const theme = useTheme() + const hideDates = differenceInDaysOmitTime(event.start, event.end) <= 0 && event.allDay + const hFormat = getHourFormat(hourFormat) + const idKey = resourceFields.idField + const hasResource = resources.filter((res) => + (Array.isArray(event[idKey]) ? event[idKey].includes(res[idKey]) : res[idKey] === event[idKey])) + + const handleDelete = async () => { + try { + triggerLoading(true) + let deletedId = event.event_id + // Trigger custom/remote when provided + if (onDelete) { + const remoteId = await onDelete(deletedId) + if (remoteId) { + deletedId = remoteId + } else { + deletedId = '' + } + } + if (deletedId) { + onTriggerViewer() + const updatedEvents = events.filter((e) => e.event_id !== deletedId) + handleState(updatedEvents, 'events') + } + } catch (error) { + console.error(error) + } finally { + triggerLoading(false) + } + } + + return ( + { + onTriggerViewer() + }} + anchorOrigin={{ + vertical: 'center', + horizontal: 'center', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'center', + }} + onClick={(e) => { + e.stopPropagation() + }} + > + {typeof customViewer === 'function' ? ( + customViewer(event, () => onTriggerViewer()) + ) : ( + + +
+
+ { + onTriggerViewer() + }} + > + + +
+ { + onTriggerViewer() + triggerDialog(true, event) + + if (onEventEdit && typeof onEventEdit === 'function') { + onEventEdit(event) + } + }} + /> +
+ {viewerTitleComponent instanceof Function ? ( + viewerTitleComponent(event) + ) : ( + + {event.title} + + )} +
+
+ + + {hideDates + ? translations.event.allDay + : `${format(event.start, `dd MMMM yyyy ${hFormat}`, { + locale, + })} - ${format(event.end, `dd MMMM yyyy ${hFormat}`, { + locale, + })}`} + + {viewerSubtitleComponent instanceof Function ? ( + viewerSubtitleComponent(event) + ) : ( + + {event.subtitle} + + )} + {hasResource.length > 0 && ( + + + {hasResource.map((res) => res[resourceFields.textField]).join(', ')} + + )} + {viewerExtraComponent instanceof Function + ? viewerExtraComponent(fields, event) + : viewerExtraComponent} +
+
+ )} +
+ ) +} + +export default EventItemPopover diff --git a/backend/src/components/scheduler/components/events/MonthEvents.tsx b/backend/src/components/scheduler/components/events/MonthEvents.tsx new file mode 100644 index 00000000..4a659b6b --- /dev/null +++ b/backend/src/components/scheduler/components/events/MonthEvents.tsx @@ -0,0 +1,141 @@ +import React, { Fragment, ReactNode, useMemo } from 'react' +import { + closestTo, + isBefore, + startOfWeek, + differenceInDays, + differenceInCalendarWeeks, + format, +} from 'date-fns' +import { Typography } from '@mui/material' +import { ProcessedEvent } from '../../types' +import EventItem from './EventItem' +import { + MONTH_BAR_HEIGHT, + MONTH_NUMBER_HEIGHT, + MULTI_DAY_EVENT_HEIGHT, +} from '../../helpers/constants' +import { convertEventTimeZone, differenceInDaysOmitTime, sortEventsByTheEarliest } from '../../helpers/generals' +import useStore from '../../hooks/useStore' +import usePosition from '../../positionManger/usePosition' + +interface MonthEventProps { + events: ProcessedEvent[]; + resourceId?: string; + today: Date; + eachWeekStart: Date[]; + eachFirstDayInCalcRow: Date | null; + daysList: Date[]; + onViewMore(day: Date): void; + cellHeight: number; +} + +const MonthEvents = ({ + events: eventsFromProps, + resourceId, + today, + eachWeekStart, + eachFirstDayInCalcRow, + daysList, + onViewMore, + cellHeight, +}: MonthEventProps) => { + const LIMIT = Math.round((cellHeight - MONTH_NUMBER_HEIGHT) / MULTI_DAY_EVENT_HEIGHT - 1) + const { translations, month, locale, timeZone } = useStore() + const { renderedSlots } = usePosition() + + const renderEvents = useMemo(() => { + const elements: ReactNode[] = [] + + const events = sortEventsByTheEarliest(Array.from(eventsFromProps)) + for (let i = 0; i < Math.min(events.length, LIMIT + 1); i += 1) { + const event = convertEventTimeZone(events[i], timeZone) + const fromPrevWeek = !!eachFirstDayInCalcRow && isBefore(event.start, eachFirstDayInCalcRow) + const start = fromPrevWeek && eachFirstDayInCalcRow ? eachFirstDayInCalcRow : event.start + let eventLength = differenceInDaysOmitTime(start, event.end) + 1 + + const toNextWeek = differenceInCalendarWeeks(event.end, start, { + weekStartsOn: month?.weekStartOn, + locale, + }) > 0 + + if (toNextWeek) { + // Rethink it + const NotAccurateWeekStart = startOfWeek(event.start, { + weekStartsOn: month?.weekStartOn, + locale, + }) + const closestStart = closestTo(NotAccurateWeekStart, eachWeekStart) + if (closestStart) { + eventLength = daysList.length + - (!eachFirstDayInCalcRow ? differenceInDays(event.start, closestStart) : 0) + } + } + + const day = format(today, 'yyyy-MM-dd') + const rendered = renderedSlots?.[resourceId || 'all']?.[day] + const position = rendered?.[event.event_id] || 0 + + const topSpace = Math.min(position, LIMIT) * MULTI_DAY_EVENT_HEIGHT + MONTH_NUMBER_HEIGHT + + if (position >= LIMIT) { + elements.push( + { + e.stopPropagation() + // onViewMore(event.start) + onViewMore(today) + }} + > + {`${Math.abs(events.length - i)} ${translations.moreEvents}`} + + ) + break + } + + elements.push( +
+ 0} + hasPrev={fromPrevWeek} + hasNext={toNextWeek} + /> +
+ ) + } + + return elements + }, [ + resourceId, + renderedSlots, + eventsFromProps, + LIMIT, + eachFirstDayInCalcRow, + month?.weekStartOn, + locale, + today, + eachWeekStart, + daysList.length, + translations.moreEvents, + onViewMore, + timeZone, + ]) + + return <>{renderEvents} +} + +export default MonthEvents diff --git a/backend/src/components/scheduler/components/events/TodayEvents.tsx b/backend/src/components/scheduler/components/events/TodayEvents.tsx new file mode 100644 index 00000000..56c1d30f --- /dev/null +++ b/backend/src/components/scheduler/components/events/TodayEvents.tsx @@ -0,0 +1,91 @@ +import React, { Fragment } from 'react' +import { differenceInMinutes } from 'date-fns' +import { BORDER_HEIGHT } from '../../helpers/constants' +import { isTimeZonedToday, traversCrossingEvents } from '../../helpers/generals' +import { ProcessedEvent } from '../../types' +import CurrentTimeBar from './CurrentTimeBar' +import EventItem from './EventItem' + +interface TodayEventsProps { + todayEvents: ProcessedEvent[]; + today: Date; + startHour: number; + endHour: number; + step: number; + minuteHeight: number; + direction: 'rtl' | 'ltr'; + timeZone?: string; +} +const TodayEvents = ({ + todayEvents, + today, + startHour, + endHour, + step, + minuteHeight, + direction, + timeZone, +}: TodayEventsProps) => { + const crossingIds: Array = [] + + return ( + <> + {isTimeZonedToday({ dateLeft: today, timeZone }) && ( + + )} + + {todayEvents.map((event, i) => { + const maxHeight = (endHour * 60 - startHour * 60) * minuteHeight + const eventHeight = differenceInMinutes(event.end, event.start) * minuteHeight + const height = Math.min(eventHeight, maxHeight) - BORDER_HEIGHT + + const calendarStartInMins = startHour * 60 + const eventStartInMins = event.start.getHours() * 60 + event.start.getMinutes() + const minituesFromTop = Math.max(eventStartInMins - calendarStartInMins, 0) + + const topSpace = minituesFromTop * minuteHeight + /** Add border factor to height of each slot */ + const slots = height / 60 + const heightBorderFactor = slots * BORDER_HEIGHT + + /** Calculate top space */ + const slotsFromTop = minituesFromTop / step + const top = topSpace + slotsFromTop + + const crossingEvents = traversCrossingEvents(todayEvents, event) + const alreadyRendered = crossingEvents.filter((e) => crossingIds.includes(e.event_id)) + crossingIds.push(event.event_id) + + return ( +
0 + ? `calc(100% - ${100 - 98 / (alreadyRendered.length + 1)}%)` + : '98%', // Leave some space to click cell + zIndex: todayEvents.length + i, + [direction === 'rtl' ? 'right' : 'left']: + alreadyRendered.length > 0 + ? `${(100 / (crossingEvents.length + 1)) * alreadyRendered.length}%` + : '', + }} + > + +
+ ) + })} + + ) +} + +export default TodayEvents diff --git a/backend/src/components/scheduler/components/hoc/DateProvider.tsx b/backend/src/components/scheduler/components/hoc/DateProvider.tsx new file mode 100644 index 00000000..5988cd2b --- /dev/null +++ b/backend/src/components/scheduler/components/hoc/DateProvider.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' +import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFnsV3' +import useStore from '../../hooks/useStore' + +interface AuxProps { + children: React.ReactNode; +} + +const DateProvider = ({ children }: AuxProps) => { + const { locale } = useStore() + + return ( + + {children} + + ) +} + +export default DateProvider diff --git a/backend/src/components/scheduler/components/inputs/DatePicker.tsx b/backend/src/components/scheduler/components/inputs/DatePicker.tsx new file mode 100644 index 00000000..bef69379 --- /dev/null +++ b/backend/src/components/scheduler/components/inputs/DatePicker.tsx @@ -0,0 +1,89 @@ +import React, { useEffect, useState } from 'react' +import { DatePicker } from '@mui/x-date-pickers/DatePicker' +import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker' +import DateProvider from '../hoc/DateProvider' +import useStore from '../../hooks/useStore' + +interface EditorDatePickerProps { + type?: 'date' | 'datetime'; + label?: string; + variant?: 'standard' | 'filled' | 'outlined'; + value: Date | string; + name: string; + onChange(name: string, date: Date): void; + error?: boolean; + errMsg?: string; + touched?: boolean; + required?: boolean; +} + +const EditorDatePicker = ({ + type = 'datetime', + value, + label, + name, + onChange, + variant = 'outlined', + error, + errMsg, + touched, + required, +}: EditorDatePickerProps) => { + const { translations } = useStore() + const [state, setState] = useState({ + touched: false, + valid: !!value, + errorMsg: errMsg || (required + ? translations?.validation?.required || 'Required' + : undefined), + }) + + const Picker = type === 'date' ? DatePicker : DateTimePicker + + const hasError = state.touched && (error || !state.valid) + + const handleChange = (_value: string | Date) => { + const isValidDate = !Number.isNaN(Date.parse(_value as string)) + const val = typeof _value === 'string' && isValidDate ? new Date(_value) : _value + let isValid = true + let errorMsg = errMsg + if (required && !val) { + isValid = false + errorMsg = errMsg || translations?.validation?.required || 'Required' + } + + setState((prev) => ({ ...prev, touched: true, valid: isValid, errorMsg })) + + onChange(name, val as Date) + } + + useEffect(() => { + if (touched) { + handleChange(value) + } + // eslint-disable-next-line + }, [touched]) + + return ( + + { + handleChange(e as Date) + }} + minutesStep={5} + slotProps={{ + textField: { + variant, + helperText: hasError && state.errorMsg, + error: hasError, + fullWidth: true, + }, + }} + /> + + ) +} + +export { EditorDatePicker } diff --git a/backend/src/components/scheduler/components/inputs/Input.tsx b/backend/src/components/scheduler/components/inputs/Input.tsx new file mode 100644 index 00000000..9d009623 --- /dev/null +++ b/backend/src/components/scheduler/components/inputs/Input.tsx @@ -0,0 +1,108 @@ +import React, { useState, useEffect } from 'react' +import { TextField, Typography } from '@mui/material' +import useStore from '../../hooks/useStore' + +interface EditorInputProps { + variant?: 'standard' | 'filled' | 'outlined'; + label?: string; + placeholder?: string; + required?: boolean; + min?: number; + max?: number; + email?: boolean; + decimal?: boolean; + disabled?: boolean; + multiline?: boolean; + rows?: number; + value: string; + name: string; + onChange(name: string, value: string, isValid: boolean): void; + touched?: boolean; +} + +const EditorInput = ({ + variant = 'outlined', + label, + placeholder, + value, + name, + required, + min, + max, + email, + decimal, + onChange, + disabled, + multiline, + rows, + touched, +}: EditorInputProps) => { + const [state, setState] = useState({ + touched: false, + valid: false, + errorMsg: '', + }) + const { translations } = useStore() + + const handleChange = (_value: string) => { + const val = _value + let isValid = true + let errorMsg = '' + if (email) { + const reg = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ + isValid = reg.test(val) && isValid + errorMsg = translations?.validation?.invalidEmail || 'Invalid Email' + } + if (decimal) { + const reg = /^[0-9]+(\.[0-9]*)?$/ + isValid = reg.test(val) && isValid + errorMsg = translations?.validation?.onlyNumbers || 'Only Numbers Allowed' + } + if (min && `${val}`.trim().length < min) { + isValid = false + errorMsg = typeof translations?.validation?.min === 'function' + ? translations?.validation?.min(min) + : translations?.validation?.min || `Minimum ${min} letters` + } + if (max && `${val}`.trim().length > max) { + isValid = false + errorMsg = typeof translations?.validation?.max === 'function' + ? translations?.validation?.max(max) + : translations?.validation?.max || `Maximum ${max} letters` + } + if (required && `${val}`.trim().length <= 0) { + isValid = false + errorMsg = translations?.validation?.required || 'Required' + } + setState({ touched: true, valid: isValid, errorMsg }) + onChange(name, val, isValid) + } + + useEffect(() => { + if (touched) { + handleChange(value) + } + // eslint-disable-next-line + }, [touched]); + + return ( + {`${label} ${required ? '*' : ''}`}} + value={value} + name={name} + onChange={(e) => handleChange(e.target.value)} + disabled={disabled} + error={state.touched && !state.valid} + helperText={state.touched && !state.valid && state.errorMsg} + multiline={multiline} + rows={rows} + style={{ width: '100%' }} + InputProps={{ + placeholder: placeholder || '', + }} + /> + ) +} + +export { EditorInput } diff --git a/backend/src/components/scheduler/components/inputs/SelectInput.tsx b/backend/src/components/scheduler/components/inputs/SelectInput.tsx new file mode 100644 index 00000000..0644f165 --- /dev/null +++ b/backend/src/components/scheduler/components/inputs/SelectInput.tsx @@ -0,0 +1,158 @@ +import React, { useState, useEffect } from 'react' +import { + FormControl, + FormHelperText, + MenuItem, + Checkbox, + useTheme, + Chip, + Typography, + CircularProgress, + InputLabel, + Select, +} from '@mui/material' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import useStore from '../../hooks/useStore' + +export type SelectOption = { + id: string | number; + text: string; + value: any; +}; +interface EditorSelectProps { + options: Array; + value: string; + name: string; + onChange(name: string, value: string, isValid: boolean): void; + variant?: 'standard' | 'filled' | 'outlined'; + label?: string; + placeholder?: string; + required?: boolean; + disabled?: boolean; + touched?: boolean; + loading?: boolean; + multiple?: 'default' | 'chips'; + errMsg?: string; +} + +const LoadingIcon = () => + +const EditorSelect = ({ + options, + value, + name, + required, + onChange, + label, + disabled, + touched, + variant = 'outlined', + loading, + multiple, + placeholder, + errMsg, +}: EditorSelectProps) => { + const theme = useTheme() + const { translations } = useStore() + const [state, setState] = useState({ + touched: false, + valid: !!value, + errorMsg: errMsg || (required + ? translations?.validation?.required || 'Required' + : undefined), + }) + + const handleChange = (_value: string | any) => { + const val = _value + let isValid = true + let errorMsg = errMsg + if (required && (multiple ? !val.length : !val)) { + isValid = false + errorMsg = errMsg || translations?.validation?.required || 'Required' + } + setState((prev) => ({ ...prev, touched: true, valid: isValid, errorMsg })) + onChange(name, val, isValid) + } + + useEffect(() => { + if (touched) { + handleChange(value) + } + // eslint-disable-next-line + }, [touched]); + + // const handleTouched = () => { + // if (!state.touched) { + // setState((prev) => ({ ...prev, touched: true, errorMsg: errMsg || prev.errorMsg })) + // } + // } + + return ( + <> + + {label && ( + + {`${label} ${required ? '*' : ''}`} + + )} + + + + {state.touched && !state.valid && state.errorMsg} + + + ) +} + +export { EditorSelect } diff --git a/backend/src/components/scheduler/components/month/MonthTable.tsx b/backend/src/components/scheduler/components/month/MonthTable.tsx new file mode 100644 index 00000000..50bca0f1 --- /dev/null +++ b/backend/src/components/scheduler/components/month/MonthTable.tsx @@ -0,0 +1,206 @@ +import React, { Avatar, Typography, useTheme } from '@mui/material' +import { + addDays, + endOfDay, + format, + isSameDay, + isSameMonth, + isWithinInterval, + setHours, + startOfDay, + startOfMonth, +} from 'date-fns' +import { Fragment, ReactNode, useCallback } from 'react' +import { + getHourFormat, + getRecurrencesForDate, + getResourcedEvents, + isTimeZonedToday, + sortEventsByTheEarliest, +} from '../../helpers/generals' +import useStore from '../../hooks/useStore' +import useSyncScroll from '../../hooks/useSyncScroll' +import { TableGrid } from '../../styles/styles' +import { DefaultResource } from '../../types' +import Cell from '../common/Cell' +import MonthEvents from '../events/MonthEvents' + +type Props = { + daysList: Date[]; + resource?: DefaultResource; + eachWeekStart: Date[]; +}; + +const MonthTable = ({ daysList, resource, eachWeekStart }: Props) => { + const { + height, + month, + selectedDate, + events, + handleGotoDay, + resourceFields, + fields, + locale, + hourFormat, + stickyNavigation, + timeZone, + onClickMore, + } = useStore() + const { weekDays, startHour, endHour, cellRenderer, headRenderer, disableGoToDay } = month! + const { headersRef, bodyRef } = useSyncScroll() + + const theme = useTheme() + const monthStart = startOfMonth(selectedDate) + const hFormat = getHourFormat(hourFormat) + const CELL_HEIGHT = height / eachWeekStart.length + + const renderCells = useCallback( + (_resource?: DefaultResource) => { + let resourcedEvents = sortEventsByTheEarliest(events) + if (_resource) { + resourcedEvents = getResourcedEvents(events, _resource, resourceFields, fields) + } + const rows: ReactNode[] = [] + const cellHeights: number[] = [] + + for (const startDay of eachWeekStart) { + const cells = weekDays.map((d) => { + const today = addDays(startDay, d) + const start = new Date(`${format(setHours(today, startHour), `yyyy/MM/dd ${hFormat}`)}`) + const end = new Date(`${format(setHours(today, endHour), `yyyy/MM/dd ${hFormat}`)}`) + const field = resourceFields.idField + const eachFirstDayInCalcRow = isSameDay(startDay, today) ? today : null + const todayEvents = resourcedEvents + .flatMap((e) => getRecurrencesForDate(e, today)) + .filter((e) => { + if (isSameDay(e.start, today)) return true + const dayInterval = { start: startOfDay(e.start), end: endOfDay(e.end) } + if (eachFirstDayInCalcRow && isWithinInterval(eachFirstDayInCalcRow, dayInterval)) return true + return false + }) + const isToday = isTimeZonedToday({ dateLeft: today, timeZone }) + const _cellHeight = 27 + 26 * todayEvents.length + 17 + 12 + 10 + cellHeights.push(_cellHeight) + // const cellHeight = Math.max(CELL_HEIGHT, ...cellHeights) + const cellHeight = CELL_HEIGHT + return ( + + + <> + {typeof headRenderer === 'function' ? ( +
{headRenderer(today)}
+ ) : ( + + { + e.stopPropagation() + if (!disableGoToDay) { + handleGotoDay(today) + } + }} + > + {format(today, 'dd')} + + + )} + + { + if (onClickMore && typeof onClickMore === 'function') { + onClickMore(e, handleGotoDay) + } else { + handleGotoDay(e) + } + }} + cellHeight={cellHeight} + /> + +
+ ) + }) + + rows.push({cells}) + } + return rows + }, + [ + CELL_HEIGHT, + cellRenderer, + daysList, + disableGoToDay, + eachWeekStart, + endHour, + events, + fields, + hFormat, + handleGotoDay, + headRenderer, + monthStart, + onClickMore, + resourceFields, + selectedDate, + startHour, + theme.palette.secondary.contrastText, + timeZone, + weekDays, + ] + ) + + return ( + <> + {/* Header Days */} + + {daysList.map((date) => ( + + {format(date, 'EE', { locale })} + + ))} + + {/* Time Cells */} + + {renderCells(resource)} + + + ) +} + +export default MonthTable diff --git a/backend/src/components/scheduler/components/nav/DayDateBtn.tsx b/backend/src/components/scheduler/components/nav/DayDateBtn.tsx new file mode 100644 index 00000000..4e9acc08 --- /dev/null +++ b/backend/src/components/scheduler/components/nav/DayDateBtn.tsx @@ -0,0 +1,68 @@ +import React, { useState } from 'react' +import { DateCalendar } from '@mui/x-date-pickers' +import { Button, Popover } from '@mui/material' +import { format, addDays } from 'date-fns' +import DateProvider from '../hoc/DateProvider' +import { LocaleArrow } from '../common/LocaleArrow' +import useStore from '../../hooks/useStore' + +interface DayDateBtnProps { + selectedDate: Date; + onChange(value: Date): void; +} + +const DayDateBtn = ({ selectedDate, onChange }: DayDateBtnProps) => { + const { locale, navigationPickerProps } = useStore() + const [anchorEl, setAnchorEl] = useState(null) + + const handleOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget) + } + const handleClose = () => { + setAnchorEl(null) + } + + const handleChange = (e: Date | null) => { + onChange(e || new Date()) + handleClose() + } + + const handlePrev = () => { + const prevDay = addDays(selectedDate, -1) + onChange(prevDay) + } + const handleNext = () => { + const nexDay = addDays(selectedDate, 1) + onChange(nexDay) + } + return ( + <> + + + + + + + + + + ) +} + +export { DayDateBtn } diff --git a/backend/src/components/scheduler/components/nav/MonthDateBtn.tsx b/backend/src/components/scheduler/components/nav/MonthDateBtn.tsx new file mode 100644 index 00000000..a2437505 --- /dev/null +++ b/backend/src/components/scheduler/components/nav/MonthDateBtn.tsx @@ -0,0 +1,68 @@ +import React, { useState } from 'react' +import { DateCalendar } from '@mui/x-date-pickers' +import { Button, Popover } from '@mui/material' +import { format, getMonth, setMonth } from 'date-fns' +import DateProvider from '../hoc/DateProvider' +import { LocaleArrow } from '../common/LocaleArrow' +import useStore from '../../hooks/useStore' + +interface MonthDateBtnProps { + selectedDate: Date; + onChange(value: Date): void; +} + +const MonthDateBtn = ({ selectedDate, onChange }: MonthDateBtnProps) => { + const { locale, navigationPickerProps } = useStore() + const currentMonth = getMonth(selectedDate) + const [anchorEl, setAnchorEl] = useState(null) + + const handleOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget) + } + const handleClose = () => { + setAnchorEl(null) + } + + const handleChange = (e: Date | null) => { + onChange(e || new Date()) + handleClose() + } + const handlePrev = () => { + const prevMonth = currentMonth - 1 + onChange(setMonth(selectedDate, prevMonth)) + } + const handleNext = () => { + const nextMonth = currentMonth + 1 + onChange(setMonth(selectedDate, nextMonth)) + } + return ( + <> + + + + + + + + + + ) +} + +export { MonthDateBtn } diff --git a/backend/src/components/scheduler/components/nav/Navigation.tsx b/backend/src/components/scheduler/components/nav/Navigation.tsx new file mode 100644 index 00000000..6981b23e --- /dev/null +++ b/backend/src/components/scheduler/components/nav/Navigation.tsx @@ -0,0 +1,195 @@ +import React, { Fragment, useState } from 'react' +import { + Button, + useTheme, + useMediaQuery, + Popover, + MenuList, + MenuItem, + IconButton, +} from '@mui/material' +import MoreVertIcon from '@mui/icons-material/MoreVert' +import ViewAgendaIcon from '@mui/icons-material/ViewAgenda' +import { WeekDateBtn } from './WeekDateBtn' +import { DayDateBtn } from './DayDateBtn' +import { MonthDateBtn } from './MonthDateBtn' +import useStore from '../../hooks/useStore' +import { NavigationDiv } from '../../styles/styles' +import { getTimeZonedDate } from '../../helpers/generals' + +export type View = 'month' | 'week' | 'day'; + +const Navigation = () => { + const { + selectedDate, + view, + week, + handleState, + getViews, + translations, + navigation, + day, + month, + disableViewNavigator, + onSelectedDateChange, + onViewChange, + stickyNavigation, + timeZone, + agenda, + toggleAgenda, + enableAgenda, + } = useStore() + const [anchorEl, setAnchorEl] = useState(null) + const theme = useTheme() + const isDesktop = useMediaQuery(theme.breakpoints.up('sm')) + const views = getViews() + + const toggleMoreMenu = (el?: Element) => { + setAnchorEl(el || null) + } + + const handleSelectedDateChange = (date: Date) => { + handleState(date, 'selectedDate') + + if (onSelectedDateChange && typeof onSelectedDateChange === 'function') { + onSelectedDateChange(date) + } + } + + const handleChangeView = (_view: View) => { + handleState(_view, 'view') + if (onViewChange && typeof onViewChange === 'function') { + onViewChange(_view, agenda) + } + } + + const renderDateSelector = () => { + switch (view) { + case 'month': + return ( + month?.navigation && ( + + ) + ) + case 'week': + return ( + week?.navigation && ( + + ) + ) + case 'day': + return ( + day?.navigation && ( + + ) + ) + default: + return '' + } + } + + if (!navigation && disableViewNavigator) return null + + return ( + +
{navigation && renderDateSelector()}
+ +
+ + {enableAgenda + && (isDesktop ? ( + + ) : ( + + + + ))} + + {views.length > 1 + && (isDesktop ? ( + views.map((v) => ( + + )) + ) : ( + <> + { + toggleMoreMenu(e.currentTarget) + }} + > + + + { + toggleMoreMenu() + }} + anchorOrigin={{ + vertical: 'center', + horizontal: 'center', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'center', + }} + > + + {views.map((v) => ( + { + toggleMoreMenu() + handleChangeView(v) + }} + > + {translations.navigation[v]} + + ))} + + + + ))} +
+
+ ) +} + +export { Navigation } diff --git a/backend/src/components/scheduler/components/nav/WeekDateBtn.tsx b/backend/src/components/scheduler/components/nav/WeekDateBtn.tsx new file mode 100644 index 00000000..1af046db --- /dev/null +++ b/backend/src/components/scheduler/components/nav/WeekDateBtn.tsx @@ -0,0 +1,77 @@ +import React, { useState } from 'react' +import { Button, Popover } from '@mui/material' +import { endOfWeek, format, startOfWeek, addDays } from 'date-fns' +import { DateCalendar } from '@mui/x-date-pickers' +import DateProvider from '../hoc/DateProvider' +import { WeekProps } from '../../views/Week' +import { LocaleArrow } from '../common/LocaleArrow' +import useStore from '../../hooks/useStore' + +interface WeekDateBtnProps { + selectedDate: Date; + onChange(value: Date): void; + weekProps: WeekProps; +} + +const WeekDateBtn = ({ selectedDate, onChange, weekProps }: WeekDateBtnProps) => { + const { locale, navigationPickerProps } = useStore() + const [anchorEl, setAnchorEl] = useState(null) + const { weekStartOn } = weekProps + const weekStart = startOfWeek(selectedDate, { weekStartsOn: weekStartOn }) + const weekEnd = endOfWeek(selectedDate, { weekStartsOn: weekStartOn }) + + const handleOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget) + } + const handleClose = () => { + setAnchorEl(null) + } + + const handleChange = (e: Date | null) => { + onChange(e || new Date()) + handleClose() + } + + const handlePrev = () => { + const ladtDayPrevWeek = addDays(weekStart, -1) + onChange(ladtDayPrevWeek) + } + + const handleNext = () => { + const firstDayNextWeek = addDays(weekEnd, 1) + onChange(firstDayNextWeek) + } + + return ( + <> + + + + + + + + + + ) +} + +export { WeekDateBtn } diff --git a/backend/src/components/scheduler/components/week/WeekTable.tsx b/backend/src/components/scheduler/components/week/WeekTable.tsx new file mode 100644 index 00000000..5feaf1fd --- /dev/null +++ b/backend/src/components/scheduler/components/week/WeekTable.tsx @@ -0,0 +1,204 @@ +import React, { Fragment, useMemo } from 'react' +import { + addMinutes, + endOfDay, + format, + isAfter, + isBefore, + isSameDay, + isToday, + startOfDay, +} from 'date-fns' +import { Typography } from '@mui/material' +import useStore from '../../hooks/useStore' +import { TableGrid } from '../../styles/styles' +import { + differenceInDaysOmitTime, + filterMultiDaySlot, + filterTodayEvents, + getHourFormat, +} from '../../helpers/generals' +import { MULTI_DAY_EVENT_HEIGHT } from '../../helpers/constants' +import { DefaultResource, ProcessedEvent } from '../../types' +import useSyncScroll from '../../hooks/useSyncScroll' +import TodayTypo from '../common/TodayTypo' +import usePosition from '../../positionManger/usePosition' +import EventItem from '../events/EventItem' +import TodayEvents from '../events/TodayEvents' +import Cell from '../common/Cell' + +type Props = { + daysList: Date[]; + hours: Date[]; + cellHeight: number; + minutesHeight: number; + resource?: DefaultResource; + resourcedEvents: ProcessedEvent[]; +}; + +const WeekTable = ({ + daysList, + hours, + cellHeight, + minutesHeight, + resourcedEvents, + resource, +}: Props) => { + const { + week, + events, + handleGotoDay, + resources, + resourceFields, + resourceViewMode, + direction, + locale, + hourFormat, + timeZone, + stickyNavigation, + } = useStore() + const { startHour, endHour, step, cellRenderer, disableGoToDay, headRenderer, hourRenderer } = week! + const { renderedSlots } = usePosition() + const { headersRef, bodyRef } = useSyncScroll() + const MULTI_SPACE = MULTI_DAY_EVENT_HEIGHT + const weekStart = startOfDay(daysList[0]) + const weekEnd = endOfDay(daysList[daysList.length - 1]) + const hFormat = getHourFormat(hourFormat) + + // Equalizing multi-day section height except in resource/tabs mode + const headerHeight = useMemo(() => { + const shouldEqualize = resources.length && resourceViewMode === 'default' + const allWeekMulti = filterMultiDaySlot( + shouldEqualize ? events : resourcedEvents, + daysList, + timeZone, + true + ) + return MULTI_SPACE * allWeekMulti.length + 45 + }, [ + MULTI_SPACE, + daysList, + events, + resourceViewMode, + resourcedEvents, + resources.length, + timeZone, + ]) + + const renderMultiDayEvents = ( + _events: ProcessedEvent[], + today: Date, + _resource?: DefaultResource + ) => { + const isFirstDayInWeek = isSameDay(weekStart, today) + const allWeekMulti = filterMultiDaySlot(_events, daysList, timeZone) + + const multiDays = allWeekMulti + .filter((e) => (isBefore(e.start, weekStart) ? isFirstDayInWeek : isSameDay(e.start, today))) + .sort((a, b) => b.end.getTime() - a.end.getTime()) + return multiDays.map((event) => { + const hasPrev = isBefore(startOfDay(event.start), weekStart) + const hasNext = isAfter(endOfDay(event.end), weekEnd) + const eventLength = differenceInDaysOmitTime(hasPrev ? weekStart : event.start, hasNext ? weekEnd : event.end) + + 1 + + const day = format(today, 'yyyy-MM-dd') + const resourceId = _resource ? _resource[resourceFields.idField] : 'all' + const rendered = renderedSlots?.[resourceId]?.[day] + const position = rendered?.[event.event_id] || 0 + + return ( +
+ +
+ ) + }) + } + + return ( + <> + {/* Header days */} + + + {daysList.map((date) => ( + + {typeof headRenderer === 'function' ? ( +
{headRenderer(date)}
+ ) : ( + + )} + {renderMultiDayEvents(resourcedEvents, date, resource)} +
+ ))} +
+ {/* Time Cells */} + + {hours.map((h, i) => ( + + + {typeof hourRenderer === 'function' ? ( +
{hourRenderer(format(h, hFormat, { locale }))}
+ ) : ( + {format(h, hFormat, { locale })} + )} +
+ {daysList.map((date) => { + const start = new Date(`${format(date, 'yyyy/MM/dd')} ${format(h, hFormat)}`) + const end = addMinutes(start, step) + const field = resourceFields.idField + return ( + + {/* Events of each day - run once on the top hour column */} + {i === 0 && ( + + )} + + + ) + })} +
+ ))} +
+ + ) +} + +export default WeekTable diff --git a/backend/src/components/scheduler/helpers/constants.ts b/backend/src/components/scheduler/helpers/constants.ts new file mode 100644 index 00000000..b9da170a --- /dev/null +++ b/backend/src/components/scheduler/helpers/constants.ts @@ -0,0 +1,4 @@ +export const BORDER_HEIGHT = 1 +export const MULTI_DAY_EVENT_HEIGHT = 28 +export const MONTH_NUMBER_HEIGHT = 27 +export const MONTH_BAR_HEIGHT = 23 diff --git a/backend/src/components/scheduler/helpers/generals.tsx b/backend/src/components/scheduler/helpers/generals.tsx new file mode 100644 index 00000000..ced5d91b --- /dev/null +++ b/backend/src/components/scheduler/helpers/generals.tsx @@ -0,0 +1,304 @@ +import { + addDays, + addMilliseconds, + addMinutes, + addSeconds, + differenceInDays, + differenceInMilliseconds, + endOfDay, + format, + isSameDay, + isWithinInterval, + startOfDay, + subMinutes, +} from 'date-fns' +import { datetime } from 'rrule' +import { View } from '../components/nav/Navigation' +import { + DefaultResource, + FieldProps, + ProcessedEvent, + ResourceFields, + SchedulerProps, +} from '../types' +import { StateEvent } from '../views/Editor' + +export const getOneView = (state: Partial): View => { + if (state.month) { + return 'month' + } if (state.week) { + return 'week' + } if (state.day) { + return 'day' + } + throw new Error('No views were selected') +} + +export const getAvailableViews = (state: SchedulerProps) => { + const views: View[] = [] + if (state.month) { + views.push('month') + } + if (state.week) { + views.push('week') + } + if (state.day) { + views.push('day') + } + return views +} + +export const arraytizeFieldVal = (field: FieldProps, val: any, event?: StateEvent) => { + const arrytize = field.config?.multiple && !Array.isArray(event?.[field.name] || field.default) + const value = arrytize ? (val ? [val] : []) : val + const validity = arrytize ? value.length : value + return { value, validity } +} + +export const getResourcedEvents = ( + events: ProcessedEvent[], + resource: DefaultResource, + resourceFields: ResourceFields, + fields: FieldProps[] +): ProcessedEvent[] => { + const keyName = resourceFields.idField + const resourceField = fields.find((f) => f.name === keyName) + const isMultiple = !!resourceField?.config?.multiple + + const resourcedEvents = [] + + for (const event of events) { + // Handle single select & multiple select accordingly + const arrytize = isMultiple && !Array.isArray(event[keyName]) + const eventVal = arrytize ? [event[keyName]] : event[keyName] + + const isThisResource = isMultiple || Array.isArray(eventVal) + ? eventVal.includes(resource[keyName]) + : eventVal === resource[keyName] + + if (isThisResource) { + resourcedEvents.push({ + ...event, + color: event.color || resource[resourceFields.colorField || ''], + }) + } + } + + return resourcedEvents +} + +export const traversCrossingEvents = ( + todayEvents: ProcessedEvent[], + event: ProcessedEvent +): ProcessedEvent[] => todayEvents.filter( + (e) => + e.event_id !== event.event_id + && (isWithinInterval(addMinutes(event.start, 1), { + start: e.start, + end: e.end, + }) + || isWithinInterval(addMinutes(event.end, -1), { + start: e.start, + end: e.end, + }) + || isWithinInterval(addMinutes(e.start, 1), { + start: event.start, + end: event.end, + }) + || isWithinInterval(addMinutes(e.end, -1), { + start: event.start, + end: event.end, + })) +) + +export const calcMinuteHeight = (cellHeight: number, step: number) => Math.ceil(cellHeight) / step + +export const calcCellHeight = (tableHeight: number, hoursLength: number) => Math.max(tableHeight / hoursLength, 60) + +export const differenceInDaysOmitTime = (start: Date, end: Date) => differenceInDays(endOfDay(addSeconds(end, -1)), startOfDay(start)) + +export const convertDateToRRuleDate = (date: Date) => datetime( + date.getFullYear(), + date.getMonth() + 1, + date.getDate(), + date.getHours(), + date.getMinutes() +) + +export const convertRRuleDateToDate = (rruleDate: Date) => new Date( + rruleDate.getUTCFullYear(), + rruleDate.getUTCMonth(), + rruleDate.getUTCDate(), + rruleDate.getUTCHours(), + rruleDate.getUTCMinutes() +) + +export const getTimeZonedDate = (date: Date, timeZone?: string) => new Date( + new Intl.DateTimeFormat('en-US', { + dateStyle: 'short', + timeStyle: 'medium', + timeZone, + }).format(date) +) + +export const convertEventTimeZone = (event: ProcessedEvent, timeZone?: string) => ({ + ...event, + start: getTimeZonedDate(event.start, timeZone), + end: getTimeZonedDate(event.end, timeZone), + convertedTz: true, +}) + +export const getRecurrencesForDate = (event: ProcessedEvent, today: Date, timeZone?: string) => { + const duration = differenceInMilliseconds(event.end, event.start) + if (event.recurring) { + return event.recurring + ?.between(today, addDays(today, 1), true) + .map((d: Date, index: number) => { + const start = convertRRuleDateToDate(d) + return { + ...event, + recurrenceId: index, + start, + end: addMilliseconds(start, duration), + } + }) + .map((_event) => convertEventTimeZone(_event, timeZone)) + } + return [convertEventTimeZone(event, timeZone)] +} + +export const sortEventsByTheLengthest = (events: ProcessedEvent[]) => events.sort((a, b) => { + const aDiff = a.end.getTime() - a.start.getTime() + const bDiff = b.end.getTime() - b.start.getTime() + return bDiff - aDiff +}) + +export const sortEventsByTheEarliest = (events: ProcessedEvent[]) => events.sort((a, b) => { + const isMulti = a.allDay || differenceInDaysOmitTime(a.start, a.end) > 0 + return isMulti ? -1 : a.start.getTime() - b.start.getTime() +}) + +export const filterTodayEvents = ( + events: ProcessedEvent[], + today: Date, + timeZone?: string +): ProcessedEvent[] => { + const list: ProcessedEvent[] = [] + + for (let i = 0; i < events.length; i += 1) { + for (const rec of getRecurrencesForDate(events[i], today, timeZone)) { + const isToday = !rec.allDay && isSameDay(today, rec.start) && !differenceInDaysOmitTime(rec.start, rec.end) + if (isToday) { + list.push(rec) + } + } + } + + // Sort by the length est event + return sortEventsByTheLengthest(list) +} + +export const filterTodayAgendaEvents = (events: ProcessedEvent[], today: Date) => { + const list: ProcessedEvent[] = events.filter((ev) => + isWithinInterval(today, { + start: startOfDay(ev.start), + end: endOfDay(subMinutes(ev.end, 1)), + })) + + return sortEventsByTheEarliest(list) +} + +export const filterMultiDaySlot = ( + events: ProcessedEvent[], + date: Date | Date[], + timeZone?: string, + lengthOnly?: boolean +) => { + const isMultiDates = Array.isArray(date) + const list: ProcessedEvent[] = [] + const multiPerDay: Record = {} + for (let i = 0; i < events.length; i += 1) { + const event = convertEventTimeZone(events[i], timeZone) + let withinSlot = event.allDay || differenceInDaysOmitTime(event.start, event.end) > 0 + + if (withinSlot) { + if (isMultiDates) { + withinSlot = date.some((weekday) => + isWithinInterval(weekday, { + start: startOfDay(event.start), + end: endOfDay(event.end), + })) + } else { + withinSlot = isWithinInterval(date, { + start: startOfDay(event.start), + end: endOfDay(event.end), + }) + } + + list.push(event) + if (isMultiDates) { + for (const d of date) { + const start = format(d, 'yyyy-MM-dd') + if (isWithinInterval(d, { start: startOfDay(event.start), end: endOfDay(event.end) })) { + multiPerDay[start] = (multiPerDay[start] || []).concat(event) + } + } + } else { + const start = format(event.start, 'yyyy-MM-dd') + multiPerDay[start] = (multiPerDay[start] || []).concat(event) + } + } + } + + if (isMultiDates && lengthOnly) { + return Object.values(multiPerDay).sort((a, b) => b.length - a.length)?.[0] || [] + } + + return list +} + +/** + * Gets the offset in minutes of the provided timeZone. + * @param timeZone The timeZone to get the offset for. + * @returns The offset in minutes of the provided timeZone. + */ +function getTimezoneOffset(timeZone: string) { + const now = new Date() + const localizedTime = new Date(now.toLocaleString('en-US', { timeZone })) + const utcTime = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' })) + return Math.round((localizedTime.getTime() - utcTime.getTime()) / (60 * 1000)) +} + +/** + * Performs the reverse of getTimeZonedDate, IE: the given date is assumed + * to already be in the provided timeZone and is reverted to the local + * browser's timeZone. + * @param date The date to convert. + * @param timeZone The timeZone to convert from. + * @returns A new date reverted from the given timeZone to local time. + */ +export const revertTimeZonedDate = (date: Date, timeZone?: string) => { + if (!timeZone) { + return date + } + + // This always gets the offset between the local computer's time + // and UTC. It has nothing to do with the value of the date object, + // despite being an instance method. + const localOffset = -date.getTimezoneOffset() + const desiredOffset = getTimezoneOffset(timeZone) + const diff = localOffset - desiredOffset + return new Date(date.getTime() + diff * 60 * 1000) +} + +export const isTimeZonedToday = ({ + dateLeft, + dateRight, + timeZone, +}: { + dateLeft: Date; + dateRight?: Date; + timeZone?: string; +}) => isSameDay(dateLeft, getTimeZonedDate(dateRight || new Date(), timeZone)) + +export const getHourFormat = (hourFormat: '12' | '24') => (hourFormat === '12' ? 'hh:mm a' : 'HH:mm') diff --git a/backend/src/components/scheduler/hooks/useCellAttributes.ts b/backend/src/components/scheduler/hooks/useCellAttributes.ts new file mode 100644 index 00000000..775b6572 --- /dev/null +++ b/backend/src/components/scheduler/hooks/useCellAttributes.ts @@ -0,0 +1,67 @@ +import { DragEvent } from 'react' +import { alpha, useTheme } from '@mui/material' +import useStore from './useStore' +import { revertTimeZonedDate } from '../helpers/generals' + +interface Props { + start: Date; + end: Date; + resourceKey: string; + resourceVal: string | number; +} +export const useCellAttributes = ({ start, end, resourceKey, resourceVal }: Props) => { + const { + triggerDialog, + onCellClick, + onDrop, + currentDragged, + setCurrentDragged, + editable, + timeZone, + } = useStore() + const theme = useTheme() + + return { + tabIndex: editable ? 0 : -1, + disableRipple: !editable, + onClick: () => { + if (editable) { + triggerDialog(true, { + start, + end, + [resourceKey]: resourceVal, + }) + } + + if (onCellClick && typeof onCellClick === 'function') { + onCellClick(start, end, resourceKey, resourceVal) + } + }, + onDragOver: (e: DragEvent) => { + e.preventDefault() + if (currentDragged) { + e.currentTarget.style.backgroundColor = alpha(theme.palette.secondary.main, 0.3) + } + }, + onDragEnter: (e: DragEvent) => { + if (currentDragged) { + e.currentTarget.style.backgroundColor = alpha(theme.palette.secondary.main, 0.3) + } + }, + onDragLeave: (e: DragEvent) => { + if (currentDragged) { + e.currentTarget.style.backgroundColor = '' + } + }, + onDrop: (e: DragEvent) => { + if (currentDragged && currentDragged.event_id) { + e.preventDefault() + e.currentTarget.style.backgroundColor = '' + const zonedStart = revertTimeZonedDate(start, timeZone) + onDrop(e, currentDragged.event_id.toString(), zonedStart, resourceKey, resourceVal) + setCurrentDragged() + } + }, + [resourceKey]: resourceVal, + } +} diff --git a/backend/src/components/scheduler/hooks/useDragAttributes.ts b/backend/src/components/scheduler/hooks/useDragAttributes.ts new file mode 100644 index 00000000..55d6d51b --- /dev/null +++ b/backend/src/components/scheduler/hooks/useDragAttributes.ts @@ -0,0 +1,31 @@ +import { DragEvent } from 'react' +import { useTheme } from '@mui/material' +import { ProcessedEvent } from '../types' +import useStore from './useStore' + +const useDragAttributes = (event: ProcessedEvent) => { + const { setCurrentDragged } = useStore() + const theme = useTheme() + return { + draggable: true, + onDragStart: (e: DragEvent) => { + e.stopPropagation() + setCurrentDragged(event) + e.currentTarget.style.backgroundColor = theme.palette.error.main + }, + onDragEnd: (e: DragEvent) => { + setCurrentDragged() + e.currentTarget.style.backgroundColor = event.color || theme.palette.primary.main + }, + onDragOver: (e: DragEvent) => { + e.stopPropagation() + e.preventDefault() + }, + onDragEnter: (e: DragEvent) => { + e.stopPropagation() + e.preventDefault() + }, + } +} + +export default useDragAttributes diff --git a/backend/src/components/scheduler/hooks/useEventPermissions.ts b/backend/src/components/scheduler/hooks/useEventPermissions.ts new file mode 100644 index 00000000..38495b5f --- /dev/null +++ b/backend/src/components/scheduler/hooks/useEventPermissions.ts @@ -0,0 +1,42 @@ +import { useMemo } from 'react' +import { ProcessedEvent } from '../types' +import useStore from './useStore' + +const useEventPermissions = (event: ProcessedEvent) => { + const { editable, deletable, draggable } = useStore() + + const canEdit = useMemo(() => { + // Priority control to event specific editable value + if (typeof event.editable !== 'undefined') { + return event.editable + } + return editable + }, [editable, event.editable]) + + const canDelete = useMemo(() => { + // Priority control to event specific deletable value + if (typeof event.deletable !== 'undefined') { + return event.deletable + } + return deletable + }, [deletable, event.deletable]) + + const canDrag = useMemo(() => { + if (!canEdit) { + return false + } + // Priority control to event specific draggable value + if (typeof event.draggable !== 'undefined') { + return event.draggable + } + return draggable + }, [draggable, event.draggable, canEdit]) + + return { + canEdit, + canDelete, + canDrag, + } +} + +export default useEventPermissions diff --git a/backend/src/components/scheduler/hooks/useStore.ts b/backend/src/components/scheduler/hooks/useStore.ts new file mode 100644 index 00000000..52eb712d --- /dev/null +++ b/backend/src/components/scheduler/hooks/useStore.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react' +import { StoreContext } from '../store/context' + +const useStore = () => useContext(StoreContext) + +export default useStore diff --git a/backend/src/components/scheduler/hooks/useSyncScroll.ts b/backend/src/components/scheduler/hooks/useSyncScroll.ts new file mode 100644 index 00000000..4ad02e9d --- /dev/null +++ b/backend/src/components/scheduler/hooks/useSyncScroll.ts @@ -0,0 +1,31 @@ +import { useEffect, useRef } from 'react' + +/** + * The solution to make headers sticky with overflow + */ +const useSyncScroll = () => { + const headersRef = useRef(null) + const bodyRef = useRef(null) + + useEffect(() => { + const header = headersRef.current + const body = bodyRef.current + const handleScroll = (event: Event) => { + const el = event.currentTarget as HTMLElement + body?.scroll({ left: el.scrollLeft }) + header?.scroll({ left: el.scrollLeft }) + } + + header?.addEventListener('scroll', handleScroll) + body?.addEventListener('scroll', handleScroll) + + return () => { + header?.removeEventListener('scroll', handleScroll) + body?.removeEventListener('scroll', handleScroll) + } + }) + + return { headersRef, bodyRef } +} + +export default useSyncScroll diff --git a/backend/src/components/scheduler/hooks/useWindowResize.ts b/backend/src/components/scheduler/hooks/useWindowResize.ts new file mode 100644 index 00000000..453ffda9 --- /dev/null +++ b/backend/src/components/scheduler/hooks/useWindowResize.ts @@ -0,0 +1,37 @@ +import { useState, useEffect } from 'react' + +export function useWindowResize() { + const [state, setState] = useState({ + width: 0, + height: 0, + }) + + useEffect(() => { + const handler = () => { + setState((_state) => { + const { innerWidth, innerHeight } = window + // Check state for change, return same state if no change happened to prevent rerender + return _state.width !== innerWidth || _state.height !== innerHeight + ? { + width: innerWidth, + height: innerHeight, + } + : _state + }) + } + + if (typeof window !== 'undefined') { + handler() + window.addEventListener('resize', handler, { + capture: false, + passive: true, + }) + } + + return () => { + window.removeEventListener('resize', handler) + } + }, []) + + return state +} diff --git a/backend/src/components/scheduler/index.tsx b/backend/src/components/scheduler/index.tsx new file mode 100644 index 00000000..5730b1ef --- /dev/null +++ b/backend/src/components/scheduler/index.tsx @@ -0,0 +1,12 @@ +import React, { forwardRef } from 'react' +import SchedulerComponent from './SchedulerComponent' +import { SchedulerProps, SchedulerRef } from './types' +import { StoreProvider } from './store/provider' + +const Scheduler = forwardRef>((props, ref) => ( + + + + )) + +export { Scheduler } diff --git a/backend/src/components/scheduler/positionManger/context.ts b/backend/src/components/scheduler/positionManger/context.ts new file mode 100644 index 00000000..30e6f579 --- /dev/null +++ b/backend/src/components/scheduler/positionManger/context.ts @@ -0,0 +1,14 @@ +import { createContext } from 'react' + +export type PositionManagerState = { + renderedSlots: { [day: string]: { [resourceId: string]: { [eventId: string]: number } } }; +}; + +type PositionManagerProps = { + setRenderedSlot(day: string, eventId: string, position: number, resourceId?: string): void; +}; + +export const PositionContext = createContext({ + renderedSlots: {}, + setRenderedSlot: () => {}, +}) diff --git a/backend/src/components/scheduler/positionManger/provider.tsx b/backend/src/components/scheduler/positionManger/provider.tsx new file mode 100644 index 00000000..13a48f8b --- /dev/null +++ b/backend/src/components/scheduler/positionManger/provider.tsx @@ -0,0 +1,102 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { eachDayOfInterval, format } from 'date-fns' +import { PositionManagerState, PositionContext } from './context' +import useStore from '../hooks/useStore' +import { DefaultResource, FieldProps, ProcessedEvent, ResourceFields } from '../types' +import { getResourcedEvents, sortEventsByTheEarliest } from '../helpers/generals' + +type Props = { + children: React.ReactNode; +}; + +const setEventPositions = (events: ProcessedEvent[]) => { + const slots: PositionManagerState['renderedSlots'][string] = {} + let position = 0 + for (let i = 0; i < events.length; i += 1) { + const event = events[i] + const eventLength = eachDayOfInterval({ start: event.start, end: event.end }) + for (let j = 0; j < eventLength.length; j += 1) { + const day = format(eventLength[j], 'yyyy-MM-dd') + if (slots[day]) { + const positions = Object.values(slots[day]) + while (positions.includes(position)) { + position += 1 + } + slots[day][event.event_id] = position + } else { + // slots[day] = { [event.event_id]: position } + slots[day] = { [event.event_id]: 0 } + } + } + + // rest + position = 0 + } + + return slots +} + +const setEventPositionsWithResources = ( + events: ProcessedEvent[], + resources: DefaultResource[], + rFields: ResourceFields, + fields: FieldProps[] +) => { + // const sorted = sortEventsByTheEarliest(events) + const sorted = sortEventsByTheEarliest(Array.from(events)) + const slots: PositionManagerState['renderedSlots'] = {} + if (resources.length) { + for (const resource of resources) { + const resourcedEvents = getResourcedEvents(sorted, resource, rFields, fields) + const positions = setEventPositions(resourcedEvents) + slots[resource[rFields.idField]] = positions + } + } else { + slots.all = setEventPositions(sorted) + } + + return slots +} + +export const PositionProvider = ({ children }: Props) => { + const { events, resources, resourceFields, fields } = useStore() + const [state, set] = useState({ + renderedSlots: setEventPositionsWithResources(events, resources, resourceFields, fields), + }) + + useEffect(() => { + set((prev) => ({ + ...prev, + renderedSlots: setEventPositionsWithResources(events, resources, resourceFields, fields), + })) + }, [events, fields, resourceFields, resources]) + + const setRenderedSlot = useCallback((day: string, eventId: string, position: number, resourceId?: string) => { + set((prev) => ({ + ...prev, + renderedSlots: { + ...prev.renderedSlots, + [resourceId || 'all']: { + ...prev.renderedSlots?.[resourceId || 'all'], + [day]: prev.renderedSlots?.[resourceId || 'all']?.[day] + ? { + ...prev.renderedSlots?.[resourceId || 'all']?.[day], + [eventId]: position, + } + : { [eventId]: position }, + }, + }, + })) + }, []) + + const contextValue = React.useMemo(() => ({ + ...state, + setRenderedSlot, + }), [state, setRenderedSlot]) + + return ( + + {children} + + ) +} diff --git a/backend/src/components/scheduler/positionManger/usePosition.ts b/backend/src/components/scheduler/positionManger/usePosition.ts new file mode 100644 index 00000000..f8dbf677 --- /dev/null +++ b/backend/src/components/scheduler/positionManger/usePosition.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react' +import { PositionContext } from './context' + +const usePosition = () => useContext(PositionContext) + +export default usePosition diff --git a/backend/src/components/scheduler/store/context.ts b/backend/src/components/scheduler/store/context.ts new file mode 100644 index 00000000..318f6693 --- /dev/null +++ b/backend/src/components/scheduler/store/context.ts @@ -0,0 +1,5 @@ +import { createContext } from 'react' +import { initialStore } from './default' +import { Store } from './types' + +export const StoreContext = createContext(initialStore) diff --git a/backend/src/components/scheduler/store/default.ts b/backend/src/components/scheduler/store/default.ts new file mode 100644 index 00000000..dcfc8607 --- /dev/null +++ b/backend/src/components/scheduler/store/default.ts @@ -0,0 +1,150 @@ +import { Breakpoint } from '@mui/material' +import { enUS } from 'date-fns/locale' +import { HourFormat, ResourceViewMode, SchedulerDirection, SchedulerProps } from '../types' +import { getOneView, getTimeZonedDate } from '../helpers/generals' + +const defaultMonth = { + weekDays: [0, 1, 2, 3, 4, 5, 6], + weekStartOn: 6, + startHour: 9, + endHour: 17, + navigation: true, + disableGoToDay: false, +} + +const defaultWeek = { + weekDays: [0, 1, 2, 3, 4, 5, 6], + weekStartOn: 6, + startHour: 9, + endHour: 17, + step: 60, + navigation: true, + disableGoToDay: false, +} + +const defaultDay = { + startHour: 9, + endHour: 17, + step: 60, + navigation: true, +} + +const defaultResourceFields = { + idField: 'assignee', + textField: 'text', + subTextField: 'subtext', + avatarField: 'avatar', + colorField: 'color', +} + +const defaultTranslations = (trans: Partial = {}) => { + const { navigation, form, event, ...other } = trans + + return { + navigation: { + month: 'Month', + week: 'Week', + day: 'Day', + agenda: 'Agenda', + today: 'Today', + ...navigation + }, + form: { + addTitle: 'Add Event', + editTitle: 'Edit Event', + confirm: 'Confirm', + delete: 'Delete', + cancel: 'Cancel', + ...form + }, + event: { + title: 'Title', + start: 'Start', + end: 'End', + allDay: 'All Day', + ...event + }, + ...({ + moreEvents: 'More...', + loading: 'Loading...', + noDataToDisplay: 'No data to display', + ...other + }), + } +} + +const defaultViews = (props: Partial) => { + const { month, week, day } = props + return { + month: month !== null ? Object.assign(defaultMonth, month) : null, + week: week !== null ? Object.assign(defaultWeek, week) : null, + day: day !== null ? Object.assign(defaultDay, day) : null, + } +} + +export const defaultProps = (props: Partial) => { + const { + // month, + // week, + // day, + translations, + resourceFields, + view, + agenda, + selectedDate, + ...otherProps + } = props + const views = defaultViews(props) + const defaultView = view || 'week' + const initialView = views[defaultView] ? defaultView : getOneView(views) + return { + ...views, + translations: defaultTranslations(translations), + resourceFields: Object.assign(defaultResourceFields, resourceFields), + view: initialView, + selectedDate: getTimeZonedDate(selectedDate || new Date(), props.timeZone), + ...({ + height: 600, + navigation: true, + disableViewNavigator: false, + events: [], + fields: [], + loading: undefined, + customEditor: undefined, + onConfirm: undefined, + onDelete: undefined, + viewerExtraComponent: undefined, + resources: [], + resourceHeaderComponent: undefined, + resourceViewMode: 'default' as ResourceViewMode, + direction: 'ltr' as SchedulerDirection, + dialogMaxWidth: 'md' as (false | Breakpoint | undefined), + locale: enUS, + deletable: true, + editable: true, + hourFormat: '12' as HourFormat, + draggable: true, + agenda, + enableAgenda: typeof agenda === 'undefined' || agenda, + ...otherProps + }), + } +} + +export const initialStore = { + ...defaultProps({}), + setProps: () => { }, + dialog: false, + selectedRange: undefined, + selectedEvent: undefined, + selectedResource: undefined, + handleState: () => { }, + getViews: () => [], + toggleAgenda: () => { }, + triggerDialog: () => { }, + triggerLoading: () => { }, + handleGotoDay: () => { }, + confirmEvent: () => { }, + setCurrentDragged: () => { }, + onDrop: () => { }, +} diff --git a/backend/src/components/scheduler/store/provider.tsx b/backend/src/components/scheduler/store/provider.tsx new file mode 100644 index 00000000..d0359b69 --- /dev/null +++ b/backend/src/components/scheduler/store/provider.tsx @@ -0,0 +1,203 @@ +import React, { DragEvent, useEffect, useState } from 'react' +import { addMinutes, differenceInMinutes, isEqual } from 'date-fns' +import { EventActions, ProcessedEvent, SchedulerProps } from '../types' +import { defaultProps, initialStore } from './default' +import { StoreContext } from './context' +import { SchedulerState, SelectedRange, Store } from './types' +import { arraytizeFieldVal, getAvailableViews } from '../helpers/generals' +import { View } from '../components/nav/Navigation' + +type Props = { + children: React.ReactNode; + initial: Partial; +}; + +export const StoreProvider = ({ children, initial }: Props) => { + const [state, set] = useState({ ...initialStore, ...defaultProps(initial) }) + + useEffect(() => { + set((prev) => ({ + ...prev, + onEventDrop: initial.onEventDrop, + customEditor: initial.customEditor, + events: initial.events || [], + })) + // Rerender if changed on some props + }, [initial.onEventDrop, initial.customEditor, initial.events]) + + const handleState = (value: SchedulerState[keyof SchedulerState], name: keyof SchedulerState) => { + set((prev) => ({ ...prev, [name]: value })) + } + + const getViews = () => getAvailableViews(state) + + const toggleAgenda = () => { + set((prev) => { + const newStatus = !prev.agenda + + if (state.onViewChange && typeof state.onViewChange === 'function') { + state.onViewChange(state.view, newStatus) + } + + return { ...prev, agenda: newStatus } + }) + } + + const triggerDialog = (status: boolean, selected?: SelectedRange | ProcessedEvent) => { + const isEvent = selected as ProcessedEvent + + set((prev) => ({ + ...prev, + dialog: status, + selectedRange: isEvent?.event_id + ? undefined + : isEvent || { + start: new Date(), + end: new Date(Date.now() + 60 * 60 * 1000), + }, + selectedEvent: isEvent?.event_id ? isEvent : undefined, + selectedResource: prev.selectedResource || isEvent?.[state.resourceFields?.idField], + })) + } + + const triggerLoading = (status: boolean) => { + // Trigger if not out-sourced by props + if (typeof initial.loading === 'undefined') { + set((prev) => ({ ...prev, loading: status })) + } + } + + const handleGotoDay = (day: Date) => { + const currentViews = getViews() + let view: View | undefined + if (currentViews.includes('day')) { + view = 'day' + set((prev) => ({ ...prev, view: 'day', selectedDate: day })) + } else if (currentViews.includes('week')) { + view = 'week' + set((prev) => ({ ...prev, view: 'week', selectedDate: day })) + } else { + console.warn('No Day/Week views available') + } + + if (!!view && state.onViewChange && typeof state.onViewChange === 'function') { + state.onViewChange(view, state.agenda) + } + + if (!!view && state.onSelectedDateChange && typeof state.onSelectedDateChange === 'function') { + state.onSelectedDateChange(day) + } + } + + const confirmEvent = (event: ProcessedEvent | ProcessedEvent[], action: EventActions) => { + let updatedEvents: ProcessedEvent[] + if (action === 'edit') { + if (Array.isArray(event)) { + updatedEvents = state.events.map((e) => { + const exist = event.find((ex) => ex.event_id === e.event_id) + return exist ? { ...e, ...exist } : e + }) + } else { + updatedEvents = state.events.map((e) => + (e.event_id === event.event_id ? { ...e, ...event } : e)) + } + } else { + updatedEvents = state.events.concat(event) + } + set((prev) => ({ ...prev, events: updatedEvents })) + } + + const setCurrentDragged = (event?: ProcessedEvent) => { + set((prev) => ({ ...prev, currentDragged: event })) + } + + const onDrop = async ( + event: DragEvent, + eventId: string, + startTime: Date, + resKey?: string, + resVal?: string | number + ) => { + // Get dropped event + const droppedEvent = state.events.find((e) => { + if (typeof e.event_id === 'number') { + return e.event_id === +eventId + } + return e.event_id === eventId + }) as ProcessedEvent + + // Check if has resource and if is multiple + const resField = state.fields.find((f) => f.name === resKey) + const isMultiple = !!resField?.config?.multiple + let newResource = resVal as string | number | string[] | number[] + if (resField) { + const eResource = droppedEvent[resKey as string] + const currentRes = arraytizeFieldVal(resField, eResource, droppedEvent).value + if (isMultiple) { + // if dropped on already owned resource + if (currentRes.includes(resVal)) { + // Omit if dropped on same time slot for multiple event + if (isEqual(droppedEvent.start, startTime)) { + return + } + newResource = currentRes + } else { + // if have multiple resource ? add other : move to other + newResource = currentRes.length > 1 ? [...currentRes, resVal] : [resVal] + } + } + } + + // Omit if dropped on same time slot for non multiple events + if (isEqual(droppedEvent.start, startTime)) { + if (!newResource || (!isMultiple && newResource === droppedEvent[resKey as string])) { + return + } + } + + // Update event time according to original duration & update resources/owners + const diff = differenceInMinutes(droppedEvent.end, droppedEvent.start) + const updatedEvent: ProcessedEvent = { + ...droppedEvent, + start: startTime, + end: addMinutes(startTime, diff), + recurring: undefined, + [resKey as string]: newResource || '', + } + + // Local + if (!state.onEventDrop || typeof state.onEventDrop !== 'function') { + confirmEvent(updatedEvent, 'edit') + return + } + // Remote + try { + triggerLoading(true) + const _event = await state.onEventDrop(event, startTime, updatedEvent, droppedEvent) + if (_event) { + confirmEvent(_event, 'edit') + } + } finally { + triggerLoading(false) + } + } + + const contextValue = React.useMemo(() => ({ + ...state, + handleState, + getViews, + toggleAgenda, + triggerDialog, + triggerLoading, + handleGotoDay, + confirmEvent, + setCurrentDragged, + onDrop, + }), [state]) // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + {children} + + ) +} diff --git a/backend/src/components/scheduler/store/types.ts b/backend/src/components/scheduler/store/types.ts new file mode 100644 index 00000000..3f518e16 --- /dev/null +++ b/backend/src/components/scheduler/store/types.ts @@ -0,0 +1,32 @@ +import { DragEvent } from 'react' +import { View } from '../components/nav/Navigation' +import { DefaultResource, EventActions, ProcessedEvent, SchedulerProps } from '../types' + +export type SelectedRange = { start: Date; end: Date }; + +export interface SchedulerState extends SchedulerProps { + dialog: boolean; + selectedRange?: SelectedRange; + selectedEvent?: ProcessedEvent; + selectedResource?: DefaultResource['assignee']; + currentDragged?: ProcessedEvent; + enableAgenda?: boolean; +} + +export interface Store extends SchedulerState { + handleState(value: SchedulerState[keyof SchedulerState], name: keyof SchedulerState): void; + getViews(): View[]; + toggleAgenda: () => void; + triggerDialog(status: boolean, event?: SelectedRange | ProcessedEvent): void; + triggerLoading(status: boolean): void; + handleGotoDay(day: Date): void; + confirmEvent(event: ProcessedEvent | ProcessedEvent[], action: EventActions): void; + setCurrentDragged(event?: ProcessedEvent): void; + onDrop( + event: DragEvent, + eventId: string, + droppedStartTime: Date, + resourceKey?: string, + resourceVal?: string | number + ): void; +} diff --git a/backend/src/components/scheduler/styles/styles.ts b/backend/src/components/scheduler/styles/styles.ts new file mode 100644 index 00000000..91e9f1f5 --- /dev/null +++ b/backend/src/components/scheduler/styles/styles.ts @@ -0,0 +1,240 @@ +import { Paper, alpha, styled } from '@mui/material' + +export const Wrapper = styled('div')<{ dialog: number }>(({ theme, dialog }) => ({ + position: 'relative', + '& .rs__table_loading': { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + zIndex: 999999, + '& .rs__table_loading_internal': { + background: dialog ? '' : alpha(theme.palette.background.paper, 0.4), + height: '100%', + '& > span': { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + flexDirection: 'column', + '& >span': { + marginBottom: 15, + }, + }, + }, + }, +})) + +export const Table = styled('div')<{ resource_count: number }>(({ resource_count: resourceCount }) => ({ + position: 'relative', + display: 'flex', + flexDirection: resourceCount > 1 ? 'row' : 'column', + width: '100%', + boxSizing: 'content-box', + '& > div': { + flexShrink: 0, + flexGrow: 1, + }, +})) + +export const NavigationDiv = styled(Paper)<{ sticky?: string }>(({ sticky = '0' }) => ({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + position: sticky === '1' ? 'sticky' : 'relative', + top: sticky === '1' ? 0 : undefined, + zIndex: sticky === '1' ? 999 : undefined, + boxShadow: 'none', + padding: '2px 0', + '& > .rs__view_navigator': { + display: 'flex', + alignItems: 'center', + }, +})) + +export const AgendaDiv = styled('div')(({ theme }) => ({ + borderStyle: 'solid', + borderColor: theme.palette.grey[300], + borderWidth: '1px 1px 0 0', + '& > .rs__agenda_row': { + display: 'flex', + '& >.rs__agenda__cell': { + padding: 4, + width: '100%', + maxWidth: 60, + '& > .MuiTypography-root': { + position: 'sticky', + top: 0, + '&.rs__hover__op': { + cursor: 'pointer', + '&:hover': { + opacity: 0.7, + textDecoration: 'underline', + }, + }, + }, + }, + '& .rs__cell': { + borderStyle: 'solid', + borderColor: theme.palette.grey[300], + borderWidth: '0 0 1px 1px', + }, + '& > .rs__agenda_items': { + flexGrow: 1, + }, + }, +})) + +export const TableGrid = styled('div')<{ + days: number; + sticky?: string; + stickyNavigation?: boolean; + indent?: string; +}>(({ days, sticky = '0', stickyNavigation, indent = '1', theme }) => ({ + display: 'grid', + gridTemplateColumns: +indent > 0 ? `10% repeat(${days}, 1fr)` : `repeat(${days}, 1fr)`, + overflowX: 'auto', + overflowY: 'hidden', + position: sticky === '1' ? 'sticky' : 'relative', + top: sticky === '1' ? (stickyNavigation ? 36 : 0) : undefined, + zIndex: sticky === '1' ? 99 : undefined, + [theme.breakpoints.down('sm')]: { + gridTemplateColumns: +indent > 0 ? `30px repeat(${days}, 1fr)` : '', + }, + borderStyle: 'solid', + borderColor: theme.palette.grey[300], + borderWidth: '0 0 0 1px', + '&:first-of-type': { + borderWidth: '1px 0 0 1px', + }, + '&:last-of-type': { + borderWidth: '0 0 1px 1px', + }, + '& .rs__cell': { + background: theme.palette.background.paper, + position: 'relative', + borderStyle: 'solid', + borderColor: theme.palette.grey[300], + borderWidth: '0 1px 1px 0', + '&.rs__header': { + '& > :first-of-type': { + padding: '2px 5px', + }, + }, + '&.rs__header__center': { + padding: '6px 0px', + }, + '&.rs__time': { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + position: 'sticky', + left: 0, + zIndex: 99, + [theme.breakpoints.down('sm')]: { + writingMode: 'vertical-rl', + }, + }, + '& > button': { + width: '100%', + height: '100%', + borderRadius: 0, + cursor: 'pointer', + '&:hover': { + background: alpha(theme.palette.primary.main, 0.1), + }, + }, + '& .rs__event__item': { + position: 'absolute', + zIndex: 1, + }, + '& .rs__multi_day': { + position: 'absolute', + zIndex: 1, + textOverflow: 'ellipsis', + }, + '& .rs__block_col': { + display: 'block', + position: 'relative', + }, + '& .rs__hover__op': { + cursor: 'pointer', + '&:hover': { + opacity: 0.7, + textDecoration: 'underline', + }, + }, + '&:not(.rs__time)': { + minWidth: 65, + }, + }, +})) + +export const EventItemPaper = styled(Paper)<{ disabled?: boolean }>(({ disabled }) => ({ + width: '99.5%', + height: '100%', + display: 'block', + cursor: disabled ? 'not-allowed' : 'pointer', + overflow: 'hidden', + '& .MuiButtonBase-root': { + width: '100%', + height: '100%', + display: 'block', + textAlign: 'left', + '& > div': { + height: '100%', + // padding: "2px 4px", + }, + }, +})) + +export const PopperInner = styled('div')(({ theme }) => ({ + maxWidth: '100%', + width: 400, + '& > div': { + padding: '5px 10px', + '& .rs__popper_actions': { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + '& .MuiIconButton-root': { + color: theme.palette.primary.contrastText, + }, + }, + }, +})) + +export const EventActions = styled('div')(({ theme }) => ({ + display: 'inherit', + '& .MuiIconButton-root': { + color: theme.palette.primary.contrastText, + }, + '& .MuiButton-root': { + '&.delete': { + color: theme.palette.error.main, + }, + '&.cancel': { + color: theme.palette.action.disabled, + }, + }, +})) + +export const TimeIndicatorBar = styled('div')(({ theme }) => ({ + position: 'absolute', + zIndex: 9, + width: '100%', + display: 'flex', + '& > div:first-of-type': { + height: 12, + width: 12, + borderRadius: '50%', + background: theme.palette.error.light, + marginLeft: -6, + marginTop: -5, + }, + '& > div:last-of-type': { + borderTop: `solid 2px ${theme.palette.error.light}`, + width: '100%', + }, +})) diff --git a/backend/src/components/scheduler/types.ts b/backend/src/components/scheduler/types.ts new file mode 100644 index 00000000..2b7d9070 --- /dev/null +++ b/backend/src/components/scheduler/types.ts @@ -0,0 +1,356 @@ +import { DialogProps, GridSize } from '@mui/material' +import { DateCalendarProps } from '@mui/x-date-pickers' +import { Locale } from 'date-fns' +import { DragEvent, ReactNode } from 'react' +import type { RRule } from 'rrule' +import { SelectOption } from './components/inputs/SelectInput' +import { View } from './components/nav/Navigation' +import { Store } from './store/types' +import { DayProps } from './views/Day' +import { StateItem } from './views/Editor' +import { MonthProps } from './views/Month' +import { WeekProps } from './views/Week' + +export type DayHours = + | 0 + | 1 + | 2 + | 3 + | 4 + | 5 + | 6 + | 7 + | 8 + | 9 + | 10 + | 11 + | 12 + | 13 + | 14 + | 15 + | 16 + | 17 + | 18 + | 19 + | 20 + | 21 + | 22 + | 23 + | 24; + +export interface CellRenderedProps { + day: Date; + start: Date; + end: Date; + height: number; + onClick(): void; + onDragOver(e: DragEvent): void; + onDragEnter(e: DragEvent): void; + onDragLeave(e: DragEvent): void; + onDrop(e: DragEvent): void; +} +interface CalendarEvent { + event_id: number | string; + title: React.ReactNode; + subtitle?: React.ReactNode; + start: Date; + end: Date; + recurring?: RRule; + disabled?: boolean; + color?: string; + textColor?: string; + editable?: boolean; + deletable?: boolean; + draggable?: boolean; + allDay?: boolean; + /** + * @default " " + * passed as a children to mui component + */ + agendaAvatar?: React.ReactElement | string; +} +export interface Translations { + navigation: Record & { today: string; agenda: string }; + form: { + addTitle: string; + editTitle: string; + confirm: string; + delete: string; + cancel: string; + }; + event: Record & { + title: string; + subtitle?: string; + start: string; + end: string; + allDay: string; + }; + validation?: { + required?: string; + invalidEmail?: string; + onlyNumbers?: string; + min?: string | ((min: number) => string); + max?: string | ((max: number) => string); + }; + moreEvents: string; + noDataToDisplay: string; + loading: string; +} + +export type InputTypes = 'input' | 'date' | 'select' | 'hidden'; + +export type ProcessedEvent = CalendarEvent & Record; + +export interface EventRendererProps + extends Pick< + React.HTMLAttributes, + 'draggable' | 'onDragStart' | 'onDragEnd' | 'onDragOver' | 'onDragEnter' | 'onClick' + > { + event: ProcessedEvent; +} +export interface FieldInputProps { + /** Available to all InputTypes */ + label?: string; + /** Available to all InputTypes */ + placeholder?: string; + /** Available to all InputTypes + * @default false + */ + required?: boolean; + /** Available to all InputTypes + * @default "outline" + */ + variant?: 'standard' | 'filled' | 'outlined'; + /** Available to all InputTypes */ + disabled?: boolean; + /** Available when @input="text" ONLY - Minimum length */ + min?: number; + /** Available when @input="text" ONLY - Maximum length */ + max?: number; + /** Available when @input="text" ONLY - Apply email Regex */ + email?: boolean; + /** Available when @input="text" ONLY - Only numbers(int/float) allowed */ + decimal?: boolean; + /** Available when @input="text" ONLY - Allow Multiline input. Use @rows property to set initial rows height */ + multiline?: boolean; + /** Available when @input="text" ONLY - initial rows height */ + rows?: number; + /** Available when @input="date" ONLY + * @default "datetime" + */ + type?: 'date' | 'datetime'; + /** Available when @input="select" ONLY - Multi-Select input style. + * if you use "default" property with this, make sure your "default" property is an instance of Array + */ + multiple?: 'chips' | 'default'; + /** Available when @input="select" ONLY - display loading spinner instead of expand arrow */ + loading?: boolean; + /** Available when @input="select" ONLY - Custom error message */ + errMsg?: string; + + /* Used for Grid alignment in a single row md | sm | xs */ + md?: GridSize; + /* Used for Grid alignment in a single row md | sm | xs */ + sm?: GridSize; + /* Used for Grid alignment in a single row md | sm | xs */ + xs?: GridSize; +} +export interface FieldProps { + name: string; + type: InputTypes; + /** Required for type="select" */ + options?: Array; + default?: string | number | Date | any; + config?: FieldInputProps; +} + +export type EventActions = 'create' | 'edit'; +export type RemoteQuery = { + start: Date; + end: Date; + view: 'day' | 'week' | 'month'; +}; +export type DefaultResource = { + assignee?: string | number; + text?: string; + subtext?: string; + avatar?: string; + color?: string; +} & Record; +export type ResourceFields = { + idField: string; + textField: string; + subTextField?: string; + avatarField?: string; + colorField?: string; +} & Record; + +export interface SchedulerHelpers { + state: Record; + close(): void; + loading(status: boolean): void; + edited?: ProcessedEvent; + onConfirm(event: ProcessedEvent | ProcessedEvent[], action: EventActions): void; + [resourceKey: string]: unknown; +} + +export type ResourceViewMode = 'default' | 'vertical' | 'tabs' + +export type SchedulerDirection = 'rtl' | 'ltr' + +export type HourFormat = '12' | '24' + +export interface SchedulerProps { + /** Min height of table + * @default 600 + */ + height: number; + /** Initial view to load */ + view: View; + /** Activate Agenda view */ + agenda?: boolean; + /** if true, day rows without event will be shown */ + alwaysShowAgendaDays?: boolean; + /** Month view settings */ + month: MonthProps | null; + /** Week view settings */ + week: WeekProps | null; + /** Day view settings */ + day: DayProps | null; + /** Initial date selected */ + selectedDate: Date; + /** Show/Hide date navigation */ + navigation?: boolean; + /** Show/Hide view navigator */ + disableViewNavigator?: boolean; + /** */ + navigationPickerProps?: Partial< + Omit< + DateCalendarProps, + 'open' | 'onClose' | 'openTo' | 'views' | 'value' | 'readOnly' | 'onChange' + > + >; + /** Events to display */ + events: ProcessedEvent[]; + /** Custom event render method */ + eventRenderer?: (props: EventRendererProps) => ReactNode | null; + /** Async function to load remote data with current view data. */ + getRemoteEvents?(params: RemoteQuery): Promise; + /** Custom additional fields with it's settings */ + fields: FieldProps[]; + /** Table loading state */ + loading?: boolean; + /** Custom loading component */ + loadingComponent?: ReactNode; + /** Async function triggered when add/edit event */ + onConfirm?(event: ProcessedEvent, action: EventActions): Promise; + /** Async function triggered when delete event */ + onDelete?(deletedId: string | number): Promise; + /** Override editor modal */ + customEditor?(scheduler: SchedulerHelpers): ReactNode; + /** Custom viewer/popper component. If used, `viewerExtraComponent` & `viewerTitleComponent` will be ignored */ + customViewer?(event: ProcessedEvent, close: () => void): ReactNode; + /** Additional component in event viewer popper */ + viewerExtraComponent?: + | ReactNode + | ((fields: FieldProps[], event: ProcessedEvent) => ReactNode); + /** Override viewer title component */ + viewerTitleComponent?(event: ProcessedEvent): ReactNode; + /** Override viewer subtitle component */ + viewerSubtitleComponent?(event: ProcessedEvent): ReactNode; + /** if true, the viewer popover will be disabled globally */ + disableViewer?: boolean; + /** Resources array to split event views with resources */ + resources: DefaultResource[]; + /** Map resources fields */ + resourceFields: ResourceFields; + /** Override header component of resource */ + resourceHeaderComponent?(resource: DefaultResource): ReactNode; + /** Triggered when resource tabs changes */ + onResourceChange?(resource: DefaultResource): void; + /** Resource header view mode + * @default "default" + */ + resourceViewMode: ResourceViewMode; + /** Direction of table */ + direction: SchedulerDirection; + /** Editor dialog maxWith + * @default "md" + */ + dialogMaxWidth: DialogProps['maxWidth']; + /** + * date-fns Locale object + */ + locale: Locale; + /** + * Localization + */ + translations: Translations; + /** + * Hour Format + */ + hourFormat: HourFormat; + /** + * Time zone IANA ID: https://data.iana.org/time-zones/releases + */ + timeZone?: string; + /** + * Triggered when event is dropped on time slot. + */ + onEventDrop?( + event: DragEvent, + droppedOn: Date, + updatedEvent: ProcessedEvent, + originalEvent: ProcessedEvent + ): Promise; + /** + * + */ + onEventClick?(event: ProcessedEvent): void; + /** + * Triggered when an event item is being edited from the popover + */ + onEventEdit?(event: ProcessedEvent): void; + /** + * If event is deletable, applied to all events globally, overridden by event specific deletable prop + * @default true + */ + deletable?: boolean; + /** + * If calendar is editable, applied to all events/cells globally, overridden by event specific editable prop + * @default true + */ + editable?: boolean; + /** + * If event is draggable, applied to all events globally, overridden by event specific draggable prop + * @default true + */ + draggable?: boolean; + /** + * Triggered when the `selectedDate` prop changes by navigation date picker or `today` button. + */ + onSelectedDateChange?(date: Date): void; + /** + * Triggered when navigation view changes. + */ + onViewChange?(view: View, agenda?: boolean): void; + /** + * If true, the navigation controller bar will be sticky + */ + stickyNavigation?: boolean; + /** + * Overrides the default behavior of more events button + */ + onClickMore?(date: Date, gotToDay: (day: Date) => void): void; + /** + * + */ + onCellClick?(start: Date, end: Date, resourceKey?: string, resourceVal?: string | number): void; +} + +export interface SchedulerRef { + el: HTMLDivElement; + scheduler: Store; +} + +// export interface Scheduler extends Partial {} diff --git a/backend/src/components/scheduler/views/Day.tsx b/backend/src/components/scheduler/views/Day.tsx new file mode 100644 index 00000000..74fb7926 --- /dev/null +++ b/backend/src/components/scheduler/views/Day.tsx @@ -0,0 +1,216 @@ +import React, { useEffect, useCallback, Fragment, ReactNode } from 'react' +import { Typography } from '@mui/material' +import { + format, + eachMinuteOfInterval, + isToday, + isBefore, + isAfter, + startOfDay, + endOfDay, + addDays, + addMinutes, + set, +} from 'date-fns' +import TodayTypo from '../components/common/TodayTypo' +import EventItem from '../components/events/EventItem' +import { CellRenderedProps, DayHours, DefaultResource, ProcessedEvent } from '../types' +import { + calcCellHeight, + calcMinuteHeight, + filterMultiDaySlot, + filterTodayEvents, + getHourFormat, + getResourcedEvents, +} from '../helpers/generals' +import { WithResources } from '../components/common/WithResources' +import Cell from '../components/common/Cell' +import TodayEvents from '../components/events/TodayEvents' +import { TableGrid } from '../styles/styles' +import { MULTI_DAY_EVENT_HEIGHT } from '../helpers/constants' +import useStore from '../hooks/useStore' +import { DayAgenda } from './DayAgenda' + +export interface DayProps { + startHour: DayHours; + endHour: DayHours; + step: number; + cellRenderer?(props: CellRenderedProps): ReactNode; + headRenderer?(day: Date): ReactNode; + hourRenderer?(hour: string): ReactNode; + navigation?: boolean; +} + +const Day = () => { + const { + day, + selectedDate, + events, + height, + getRemoteEvents, + triggerLoading, + handleState, + resources, + resourceFields, + resourceViewMode, + fields, + direction, + locale, + hourFormat, + timeZone, + stickyNavigation, + agenda, + } = useStore() + + const { startHour, endHour, step, cellRenderer, headRenderer, hourRenderer } = day! + const START_TIME = set(selectedDate, { hours: startHour, minutes: 0, seconds: 0 }) + const END_TIME = set(selectedDate, { hours: endHour, minutes: -step, seconds: 0 }) + const hours = eachMinuteOfInterval( + { + start: START_TIME, + end: END_TIME, + }, + { step } + ) + const CELL_HEIGHT = calcCellHeight(height, hours.length) + const MINUTE_HEIGHT = calcMinuteHeight(CELL_HEIGHT, step) + const hFormat = getHourFormat(hourFormat) + + const fetchEvents = useCallback(async () => { + try { + triggerLoading(true) + const start = addDays(START_TIME, -1) + const end = addDays(END_TIME, 1) + const _events = await getRemoteEvents!({ + start, + end, + view: 'day', + }) + if (_events && _events?.length) { + handleState(_events, 'events') + } + } finally { + triggerLoading(false) + } + // eslint-disable-next-line + }, [selectedDate, getRemoteEvents]); + + useEffect(() => { + if (getRemoteEvents instanceof Function) { + fetchEvents() + } + }, [fetchEvents, getRemoteEvents]) + + const renderMultiDayEvents = (_events: ProcessedEvent[]) => { + const todayMulti = filterMultiDaySlot(_events, selectedDate, timeZone) + return ( +
+ {todayMulti.map((event, i) => { + const hasPrev = isBefore(event.start, startOfDay(selectedDate)) + const hasNext = isAfter(event.end, endOfDay(selectedDate)) + return ( +
+ +
+ ) + })} +
+ ) + } + + const renderTable = (resource?: DefaultResource) => { + let resourcedEvents = events + if (resource) { + resourcedEvents = getResourcedEvents(events, resource, resourceFields, fields) + } + + if (agenda) { + return + } + + // Equalizing multi-day section height + const shouldEqualize = resources.length && resourceViewMode === 'default' + const allWeekMulti = filterMultiDaySlot( + shouldEqualize ? events : resourcedEvents, + selectedDate, + timeZone + ) + const headerHeight = MULTI_DAY_EVENT_HEIGHT * allWeekMulti.length + 45 + return ( + <> + {/* Header */} + + + + {typeof headRenderer === 'function' ? ( +
{headRenderer(selectedDate)}
+ ) : ( + + )} + {renderMultiDayEvents(resourcedEvents)} +
+
+ + {/* Body */} + {hours.map((h, i) => { + const start = new Date(`${format(selectedDate, 'yyyy/MM/dd')} ${format(h, hFormat)}`) + const end = addMinutes(start, step) + const field = resourceFields.idField + return ( + + {/* Time Cells */} + + {typeof hourRenderer === 'function' ? ( +
{hourRenderer(format(h, hFormat, { locale }))}
+ ) : ( + {format(h, hFormat, { locale })} + )} +
+ + {/* Events of this day - run once on the top hour column */} + {i === 0 && ( + + )} + {/* Cell */} + + +
+ ) + })} +
+ + ) + } + + return resources.length ? : renderTable() +} + +export { Day } diff --git a/backend/src/components/scheduler/views/DayAgenda.tsx b/backend/src/components/scheduler/views/DayAgenda.tsx new file mode 100644 index 00000000..f1a231df --- /dev/null +++ b/backend/src/components/scheduler/views/DayAgenda.tsx @@ -0,0 +1,46 @@ +import React, { useMemo } from 'react' +import { format } from 'date-fns' +import { Typography } from '@mui/material' +import { AgendaDiv } from '../styles/styles' +import { ProcessedEvent } from '../types' +import useStore from '../hooks/useStore' +import { filterTodayAgendaEvents } from '../helpers/generals' +import AgendaEventsList from '../components/events/AgendaEventsList' +import EmptyAgenda from '../components/events/EmptyAgenda' + +type Props = { + events: ProcessedEvent[]; +}; +const DayAgenda = ({ events }: Props) => { + const { day, locale, selectedDate, translations, alwaysShowAgendaDays } = useStore() + const { headRenderer } = day! + + const dayEvents = useMemo(() => filterTodayAgendaEvents(events, selectedDate), [events, selectedDate]) + + if (!alwaysShowAgendaDays && !dayEvents.length) { + return + } + + return ( + +
+
+ {typeof headRenderer === 'function' ? ( +
{headRenderer(selectedDate)}
+ ) : ( + {format(selectedDate, 'dd E', { locale })} + )} +
+
+ {dayEvents.length > 0 ? ( + + ) : ( + {translations.noDataToDisplay} + )} +
+
+
+ ) +} + +export { DayAgenda } diff --git a/backend/src/components/scheduler/views/Editor.tsx b/backend/src/components/scheduler/views/Editor.tsx new file mode 100644 index 00000000..e6ab2f00 --- /dev/null +++ b/backend/src/components/scheduler/views/Editor.tsx @@ -0,0 +1,258 @@ +import React, { Fragment, useState } from 'react' +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Grid, + useMediaQuery, + useTheme, +} from '@mui/material' +import { addMinutes, differenceInMinutes } from 'date-fns' +import { EditorDatePicker } from '../components/inputs/DatePicker' +import { EditorInput } from '../components/inputs/Input' +import { EditorSelect } from '../components/inputs/SelectInput' +import { arraytizeFieldVal, revertTimeZonedDate } from '../helpers/generals' +import useStore from '../hooks/useStore' +import { SelectedRange } from '../store/types' +import { + EventActions, + FieldInputProps, + FieldProps, + InputTypes, + ProcessedEvent, + SchedulerHelpers, +} from '../types' + +export type StateItem = { + value: any; + validity: boolean; + type: InputTypes; + config?: FieldInputProps; +}; + +export type StateEvent = (ProcessedEvent & SelectedRange) | Record; + +const initialState = (fields: FieldProps[], event?: StateEvent): Record => { + const customFields = {} as Record + for (const field of fields) { + const defVal = arraytizeFieldVal(field, field.default, event) + const eveVal = arraytizeFieldVal(field, event?.[field.name], event) + + customFields[field.name] = { + value: eveVal.value || defVal.value || '', + validity: field.config?.required ? !!eveVal.validity || !!defVal.validity : true, + type: field.type, + config: field.config, + } + } + + return { + event_id: { + value: event?.event_id || null, + validity: true, + type: 'hidden', + }, + title: { + value: event?.title || '', + validity: !!event?.title, + type: 'input', + config: { label: 'Title', required: true, min: 3 }, + }, + subtitle: { + value: event?.subtitle || '', + validity: true, + type: 'input', + config: { label: 'Subtitle', required: false }, + }, + start: { + value: event?.start || new Date(), + validity: true, + type: 'date', + config: { label: 'Start', sm: 6 }, + }, + end: { + value: event?.end || new Date(), + validity: true, + type: 'date', + config: { label: 'End', sm: 6 }, + }, + ...customFields, + } +} + +const Editor = () => { + const { + fields, + dialog, + triggerDialog, + selectedRange, + selectedEvent, + resourceFields, + selectedResource, + triggerLoading, + onConfirm, + customEditor, + confirmEvent, + dialogMaxWidth, + translations, + timeZone, + } = useStore() + const [state, setState] = useState(initialState(fields, selectedEvent || selectedRange)) + const [touched, setTouched] = useState(false) + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('sm')) + + const handleEditorState = (name: string, value: any, validity: boolean) => { + setState((prev) => ({ + ...prev, + [name]: { ...prev[name], value, validity }, + })) + } + + const handleClose = (clearState?: boolean) => { + if (clearState) { + setState(initialState(fields)) + } + triggerDialog(false) + } + + const handleConfirm = async () => { + let body = {} as ProcessedEvent + for (const key in state) { + if (Object.prototype.hasOwnProperty.call(state, key)) { + body[key] = state[key].value + if (!customEditor && !state[key].validity) { + setTouched(true) + return + } + } + } + try { + triggerLoading(true) + // Auto fix date + body.end = body.start >= body.end + ? addMinutes(body.start, differenceInMinutes(selectedRange?.end ?? new Date(), selectedRange?.start ?? new Date())) + : body.end + // Specify action + const action: EventActions = selectedEvent?.event_id ? 'edit' : 'create' + // Trigger custom/remote when provided + if (onConfirm) { + body = await onConfirm(body, action) + } else { + // Create/Edit local data + body.event_id = selectedEvent?.event_id || Date.now().toString(36) + Math.random().toString(36).slice(2) + } + + body.start = revertTimeZonedDate(body.start, timeZone) + body.end = revertTimeZonedDate(body.end, timeZone) + + confirmEvent(body, action) + handleClose(true) + } catch (error) { + console.error(error) + } finally { + triggerLoading(false) + } + } + const renderInputs = (key: string) => { + const stateItem = state[key] + switch (stateItem.type) { + case 'input': + return ( + + ) + case 'date': + return ( + handleEditorState(...args, true)} + touched={touched} + {...stateItem.config} + label={translations.event[key] || stateItem.config?.label} + /> + ) + case 'select': { + const field = fields.find((f) => f.name === key) + return ( + + ) + } + default: + return '' + } + } + + const renderEditor = () => { + if (customEditor) { + const schedulerHelpers: SchedulerHelpers = { + state, + close: () => triggerDialog(false), + loading: (load) => triggerLoading(load), + edited: selectedEvent, + onConfirm: confirmEvent, + [resourceFields.idField]: selectedResource, + } + return customEditor(schedulerHelpers) + } + return ( + <> + + {selectedEvent ? translations.form.editTitle : translations.form.addTitle} + + + + {Object.keys(state).map((key) => { + const item = state[key] + return ( + + {renderInputs(key)} + + ) + })} + + + + + + + + ) + } + + return ( + { + triggerDialog(false) + }} + > + {renderEditor()} + + ) +} + +export default Editor diff --git a/backend/src/components/scheduler/views/Month.tsx b/backend/src/components/scheduler/views/Month.tsx new file mode 100644 index 00000000..35c2f4fd --- /dev/null +++ b/backend/src/components/scheduler/views/Month.tsx @@ -0,0 +1,92 @@ +import React, { useEffect, useCallback, ReactNode } from 'react' +import { addDays, eachWeekOfInterval, endOfMonth, startOfMonth } from 'date-fns' +import { CellRenderedProps, DayHours, DefaultResource } from '../types' +import { getResourcedEvents, sortEventsByTheEarliest } from '../helpers/generals' +import { WithResources } from '../components/common/WithResources' +import useStore from '../hooks/useStore' +import { MonthAgenda } from './MonthAgenda' +import MonthTable from '../components/month/MonthTable' + +export type WeekDays = 0 | 1 | 2 | 3 | 4 | 5 | 6; +export interface MonthProps { + weekDays: WeekDays[]; + weekStartOn: WeekDays; + startHour: DayHours; + endHour: DayHours; + cellRenderer?(props: CellRenderedProps): ReactNode; + headRenderer?(day: Date): ReactNode; + navigation?: boolean; + disableGoToDay?: boolean; +} + +const Month = () => { + const { + month, + selectedDate, + events, + getRemoteEvents, + triggerLoading, + handleState, + resources, + resourceFields, + fields, + agenda, + } = useStore() + + const { weekStartOn, weekDays } = month! + const monthStart = startOfMonth(selectedDate) + const monthEnd = endOfMonth(selectedDate) + const eachWeekStart = eachWeekOfInterval( + { + start: monthStart, + end: monthEnd, + }, + { weekStartsOn: weekStartOn } + ) + const daysList = weekDays.map((d) => addDays(eachWeekStart[0], d)) + + const fetchEvents = useCallback(async () => { + try { + triggerLoading(true) + const start = eachWeekStart[0] + const end = addDays(eachWeekStart[eachWeekStart.length - 1], daysList.length) + const _events = await getRemoteEvents!({ + start, + end, + view: 'month', + }) + if (_events && _events?.length) { + handleState(_events, 'events') + } + } finally { + triggerLoading(false) + } + // eslint-disable-next-line + }, [selectedDate, getRemoteEvents]); + + useEffect(() => { + if (getRemoteEvents instanceof Function) { + fetchEvents() + } + }, [fetchEvents, getRemoteEvents]) + + const renderTable = useCallback( + (resource?: DefaultResource) => { + if (agenda) { + let resourcedEvents = sortEventsByTheEarliest(events) + if (resource) { + resourcedEvents = getResourcedEvents(events, resource, resourceFields, fields) + } + + return + } + + return + }, + [agenda, daysList, eachWeekStart, events, fields, resourceFields] + ) + + return resources.length ? : renderTable() +} + +export { Month } diff --git a/backend/src/components/scheduler/views/MonthAgenda.tsx b/backend/src/components/scheduler/views/MonthAgenda.tsx new file mode 100644 index 00000000..c9997276 --- /dev/null +++ b/backend/src/components/scheduler/views/MonthAgenda.tsx @@ -0,0 +1,79 @@ +import React, { useMemo } from 'react' +import { format, isSameMonth, getDaysInMonth, isToday } from 'date-fns' +import { Typography } from '@mui/material' +import { AgendaDiv } from '../styles/styles' +import { ProcessedEvent } from '../types' +import useStore from '../hooks/useStore' +import { filterTodayAgendaEvents, isTimeZonedToday } from '../helpers/generals' +import AgendaEventsList from '../components/events/AgendaEventsList' +import EmptyAgenda from '../components/events/EmptyAgenda' + +type Props = { + events: ProcessedEvent[]; +}; +const MonthAgenda = ({ events }: Props) => { + const { + month, + handleGotoDay, + locale, + timeZone, + selectedDate, + translations, + alwaysShowAgendaDays, + } = useStore() + const { disableGoToDay, headRenderer } = month! + const daysOfMonth = getDaysInMonth(selectedDate) + const daysList = Array.from({ length: daysOfMonth }, (_, i) => i + 1) + + const monthEvents = useMemo(() => events.filter((event) => isSameMonth(event.start, selectedDate)), [events, selectedDate]) + + if (!alwaysShowAgendaDays && !monthEvents.length) { + return + } + + return ( + + {daysList.map((i) => { + const day = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), i) + const today = isTimeZonedToday({ dateLeft: day, timeZone }) + const dayEvents = filterTodayAgendaEvents(events, day) + + if (!alwaysShowAgendaDays && !dayEvents.length) return null + + return ( +
+
+ {typeof headRenderer === 'function' ? ( +
{headRenderer(day)}
+ ) : ( + { + e.stopPropagation() + if (!disableGoToDay) { + handleGotoDay(day) + } + }} + > + {format(day, 'dd E', { locale })} + + )} +
+
+ {dayEvents.length > 0 ? ( + + ) : ( + {translations.noDataToDisplay} + )} +
+
+ ) + })} +
+ ) +} + +export { MonthAgenda } diff --git a/backend/src/components/scheduler/views/Week.tsx b/backend/src/components/scheduler/views/Week.tsx new file mode 100644 index 00000000..1c9e8f56 --- /dev/null +++ b/backend/src/components/scheduler/views/Week.tsx @@ -0,0 +1,104 @@ +import React, { useEffect, useCallback, ReactNode } from 'react' +import { startOfWeek, addDays, eachMinuteOfInterval, endOfDay, startOfDay, set } from 'date-fns' +import { CellRenderedProps, DayHours, DefaultResource } from '../types' +import { WeekDays } from './Month' +import { calcCellHeight, calcMinuteHeight, getResourcedEvents } from '../helpers/generals' +import { WithResources } from '../components/common/WithResources' +import useStore from '../hooks/useStore' +import { WeekAgenda } from './WeekAgenda' +import WeekTable from '../components/week/WeekTable' + +export interface WeekProps { + weekDays: WeekDays[]; + weekStartOn: WeekDays; + startHour: DayHours; + endHour: DayHours; + step: number; + cellRenderer?(props: CellRenderedProps): ReactNode; + headRenderer?(day: Date): ReactNode; + hourRenderer?(hour: string): ReactNode; + navigation?: boolean; + disableGoToDay?: boolean; +} + +const Week = () => { + const { + week, + selectedDate, + height, + events, + getRemoteEvents, + triggerLoading, + handleState, + resources, + resourceFields, + fields, + agenda, + } = useStore() + const { weekStartOn, weekDays, startHour, endHour, step } = week! + const _weekStart = startOfWeek(selectedDate, { weekStartsOn: weekStartOn }) + const daysList = weekDays.map((d) => addDays(_weekStart, d)) + const weekStart = startOfDay(daysList[0]) + const weekEnd = endOfDay(daysList[daysList.length - 1]) + const START_TIME = set(selectedDate, { hours: startHour, minutes: 0, seconds: 0 }) + const END_TIME = set(selectedDate, { hours: endHour, minutes: -step, seconds: 0 }) + const hours = eachMinuteOfInterval( + { + start: START_TIME, + end: END_TIME, + }, + { step } + ) + const CELL_HEIGHT = calcCellHeight(height, hours.length) + const MINUTE_HEIGHT = calcMinuteHeight(CELL_HEIGHT, step) + + const fetchEvents = useCallback(async () => { + try { + triggerLoading(true) + + const _events = await getRemoteEvents!({ + start: weekStart, + end: weekEnd, + view: 'week', + }) + if (Array.isArray(_events)) { + handleState(_events, 'events') + } + } finally { + triggerLoading(false) + } + // eslint-disable-next-line + }, [selectedDate, getRemoteEvents]); + + useEffect(() => { + if (getRemoteEvents instanceof Function) { + fetchEvents() + } + }, [fetchEvents, getRemoteEvents]) + + const renderTable = (resource?: DefaultResource) => { + let resourcedEvents = events + if (resource) { + resourcedEvents = getResourcedEvents(events, resource, resourceFields, fields) + } + + if (agenda) { + return + } + + return ( + + ) + } + + return resources.length ? : renderTable() +} + +export { Week } diff --git a/backend/src/components/scheduler/views/WeekAgenda.tsx b/backend/src/components/scheduler/views/WeekAgenda.tsx new file mode 100644 index 00000000..2079334f --- /dev/null +++ b/backend/src/components/scheduler/views/WeekAgenda.tsx @@ -0,0 +1,69 @@ +import React, { useMemo } from 'react' +import { format, isToday } from 'date-fns' +import { Typography } from '@mui/material' +import { AgendaDiv } from '../styles/styles' +import { ProcessedEvent } from '../types' +import useStore from '../hooks/useStore' +import { filterTodayAgendaEvents, isTimeZonedToday } from '../helpers/generals' +import AgendaEventsList from '../components/events/AgendaEventsList' +import EmptyAgenda from '../components/events/EmptyAgenda' + +type Props = { + daysList: Date[]; + events: ProcessedEvent[]; +}; +const WeekAgenda = ({ daysList, events }: Props) => { + const { week, handleGotoDay, locale, timeZone, translations, alwaysShowAgendaDays } = useStore() + const { disableGoToDay, headRenderer } = week! + + const hasEvents = useMemo(() => daysList.some((day) => filterTodayAgendaEvents(events, day).length > 0), [daysList, events]) + + if (!alwaysShowAgendaDays && !hasEvents) { + return + } + + return ( + + {daysList.map((day) => { + const today = isTimeZonedToday({ dateLeft: day, timeZone }) + const dayEvents = filterTodayAgendaEvents(events, day) + + if (!alwaysShowAgendaDays && !dayEvents.length) return null + + return ( +
+
+ {typeof headRenderer === 'function' ? ( +
{headRenderer(day)}
+ ) : ( + { + e.stopPropagation() + if (!disableGoToDay) { + handleGotoDay(day) + } + }} + > + {format(day, 'dd E', { locale })} + + )} +
+
+ {dayEvents.length > 0 ? ( + + ) : ( + {translations.noDataToDisplay} + )} +
+
+ ) + })} +
+ ) +} + +export { WeekAgenda } diff --git a/backend/src/lang/header.ts b/backend/src/lang/header.ts index e65fbc18..c7ac0168 100644 --- a/backend/src/lang/header.ts +++ b/backend/src/lang/header.ts @@ -4,6 +4,7 @@ import * as langHelper from '@/common/langHelper' const strings = new LocalizedStrings({ fr: { DASHBOARD: 'Tableau de bord', + SCHEDULER: 'Planificateur', HOME: 'Accueil', AGENCIES: 'Agencies', LOCATIONS: 'Lieux', @@ -19,6 +20,7 @@ const strings = new LocalizedStrings({ }, en: { DASHBOARD: 'Dashboard', + SCHEDULER: 'Property Scheduler', HOME: 'Home', AGENCIES: 'Agencies', LOCATIONS: 'Locations', diff --git a/backend/src/pages/Scheduler.tsx b/backend/src/pages/Scheduler.tsx new file mode 100644 index 00000000..463205b4 --- /dev/null +++ b/backend/src/pages/Scheduler.tsx @@ -0,0 +1,98 @@ +import React, { useState } from 'react' +import { Button } from '@mui/material' +import * as movininTypes from ':movinin-types' +import * as movininHelper from ':movinin-helper' +import { strings } from '@/lang/bookings' +import env from '@/config/env.config' +import * as helper from '@/common/helper' +import * as AgencyService from '@/services/AgencyService' +import PropertyScheduler from '@/components/PropertyScheduler' +import AgencyFilter from '@/components/AgencyFilter' +import StatusFilter from '@/components/StatusFilter' +import PropertySchedulerFilter from '@/components/PropertySchedulerFilter' + +import Layout from '@/components/Layout' + +import '@/assets/css/scheduler.css' + +const Scheduler = () => { + const [user, setUser] = useState() + const [leftPanel, setLeftPanel] = useState(false) + const [admin, setAdmin] = useState(false) + const [allAgencies, setAllAgencies] = useState([]) + const [agencies, setAgencies] = useState() + const [statuses, setStatuses] = useState(helper.getBookingStatuses().map((status) => status.value)) + const [filter, setFilter] = useState() + + const handleAgencyFilterChange = (_agencies: string[]) => { + setAgencies(_agencies) + } + + const handleStatusFilterChange = (_statuses: movininTypes.BookingStatus[]) => { + setStatuses(_statuses) + } + + const handlePropertySchedulerFilterSubmit = (_filter: movininTypes.Filter | null) => { + setFilter(_filter) + } + + const onLoad = async (_user?: movininTypes.User) => { + if (_user) { + const _admin = helper.admin(_user) + setUser(_user) + setAdmin(_admin) + setLeftPanel(!_admin) + + const _allAgencies = await AgencyService.getAllAgencies() + const _agencies = _admin ? movininHelper.flattenAgencies(_allAgencies) : [_user._id ?? ''] + setAllAgencies(_allAgencies) + setAgencies(_agencies) + setLeftPanel(true) + } + } + + return ( + + {user && agencies && ( +
+
+ {leftPanel && ( + <> + + {admin + && ( + + )} + + + + )} +
+
+ +
+
+ )} +
+ ) +} + +export default Scheduler diff --git a/packages/movinin-types/index.ts b/packages/movinin-types/index.ts index 72b92c79..f1e67b52 100644 --- a/packages/movinin-types/index.ts +++ b/packages/movinin-types/index.ts @@ -147,6 +147,7 @@ export interface CheckoutPayload { export interface Filter { from?: Date + dateBetween?: Date to?: Date location?: string keyword?: string