Merge pull request #267 from Malte-D/feature/select-calendars-to-check-for-availability
Feature: select calendars to check for availability
This commit is contained in:
		
						commit
						2960463f77
					
				
					 8 changed files with 368 additions and 47 deletions
				
			
		
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							|  | @ -35,3 +35,6 @@ yarn-error.log* | ||||||
| 
 | 
 | ||||||
| # vercel | # vercel | ||||||
| .vercel | .vercel | ||||||
|  | 
 | ||||||
|  | # Webstorm | ||||||
|  | .idea | ||||||
|  |  | ||||||
|  | @ -110,6 +110,17 @@ paths: | ||||||
|       summary: Deletes an event type |       summary: Deletes an event type | ||||||
|       tags: |       tags: | ||||||
|         - Availability |         - Availability | ||||||
|  |   /api/availability/calendars: | ||||||
|  |     post: | ||||||
|  |       description: Selects calendar for availability checking. | ||||||
|  |       summary: Adds selected calendar | ||||||
|  |       tags: | ||||||
|  |         - Availability | ||||||
|  |     delete: | ||||||
|  |       description: Removes a calendar from availability checking. | ||||||
|  |       summary: Deletes a selected calendar | ||||||
|  |       tags: | ||||||
|  |         - Availability | ||||||
|   /api/book/:user: |   /api/book/:user: | ||||||
|     post: |     post: | ||||||
|       description: Creates a booking in the user's calendar. |       description: Creates a booking in the user's calendar. | ||||||
|  |  | ||||||
|  | @ -66,6 +66,13 @@ interface CalendarEvent { | ||||||
|     attendees: Person[]; |     attendees: Person[]; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | interface IntegrationCalendar { | ||||||
|  |     integration: string; | ||||||
|  |     primary: boolean; | ||||||
|  |     externalId: string; | ||||||
|  |     name: string; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| interface CalendarApiAdapter { | interface CalendarApiAdapter { | ||||||
|     createEvent(event: CalendarEvent): Promise<any>; |     createEvent(event: CalendarEvent): Promise<any>; | ||||||
| 
 | 
 | ||||||
|  | @ -73,7 +80,9 @@ interface CalendarApiAdapter { | ||||||
| 
 | 
 | ||||||
|     deleteEvent(uid: String); |     deleteEvent(uid: String); | ||||||
| 
 | 
 | ||||||
|     getAvailability(dateFrom, dateTo): Promise<any>; |     getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise<any>; | ||||||
|  | 
 | ||||||
|  |     listCalendars(): Promise<IntegrationCalendar[]>; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { | const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { | ||||||
|  | @ -112,37 +121,57 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { | ||||||
|         } |         } | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     return { |     const integrationType = "office365_calendar"; | ||||||
|         getAvailability: (dateFrom, dateTo) => { |  | ||||||
|             const payload = { |  | ||||||
|                 schedules: [credential.key.email], |  | ||||||
|                 startTime: { |  | ||||||
|                     dateTime: dateFrom, |  | ||||||
|                     timeZone: 'UTC', |  | ||||||
|                 }, |  | ||||||
|                 endTime: { |  | ||||||
|                     dateTime: dateTo, |  | ||||||
|                     timeZone: 'UTC', |  | ||||||
|                 }, |  | ||||||
|                 availabilityViewInterval: 60 |  | ||||||
|             }; |  | ||||||
| 
 | 
 | ||||||
|             return auth.getToken().then( |     function listCalendars(): Promise<IntegrationCalendar[]> { | ||||||
|                 (accessToken) => fetch('https://graph.microsoft.com/v1.0/me/calendar/getSchedule', { |         return auth.getToken().then(accessToken => fetch('https://graph.microsoft.com/v1.0/me/calendars', { | ||||||
|                     method: 'post', |               method: 'get', | ||||||
|               headers: { |               headers: { | ||||||
|                   'Authorization': 'Bearer ' + accessToken, |                   'Authorization': 'Bearer ' + accessToken, | ||||||
|                   'Content-Type': 'application/json' |                   'Content-Type': 'application/json' | ||||||
|               }, |               }, | ||||||
|                     body: JSON.stringify(payload) |           }).then(handleErrorsJson) | ||||||
|  |             .then(responseBody => { | ||||||
|  |                 return responseBody.value.map(cal => { | ||||||
|  |                     const calendar: IntegrationCalendar = { | ||||||
|  |                         externalId: cal.id, integration: integrationType, name: cal.name, primary: cal.isDefaultCalendar | ||||||
|  |                     } | ||||||
|  |                     return calendar; | ||||||
|  |                 }); | ||||||
|  |             }) | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return { | ||||||
|  |         getAvailability: (dateFrom, dateTo, selectedCalendars) => { | ||||||
|  |             const filter = "?$filter=start/dateTime ge '" +  dateFrom + "' and end/dateTime le '" + dateTo + "'" | ||||||
|  |             return auth.getToken().then( | ||||||
|  |                 (accessToken) => { | ||||||
|  |                     const selectedCalendarIds = selectedCalendars.filter(e => e.integration === integrationType).map(e => e.externalId); | ||||||
|  |                     if (selectedCalendarIds.length == 0 && selectedCalendars.length > 0){ | ||||||
|  |                         // Only calendars of other integrations selected
 | ||||||
|  |                         return Promise.resolve([]); | ||||||
|  |                     } | ||||||
|  | 
 | ||||||
|  |                     return (selectedCalendarIds.length == 0 | ||||||
|  |                       ? listCalendars().then(cals => cals.map(e => e.externalId)) | ||||||
|  |                       : Promise.resolve(selectedCalendarIds).then(x => x)).then((ids: string[]) => { | ||||||
|  |                         const urls = ids.map(calendarId => 'https://graph.microsoft.com/v1.0/me/calendars/' + calendarId + '/events' + filter) | ||||||
|  |                         return Promise.all(urls.map(url => fetch(url, { | ||||||
|  |                             method: 'get', | ||||||
|  |                             headers: { | ||||||
|  |                                 'Authorization': 'Bearer ' + accessToken, | ||||||
|  |                                 'Prefer': 'outlook.timezone="Etc/GMT"' | ||||||
|  |                             } | ||||||
|                         }) |                         }) | ||||||
|                           .then(handleErrorsJson) |                           .then(handleErrorsJson) | ||||||
|                     .then(responseBody => { |                           .then(responseBody => responseBody.value.map((evt) => ({ | ||||||
|                         return responseBody.value[0].scheduleItems.map((evt) => ({ |  | ||||||
|                                 start: evt.start.dateTime + 'Z', |                                 start: evt.start.dateTime + 'Z', | ||||||
|                                 end: evt.end.dateTime + 'Z' |                                 end: evt.end.dateTime + 'Z' | ||||||
|                             })) |                             })) | ||||||
|  |                           ))).then(results => results.reduce((acc, events) => acc.concat(events), [])) | ||||||
|                     }) |                     }) | ||||||
|  |                 } | ||||||
|             ).catch((err) => { |             ).catch((err) => { | ||||||
|                 console.log(err); |                 console.log(err); | ||||||
|             }); |             }); | ||||||
|  | @ -172,28 +201,37 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { | ||||||
|             }, |             }, | ||||||
|             body: JSON.stringify(translateEvent(event)) |             body: JSON.stringify(translateEvent(event)) | ||||||
|         }).then(handleErrorsRaw)), |         }).then(handleErrorsRaw)), | ||||||
|  |         listCalendars | ||||||
|     } |     } | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const GoogleCalendar = (credential): CalendarApiAdapter => { | const GoogleCalendar = (credential): CalendarApiAdapter => { | ||||||
|     const myGoogleAuth = googleAuth(); |     const myGoogleAuth = googleAuth(); | ||||||
|     myGoogleAuth.setCredentials(credential.key); |     myGoogleAuth.setCredentials(credential.key); | ||||||
|  |     const integrationType = "google_calendar"; | ||||||
|  | 
 | ||||||
|     return { |     return { | ||||||
|         getAvailability: (dateFrom, dateTo) => new Promise((resolve, reject) => { |         getAvailability: (dateFrom, dateTo, selectedCalendars) => new Promise((resolve, reject) => { | ||||||
|             const calendar = google.calendar({version: 'v3', auth: myGoogleAuth}); |             const calendar = google.calendar({version: 'v3', auth: myGoogleAuth}); | ||||||
|             calendar.calendarList |             calendar.calendarList | ||||||
|                 .list() |                 .list() | ||||||
|                 .then(cals => { |                 .then(cals => { | ||||||
|  |                     const filteredItems = cals.data.items.filter(i => selectedCalendars.findIndex(e => e.externalId === i.id) > -1) | ||||||
|  |                     if (filteredItems.length == 0 && selectedCalendars.length > 0){ | ||||||
|  |                         // Only calendars of other integrations selected
 | ||||||
|  |                         resolve([]); | ||||||
|  |                     } | ||||||
|                     calendar.freebusy.query({ |                     calendar.freebusy.query({ | ||||||
|                         requestBody: { |                         requestBody: { | ||||||
|                             timeMin: dateFrom, |                             timeMin: dateFrom, | ||||||
|                             timeMax: dateTo, |                             timeMax: dateTo, | ||||||
|                             items: cals.data.items |                             items: filteredItems.length > 0 ? filteredItems : cals.data.items | ||||||
|                         } |                         } | ||||||
|                     }, (err, apires) => { |                     }, (err, apires) => { | ||||||
|                         if (err) { |                         if (err) { | ||||||
|                             reject(err); |                             reject(err); | ||||||
|                         } |                         } | ||||||
|  | 
 | ||||||
|                         resolve( |                         resolve( | ||||||
|                             Object.values(apires.data.calendars).flatMap( |                             Object.values(apires.data.calendars).flatMap( | ||||||
|                                 (item) => item["busy"] |                                 (item) => item["busy"] | ||||||
|  | @ -300,6 +338,22 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { | ||||||
|                 } |                 } | ||||||
|                 return resolve(event.data); |                 return resolve(event.data); | ||||||
|             }); |             }); | ||||||
|  |         }), | ||||||
|  |         listCalendars: () => new Promise((resolve, reject) => { | ||||||
|  |             const calendar = google.calendar({version: 'v3', auth: myGoogleAuth}); | ||||||
|  |             calendar.calendarList | ||||||
|  |               .list() | ||||||
|  |               .then(cals => { | ||||||
|  |                   resolve(cals.data.items.map(cal => { | ||||||
|  |                       const calendar: IntegrationCalendar = { | ||||||
|  |                           externalId: cal.id, integration: integrationType, name: cal.summary, primary: cal.primary | ||||||
|  |                       } | ||||||
|  |                       return calendar; | ||||||
|  |                   })) | ||||||
|  |               }) | ||||||
|  |               .catch((err) => { | ||||||
|  |                   reject(err); | ||||||
|  |               }); | ||||||
|         }) |         }) | ||||||
|     }; |     }; | ||||||
| }; | }; | ||||||
|  | @ -316,11 +370,18 @@ const calendars = (withCredentials): CalendarApiAdapter[] => withCredentials.map | ||||||
|     } |     } | ||||||
| }).filter(Boolean); | }).filter(Boolean); | ||||||
| 
 | 
 | ||||||
| 
 | const getBusyTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) => Promise.all( | ||||||
| const getBusyTimes = (withCredentials, dateFrom, dateTo) => Promise.all( |     calendars(withCredentials).map(c => c.getAvailability(dateFrom, dateTo, selectedCalendars)) | ||||||
|     calendars(withCredentials).map(c => c.getAvailability(dateFrom, dateTo)) |  | ||||||
| ).then( | ).then( | ||||||
|     (results) => results.reduce((acc, availability) => acc.concat(availability), []) |     (results) => { | ||||||
|  |         return results.reduce((acc, availability) => acc.concat(availability), []) | ||||||
|  |     } | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | const listCalendars = (withCredentials) => Promise.all( | ||||||
|  |   calendars(withCredentials).map(c => c.listCalendars()) | ||||||
|  | ).then( | ||||||
|  |   (results) => results.reduce((acc, calendars) => acc.concat(calendars), []) | ||||||
| ); | ); | ||||||
| 
 | 
 | ||||||
| const createEvent = (credential, calEvent: CalendarEvent): Promise<any> => { | const createEvent = (credential, calEvent: CalendarEvent): Promise<any> => { | ||||||
|  | @ -352,4 +413,4 @@ const deleteEvent = (credential, uid: String): Promise<any> => { | ||||||
|     return Promise.resolve({}); |     return Promise.resolve({}); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export {getBusyTimes, createEvent, updateEvent, deleteEvent, CalendarEvent}; | export {getBusyTimes, createEvent, updateEvent, deleteEvent, CalendarEvent, listCalendars, IntegrationCalendar}; | ||||||
|  |  | ||||||
|  | @ -15,6 +15,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) | ||||||
|         } |         } | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     const availability = await getBusyTimes(currentUser.credentials, req.query.dateFrom, req.query.dateTo); |     const selectedCalendars = (await prisma.selectedCalendar.findMany({ | ||||||
|  |         where: { | ||||||
|  |             userId: currentUser.id | ||||||
|  |         } | ||||||
|  |     })); | ||||||
|  | 
 | ||||||
|  |     const availability = await getBusyTimes(currentUser.credentials, req.query.dateFrom, req.query.dateTo, selectedCalendars); | ||||||
|     res.status(200).json(availability); |     res.status(200).json(availability); | ||||||
| } | } | ||||||
|  |  | ||||||
							
								
								
									
										69
									
								
								pages/api/availability/calendar.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								pages/api/availability/calendar.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,69 @@ | ||||||
|  | import type { NextApiRequest, NextApiResponse } from 'next'; | ||||||
|  | import { getSession } from 'next-auth/client'; | ||||||
|  | import prisma from '../../../lib/prisma'; | ||||||
|  | import {IntegrationCalendar, listCalendars} from "../../../lib/calendarClient"; | ||||||
|  | 
 | ||||||
|  | export default async function handler(req: NextApiRequest, res: NextApiResponse) { | ||||||
|  |     const session = await getSession({req: req}); | ||||||
|  | 
 | ||||||
|  |     if (!session) { | ||||||
|  |         res.status(401).json({message: "Not authenticated"}); | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const currentUser = await prisma.user.findFirst({ | ||||||
|  |         where: { | ||||||
|  |             id: session.user.id, | ||||||
|  |         }, | ||||||
|  |         select: { | ||||||
|  |             credentials: true, | ||||||
|  |             timeZone: true, | ||||||
|  |             id: true | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     if (req.method == "POST") { | ||||||
|  |         await prisma.selectedCalendar.create({ | ||||||
|  |             data: { | ||||||
|  |                 user: { | ||||||
|  |                     connect: { | ||||||
|  |                         id: currentUser.id | ||||||
|  |                     } | ||||||
|  |                 }, | ||||||
|  |                 integration: req.body.integration, | ||||||
|  |                 externalId: req.body.externalId | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |         res.status(200).json({message: "Calendar Selection Saved"}); | ||||||
|  | 
 | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (req.method == "DELETE") { | ||||||
|  |         await prisma.selectedCalendar.delete({ | ||||||
|  |             where: { | ||||||
|  |                 userId_integration_externalId: { | ||||||
|  |                     userId: currentUser.id, | ||||||
|  |                     externalId: req.body.externalId, | ||||||
|  |                     integration: req.body.integration | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         res.status(200).json({message: "Calendar Selection Saved"}); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (req.method == "GET") { | ||||||
|  |         const selectedCalendarIds = await prisma.selectedCalendar.findMany({ | ||||||
|  |             where: { | ||||||
|  |                 userId: currentUser.id | ||||||
|  |             }, | ||||||
|  |             select: { | ||||||
|  |                 externalId: true | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         const calendars: IntegrationCalendar[] = await listCalendars(currentUser.credentials); | ||||||
|  |         const selectableCalendars = calendars.map(cal => {return {selected: selectedCalendarIds.findIndex(s => s.externalId === cal.externalId) > -1, ...cal}}); | ||||||
|  |         res.status(200).json(selectableCalendars); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -2,29 +2,82 @@ import Head from 'next/head'; | ||||||
| import Link from 'next/link'; | import Link from 'next/link'; | ||||||
| import prisma from '../../lib/prisma'; | import prisma from '../../lib/prisma'; | ||||||
| import Shell from '../../components/Shell'; | import Shell from '../../components/Shell'; | ||||||
| import {useState} from 'react'; | import {useEffect, useState} from 'react'; | ||||||
| import {getSession, useSession} from 'next-auth/client'; | import {getSession, useSession} from 'next-auth/client'; | ||||||
| import {CheckCircleIcon, ChevronRightIcon, PlusIcon, XCircleIcon} from '@heroicons/react/solid'; | import {CalendarIcon, CheckCircleIcon, ChevronRightIcon, PlusIcon, XCircleIcon} from '@heroicons/react/solid'; | ||||||
| import {InformationCircleIcon} from '@heroicons/react/outline'; | import {InformationCircleIcon} from '@heroicons/react/outline'; | ||||||
|  | import { Switch } from '@headlessui/react' | ||||||
| 
 | 
 | ||||||
| export default function Home({ integrations }) { | export default function Home({ integrations }) { | ||||||
|     const [session, loading] = useSession(); |     const [session, loading] = useSession(); | ||||||
|     const [showAddModal, setShowAddModal] = useState(false); |     const [showAddModal, setShowAddModal] = useState(false); | ||||||
| 
 |     const [showSelectCalendarModal, setShowSelectCalendarModal] = useState(false); | ||||||
|     if (loading) { |     const [selectableCalendars, setSelectableCalendars] = useState([]); | ||||||
|         return <p className="text-gray-400">Loading...</p>; |  | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|     function toggleAddModal() { |     function toggleAddModal() { | ||||||
|         setShowAddModal(!showAddModal); |         setShowAddModal(!showAddModal); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     function toggleShowCalendarModal() { | ||||||
|  |         setShowSelectCalendarModal(!showSelectCalendarModal); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     function loadCalendars() { | ||||||
|  |         fetch('api/availability/calendar') | ||||||
|  |           .then((response) => response.json()) | ||||||
|  |           .then(data => { | ||||||
|  |               setSelectableCalendars(data) | ||||||
|  |           }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     function integrationHandler(type) { |     function integrationHandler(type) { | ||||||
|         fetch('/api/integrations/' + type.replace('_', '') + '/add') |         fetch('/api/integrations/' + type.replace('_', '') + '/add') | ||||||
|             .then((response) => response.json()) |             .then((response) => response.json()) | ||||||
|             .then((data) => window.location.href = data.url); |             .then((data) => window.location.href = data.url); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     function calendarSelectionHandler(calendar) { | ||||||
|  |         return (selected) => { | ||||||
|  |             let cals = [...selectableCalendars]; | ||||||
|  |             let i = cals.findIndex(c => c.externalId === calendar.externalId); | ||||||
|  |             cals[i].selected = selected; | ||||||
|  |             setSelectableCalendars(cals); | ||||||
|  |             if (selected) { | ||||||
|  |                 fetch('api/availability/calendar', { | ||||||
|  |                     method: 'POST', | ||||||
|  |                     headers: { | ||||||
|  |                         'Content-Type': 'application/json' | ||||||
|  |                     }, | ||||||
|  |                     body: JSON.stringify(cals[i]) | ||||||
|  |                 }).then((response) => response.json()); | ||||||
|  |             } else { | ||||||
|  |                 fetch('api/availability/calendar', { | ||||||
|  |                     method: 'DELETE', headers: { | ||||||
|  |                         'Content-Type': 'application/json' | ||||||
|  |                     }, body: JSON.stringify(cals[i]) | ||||||
|  |                 }).then((response) => response.json()); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     function getCalendarIntegrationImage(integrationType: string){ | ||||||
|  |         switch (integrationType) { | ||||||
|  |             case "google_calendar": return "integrations/google-calendar.png"; | ||||||
|  |             case "office365_calendar": return "integrations/office-365.png"; | ||||||
|  |             default: return ""; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     function classNames(...classes) { | ||||||
|  |         return classes.filter(Boolean).join(' ') | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     useEffect(loadCalendars, [integrations]); | ||||||
|  | 
 | ||||||
|  |     if (loading) { | ||||||
|  |         return <p className="text-gray-400">Loading...</p>; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     return ( |     return ( | ||||||
|         <div> |         <div> | ||||||
|             <Head> |             <Head> | ||||||
|  | @ -39,7 +92,7 @@ export default function Home({ integrations }) { | ||||||
|                         Add new integration |                         Add new integration | ||||||
|                     </button> |                     </button> | ||||||
|                 </div> |                 </div> | ||||||
|                 <div className="bg-white shadow overflow-hidden rounded-lg"> |                 <div className="bg-white shadow overflow-hidden rounded-lg mb-8"> | ||||||
|                     {integrations.filter( (ig) => ig.credential ).length !== 0 ? <ul className="divide-y divide-gray-200"> |                     {integrations.filter( (ig) => ig.credential ).length !== 0 ? <ul className="divide-y divide-gray-200"> | ||||||
|                         {integrations.filter(ig => ig.credential).map( (ig) => (<li> |                         {integrations.filter(ig => ig.credential).map( (ig) => (<li> | ||||||
|                             <Link href={"/integrations/" + ig.credential.id}> |                             <Link href={"/integrations/" + ig.credential.id}> | ||||||
|  | @ -165,6 +218,104 @@ export default function Home({ integrations }) { | ||||||
|                     </div> |                     </div> | ||||||
|                 </div> |                 </div> | ||||||
|                 } |                 } | ||||||
|  |                 <div className="bg-white shadow rounded-lg"> | ||||||
|  |                     <div className="px-4 py-5 sm:p-6"> | ||||||
|  |                         <h3 className="text-lg leading-6 font-medium text-gray-900"> | ||||||
|  |                             Select calendars | ||||||
|  |                         </h3> | ||||||
|  |                         <div className="mt-2 max-w-xl text-sm text-gray-500"> | ||||||
|  |                             <p> | ||||||
|  |                                Select which calendars are checked for availability to prevent double bookings. | ||||||
|  |                             </p> | ||||||
|  |                         </div> | ||||||
|  |                         <div className="mt-5"> | ||||||
|  |                             <button type="button" onClick={toggleShowCalendarModal} className="btn btn-primary"> | ||||||
|  |                                 Select calendars | ||||||
|  |                             </button> | ||||||
|  |                         </div> | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |                 {showSelectCalendarModal && | ||||||
|  |                 <div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true"> | ||||||
|  |                     <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> | ||||||
|  |                         {/* <!-- | ||||||
|  |                           Background overlay, show/hide based on modal state. | ||||||
|  | 
 | ||||||
|  |                           Entering: "ease-out duration-300" | ||||||
|  |                             From: "opacity-0" | ||||||
|  |                             To: "opacity-100" | ||||||
|  |                           Leaving: "ease-in duration-200" | ||||||
|  |                             From: "opacity-100" | ||||||
|  |                             To: "opacity-0" | ||||||
|  |                         --> */} | ||||||
|  |                         <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div> | ||||||
|  |                         <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span> | ||||||
|  |                         {/* <!-- | ||||||
|  |                           Modal panel, show/hide based on modal state. | ||||||
|  | 
 | ||||||
|  |                           Entering: "ease-out duration-300" | ||||||
|  |                             From: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" | ||||||
|  |                             To: "opacity-100 translate-y-0 sm:scale-100" | ||||||
|  |                           Leaving: "ease-in duration-200" | ||||||
|  |                             From: "opacity-100 translate-y-0 sm:scale-100" | ||||||
|  |                             To: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" | ||||||
|  |                         --> */} | ||||||
|  |                         <div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6"> | ||||||
|  |                             <div className="sm:flex sm:items-start"> | ||||||
|  |                                 <div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10"> | ||||||
|  |                                     <CalendarIcon className="h-6 w-6 text-blue-600" /> | ||||||
|  |                                 </div> | ||||||
|  |                                 <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> | ||||||
|  |                                     <h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title"> | ||||||
|  |                                         Select calendars | ||||||
|  |                                     </h3> | ||||||
|  |                                     <div> | ||||||
|  |                                         <p className="text-sm text-gray-400"> | ||||||
|  |                                             If no entry is selected, all calendars will be checked | ||||||
|  |                                         </p> | ||||||
|  |                                     </div> | ||||||
|  |                                 </div> | ||||||
|  |                             </div> | ||||||
|  |                             <div className="my-4"> | ||||||
|  |                                 <ul className="divide-y divide-gray-200"> | ||||||
|  |                                     {selectableCalendars.map( (calendar) => (<li className="flex py-4"> | ||||||
|  |                                         <div className="w-1/12 mr-4 pt-2"> | ||||||
|  |                                             <img className="h-8 w-8 mr-2" src={getCalendarIntegrationImage(calendar.integration)} alt={calendar.integration} /> | ||||||
|  |                                         </div> | ||||||
|  |                                         <div className="w-10/12 pt-3"> | ||||||
|  |                                             <h2 className="text-gray-800 font-medium">{ calendar.name }</h2> | ||||||
|  |                                         </div> | ||||||
|  |                                         <div className="w-2/12 text-right pt-3"> | ||||||
|  |                                             <Switch | ||||||
|  |                                               checked={calendar.selected} | ||||||
|  |                                               onChange={calendarSelectionHandler(calendar)} | ||||||
|  |                                               className={classNames( | ||||||
|  |                                                 calendar.selected ? 'bg-indigo-600' : 'bg-gray-200', | ||||||
|  |                                                 'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500' | ||||||
|  |                                               )} | ||||||
|  |                                             > | ||||||
|  |                                                 <span className="sr-only">Select calendar</span> | ||||||
|  |                                                 <span | ||||||
|  |                                                   aria-hidden="true" | ||||||
|  |                                                   className={classNames( | ||||||
|  |                                                     calendar.selected ? 'translate-x-5' : 'translate-x-0', | ||||||
|  |                                                     'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200' | ||||||
|  |                                                   )} | ||||||
|  |                                                 /> | ||||||
|  |                                             </Switch> | ||||||
|  |                                         </div> | ||||||
|  |                                     </li>))} | ||||||
|  |                                 </ul> | ||||||
|  |                             </div> | ||||||
|  |                             <div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse"> | ||||||
|  |                                 <button onClick={toggleShowCalendarModal} type="button" className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm"> | ||||||
|  |                                     Close | ||||||
|  |                                 </button> | ||||||
|  |                             </div> | ||||||
|  |                         </div> | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |                 } | ||||||
|             </Shell> |             </Shell> | ||||||
|         </div> |         </div> | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|  | @ -0,0 +1,11 @@ | ||||||
|  | -- CreateTable | ||||||
|  | CREATE TABLE "SelectedCalendar" ( | ||||||
|  |     "userId" INTEGER NOT NULL, | ||||||
|  |     "integration" TEXT NOT NULL, | ||||||
|  |     "externalId" TEXT NOT NULL, | ||||||
|  | 
 | ||||||
|  |     PRIMARY KEY ("userId","integration","externalId") | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | -- AddForeignKey | ||||||
|  | ALTER TABLE "SelectedCalendar" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; | ||||||
|  | @ -49,6 +49,7 @@ model User { | ||||||
|   credentials   Credential[] |   credentials   Credential[] | ||||||
|   teams         Membership[] |   teams         Membership[] | ||||||
|   bookings      Booking[] |   bookings      Booking[] | ||||||
|  |   selectedCalendars      SelectedCalendar[] | ||||||
|   @@map(name: "users") |   @@map(name: "users") | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -120,3 +121,11 @@ model Booking { | ||||||
|   createdAt     DateTime    @default(now()) |   createdAt     DateTime    @default(now()) | ||||||
|   updatedAt     DateTime? |   updatedAt     DateTime? | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | model SelectedCalendar { | ||||||
|  |   user          User       @relation(fields: [userId], references: [id]) | ||||||
|  |   userId        Int | ||||||
|  |   integration   String | ||||||
|  |   externalId    String | ||||||
|  |   @@id([userId,integration,externalId]) | ||||||
|  | } | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue
	
	 Bailey Pumfleet
						Bailey Pumfleet