This commit is contained in:
Alex van Andel 2021-06-14 18:53:20 +00:00
parent 49cb191254
commit 03f583b021
15 changed files with 441 additions and 11 deletions

View file

@ -28,6 +28,8 @@ tags:
description: Manage integrations
- name: User
description: Manage the user's profile and settings
- name: Team
description: Group users into teams
paths:
/api/auth/signin:
get:
@ -144,4 +146,9 @@ paths:
description: Updates a user's profile.
summary: Updates a user's profile
tags:
- User
- User
/api/availability/schedule:
path:
description: "Updates a schedule"
tags:
- Availability

View file

@ -0,0 +1,6 @@
import {Dayjs} from "dayjs";
interface Schedule {
startDate: Dayjs;
endDate: Dayjs;
}

View file

@ -0,0 +1,7 @@
/*export default function DateOverrideModal(props) {
return (
);
}*/

View file

@ -0,0 +1,100 @@
import {ClockIcon} from "@heroicons/react/outline";
import {useRef} from "react";
export default function SetTimesModal(props) {
const isNew = props.isNew || false;
const {startDate, endDate} = props.schedule;
const startHoursRef = useRef<HTMLInputElement>();
const startMinsRef = useRef<HTMLInputElement>();
const endHoursRef = useRef<HTMLInputElement>();
const endMinsRef = useRef<HTMLInputElement>();
function updateStartEndTimesHandler(event) {
event.preventDefault();
const enteredStartHours = parseInt(startHoursRef.current.value);
const enteredStartMins = parseInt(startMinsRef.current.value);
const enteredEndHours = parseInt(endHoursRef.current.value);
const enteredEndMins = parseInt(endMinsRef.current.value);
props.onChange({
startDate: startDate.minute(enteredStartMins).hour(enteredStartHours),
endDate: endDate.minute(enteredEndMins).hour(enteredEndHours),
});
props.onExit(0);
}
return (
<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">
<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">&#8203;</span>
<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 mb-4">
<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">
<ClockIcon 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">
Change when you are available for bookings
</h3>
<div>
<p className="text-sm text-gray-500">
Set your work schedule
</p>
</div>
</div>
</div>
<form onSubmit={updateStartEndTimesHandler} noValidate>
<div className="flex mb-4">
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">Start time</label>
<div>
<label htmlFor="startHours" className="sr-only">Hours</label>
<input ref={startHoursRef} type="number" min="0" max="23" maxLength="2" name="hours" id="startHours"
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
placeholder="9" defaultValue={startDate.format('H')} />
</div>
<span className="mx-2 pt-1">:</span>
<div>
<label htmlFor="startMinutes" className="sr-only">Minutes</label>
<input ref={startMinsRef} type="number" min="0" max="59" step="15" maxLength="2" name="minutes" id="startMinutes"
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
placeholder="30" defaultValue={startDate.format('m')} />
</div>
</div>
<div className="flex">
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">End time</label>
<div>
<label htmlFor="endHours" className="sr-only">Hours</label>
<input ref={endHoursRef} type="number" min="0" max="23" maxLength="2" name="hours" id="endHours"
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
placeholder="17" defaultValue={endDate.format('H')} />
</div>
<span className="mx-2 pt-1">:</span>
<div>
<label htmlFor="endMinutes" className="sr-only">Minutes</label>
<input ref={endMinsRef} type="number" min="0" max="59" maxLength="2" step="15" name="minutes" id="endMinutes"
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
placeholder="30" defaultValue={endDate.format('m')} />
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="submit" className="btn btn-primary">
Save
</button>
<button onClick={props.onExit} type="button" className="btn btn-white mr-2">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>);
}

101
components/ui/Scheduler.tsx Normal file
View file

@ -0,0 +1,101 @@
import React, {useEffect, useState} from "react";
import TimezoneSelect from "react-timezone-select";
import {PencilAltIcon, TrashIcon} from "@heroicons/react/outline";
import {WeekdaySelect} from "./WeekdaySelect";
import SetTimesModal from "../modal/SetTimesModal";
import Schedule from '../../lib/schedule.model';
import dayjs, {Dayjs} from "dayjs";
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
dayjs.extend(utc);
dayjs.extend(timezone);
export const Scheduler = (props) => {
const [ showSetTimesModal, setShowSetTimesModal ]: boolean = useState(false);
const [ schedules, setSchedules ]: Schedule[] = useState(
props.schedules.map( (schedule, idx) => ({
startDate: dayjs(schedule.startDate),
endDate: dayjs(schedule.startDate).startOf('day').add(schedule.length, 'minutes'),
key: idx
}) )
);
const [ timeZone, setTimeZone ] = useState(props.timeZone);
const [ selectedSchedule, setSelectedSchedule ]: Schedule | null = useState(null);
const addNewSchedule = () => {
setSelectedSchedule({
startDate: dayjs().startOf('day').add(0, 'minutes'),
endDate: dayjs().startOf('day').add(1439, 'minutes'),
});
setShowSetTimesModal(true);
}
const upsertSchedule = (changed: Schedule) => {
if (changed.key) {
schedules.splice(
schedules.findIndex( (schedule) => changed.key === schedule.key ), 1, changed
)
setSchedules([].concat(schedules)); // update
}
else {
console.log(changed);
setSchedules(schedules.concat([changed])); // insert
}
}
const removeSchedule = (toRemove: Schedule) => {
schedules.splice(schedules.findIndex( (schedule) => schedule.key === toRemove.key ), 1);
setSchedules([].concat(schedules));
};
return (
<div>
<div className="rounded border flex">
<div className="w-3/5">
<div className="w-3/4 p-2">
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
Timezone
</label>
<div className="mt-1">
<TimezoneSelect id="timeZone" value={timeZone} onChange={setTimeZone} className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md" />
</div>
</div>
<ul>
{schedules.length > 0 && schedules.map( (schedule) =>
<li key={schedule.key} className="py-2 flex justify-between border-t">
<div className="inline-flex ml-2">
<WeekdaySelect />
<button className="ml-2 text-sm px-2" type="button" onClick={() => { setSelectedSchedule(schedule); setShowSetTimesModal(true) }}>
{schedule.startDate.format(schedule.startDate.minute() === 0 ? 'ha' : 'h:mma')} until {schedule.endDate.format(schedule.endDate.minute() === 0 ? 'ha' : 'h:mma')}
</button>
</div>
<button type="button" onClick={() => removeSchedule(schedule)}
className="btn-sm bg-transparent px-2 py-1 ml-1">
<TrashIcon className="h-6 w-6 inline text-gray-400 -mt-1" />
</button>
</li>)}
</ul>
<hr />
<button type="button" onClick={addNewSchedule} className="btn-white btn-sm m-2">Add another</button>
</div>
<div className="border-l p-2 w-2/5 text-sm bg-gray-50">
{/*<p className="font-bold mb-2">Add date overrides</p>
<p className="mb-2">
Add dates when your availability changes from your weekly hours
</p>
<button className="btn-sm btn-white">Add a date override</button>*/}
</div>
</div>
{showSetTimesModal &&
<SetTimesModal schedule={selectedSchedule}
onChange={upsertSchedule}
onExit={() => setShowSetTimesModal(false)} />
}
{/*{showDateOverrideModal &&
<DateOverrideModal />
}*/}
</div>
);
}

View file

@ -0,0 +1,39 @@
import React, {useState} from "react";
export const WeekdaySelect = (props) => {
const [ activeDays, setActiveDays ] = useState([false, true, true, true, true, true, false]);
const days = [ 'S', 'M', 'T', 'W', 'T', 'F', 'S' ];
const toggleDay = (e, idx: number) => {
e.preventDefault();
activeDays[idx] = !activeDays[idx];
console.log(activeDays);
setActiveDays([].concat(activeDays));
}
return (
<div className="weekdaySelect">
<div className="inline-flex">
{days.map( (day, idx) => activeDays[idx] ?
<button key={idx} onClick={(e) => toggleDay(e, idx)}
style={ {"marginLeft": "-2px"} }
className={`
active focus:outline-none border-2 border-blue-500 px-2 py-1 rounded
${activeDays[idx+1] ? 'rounded-r-none': ''}
${activeDays[idx-1] ? 'rounded-l-none': ''}
${idx === 0 ? 'rounded-l' : ''}
${idx === days.length-1 ? 'rounded-r' : ''}
`}>
{day}
</button>
:
<button key={idx} onClick={(e) => toggleDay(e, idx)}
style={ {"marginTop": "1px", "marginBottom": "1px"} }
className={`border focus:outline-none px-2 py-1 rounded-none ${idx === 0 ? 'rounded-l' : 'border-l-0'} ${idx === days.length-1 ? 'rounded-r' : ''}`}>
{day}
</button>
)}
</div>
</div>);
}

7
lib/schedule.model.tsx Normal file
View file

@ -0,0 +1,7 @@
import {Dayjs} from "dayjs";
export default interface Schedule {
key: number;
startDate: Dayjs;
endDate: Dayjs;
}

View file

@ -0,0 +1,45 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getSession } from 'next-auth/client';
import prisma from '../../../lib/prisma';
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;
}
PUT /api/availability/schedule/{id}/timezone
{
"timeZone": "Europe/London"
}
PATCH /api/availability/schedule {
"schedules": [
{
}
],
"overrides": {
}
}
if (req.method == "PATCH") {
const startMins = req.body.start;
const endMins = req.body.end;
const updateDay = await prisma.schedule.update({
where: {
id: session.user.id,
},
data: {
startTime: startMins,
endTime: endMins
},
});
res.status(200).json({message: 'Start and end times updated successfully'});
}
}

View file

@ -0,0 +1,18 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import prisma from '../../../lib/prisma';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { user } = req.query
const schedules = await prisma.schedule.find({
where: {
eventTypeId: req.query.type,
},
select: {
credentials: true,
timeZone: true
}
});
return res.status(202).send(null);
}

View file

@ -0,0 +1,30 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getSession } from 'next-auth/client';
import prisma from '../../../lib/prisma';
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;
}
if (req.method == "PATCH") {
const startMins = req.body.start;
const endMins = req.body.end;
const updateWeek = await prisma.schedule.update({
where: {
id: session.user.id,
},
data: {
startTime: startMins,
endTime: endMins
},
});
res.status(200).json({message: 'Start and end times updated successfully'});
}
}

View file

@ -7,6 +7,8 @@ import prisma from '../../../lib/prisma';
import { LocationType } from '../../../lib/location';
import Shell from '../../../components/Shell';
import { useSession, getSession } from 'next-auth/client';
import {Scheduler} from "../../../components/ui/Scheduler";
import {
LocationMarkerIcon,
PlusCircleIcon,
@ -14,6 +16,12 @@ import {
PhoneIcon,
} from '@heroicons/react/outline';
import dayjs, {Dayjs} from "dayjs";
import utc from 'dayjs/plugin/utc';
dayjs.extend(utc);
import timezone from 'dayjs/plugin/timezone';
dayjs.extend(timezone);
export default function EventType(props) {
const router = useRouter();
@ -32,6 +40,8 @@ export default function EventType(props) {
return <p className="text-gray-400">Loading...</p>;
}
console.log(props);
async function updateEventTypeHandler(event) {
event.preventDefault();
@ -142,7 +152,7 @@ export default function EventType(props) {
<Shell heading={'Event Type - ' + props.eventType.title}>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-3 sm:col-span-2">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="bg-white overflow-hidden shadow rounded-lg mb-4">
<div className="px-4 py-5 sm:p-6">
<form onSubmit={updateEventTypeHandler}>
<div className="mb-4">
@ -232,7 +242,7 @@ export default function EventType(props) {
</div>
</div>
</div>
<div className="my-8">
<div className="my-6 mb-4">
<div className="relative flex items-start">
<div className="flex items-center h-5">
<input
@ -252,9 +262,16 @@ export default function EventType(props) {
</div>
</div>
</div>
<button type="submit" className="btn btn-primary">Update</button>
<Link href="/availability"><a className="ml-2 btn btn-white">Cancel</a></Link>
</form>
<hr className="my-4"/>
<div>
<h3 className="mb-2">How do you want to offer your availability for this event type?</h3>
<Scheduler timeZone={props.user.timeZone} schedules={props.schedules} />
<div className="py-4 flex justify-end">
<Link href="/availability"><a className="mr-2 btn btn-white">Cancel</a></Link>
<button type="submit" className="btn btn-primary">Update</button>
</div>
</div>
</div>
</div>
</div>
@ -332,7 +349,10 @@ export async function getServerSideProps(context) {
email: session.user.email,
},
select: {
username: true
username: true,
timeZone: true,
startTime: true,
endTime: true,
}
});
@ -351,10 +371,21 @@ export async function getServerSideProps(context) {
}
});
const utcOffset = dayjs().tz(user.timeZone).utcOffset();
const schedules = [
{
key: 0,
startDate: dayjs.utc().startOf('day').add(user.startTime - utcOffset, 'minutes').format(),
length: user.endTime,
}
];
return {
props: {
user,
eventType
},
props: {
user,
eventType,
schedules
},
}
}

View file

@ -21,6 +21,7 @@ model EventType {
user User? @relation(fields: [userId], references: [id])
userId Int?
bookings Booking[]
availability Interval[]
}
model Credential {
@ -49,6 +50,8 @@ model User {
credentials Credential[]
teams Membership[]
bookings Booking[]
availability Interval[]
@@map(name: "users")
}
@ -119,4 +122,16 @@ model Booking {
createdAt DateTime @default(now())
updatedAt DateTime?
}
}
model Interval {
id Int @default(autoincrement()) @id
label String?
user User? @relation(fields: [userId], references: [id])
userId Int?
eventType EventType? @relation(fields: [eventTypeId], references: [id])
eventTypeId Int?
startTime DateTime
length Int
isOverride Boolean @default(false)
}

View file

@ -1,4 +1,8 @@
table tbody tr:nth-child(odd) {
@apply bg-gray-50;
}
.highlight-odd > *:nth-child(odd) {
@apply bg-gray-50;
}

View file

@ -24,4 +24,24 @@ body {
#timeZone input:focus {
box-shadow: none;
}
.weekdaySelect {
font-family: "Courier New", sans-serif;
}
.weekdaySelect button.active:first-child {
margin-left: -1px !important;
}
.weekdaySelect button:not(.active) {
padding-left: calc(0.5rem + 0px);
margin-right: 1px;
}
.weekdaySelect button.active + button.active {
border-color: rgba(3, 169, 244, var(--tw-border-opacity))
rgba(3, 169, 244, var(--tw-border-opacity))
rgba(3, 169, 244, var(--tw-border-opacity))
white;
}