2024-01-10 10:19:54 +02:00
|
|
|
import { Box, Card, Divider, Stack, Typography } from "@mui/material";
|
2024-01-09 17:23:40 +02:00
|
|
|
import dayjs, { Dayjs } from "dayjs";
|
2024-01-10 10:19:54 +02:00
|
|
|
import { MouseEvent, useEffect, useState } from "react";
|
2024-01-09 17:23:40 +02:00
|
|
|
import * as utc from 'dayjs/plugin/utc';
|
|
|
|
import * as timezone from 'dayjs/plugin/timezone';
|
|
|
|
import * as localizedFormat from 'dayjs/plugin/localizedFormat';
|
|
|
|
import utils from "../utils";
|
|
|
|
import { EventTypes } from "../types/Event";
|
2024-01-10 00:18:42 +02:00
|
|
|
import "./css/AvailabilityPicker.css";
|
2024-01-10 10:19:54 +02:00
|
|
|
import classNames from 'classnames';
|
2024-01-10 00:18:42 +02:00
|
|
|
// import { alpha } from '@material-ui/core/styles/colorManipulator';
|
2024-01-09 17:23:40 +02:00
|
|
|
|
|
|
|
dayjs.extend(utc)
|
|
|
|
dayjs.extend(timezone)
|
|
|
|
dayjs.extend(localizedFormat)
|
|
|
|
|
|
|
|
type AvailabilityTime = {
|
|
|
|
fromTime: Dayjs,
|
|
|
|
toTime: Dayjs
|
|
|
|
}
|
|
|
|
|
|
|
|
type AvailabilityDay = {
|
|
|
|
forDate: Dayjs,
|
|
|
|
availableTimes: AvailabilityTime[]
|
|
|
|
}
|
|
|
|
|
2024-01-10 00:18:42 +02:00
|
|
|
type GhostPreviewProps = {
|
|
|
|
top: number,
|
|
|
|
left: number,
|
2024-01-10 10:19:54 +02:00
|
|
|
width: number,
|
|
|
|
height: number
|
2024-01-10 00:18:42 +02:00
|
|
|
}
|
|
|
|
|
2024-01-09 17:23:40 +02:00
|
|
|
const HALFHOUR_DISPLAY_HEIGHT: number = 15;
|
|
|
|
|
2024-01-10 10:19:54 +02:00
|
|
|
const Hour = (props: {
|
|
|
|
dateTime: Dayjs,
|
|
|
|
isFullHourSelected: boolean,
|
|
|
|
isHalfHourSelected: boolean,
|
|
|
|
onMouseEnterHalfhour: (e: MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: Dayjs) => void,
|
|
|
|
onMouseClickOnHalfhour: (time: Dayjs) => void
|
|
|
|
}) => {
|
|
|
|
let isEvenHour = props.dateTime.hour() % 2 == 0;
|
2024-01-09 17:23:40 +02:00
|
|
|
|
2024-01-10 10:19:54 +02:00
|
|
|
return (
|
|
|
|
<Box
|
|
|
|
className={classNames({ "hour-light": isEvenHour, "hour-dark": !isEvenHour })}
|
|
|
|
>
|
|
|
|
<Box
|
|
|
|
className={classNames("full-hour", { "selected-availability": props.isFullHourSelected })}
|
|
|
|
height={HALFHOUR_DISPLAY_HEIGHT}
|
|
|
|
onMouseEnter={(e) => props.onMouseEnterHalfhour(e, props.dateTime)}
|
|
|
|
onClick={(_) => props.onMouseClickOnHalfhour(props.dateTime)}
|
|
|
|
>
|
|
|
|
<Typography className={"noselect time-text"}>
|
|
|
|
{ utils.formatTimeFromHourOfDay(props.dateTime.hour(), 0) }
|
|
|
|
</Typography>
|
|
|
|
</Box>
|
|
|
|
<Box
|
|
|
|
className={classNames("half-hour", { "selected-availability": props.isHalfHourSelected })}
|
|
|
|
height={HALFHOUR_DISPLAY_HEIGHT}
|
|
|
|
onMouseEnter={(e) => props.onMouseEnterHalfhour(e, props.dateTime.add(30, "minutes"))}
|
|
|
|
onClick={(_) => props.onMouseClickOnHalfhour(props.dateTime.add(30, "minutes"))}
|
|
|
|
/>
|
|
|
|
</Box>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
const isSelectedAvailability = (day: AvailabilityDay, time: Dayjs): boolean => {
|
|
|
|
return day.availableTimes.some(t => t.fromTime.unix() <= time.unix() && time.unix() <= t.toTime.unix());
|
|
|
|
}
|
|
|
|
|
|
|
|
const Day = (props: {
|
|
|
|
day: AvailabilityDay,
|
|
|
|
eventType: String,
|
|
|
|
onMouseEnterHalfhour: (e: MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: dayjs.Dayjs) => void,
|
|
|
|
onMouseClickHalfhour: (day: AvailabilityDay, time: dayjs.Dayjs) => void
|
|
|
|
}) => {
|
|
|
|
|
|
|
|
const generateHours = (): JSX.Element[] => {
|
|
|
|
let hours: JSX.Element[] = [];
|
|
|
|
|
|
|
|
for (var i = 0; i < 24; i++) {
|
|
|
|
let time = props.day.forDate.set("hour", i);
|
|
|
|
hours.push(
|
|
|
|
<Hour
|
|
|
|
key={time.unix()}
|
|
|
|
dateTime={time}
|
|
|
|
isFullHourSelected={isSelectedAvailability(props.day, time)}
|
|
|
|
isHalfHourSelected={isSelectedAvailability(props.day, time.set("minutes", 30))}
|
|
|
|
onMouseEnterHalfhour={(e: MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: dayjs.Dayjs): void => {
|
|
|
|
props.onMouseEnterHalfhour(e, time);
|
|
|
|
}}
|
|
|
|
onMouseClickOnHalfhour={(time: dayjs.Dayjs): void => {
|
|
|
|
props.onMouseClickHalfhour(props.day, time);
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
return hours;
|
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
|
|
<Card
|
|
|
|
|
|
|
|
key={props.day.forDate.format()}
|
|
|
|
className={"day-card"}
|
|
|
|
variant="outlined"
|
|
|
|
>
|
|
|
|
<Box
|
|
|
|
sx={{ width: "100%" }}
|
|
|
|
padding={1}
|
|
|
|
>
|
|
|
|
{
|
|
|
|
(props.eventType === EventTypes.WEEK) &&
|
|
|
|
<Typography>
|
|
|
|
{ props.day.forDate.format("dddd") }
|
|
|
|
</Typography>
|
|
|
|
}
|
|
|
|
{
|
|
|
|
(props.eventType === EventTypes.DAY) &&
|
|
|
|
<Typography>
|
|
|
|
Any Day
|
|
|
|
</Typography>
|
|
|
|
}
|
|
|
|
{
|
|
|
|
(props.eventType === EventTypes.DATE_RANGE || props.eventType === EventTypes.SPECIFIC_DATE) &&
|
|
|
|
<Typography>
|
|
|
|
{ props.day.forDate.format("LL") }
|
|
|
|
</Typography>
|
|
|
|
}
|
|
|
|
</Box>
|
|
|
|
|
|
|
|
<Divider></Divider>
|
|
|
|
|
|
|
|
{generateHours()}
|
|
|
|
|
|
|
|
</Card>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
const AvailabilityPicker = (props: {
|
2024-01-09 17:23:40 +02:00
|
|
|
fromDate: Dayjs,
|
|
|
|
toDate: Dayjs,
|
|
|
|
eventType: String,
|
|
|
|
availabilityDurationInMinutes: number
|
2024-01-10 10:19:54 +02:00
|
|
|
}) => {
|
2024-01-10 00:18:42 +02:00
|
|
|
|
2024-01-09 17:23:40 +02:00
|
|
|
const [days, setDays] = useState<AvailabilityDay[]>([]);
|
2024-01-10 00:18:42 +02:00
|
|
|
const [ghostPreviewProps, setGhostPreviewProps] = useState<GhostPreviewProps | null>();
|
2024-01-09 17:23:40 +02:00
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
let localTimezone = dayjs.tz.guess();
|
|
|
|
|
|
|
|
let localFromDate = props.fromDate.tz(localTimezone);
|
|
|
|
let localToDate = props.toDate.tz(localTimezone);
|
|
|
|
|
|
|
|
switch (props.eventType) {
|
|
|
|
case EventTypes.SPECIFIC_DATE: {
|
|
|
|
createAvailabilitiesBasedOnInitialDate(localFromDate, 1);
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
case EventTypes.DATE_RANGE: {
|
|
|
|
createAvailabilitiesBasedOnInitialDate(localFromDate, Math.abs(localFromDate.diff(localToDate, "day", false)));
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
case EventTypes.DAY: {
|
|
|
|
createAvailabilitiesBasedOnUnspecifiedInitialDate(1, localTimezone);
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
case EventTypes.WEEK: {
|
|
|
|
createAvailabilitiesBasedOnUnspecifiedInitialDate(7, localTimezone);
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}, [props]);
|
|
|
|
|
2024-01-10 10:19:54 +02:00
|
|
|
const createAvailabilitiesBasedOnUnspecifiedInitialDate = (numberOfDays: number, tz: string) => {
|
2024-01-09 17:23:40 +02:00
|
|
|
createAvailabilitiesBasedOnInitialDate(dayjs.tz("1970-01-05 00:00:00", tz), numberOfDays);
|
|
|
|
}
|
|
|
|
|
2024-01-10 10:19:54 +02:00
|
|
|
const createAvailabilitiesBasedOnInitialDate = (date: Dayjs, numberOfDays: number) => {
|
2024-01-09 17:23:40 +02:00
|
|
|
let availabilities: AvailabilityDay[] = [];
|
|
|
|
|
|
|
|
for (var i: number = 0; i < numberOfDays; i++) {
|
|
|
|
let availability: AvailabilityDay = {
|
|
|
|
forDate: date.add(i, "day").startOf("day"),
|
|
|
|
availableTimes: []
|
|
|
|
}
|
|
|
|
|
|
|
|
availabilities.push(availability);
|
|
|
|
}
|
|
|
|
|
|
|
|
setDays(availabilities);
|
|
|
|
}
|
|
|
|
|
2024-01-10 10:19:54 +02:00
|
|
|
const displayGhostPeriod = (e: MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: Dayjs) => {
|
2024-01-10 00:18:42 +02:00
|
|
|
let timeInMinutes = (time.hour() * 60) + time.minute();
|
|
|
|
let timeLeftInDayLessThanDuration = (timeInMinutes + props.availabilityDurationInMinutes) > 24 * 60;
|
2024-01-09 17:23:40 +02:00
|
|
|
|
2024-01-10 00:18:42 +02:00
|
|
|
if (timeLeftInDayLessThanDuration) {
|
2024-01-09 17:23:40 +02:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2024-01-10 00:18:42 +02:00
|
|
|
let scrollTop = document.getElementById('availability-picker')?.scrollTop ?? 0;
|
|
|
|
let scrollLeft = document.getElementById('availability-picker')?.scrollLeft ?? 0;
|
|
|
|
|
2024-01-10 10:19:54 +02:00
|
|
|
// @ts-ignore
|
2024-01-10 00:18:42 +02:00
|
|
|
const element = e.target.getBoundingClientRect();
|
2024-01-09 17:23:40 +02:00
|
|
|
|
2024-01-10 00:18:42 +02:00
|
|
|
setGhostPreviewProps({
|
2024-01-10 10:19:54 +02:00
|
|
|
// @ts-ignore
|
2024-01-10 00:18:42 +02:00
|
|
|
top: e.target?.offsetTop - scrollTop,
|
2024-01-10 10:19:54 +02:00
|
|
|
// @ts-ignore
|
2024-01-10 00:18:42 +02:00
|
|
|
left: e.target?.offsetLeft - scrollLeft,
|
|
|
|
width: element.width,
|
2024-01-10 10:19:54 +02:00
|
|
|
height: (props.availabilityDurationInMinutes/60) * 2 * HALFHOUR_DISPLAY_HEIGHT
|
2024-01-10 00:18:42 +02:00
|
|
|
})
|
2024-01-09 17:23:40 +02:00
|
|
|
}
|
|
|
|
|
2024-01-10 10:19:54 +02:00
|
|
|
const createAvailability = (day: AvailabilityDay, time: Dayjs) => {
|
2024-01-10 00:18:42 +02:00
|
|
|
let fromTime = time;
|
|
|
|
let toTime = time.add(props.availabilityDurationInMinutes, "minutes");
|
2024-01-09 17:23:40 +02:00
|
|
|
|
2024-01-10 00:18:42 +02:00
|
|
|
let existingTimeContainingFrom = day.availableTimes.findIndex(t => (t.fromTime.isBefore(fromTime) || t.fromTime.isSame(fromTime)) && (t.toTime.isAfter(fromTime) || t.toTime.isSame(fromTime)));
|
|
|
|
let existingTimeContainingTo = day.availableTimes.findIndex(t => (t.fromTime.isBefore(toTime) || t.fromTime.isSame(toTime)) && (t.toTime.isAfter(toTime) || t.toTime.isSame(toTime)));
|
2024-01-09 17:23:40 +02:00
|
|
|
|
2024-01-10 00:18:42 +02:00
|
|
|
// the newly created availability crosses another single one. Both have the same from and to. Do nothing.
|
|
|
|
if (existingTimeContainingFrom >= 0 && existingTimeContainingTo >= 0 && existingTimeContainingFrom === existingTimeContainingTo) {
|
2024-01-09 17:23:40 +02:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2024-01-10 00:18:42 +02:00
|
|
|
// the newly created availability crosses 2 existing ones. Combine all of them into a single one.
|
|
|
|
if (existingTimeContainingFrom >= 0 && existingTimeContainingTo >= 0 && existingTimeContainingFrom !== existingTimeContainingTo) {
|
|
|
|
let newFrom = day.availableTimes[existingTimeContainingFrom].fromTime;
|
|
|
|
let newTo = day.availableTimes[existingTimeContainingFrom].toTime;
|
|
|
|
|
|
|
|
day.availableTimes.splice(existingTimeContainingFrom);
|
|
|
|
day.availableTimes.splice(existingTimeContainingTo);
|
2024-01-09 17:23:40 +02:00
|
|
|
|
2024-01-10 00:18:42 +02:00
|
|
|
day.availableTimes.push({
|
|
|
|
fromTime: newFrom,
|
|
|
|
toTime: newTo
|
|
|
|
});
|
2024-01-09 17:23:40 +02:00
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2024-01-10 00:18:42 +02:00
|
|
|
// The newly created availability from is within an existing one. Combine the 2 into one.
|
|
|
|
if (existingTimeContainingFrom >= 0 && existingTimeContainingTo < 0) {
|
|
|
|
let newFrom = day.availableTimes[existingTimeContainingFrom].fromTime;
|
|
|
|
|
|
|
|
day.availableTimes.splice(existingTimeContainingFrom);
|
|
|
|
|
|
|
|
day.availableTimes.push({
|
|
|
|
fromTime: newFrom,
|
|
|
|
toTime: toTime
|
|
|
|
});
|
2024-01-09 17:23:40 +02:00
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2024-01-10 00:18:42 +02:00
|
|
|
// The newly created availability to is within an existing one. Combine the 2 into one.
|
|
|
|
if (existingTimeContainingFrom >= 0 && existingTimeContainingTo < 0) {
|
|
|
|
let newTo = day.availableTimes[existingTimeContainingFrom].toTime;
|
2024-01-09 17:23:40 +02:00
|
|
|
|
2024-01-10 00:18:42 +02:00
|
|
|
day.availableTimes.splice(existingTimeContainingFrom);
|
2024-01-09 17:23:40 +02:00
|
|
|
|
2024-01-10 00:18:42 +02:00
|
|
|
day.availableTimes.push({
|
|
|
|
fromTime: fromTime,
|
|
|
|
toTime: newTo
|
|
|
|
});
|
2024-01-09 17:23:40 +02:00
|
|
|
|
2024-01-10 00:18:42 +02:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
day.availableTimes.push({
|
|
|
|
fromTime: fromTime,
|
|
|
|
toTime: toTime
|
|
|
|
});
|
2024-01-09 17:23:40 +02:00
|
|
|
|
2024-01-10 00:18:42 +02:00
|
|
|
setDays([...days])
|
2024-01-09 17:23:40 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
2024-01-10 00:18:42 +02:00
|
|
|
<Box
|
2024-01-10 10:19:54 +02:00
|
|
|
className={"availability-parent-box"}
|
2024-01-09 17:23:40 +02:00
|
|
|
>
|
2024-01-10 00:18:42 +02:00
|
|
|
<Stack
|
|
|
|
id="availability-picker"
|
|
|
|
direction="row"
|
|
|
|
spacing={1}
|
|
|
|
justifyContent={"safe center"}
|
2024-01-10 10:19:54 +02:00
|
|
|
className={"availability-parent-stack"}
|
|
|
|
onScroll={(_) => setGhostPreviewProps(null)}
|
2024-01-10 00:18:42 +02:00
|
|
|
>
|
|
|
|
{
|
2024-01-10 10:19:54 +02:00
|
|
|
days.map(day =>
|
|
|
|
<Day
|
|
|
|
key={day.forDate.unix()}
|
|
|
|
day={day}
|
|
|
|
eventType={props.eventType}
|
|
|
|
onMouseEnterHalfhour={(e: MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: dayjs.Dayjs) => {
|
|
|
|
displayGhostPeriod(e, time);
|
|
|
|
}}
|
|
|
|
onMouseClickHalfhour={(day: AvailabilityDay, time: dayjs.Dayjs) => {
|
|
|
|
createAvailability(day, time);
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
)
|
2024-01-10 00:18:42 +02:00
|
|
|
}
|
|
|
|
</Stack>
|
2024-01-09 17:23:40 +02:00
|
|
|
{
|
2024-01-10 00:18:42 +02:00
|
|
|
(ghostPreviewProps !== null) &&
|
|
|
|
<Box
|
2024-01-10 10:19:54 +02:00
|
|
|
className={"ghost-box"}
|
|
|
|
top={ghostPreviewProps?.top}
|
|
|
|
left={ghostPreviewProps?.left}
|
|
|
|
width={ghostPreviewProps?.width}
|
|
|
|
height={ghostPreviewProps?.height}
|
2024-01-10 00:18:42 +02:00
|
|
|
>
|
|
|
|
</Box>
|
2024-01-09 17:23:40 +02:00
|
|
|
}
|
2024-01-10 00:18:42 +02:00
|
|
|
</Box>
|
2024-01-09 17:23:40 +02:00
|
|
|
);
|
2024-01-10 10:19:54 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
export default AvailabilityPicker;
|