2022-09-06 22:58:16 +00:00
|
|
|
import classNames from "classnames";
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
|
import {
|
|
|
|
Controller,
|
|
|
|
useFieldArray,
|
|
|
|
UseFieldArrayAppend,
|
|
|
|
UseFieldArrayRemove,
|
|
|
|
useFormContext,
|
|
|
|
} from "react-hook-form";
|
|
|
|
import { GroupBase, Props } from "react-select";
|
|
|
|
|
|
|
|
import dayjs, { ConfigType, Dayjs } from "@calcom/dayjs";
|
|
|
|
import { defaultDayRange as DEFAULT_DAY_RANGE } from "@calcom/lib/availability";
|
|
|
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
|
|
import { weekdayNames } from "@calcom/lib/weekday";
|
|
|
|
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
|
|
|
import { TimeRange } from "@calcom/types/schedule";
|
|
|
|
import { Icon } from "@calcom/ui";
|
|
|
|
import Dropdown, { DropdownMenuContent, DropdownMenuTrigger } from "@calcom/ui/Dropdown";
|
|
|
|
import { Button, Select, Switch, Tooltip } from "@calcom/ui/v2";
|
|
|
|
|
|
|
|
const Schedule = () => {
|
|
|
|
const { i18n } = useLocale();
|
|
|
|
const form = useFormContext();
|
|
|
|
|
|
|
|
const initialValue = form.watch();
|
|
|
|
|
|
|
|
const copyAllPosition = (initialValue["schedule"] as Array<TimeRange[]>)?.findIndex(
|
|
|
|
(item: TimeRange[]) => item.length > 0
|
|
|
|
);
|
|
|
|
|
|
|
|
return (
|
|
|
|
<>
|
|
|
|
{/* First iterate for each day */}
|
|
|
|
{weekdayNames(i18n.language, 0, "long").map((weekday, num) => {
|
|
|
|
const name = `schedule.${num}`;
|
|
|
|
const copyAllShouldRender = copyAllPosition === num;
|
|
|
|
return (
|
|
|
|
<div className="mb-1 flex w-full flex-col py-1 sm:flex-row" key={weekday}>
|
|
|
|
{/* Label & switch container */}
|
|
|
|
<div className="flex justify-between">
|
|
|
|
<div>
|
|
|
|
<label className="flex flex-row items-center">
|
|
|
|
<Switch
|
|
|
|
defaultChecked={initialValue["schedule"][num].length > 0}
|
|
|
|
checked={!!initialValue["schedule"][num].length}
|
|
|
|
onCheckedChange={(isChecked) => {
|
|
|
|
form.setValue(name, isChecked ? [DEFAULT_DAY_RANGE] : []);
|
|
|
|
}}
|
|
|
|
className="relative mx-2 my-[6px] h-6 w-10 rounded-full bg-gray-200"
|
|
|
|
/>
|
|
|
|
<span className="inline-block min-w-[88px] text-sm capitalize">{weekday}</span>
|
|
|
|
</label>
|
|
|
|
</div>
|
|
|
|
<div className="inline sm:hidden">
|
|
|
|
<ActionButtons
|
|
|
|
name={name}
|
|
|
|
setValue={form.setValue}
|
|
|
|
watcher={form.watch(name, initialValue[name])}
|
|
|
|
copyAllShouldRender={copyAllShouldRender}
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div className="w-full sm:ml-2">
|
|
|
|
<DayRanges name={name} copyAllShouldRender={copyAllShouldRender} />
|
|
|
|
</div>
|
|
|
|
<div className="my-2 h-[1px] w-full bg-gray-200 sm:hidden" />
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
})}
|
|
|
|
</>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
const DayRanges = ({
|
|
|
|
name,
|
|
|
|
copyAllShouldRender,
|
|
|
|
}: {
|
|
|
|
name: string;
|
|
|
|
defaultValue?: TimeRange[];
|
|
|
|
copyAllShouldRender?: boolean;
|
|
|
|
}) => {
|
|
|
|
const form = useFormContext();
|
|
|
|
|
|
|
|
const fields = form.watch(`${name}` as `schedule.0`);
|
|
|
|
|
|
|
|
const { remove } = useFieldArray({
|
|
|
|
name,
|
|
|
|
});
|
|
|
|
|
|
|
|
return (
|
|
|
|
<>
|
|
|
|
{fields.map((field: { id: string }, index: number) => (
|
2022-09-07 01:34:33 +00:00
|
|
|
<div key={field.id} className="mt-2 mb-2 flex rtl:space-x-reverse sm:mt-0">
|
2022-09-06 22:58:16 +00:00
|
|
|
<TimeRangeField name={`${name}.${index}`} />
|
|
|
|
{index === 0 && (
|
|
|
|
<div className="hidden sm:inline">
|
|
|
|
<ActionButtons
|
|
|
|
name={name}
|
|
|
|
setValue={form.setValue}
|
|
|
|
watcher={form.watch(name)}
|
|
|
|
copyAllShouldRender={copyAllShouldRender}
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
{index !== 0 && <RemoveTimeButton index={index} remove={remove} />}
|
|
|
|
</div>
|
|
|
|
))}
|
|
|
|
</>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
const RemoveTimeButton = ({
|
|
|
|
index,
|
|
|
|
remove,
|
|
|
|
className,
|
|
|
|
}: {
|
|
|
|
index: number | number[];
|
|
|
|
remove: UseFieldArrayRemove;
|
|
|
|
className?: string;
|
|
|
|
}) => {
|
|
|
|
return (
|
|
|
|
<Button
|
|
|
|
type="button"
|
|
|
|
size="icon"
|
|
|
|
color="minimal"
|
|
|
|
StartIcon={Icon.FiTrash}
|
|
|
|
onClick={() => remove(index)}
|
|
|
|
className={className}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
interface TimeRangeFieldProps {
|
|
|
|
name: string;
|
|
|
|
className?: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
const TimeRangeField = ({ name, className }: TimeRangeFieldProps) => {
|
|
|
|
const { watch } = useFormContext();
|
|
|
|
|
|
|
|
const values = watch(name);
|
|
|
|
const minEnd = values["start"];
|
|
|
|
const maxStart = values["end"];
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div className={classNames("mx-1 flex", className)}>
|
|
|
|
<Controller
|
|
|
|
name={`${name}.start`}
|
|
|
|
render={({ field: { onChange } }) => {
|
|
|
|
return (
|
|
|
|
<LazySelect
|
|
|
|
className="h-9 w-[100px]"
|
|
|
|
value={values["start"]}
|
|
|
|
max={maxStart}
|
|
|
|
onChange={(option) => {
|
|
|
|
onChange(new Date(option?.value as number));
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
<span className="mx-2 w-2 self-center"> - </span>
|
|
|
|
<Controller
|
|
|
|
name={`${name}.end`}
|
|
|
|
render={({ field: { onChange } }) => (
|
|
|
|
<LazySelect
|
|
|
|
className="w-[100px] rounded-md"
|
|
|
|
value={values["end"]}
|
|
|
|
min={minEnd}
|
|
|
|
onChange={(option) => {
|
|
|
|
onChange(new Date(option?.value as number));
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
)}
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
const LazySelect = ({
|
|
|
|
value,
|
|
|
|
min,
|
|
|
|
max,
|
|
|
|
...props
|
|
|
|
}: Omit<Props<IOption, false, GroupBase<IOption>>, "value"> & {
|
|
|
|
value: ConfigType;
|
|
|
|
min?: ConfigType;
|
|
|
|
max?: ConfigType;
|
|
|
|
}) => {
|
|
|
|
// Lazy-loaded options, otherwise adding a field has a noticeable redraw delay.
|
|
|
|
const { options, filter } = useOptions();
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
filter({ current: value });
|
|
|
|
}, [filter, value]);
|
|
|
|
|
|
|
|
return (
|
|
|
|
<Select
|
|
|
|
options={options}
|
|
|
|
onMenuOpen={() => {
|
|
|
|
if (min) filter({ offset: min });
|
|
|
|
if (max) filter({ limit: max });
|
|
|
|
}}
|
|
|
|
value={options.find((option) => option.value === dayjs(value).toDate().valueOf())}
|
|
|
|
onMenuClose={() => filter({ current: value })}
|
|
|
|
components={{ DropdownIndicator: () => null, IndicatorSeparator: () => null }}
|
|
|
|
{...props}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
interface IOption {
|
|
|
|
readonly label: string;
|
|
|
|
readonly value: number;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Creates an array of times on a 15 minute interval from
|
|
|
|
* 00:00:00 (Start of day) to
|
|
|
|
* 23:45:00 (End of day with enough time for 15 min booking)
|
|
|
|
*/
|
|
|
|
/** Begin Time Increments For Select */
|
|
|
|
const INCREMENT = 15;
|
|
|
|
const useOptions = () => {
|
|
|
|
// Get user so we can determine 12/24 hour format preferences
|
|
|
|
const query = useMeQuery();
|
|
|
|
const { timeFormat } = query.data || { timeFormat: null };
|
|
|
|
|
|
|
|
const [filteredOptions, setFilteredOptions] = useState<IOption[]>([]);
|
|
|
|
|
|
|
|
const options = useMemo(() => {
|
|
|
|
const end = dayjs().utc().endOf("day");
|
|
|
|
let t: Dayjs = dayjs().utc().startOf("day");
|
|
|
|
|
|
|
|
const options: IOption[] = [];
|
|
|
|
while (t.isBefore(end)) {
|
|
|
|
options.push({
|
|
|
|
value: t.toDate().valueOf(),
|
|
|
|
label: dayjs(t)
|
|
|
|
.utc()
|
|
|
|
.format(timeFormat === 12 ? "h:mma" : "HH:mm"),
|
|
|
|
});
|
|
|
|
t = t.add(INCREMENT, "minutes");
|
|
|
|
}
|
|
|
|
return options;
|
|
|
|
}, [timeFormat]);
|
|
|
|
|
|
|
|
const filter = useCallback(
|
|
|
|
({ offset, limit, current }: { offset?: ConfigType; limit?: ConfigType; current?: ConfigType }) => {
|
|
|
|
if (current) {
|
|
|
|
const currentOption = options.find((option) => option.value === dayjs(current).toDate().valueOf());
|
|
|
|
if (currentOption) setFilteredOptions([currentOption]);
|
|
|
|
} else
|
|
|
|
setFilteredOptions(
|
|
|
|
options.filter((option) => {
|
|
|
|
const time = dayjs(option.value);
|
|
|
|
return (!limit || time.isBefore(limit)) && (!offset || time.isAfter(offset));
|
|
|
|
})
|
|
|
|
);
|
|
|
|
},
|
|
|
|
[options]
|
|
|
|
);
|
|
|
|
|
|
|
|
return { options: filteredOptions, filter };
|
|
|
|
};
|
|
|
|
|
|
|
|
const ActionButtons = ({
|
|
|
|
name,
|
|
|
|
watcher,
|
|
|
|
setValue,
|
|
|
|
copyAllShouldRender,
|
|
|
|
}: {
|
|
|
|
name: string;
|
|
|
|
watcher: TimeRange[];
|
|
|
|
setValue: (key: string, value: TimeRange[]) => void;
|
|
|
|
copyAllShouldRender?: boolean;
|
|
|
|
}) => {
|
|
|
|
const { t } = useLocale();
|
|
|
|
const form = useFormContext();
|
|
|
|
|
|
|
|
const values = form.watch();
|
|
|
|
const { append } = useFieldArray({
|
|
|
|
name,
|
|
|
|
});
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div className="flex items-center">
|
|
|
|
<Tooltip content={t("add_time_availability") as string}>
|
|
|
|
<Button
|
|
|
|
className="text-neutral-400"
|
|
|
|
type="button"
|
|
|
|
color="minimal"
|
|
|
|
size="icon"
|
|
|
|
StartIcon={Icon.FiPlus}
|
|
|
|
onClick={() => {
|
|
|
|
handleAppend({
|
|
|
|
fields: watcher,
|
|
|
|
/* Generics should help with this, but forgive us father as I have sinned */
|
|
|
|
append: append as unknown as UseFieldArrayAppend<TimeRange>,
|
|
|
|
});
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
</Tooltip>
|
|
|
|
<Dropdown>
|
|
|
|
<Tooltip content={t("duplicate") as string}>
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
<Button type="button" color="minimal" size="icon" StartIcon={Icon.FiCopy} />
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
</Tooltip>
|
|
|
|
<DropdownMenuContent>
|
|
|
|
<CopyTimes
|
|
|
|
disabled={[parseInt(name.substring(name.lastIndexOf(".") + 1), 10)]}
|
|
|
|
onApply={(selected) =>
|
|
|
|
selected.forEach((day) => {
|
|
|
|
setValue(name.substring(0, name.lastIndexOf(".") + 1) + day, watcher);
|
|
|
|
})
|
|
|
|
}
|
|
|
|
/>
|
|
|
|
</DropdownMenuContent>
|
|
|
|
</Dropdown>
|
|
|
|
{/* This only displays on Desktop */}
|
|
|
|
{copyAllShouldRender && (
|
|
|
|
<Tooltip content={t("add_time_availability") as string}>
|
|
|
|
<Button
|
|
|
|
color="minimal"
|
|
|
|
className="whitespace-nowrap text-sm text-neutral-400"
|
|
|
|
type="button"
|
|
|
|
onClick={() => {
|
|
|
|
values["schedule"].forEach((item: TimeRange[], index: number) => {
|
|
|
|
if (item.length > 0) {
|
|
|
|
setValue(`schedule.${index}`, watcher);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}}
|
|
|
|
title={`${t("copy_all")}`}>
|
|
|
|
{t("copy_all")}
|
|
|
|
</Button>
|
|
|
|
</Tooltip>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
const handleAppend = ({
|
|
|
|
fields = [],
|
|
|
|
append,
|
|
|
|
}: {
|
|
|
|
fields: TimeRange[];
|
|
|
|
append: UseFieldArrayAppend<TimeRange>;
|
|
|
|
}) => {
|
|
|
|
if (fields.length === 0) {
|
|
|
|
return append(DEFAULT_DAY_RANGE);
|
|
|
|
}
|
|
|
|
const nextRangeStart = dayjs((fields[fields.length - 1] as unknown as TimeRange).end);
|
|
|
|
const nextRangeEnd = dayjs(nextRangeStart).add(1, "hour");
|
|
|
|
|
|
|
|
if (nextRangeEnd.isBefore(nextRangeStart.endOf("day"))) {
|
|
|
|
return append({
|
|
|
|
start: nextRangeStart.toDate(),
|
|
|
|
end: nextRangeEnd.toDate(),
|
|
|
|
});
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
const CopyTimes = ({ disabled, onApply }: { disabled: number[]; onApply: (selected: number[]) => void }) => {
|
|
|
|
const [selected, setSelected] = useState<number[]>([]);
|
|
|
|
const { i18n, t } = useLocale();
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div className="m-4 space-y-2 py-4">
|
|
|
|
<p className="h6 text-xs font-medium uppercase text-neutral-400">Copy times to</p>
|
|
|
|
<ol className="space-y-2">
|
|
|
|
{weekdayNames(i18n.language).map((weekday, num) => (
|
|
|
|
<li key={weekday}>
|
|
|
|
<label className="flex w-full items-center justify-between">
|
|
|
|
<span className="px-1">{weekday}</span>
|
|
|
|
<input
|
|
|
|
value={num}
|
|
|
|
defaultChecked={disabled.includes(num)}
|
|
|
|
disabled={disabled.includes(num)}
|
|
|
|
onChange={(e) => {
|
|
|
|
if (e.target.checked && !selected.includes(num)) {
|
|
|
|
setSelected(selected.concat([num]));
|
|
|
|
} else if (!e.target.checked && selected.includes(num)) {
|
|
|
|
setSelected(selected.slice(selected.indexOf(num), 1));
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
type="checkbox"
|
|
|
|
className="inline-block rounded-[4px] border-gray-300 text-neutral-900 focus:ring-neutral-500 disabled:text-neutral-400"
|
|
|
|
/>
|
|
|
|
</label>
|
|
|
|
</li>
|
|
|
|
))}
|
|
|
|
</ol>
|
|
|
|
<div className="pt-2">
|
|
|
|
<Button className="w-full justify-center" color="primary" onClick={() => onApply(selected)}>
|
|
|
|
{t("apply")}
|
|
|
|
</Button>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
export default Schedule;
|