Feature/multiple schedules post turbo (#2150)
* Concluded merge * Applied stash to newly merged * Always disconnect + remove redundant success message * Added named dialog to replace new=1 * Merged with main p2 * Set eventTypeId to @unique * WIP * Undo vscode changes * Availability dropdown works * Remove console.log + set schedule to null as it is unneeded * Added schedule to availability endpoint * Reduce one refresh; hotfix state inconsistency with forced refresh for now * Add missing translations * Fixed some type errors I missed * Ditch outdated remnant from before packages/prisma * Remove Availability section for teams * Bringing back the Availability section temporarily to teams to allow configuration * Migrated getting-started to new availability system + updated translations + updated seed * Fixed type error coming from main * Titlecase 'default' by providing translation * Fixed broken 'radio' buttons. * schedule deleted translation added * Added empty state for when no schedules are configured * Added correct created message + hotfix reload hard on delete to refresh state * Removed index renames * Type fixes * Update NewScheduleButton.tsx Co-authored-by: zomars <zomars@me.com> Co-authored-by: Bailey Pumfleet <pumfleet@hey.com> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
parent
bcbf8390e0
commit
6a211dd5b3
28 changed files with 1073 additions and 1207 deletions
|
@ -307,7 +307,7 @@ export default function Shell(props: {
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="block min-h-[80px] justify-between px-4 sm:flex sm:px-6 md:px-8">
|
||||
<div className="block justify-between px-4 sm:flex sm:px-6 md:px-8">
|
||||
{props.HeadingLeftIcon && <div className="ltr:mr-4">{props.HeadingLeftIcon}</div>}
|
||||
<div className="mb-8 w-full">
|
||||
<h1 className="font-cal mb-1 text-xl text-gray-900">{props.heading}</h1>
|
||||
|
|
77
apps/web/components/availability/NewScheduleButton.tsx
Normal file
77
apps/web/components/availability/NewScheduleButton.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
import { PlusIcon } from "@heroicons/react/solid";
|
||||
import { useRouter } from "next/router";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { Button } from "@calcom/ui";
|
||||
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import { Form, TextField } from "@calcom/ui/form/fields";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
export function NewScheduleButton({ name = "new-schedule" }: { name?: string }) {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
|
||||
const form = useForm<{
|
||||
name: string;
|
||||
}>();
|
||||
const { register } = form;
|
||||
|
||||
const createMutation = trpc.useMutation("viewer.availability.schedule.create", {
|
||||
onSuccess: async ({ schedule }) => {
|
||||
await router.push("/availability/" + schedule.id);
|
||||
showToast(t("schedule_created_successfully", { scheduleName: schedule.name }), "success");
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof HttpError) {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
|
||||
if (err.data?.code === "UNAUTHORIZED") {
|
||||
const message = `${err.data.code}: You are not able to create this event`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog name={name} clearQueryParamsOnClose={["copy-schedule-id"]}>
|
||||
<DialogTrigger asChild>
|
||||
<Button data-testid={name} StartIcon={PlusIcon}>
|
||||
{t("new_schedule_btn")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
||||
{t("add_new_schedule")}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{t("new_event_type_to_book_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={(values) => {
|
||||
createMutation.mutate(values);
|
||||
}}>
|
||||
<div className="mt-3 space-y-4">
|
||||
<TextField label={t("name")} {...register("name")} />
|
||||
</div>
|
||||
<div className="mt-8 flex flex-row-reverse gap-x-2">
|
||||
<Button type="submit" loading={createMutation.isLoading}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary">{t("cancel")}</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
|
@ -147,7 +147,7 @@ const ScheduleBlock = ({ name, day, weekday }: ScheduleBlockProps) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<fieldset className="flex min-h-[86px] flex-col justify-between space-y-2 py-5 sm:flex-row sm:space-y-0">
|
||||
<fieldset className="flex flex-col justify-between space-y-2 py-5 sm:flex-row sm:space-y-0">
|
||||
<div className="w-1/3">
|
||||
<label className="flex items-center space-x-2 rtl:space-x-reverse">
|
||||
<input
|
34
apps/web/components/ui/EditableHeading.tsx
Normal file
34
apps/web/components/ui/EditableHeading.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { PencilIcon } from "@heroicons/react/solid";
|
||||
import { useState } from "react";
|
||||
|
||||
const EditableHeading = ({ title, onChange }: { title: string; onChange: (value: string) => void }) => {
|
||||
const [editIcon, setEditIcon] = useState(true);
|
||||
return (
|
||||
<div className="group relative cursor-pointer" onClick={() => setEditIcon(false)}>
|
||||
{editIcon ? (
|
||||
<>
|
||||
<h1
|
||||
style={{ fontSize: 22, letterSpacing: "-0.0009em" }}
|
||||
className="inline pl-0 text-gray-900 focus:text-black group-hover:text-gray-500">
|
||||
{title}
|
||||
</h1>
|
||||
<PencilIcon className="ml-1 -mt-1 inline h-4 w-4 text-gray-700 group-hover:text-gray-500" />
|
||||
</>
|
||||
) : (
|
||||
<div style={{ marginBottom: -11 }}>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
style={{ top: -6, fontSize: 22 }}
|
||||
required
|
||||
className="relative h-10 w-full cursor-pointer border-none bg-transparent pl-0 text-gray-900 hover:text-gray-700 focus:text-black focus:outline-none focus:ring-0"
|
||||
defaultValue={title}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditableHeading;
|
|
@ -1,8 +1 @@
|
|||
// By default starts on Sunday (Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday)
|
||||
export function weekdayNames(locale: string | string[], weekStart = 0, type: "short" | "long" = "long") {
|
||||
return Array.from(Array(7).keys()).map((d) => nameOfDay(locale, d + weekStart, type));
|
||||
}
|
||||
|
||||
export function nameOfDay(locale: string | string[], day: number, type: "short" | "long" = "long") {
|
||||
return new Intl.DateTimeFormat(locale, { weekday: type }).format(new Date(1970, 0, day + 4));
|
||||
}
|
||||
export * from "@calcom/lib/weekday";
|
||||
|
|
|
@ -43,6 +43,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
periodDays: true,
|
||||
periodCountCalendarDays: true,
|
||||
schedulingType: true,
|
||||
schedule: {
|
||||
select: {
|
||||
availability: true,
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
minimumBookingNotice: true,
|
||||
beforeEventBuffer: true,
|
||||
afterEventBuffer: true,
|
||||
|
@ -80,6 +86,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
hideBranding: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
defaultScheduleId: true,
|
||||
schedules: {
|
||||
select: {
|
||||
availability: true,
|
||||
timeZone: true,
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
theme: true,
|
||||
plan: true,
|
||||
eventTypes: {
|
||||
|
@ -175,13 +189,24 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
periodEndDate: eventType.periodEndDate?.toString() ?? null,
|
||||
});
|
||||
|
||||
const schedule = eventType.schedule
|
||||
? { ...eventType.schedule }
|
||||
: {
|
||||
...user.schedules.filter(
|
||||
(schedule) => !user.defaultScheduleId || schedule.id === user.defaultScheduleId
|
||||
)[0],
|
||||
};
|
||||
|
||||
const timeZone = schedule.timeZone || eventType.timeZone || user.timeZone;
|
||||
|
||||
const workingHours = getWorkingHours(
|
||||
{
|
||||
timeZone: eventType.timeZone || user.timeZone,
|
||||
timeZone,
|
||||
},
|
||||
eventType.availability.length ? eventType.availability : user.availability
|
||||
schedule.availability || (eventType.availability.length ? eventType.availability : user.availability)
|
||||
);
|
||||
|
||||
eventTypeObject.schedule = null;
|
||||
eventTypeObject.availability = [];
|
||||
|
||||
return {
|
||||
|
|
|
@ -36,6 +36,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
startTime: true,
|
||||
endTime: true,
|
||||
selectedCalendars: true,
|
||||
schedules: {
|
||||
select: {
|
||||
availability: true,
|
||||
timeZone: true,
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
defaultScheduleId: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -44,6 +52,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
where: { id },
|
||||
select: {
|
||||
timeZone: true,
|
||||
schedule: {
|
||||
select: {
|
||||
availability: true,
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
availability: {
|
||||
select: {
|
||||
startTime: true,
|
||||
|
@ -76,10 +90,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(),
|
||||
}));
|
||||
|
||||
const timeZone = eventType?.timeZone || currentUser.timeZone;
|
||||
const schedule = eventType?.schedule
|
||||
? { ...eventType?.schedule }
|
||||
: {
|
||||
...currentUser.schedules.filter(
|
||||
(schedule) => !currentUser.defaultScheduleId || schedule.id === currentUser.defaultScheduleId
|
||||
)[0],
|
||||
};
|
||||
|
||||
const timeZone = schedule.timeZone || eventType?.timeZone || currentUser.timeZone;
|
||||
|
||||
const workingHours = getWorkingHours(
|
||||
{ timeZone },
|
||||
eventType?.availability.length ? eventType.availability : currentUser.availability
|
||||
{
|
||||
timeZone,
|
||||
},
|
||||
schedule.availability ||
|
||||
(eventType?.availability.length ? eventType.availability : currentUser.availability)
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
|
|
154
apps/web/pages/availability/[schedule].tsx
Normal file
154
apps/web/pages/availability/[schedule].tsx
Normal file
|
@ -0,0 +1,154 @@
|
|||
import { BadgeCheckIcon } from "@heroicons/react/solid";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import TimezoneSelect from "react-timezone-select";
|
||||
|
||||
import { DEFAULT_SCHEDULE, availabilityAsString } from "@calcom/lib/availability";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import Switch from "@calcom/ui/Switch";
|
||||
import { Form } from "@calcom/ui/form/fields";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import Shell from "@components/Shell";
|
||||
import Schedule from "@components/availability/Schedule";
|
||||
import EditableHeading from "@components/ui/EditableHeading";
|
||||
|
||||
export function AvailabilityForm(props: inferQueryOutput<"viewer.availability.schedule">) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
schedule: props.availability || DEFAULT_SCHEDULE,
|
||||
isDefault: !!props.isDefault,
|
||||
timeZone: props.timeZone,
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = trpc.useMutation("viewer.availability.schedule.update", {
|
||||
onSuccess: async () => {
|
||||
await router.push("/availability");
|
||||
window.location.reload();
|
||||
showToast(t("availability_updated_successfully"), "success");
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof HttpError) {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={async (values) => {
|
||||
updateMutation.mutate({
|
||||
scheduleId: parseInt(router.query.schedule as string, 10),
|
||||
name: props.schedule.name,
|
||||
...values,
|
||||
});
|
||||
}}
|
||||
className="grid grid-cols-3 gap-2">
|
||||
<div className="col-span-3 space-y-2 lg:col-span-2">
|
||||
<div className="divide-y rounded-sm border border-gray-200 bg-white px-4 py-5 sm:p-6">
|
||||
<h3 className="mb-5 text-base font-medium leading-6 text-gray-900">{t("change_start_end")}</h3>
|
||||
<Schedule name="schedule" />
|
||||
</div>
|
||||
<div className="space-x-2 text-right">
|
||||
<Button color="secondary" href="/availability" tabIndex={-1}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button>{t("save")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-40 col-span-3 ml-2 space-y-2 lg:col-span-1">
|
||||
{props.isDefault ? (
|
||||
<div className="inline-block rounded border border-gray-300 bg-gray-200 px-2 py-0.5 pl-1.5 text-sm font-medium text-neutral-800">
|
||||
<span className="flex items-center">
|
||||
<BadgeCheckIcon className="mr-1 h-4 w-4" /> {t("default")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Controller
|
||||
name="isDefault"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Switch label={t("set_to_default")} onCheckedChange={onChange} checked={value} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
|
||||
{t("timezone")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<Controller
|
||||
name="timeZone"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TimezoneSelect
|
||||
value={value}
|
||||
className="focus:border-brand mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-black sm:text-sm"
|
||||
onChange={(timezone) => onChange(timezone.value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 rounded-sm border border-gray-200 px-4 py-5 sm:p-6 ">
|
||||
<h3 className="text-base font-medium leading-6 text-gray-900">
|
||||
{t("something_doesnt_look_right")}
|
||||
</h3>
|
||||
<div className="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p>{t("troubleshoot_availability")}</p>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<Button href="/availability/troubleshoot" color="secondary">
|
||||
{t("launch_troubleshooter")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Availability() {
|
||||
const router = useRouter();
|
||||
const { i18n } = useLocale();
|
||||
const query = trpc.useQuery([
|
||||
"viewer.availability.schedule",
|
||||
{
|
||||
scheduleId: parseInt(router.query.schedule as string),
|
||||
},
|
||||
]);
|
||||
const [name, setName] = useState<string>();
|
||||
return (
|
||||
<div>
|
||||
<QueryCell
|
||||
query={query}
|
||||
success={({ data }) => {
|
||||
return (
|
||||
<Shell
|
||||
heading={<EditableHeading title={data.schedule.name} onChange={setName} />}
|
||||
subtitle={data.schedule.availability.map((availability) => (
|
||||
<>
|
||||
{availabilityAsString(availability, i18n.language)}
|
||||
<br />
|
||||
</>
|
||||
))}>
|
||||
<AvailabilityForm
|
||||
{...{ ...data, schedule: { ...data.schedule, name: name || data.schedule.name } }}
|
||||
/>
|
||||
</Shell>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,139 +1,115 @@
|
|||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { ClockIcon } from "@heroicons/react/outline";
|
||||
import { DotsHorizontalIcon, TrashIcon } from "@heroicons/react/solid";
|
||||
import { Availability } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
|
||||
import { availabilityAsString } from "@calcom/lib/availability";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Form } from "@calcom/ui/form/fields";
|
||||
import { Button } from "@calcom/ui";
|
||||
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@calcom/ui/Dropdown";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import { DEFAULT_SCHEDULE } from "@lib/availability";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
import { Schedule as ScheduleType } from "@lib/types/schedule";
|
||||
|
||||
import Shell from "@components/Shell";
|
||||
import Schedule from "@components/ui/form/Schedule";
|
||||
import { NewScheduleButton } from "@components/availability/NewScheduleButton";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
type FormValues = {
|
||||
schedule: ScheduleType;
|
||||
};
|
||||
|
||||
export function AvailabilityForm(props: inferQueryOutput<"viewer.availability">) {
|
||||
const CreateFirstScheduleView = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const createSchedule = async ({ schedule }: FormValues) => {
|
||||
const res = await fetch(`/api/schedule`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ schedule, timeZone: props.timeZone }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error((await res.json()).message);
|
||||
}
|
||||
const responseData = await res.json();
|
||||
showToast(t("availability_updated_successfully"), "success");
|
||||
return responseData.data;
|
||||
};
|
||||
|
||||
const schema = z.object({
|
||||
schedule: z
|
||||
.object({
|
||||
start: z.date(),
|
||||
end: z.date(),
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
if (dayjs(val.end).isBefore(dayjs(val.start))) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Invalid entry: End time can not be before start time",
|
||||
path: ["end"],
|
||||
});
|
||||
}
|
||||
})
|
||||
.optional()
|
||||
.array()
|
||||
.array(),
|
||||
});
|
||||
|
||||
const days = [
|
||||
t("sunday_time_error"),
|
||||
t("monday_time_error"),
|
||||
t("tuesday_time_error"),
|
||||
t("wednesday_time_error"),
|
||||
t("thursday_time_error"),
|
||||
t("friday_time_error"),
|
||||
t("saturday_time_error"),
|
||||
];
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
schedule: props.schedule || DEFAULT_SCHEDULE,
|
||||
},
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={async (values) => {
|
||||
await createSchedule(values);
|
||||
}}
|
||||
className="col-span-3 space-y-2 lg:col-span-2">
|
||||
<div className="divide-y rounded-sm border border-gray-200 bg-white px-4 py-5 sm:p-6">
|
||||
<h3 className="mb-5 text-base font-medium leading-6 text-gray-900">{t("change_start_end")}</h3>
|
||||
<Schedule name="schedule" />
|
||||
<div className="md:py-20">
|
||||
<div className="mx-auto block text-center md:max-w-screen-sm">
|
||||
<ClockIcon className="inline w-12 text-neutral-400" />
|
||||
<h3 className="mt-2 text-xl font-bold text-neutral-900">{t("new_schedule_heading")}</h3>
|
||||
<p className="text-md mt-1 mb-2 text-neutral-600">{t("new_schedule_description")}</p>
|
||||
<NewScheduleButton name="first-new-schedule" />
|
||||
</div>
|
||||
{form.formState.errors.schedule && (
|
||||
<Alert
|
||||
className="mt-1"
|
||||
severity="error"
|
||||
message={
|
||||
days[form.formState.errors.schedule.length - 1] + " : " + t("error_end_time_before_start_time")
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function AvailabilityList({ schedules }: inferQueryOutput<"viewer.availability.list">) {
|
||||
const { t, i18n } = useLocale();
|
||||
const deleteMutation = trpc.useMutation("viewer.availability.schedule.delete", {
|
||||
onSuccess: async () => {
|
||||
showToast(t("schedule_deleted_successfully"), "success");
|
||||
window.location.reload();
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof HttpError) {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
/>
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div className="-mx-4 mb-16 overflow-hidden rounded-sm border border-gray-200 bg-white sm:mx-0">
|
||||
{schedules.length === 0 && <CreateFirstScheduleView />}
|
||||
<ul className="divide-y divide-neutral-200" data-testid="schedules">
|
||||
{schedules.map((schedule) => (
|
||||
<li key={schedule.id}>
|
||||
<div className="flex items-center justify-between py-5 hover:bg-neutral-50">
|
||||
<div className="group flex w-full items-center justify-between hover:bg-neutral-50 sm:px-6">
|
||||
<Link href={"/availability/" + schedule.id}>
|
||||
<a className="flex-grow truncate text-sm" title={schedule.name}>
|
||||
<div>
|
||||
<span className="truncate font-medium text-neutral-900">{schedule.name}</span>
|
||||
{schedule.isDefault && (
|
||||
<span className="ml-2 inline items-center rounded-sm bg-yellow-100 px-1.5 py-0.5 text-xs font-medium text-yellow-800">
|
||||
{t("default")}
|
||||
</span>
|
||||
)}
|
||||
<div className="text-right">
|
||||
<Button>{t("save")}</Button>
|
||||
</div>
|
||||
</Form>
|
||||
<div className="min-w-40 col-span-3 ltr:ml-2 rtl:mr-2 lg:col-span-1">
|
||||
<div className="rounded-sm border border-gray-200 px-4 py-5 sm:p-6 ">
|
||||
<h3 className="text-base font-medium leading-6 text-gray-900">
|
||||
{t("something_doesnt_look_right")}
|
||||
</h3>
|
||||
<div className="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p>{t("troubleshoot_availability")}</p>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
{schedule.availability.map((availability: Availability) => (
|
||||
<>
|
||||
{availabilityAsString(availability, i18n.language)}
|
||||
<br />
|
||||
</>
|
||||
))}
|
||||
</p>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<Button href="/availability/troubleshoot" color="secondary">
|
||||
{t("launch_troubleshooter")}
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger className="group mr-5 h-10 w-10 border border-transparent p-0 text-neutral-400 hover:border-gray-200">
|
||||
<DotsHorizontalIcon className="h-5 w-5 group-hover:text-gray-800" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
onClick={() =>
|
||||
deleteMutation.mutate({
|
||||
scheduleId: schedule.id,
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="w-full font-normal"
|
||||
StartIcon={TrashIcon}>
|
||||
{t("delete_schedule")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Availability() {
|
||||
export default function AvailabilityPage() {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.useQuery(["viewer.availability"]);
|
||||
const query = trpc.useQuery(["viewer.availability.list"]);
|
||||
return (
|
||||
<div>
|
||||
<Shell heading={t("availability")} subtitle={t("configure_availability")}>
|
||||
<QueryCell query={query} success={({ data }) => <AvailabilityForm {...data} />} />
|
||||
<Shell heading={t("availability")} subtitle={t("configure_availability")} CTA={<NewScheduleButton />}>
|
||||
<QueryCell query={query} success={({ data }) => <AvailabilityList {...data} />} />
|
||||
</Shell>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -14,28 +14,32 @@ import {
|
|||
} from "@heroicons/react/solid";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { Availability, EventTypeCustomInput, PeriodType, Prisma, SchedulingType } from "@prisma/client";
|
||||
import { EventTypeCustomInput, PeriodType, Prisma, SchedulingType } from "@prisma/client";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import classNames from "classnames";
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import Select from "react-select";
|
||||
import Select, { Props as SelectProps } from "react-select";
|
||||
import { JSONObject } from "superjson/dist/types";
|
||||
import { z } from "zod";
|
||||
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { StripeData } from "@calcom/stripe/server";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import Switch from "@calcom/ui/Switch";
|
||||
import { Form } from "@calcom/ui/form/fields";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import { asStringOrThrow, asStringOrUndefined } from "@lib/asStringOrNull";
|
||||
import { getSession } from "@lib/auth";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
|
@ -54,7 +58,6 @@ import Shell from "@components/Shell";
|
|||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import CustomInputTypeForm from "@components/pages/eventtypes/CustomInputTypeForm";
|
||||
import InfoBadge from "@components/ui/InfoBadge";
|
||||
import { Scheduler } from "@components/ui/Scheduler";
|
||||
import CheckboxField from "@components/ui/form/CheckboxField";
|
||||
import CheckedSelect from "@components/ui/form/CheckedSelect";
|
||||
import { DateRangePicker } from "@components/ui/form/DateRangePicker";
|
||||
|
@ -77,7 +80,6 @@ interface NFT extends Token {
|
|||
// Some OpenSea NFTs have several contracts
|
||||
contracts: Array<Token>;
|
||||
}
|
||||
type AvailabilityInput = Pick<Availability, "days" | "startTime" | "endTime">;
|
||||
|
||||
type OptionTypeBase = {
|
||||
label: string;
|
||||
|
@ -98,6 +100,41 @@ const addDefaultLocationOptions = (
|
|||
});
|
||||
};
|
||||
|
||||
const AvailabilitySelect = ({ className, ...props }: SelectProps) => {
|
||||
const query = trpc.useQuery(["viewer.availability.list"]);
|
||||
|
||||
return (
|
||||
<QueryCell
|
||||
query={query}
|
||||
success={({ data }) => {
|
||||
const options = data.schedules.map((schedule) => ({
|
||||
value: schedule.id,
|
||||
label: schedule.name,
|
||||
}));
|
||||
|
||||
const value = options.find((option) =>
|
||||
props.value
|
||||
? option.value === props.value
|
||||
: option.value === data.schedules.find((schedule) => schedule.isDefault)?.id
|
||||
);
|
||||
return (
|
||||
<Select
|
||||
{...props}
|
||||
options={options}
|
||||
isSearchable={false}
|
||||
classNamePrefix="react-select"
|
||||
className={classNames(
|
||||
"react-select-container focus:border-primary-500 focus:ring-primary-500 block w-full min-w-0 flex-1 rounded-sm border border-gray-300 sm:text-sm",
|
||||
className
|
||||
)}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
const { t } = useLocale();
|
||||
const PERIOD_TYPES = [
|
||||
|
@ -169,7 +206,6 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
|
||||
const [editIcon, setEditIcon] = useState(true);
|
||||
const [showLocationModal, setShowLocationModal] = useState(false);
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
||||
const [selectedLocation, setSelectedLocation] = useState<OptionTypeBase | undefined>(undefined);
|
||||
const [selectedCustomInput, setSelectedCustomInput] = useState<EventTypeCustomInput | undefined>(undefined);
|
||||
const [selectedCustomInputModalOpen, setSelectedCustomInputModalOpen] = useState(false);
|
||||
|
@ -185,11 +221,6 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
const [requirePayment, setRequirePayment] = useState(eventType.price > 0);
|
||||
const [advancedSettingsVisible, setAdvancedSettingsVisible] = useState(false);
|
||||
|
||||
const [availabilityState, setAvailabilityState] = useState<{
|
||||
openingHours: AvailabilityInput[];
|
||||
dateOverrides: AvailabilityInput[];
|
||||
}>({ openingHours: [], dateOverrides: [] });
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTokens = async () => {
|
||||
// Get a list of most popular ERC20s and ERC777s, combine them into a single list, set as tokensList
|
||||
|
@ -225,10 +256,6 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
fetchTokens();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTimeZone(eventType.timeZone || "");
|
||||
}, []);
|
||||
|
||||
async function deleteEventTypeHandler(event: React.MouseEvent<HTMLElement, MouseEvent>) {
|
||||
event.preventDefault();
|
||||
|
||||
|
@ -383,11 +410,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
locations: { type: LocationType; address?: string; link?: string }[];
|
||||
customInputs: EventTypeCustomInput[];
|
||||
users: string[];
|
||||
availability: {
|
||||
openingHours: AvailabilityInput[];
|
||||
dateOverrides: AvailabilityInput[];
|
||||
};
|
||||
timeZone: string;
|
||||
schedule: number;
|
||||
periodType: PeriodType;
|
||||
periodDays: number;
|
||||
periodCountCalendarDays: "1" | "0";
|
||||
|
@ -403,6 +426,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
}>({
|
||||
defaultValues: {
|
||||
locations: eventType.locations || [],
|
||||
schedule: eventType.schedule?.id,
|
||||
periodDates: {
|
||||
startDate: periodDates.startDate,
|
||||
endDate: periodDates.endDate,
|
||||
|
@ -748,7 +772,6 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
updateMutation.mutate({
|
||||
...input,
|
||||
locations,
|
||||
availability: availabilityState,
|
||||
periodStartDate: periodDates.startDate,
|
||||
periodEndDate: periodDates.endDate,
|
||||
periodCountCalendarDays: periodCountCalendarDays === "1",
|
||||
|
@ -1346,8 +1369,6 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-neutral-200" />
|
||||
<div className="block sm:flex">
|
||||
<div className="min-w-48 mb-4 sm:mb-0">
|
||||
<label
|
||||
|
@ -1358,33 +1379,27 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
</div>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
name="availability"
|
||||
name="schedule"
|
||||
control={formMethods.control}
|
||||
render={() => (
|
||||
<Scheduler
|
||||
setAvailability={(val) => {
|
||||
const schedule = {
|
||||
openingHours: val.openingHours,
|
||||
dateOverrides: val.dateOverrides,
|
||||
};
|
||||
// Updating internal state that would be sent on mutation
|
||||
setAvailabilityState(schedule);
|
||||
// Updating form values displayed, but this one doesn't reach form submit scope
|
||||
formMethods.setValue("availability", schedule);
|
||||
}}
|
||||
setTimeZone={(timeZone) => {
|
||||
formMethods.setValue("timeZone", timeZone);
|
||||
setSelectedTimeZone(timeZone);
|
||||
}}
|
||||
timeZone={selectedTimeZone}
|
||||
availability={availability.map((schedule) => ({
|
||||
...schedule,
|
||||
startTime: new Date(schedule.startTime),
|
||||
endTime: new Date(schedule.endTime),
|
||||
}))}
|
||||
render={({ field }) => (
|
||||
<AvailabilitySelect
|
||||
{...field}
|
||||
onChange={(selected: { label: string; value: number }) =>
|
||||
field.onChange(selected.value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Link href="/availability">
|
||||
<a>
|
||||
<Alert
|
||||
className="mt-1 text-xs"
|
||||
severity="info"
|
||||
message="You can manage your schedules on the Availability page."
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -1802,6 +1817,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
select: userSelect,
|
||||
},
|
||||
schedulingType: true,
|
||||
schedule: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
userId: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { ArrowRightIcon } from "@heroicons/react/outline";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Prisma, IdentityProvider } from "@prisma/client";
|
||||
import { IdentityProvider, Prisma } from "@prisma/client";
|
||||
import classnames from "classnames";
|
||||
import dayjs from "dayjs";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
|
@ -35,9 +35,9 @@ import { Schedule as ScheduleType } from "@lib/types/schedule";
|
|||
|
||||
import { ClientSuspense } from "@components/ClientSuspense";
|
||||
import Loader from "@components/Loader";
|
||||
import Schedule from "@components/availability/Schedule";
|
||||
import { CalendarListContainer } from "@components/integrations/CalendarListContainer";
|
||||
import Text from "@components/ui/Text";
|
||||
import Schedule from "@components/ui/form/Schedule";
|
||||
|
||||
import getEventTypes from "../lib/queries/event-types/get-event-types";
|
||||
|
||||
|
@ -134,22 +134,12 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
|
|||
return responseData.data;
|
||||
};
|
||||
|
||||
const createSchedule = async ({ schedule }: ScheduleFormValues) => {
|
||||
const res = await fetch(`/api/schedule`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ schedule }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
const createSchedule = trpc.useMutation("viewer.availability.schedule.create", {
|
||||
onError: (err) => {
|
||||
throw new Error(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error((await res.json()).message);
|
||||
}
|
||||
const responseData = await res.json();
|
||||
return responseData.data;
|
||||
};
|
||||
|
||||
/** Name */
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
const usernameRef = useRef<HTMLInputElement>(null);
|
||||
|
@ -444,7 +434,10 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
|
|||
handleSubmit={async (values) => {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await createSchedule({ ...values });
|
||||
await createSchedule.mutate({
|
||||
name: t("default_schedule_name"),
|
||||
...values,
|
||||
});
|
||||
debouncedHandleConfirmStep();
|
||||
setSubmitting(false);
|
||||
} catch (error) {
|
||||
|
|
|
@ -137,8 +137,6 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
|||
const localeOptions = useMemo(() => {
|
||||
return (router.locales || []).map((locale) => ({
|
||||
value: locale,
|
||||
// FIXME
|
||||
// @ts-ignore
|
||||
label: new Intl.DisplayNames(props.localeProp, { type: "language" }).of(locale) || "",
|
||||
}));
|
||||
}, [props.localeProp, router.locales]);
|
||||
|
|
|
@ -69,6 +69,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
timeZone: true,
|
||||
slotInterval: true,
|
||||
metadata: true,
|
||||
schedule: {
|
||||
select: {
|
||||
timeZone: true,
|
||||
availability: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -82,13 +88,17 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
|
||||
const [eventType] = team.eventTypes;
|
||||
|
||||
const timeZone = eventType.schedule?.timeZone || eventType.timeZone || undefined;
|
||||
|
||||
const workingHours = getWorkingHours(
|
||||
{
|
||||
timeZone: eventType.timeZone || undefined,
|
||||
timeZone,
|
||||
},
|
||||
eventType.availability
|
||||
eventType.schedule?.availability || eventType.availability
|
||||
);
|
||||
|
||||
eventType.schedule = null;
|
||||
|
||||
const eventTypeObject = Object.assign({}, eventType, {
|
||||
metadata: (eventType.metadata || {}) as JSONObject,
|
||||
periodStartDate: eventType.periodStartDate?.toString() ?? null,
|
||||
|
|
|
@ -670,6 +670,16 @@
|
|||
"prisma_studio_tip_description": "Learn how to set up your first user",
|
||||
"contact_sales": "Contact Sales",
|
||||
"error_404": "Error 404",
|
||||
"default": "Default",
|
||||
"set_to_default": "Set to Default",
|
||||
"new_schedule_btn": "New schedule",
|
||||
"add_new_schedule": "Add a new schedule",
|
||||
"delete_schedule": "Delete schedule",
|
||||
"schedule_created_successfully": "{{scheduleName}} schedule created successfully",
|
||||
"schedule_deleted_successfully": "Schedule deleted successfully",
|
||||
"default_schedule_name": "Working Hours",
|
||||
"new_schedule_heading": "Create an availability schedule",
|
||||
"new_schedule_description": "Creating availability schedules allows you to manage availability across event types. They can be applied to one or more event types.",
|
||||
"requires_ownership_of_a_token": "Requires ownership of a token belonging to the following address:",
|
||||
"example_name": "John Doe",
|
||||
"time_format": "Time format",
|
||||
|
|
|
@ -520,5 +520,8 @@
|
|||
"calendar": "Agenda",
|
||||
"not_installed": "Niet geïnstalleerd",
|
||||
"error_password_mismatch": "Wachtwoorden komen niet overeen.",
|
||||
"error_required_field": "Dit veld is verplicht."
|
||||
"error_required_field": "Dit veld is verplicht.",
|
||||
"default": "standaard keuze",
|
||||
"set_to_default": "Zet als standaard keuze",
|
||||
"click_here_to_add_a_new_schedule": "Klik hier om een nieuwe planning aan te maken"
|
||||
}
|
||||
|
|
|
@ -37,6 +37,7 @@ async function getUserFromSession({
|
|||
weekStart: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
defaultScheduleId: true,
|
||||
bufferTime: true,
|
||||
theme: true,
|
||||
createdDate: true,
|
||||
|
|
|
@ -19,8 +19,8 @@ import {
|
|||
samlTenantProduct,
|
||||
} from "@lib/saml";
|
||||
import slugify from "@lib/slugify";
|
||||
import { Schedule } from "@lib/types/schedule";
|
||||
|
||||
import { availabilityRouter } from "@server/routers/viewer/availability";
|
||||
import { eventTypesRouter } from "@server/routers/viewer/eventTypes";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
|
@ -564,48 +564,6 @@ const loggedInViewerRouter = createProtectedRouter()
|
|||
};
|
||||
},
|
||||
})
|
||||
.query("availability", {
|
||||
async resolve({ ctx }) {
|
||||
const { prisma, user } = ctx;
|
||||
const availabilityQuery = await prisma.availability.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
const schedule = availabilityQuery.reduce(
|
||||
(schedule: Schedule, availability) => {
|
||||
availability.days.forEach((day) => {
|
||||
schedule[day].push({
|
||||
start: new Date(
|
||||
Date.UTC(
|
||||
new Date().getUTCFullYear(),
|
||||
new Date().getUTCMonth(),
|
||||
new Date().getUTCDate(),
|
||||
availability.startTime.getUTCHours(),
|
||||
availability.startTime.getUTCMinutes()
|
||||
)
|
||||
),
|
||||
end: new Date(
|
||||
Date.UTC(
|
||||
new Date().getUTCFullYear(),
|
||||
new Date().getUTCMonth(),
|
||||
new Date().getUTCDate(),
|
||||
availability.endTime.getUTCHours(),
|
||||
availability.endTime.getUTCMinutes()
|
||||
)
|
||||
),
|
||||
});
|
||||
});
|
||||
return schedule;
|
||||
},
|
||||
Array.from([...Array(7)]).map(() => [])
|
||||
);
|
||||
return {
|
||||
schedule,
|
||||
timeZone: user.timeZone,
|
||||
};
|
||||
},
|
||||
})
|
||||
.mutation("updateProfile", {
|
||||
input: z.object({
|
||||
username: z.string().optional(),
|
||||
|
@ -840,5 +798,6 @@ export const viewerRouter = createRouter()
|
|||
.merge(publicViewerRouter)
|
||||
.merge(loggedInViewerRouter)
|
||||
.merge("eventTypes.", eventTypesRouter)
|
||||
.merge("availability.", availabilityRouter)
|
||||
.merge("teams.", viewerTeamsRouter)
|
||||
.merge("webhook.", webhookRouter);
|
||||
|
|
218
apps/web/server/routers/viewer/availability.tsx
Normal file
218
apps/web/server/routers/viewer/availability.tsx
Normal file
|
@ -0,0 +1,218 @@
|
|||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getAvailabilityFromSchedule } from "@lib/availability";
|
||||
import { Schedule } from "@lib/types/schedule";
|
||||
|
||||
import { createProtectedRouter } from "@server/createRouter";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
export const availabilityRouter = createProtectedRouter()
|
||||
.query("list", {
|
||||
async resolve({ ctx }) {
|
||||
const { prisma, user } = ctx;
|
||||
const schedules = await prisma.schedule.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
availability: true,
|
||||
timeZone: true,
|
||||
},
|
||||
orderBy: {
|
||||
id: "asc",
|
||||
},
|
||||
});
|
||||
return {
|
||||
schedules: schedules.map((schedule) => ({
|
||||
...schedule,
|
||||
isDefault: user.defaultScheduleId === schedule.id || schedules.length === 1,
|
||||
})),
|
||||
};
|
||||
},
|
||||
})
|
||||
.query("schedule", {
|
||||
input: z.object({
|
||||
scheduleId: z.number(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const { prisma, user } = ctx;
|
||||
const schedule = await prisma.schedule.findUnique({
|
||||
where: {
|
||||
id: input.scheduleId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
name: true,
|
||||
availability: true,
|
||||
timeZone: true,
|
||||
},
|
||||
});
|
||||
if (!schedule || schedule.userId !== user.id) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
}
|
||||
const availability = schedule.availability.reduce(
|
||||
(schedule: Schedule, availability) => {
|
||||
availability.days.forEach((day) => {
|
||||
schedule[day].push({
|
||||
start: new Date(
|
||||
Date.UTC(
|
||||
new Date().getUTCFullYear(),
|
||||
new Date().getUTCMonth(),
|
||||
new Date().getUTCDate(),
|
||||
availability.startTime.getUTCHours(),
|
||||
availability.startTime.getUTCMinutes()
|
||||
)
|
||||
),
|
||||
end: new Date(
|
||||
Date.UTC(
|
||||
new Date().getUTCFullYear(),
|
||||
new Date().getUTCMonth(),
|
||||
new Date().getUTCDate(),
|
||||
availability.endTime.getUTCHours(),
|
||||
availability.endTime.getUTCMinutes()
|
||||
)
|
||||
),
|
||||
});
|
||||
});
|
||||
return schedule;
|
||||
},
|
||||
Array.from([...Array(7)]).map(() => [])
|
||||
);
|
||||
return {
|
||||
schedule,
|
||||
availability,
|
||||
timeZone: schedule.timeZone || user.timeZone,
|
||||
isDefault: !user.defaultScheduleId || user.defaultScheduleId === schedule.id,
|
||||
};
|
||||
},
|
||||
})
|
||||
.mutation("schedule.create", {
|
||||
input: z.object({
|
||||
name: z.string(),
|
||||
copyScheduleId: z.number().optional(),
|
||||
schedule: z
|
||||
.array(
|
||||
z.array(
|
||||
z.object({
|
||||
start: z.date(),
|
||||
end: z.date(),
|
||||
})
|
||||
)
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
async resolve({ input, ctx }) {
|
||||
const { user, prisma } = ctx;
|
||||
const data: Prisma.ScheduleCreateInput = {
|
||||
name: input.name,
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (input.schedule) {
|
||||
const availability = getAvailabilityFromSchedule(input.schedule);
|
||||
data.availability = {
|
||||
createMany: {
|
||||
data: availability.map((schedule) => ({
|
||||
days: schedule.days,
|
||||
startTime: schedule.startTime,
|
||||
endTime: schedule.endTime,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
const schedule = await prisma.schedule.create({
|
||||
data,
|
||||
});
|
||||
return { schedule };
|
||||
},
|
||||
})
|
||||
.mutation("schedule.delete", {
|
||||
input: z.object({
|
||||
scheduleId: z.number(),
|
||||
}),
|
||||
async resolve({ input, ctx }) {
|
||||
const { user, prisma } = ctx;
|
||||
|
||||
if (user.defaultScheduleId === input.scheduleId) {
|
||||
// unset default
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
defaultScheduleId: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
await prisma.schedule.delete({
|
||||
where: {
|
||||
id: input.scheduleId,
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
.mutation("schedule.update", {
|
||||
input: z.object({
|
||||
scheduleId: z.number(),
|
||||
timeZone: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
schedule: z.array(
|
||||
z.array(
|
||||
z.object({
|
||||
start: z.date(),
|
||||
end: z.date(),
|
||||
})
|
||||
)
|
||||
),
|
||||
}),
|
||||
async resolve({ input, ctx }) {
|
||||
const { user, prisma } = ctx;
|
||||
const availability = getAvailabilityFromSchedule(input.schedule);
|
||||
|
||||
if (input.isDefault) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
defaultScheduleId: input.scheduleId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.schedule.update({
|
||||
where: {
|
||||
id: input.scheduleId,
|
||||
},
|
||||
data: {
|
||||
timeZone: input.timeZone,
|
||||
name: input.name,
|
||||
availability: {
|
||||
deleteMany: {
|
||||
scheduleId: {
|
||||
equals: input.scheduleId,
|
||||
},
|
||||
},
|
||||
createMany: {
|
||||
data: availability.map((schedule) => ({
|
||||
days: schedule.days,
|
||||
startTime: schedule.startTime,
|
||||
endTime: schedule.endTime,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
|
@ -63,27 +63,16 @@ function handleCustomInputs(customInputs: EventTypeCustomInput[], eventTypeId: n
|
|||
};
|
||||
}
|
||||
|
||||
const AvailabilityInput = _AvailabilityModel.pick({
|
||||
days: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
});
|
||||
|
||||
const EventTypeUpdateInput = _EventTypeModel
|
||||
/** Optional fields */
|
||||
.extend({
|
||||
availability: z
|
||||
.object({
|
||||
openingHours: z.array(AvailabilityInput).optional(),
|
||||
dateOverrides: z.array(AvailabilityInput).optional(),
|
||||
})
|
||||
.optional(),
|
||||
customInputs: z.array(_EventTypeCustomInputModel),
|
||||
destinationCalendar: _DestinationCalendarModel.pick({
|
||||
integration: true,
|
||||
externalId: true,
|
||||
}),
|
||||
users: z.array(stringOrNumber).optional(),
|
||||
schedule: z.number().optional(),
|
||||
})
|
||||
.partial()
|
||||
.merge(
|
||||
|
@ -190,7 +179,7 @@ export const eventTypesRouter = createProtectedRouter()
|
|||
.mutation("update", {
|
||||
input: EventTypeUpdateInput.strict(),
|
||||
async resolve({ ctx, input }) {
|
||||
const { availability, periodType, locations, destinationCalendar, customInputs, users, id, ...rest } =
|
||||
const { schedule, periodType, locations, destinationCalendar, customInputs, users, id, ...rest } =
|
||||
input;
|
||||
const data: Prisma.EventTypeUpdateInput = rest;
|
||||
data.locations = locations ?? undefined;
|
||||
|
@ -211,6 +200,14 @@ export const eventTypesRouter = createProtectedRouter()
|
|||
data.customInputs = handleCustomInputs(customInputs, id);
|
||||
}
|
||||
|
||||
if (schedule) {
|
||||
data.schedule = {
|
||||
connect: {
|
||||
id: schedule,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (users) {
|
||||
data.users = {
|
||||
set: [],
|
||||
|
@ -218,20 +215,6 @@ export const eventTypesRouter = createProtectedRouter()
|
|||
};
|
||||
}
|
||||
|
||||
if (availability?.openingHours) {
|
||||
await ctx.prisma.availability.deleteMany({
|
||||
where: {
|
||||
eventTypeId: input.id,
|
||||
},
|
||||
});
|
||||
|
||||
data.availability = {
|
||||
createMany: {
|
||||
data: availability.openingHours,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const eventType = await ctx.prisma.eventType.update({
|
||||
where: { id },
|
||||
data,
|
||||
|
|
|
@ -9,6 +9,14 @@
|
|||
--brand-text-color-dark-mode: #292929;
|
||||
}
|
||||
|
||||
button[role="switch"][data-state="checked"] {
|
||||
@apply bg-gray-900;
|
||||
}
|
||||
|
||||
button[role="switch"][data-state="checked"] span {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
/* PhoneInput dark-mode overrides (it would add a lot of boilerplate to do this in JavaScript) */
|
||||
.PhoneInputInput {
|
||||
@apply border-0 text-sm focus:ring-0;
|
||||
|
|
|
@ -3,6 +3,7 @@ import customParseFormat from "dayjs/plugin/customParseFormat";
|
|||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
import { nameOfDay } from "@calcom/lib/weekday";
|
||||
import type { Availability } from "@calcom/prisma/client";
|
||||
import type { Schedule, TimeRange, WorkingHours } from "@calcom/types/schedule";
|
||||
|
||||
|
@ -38,7 +39,9 @@ export function getAvailabilityFromSchedule(schedule: Schedule): Availability[]
|
|||
let idx;
|
||||
if (
|
||||
(idx = availability.findIndex(
|
||||
(schedule) => schedule.startTime === time.start && schedule.endTime === time.end
|
||||
(schedule) =>
|
||||
schedule.startTime.toString() === time.start.toString() &&
|
||||
schedule.endTime.toString() === time.end.toString()
|
||||
)) !== -1
|
||||
) {
|
||||
availability[idx].days.push(day);
|
||||
|
@ -124,3 +127,41 @@ export function getWorkingHours(
|
|||
|
||||
return workingHours;
|
||||
}
|
||||
|
||||
export function availabilityAsString(availability: Availability, locale: string) {
|
||||
const weekSpan = (availability: Availability) => {
|
||||
const days = availability.days.slice(1).reduce(
|
||||
(days, day) => {
|
||||
if (days[days.length - 1].length === 1 && days[days.length - 1][0] === day - 1) {
|
||||
// append if the range is not complete (but the next day needs adding)
|
||||
days[days.length - 1].push(day);
|
||||
} else if (days[days.length - 1][days[days.length - 1].length - 1] === day - 1) {
|
||||
// range complete, overwrite if the last day directly preceeds the current day
|
||||
days[days.length - 1] = [days[days.length - 1][0], day];
|
||||
} else {
|
||||
// new range
|
||||
days.push([day]);
|
||||
}
|
||||
return days;
|
||||
},
|
||||
[[availability.days[0]]] as number[][]
|
||||
);
|
||||
return days
|
||||
.map((dayRange) => dayRange.map((day) => nameOfDay(locale, day, "short")).join(" - "))
|
||||
.join(", ");
|
||||
};
|
||||
|
||||
const timeSpan = (availability: Availability) => {
|
||||
return (
|
||||
new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "numeric" }).format(
|
||||
new Date(availability.startTime.toISOString().slice(0, -1))
|
||||
) +
|
||||
" - " +
|
||||
new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "numeric" }).format(
|
||||
new Date(availability.endTime.toISOString().slice(0, -1))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return weekSpan(availability) + ", " + timeSpan(availability);
|
||||
}
|
||||
|
|
8
packages/lib/weekday.ts
Normal file
8
packages/lib/weekday.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
// By default starts on Sunday (Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday)
|
||||
export function weekdayNames(locale: string | string[], weekStart = 0, type: "short" | "long" = "long") {
|
||||
return Array.from(Array(7).keys()).map((d) => nameOfDay(locale, d + weekStart, type));
|
||||
}
|
||||
|
||||
export function nameOfDay(locale: string | string[], day: number, type: "short" | "long" = "long") {
|
||||
return new Intl.DateTimeFormat(locale, { weekday: type }).format(new Date(1970, 0, day + 4));
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `label` on the `Availability` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `freeBusyTimes` on the `Schedule` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `title` on the `Schedule` table. All the data in the column will be lost.
|
||||
- A unique constraint covering the columns `[eventTypeId]` on the table `Schedule` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `name` to the `Schedule` table without a default value. This is not possible if the table is not empty.
|
||||
- Made the column `userId` on table `Schedule` required. This step will fail if there are existing NULL values in that column.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Availability" DROP COLUMN "label",
|
||||
ADD COLUMN "scheduleId" INTEGER;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Schedule" DROP COLUMN "freeBusyTimes",
|
||||
DROP COLUMN "title",
|
||||
ADD COLUMN "name" TEXT NOT NULL,
|
||||
ADD COLUMN "timeZone" TEXT,
|
||||
ALTER COLUMN "userId" SET NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "defaultScheduleId" INTEGER;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Schedule_eventTypeId_key" ON "Schedule"("eventTypeId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Availability" ADD CONSTRAINT "Availability_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "Schedule"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
@ -62,7 +62,7 @@ model EventType {
|
|||
beforeEventBuffer Int @default(0)
|
||||
afterEventBuffer Int @default(0)
|
||||
schedulingType SchedulingType?
|
||||
Schedule Schedule[]
|
||||
schedule Schedule?
|
||||
price Int @default(0)
|
||||
currency String @default("usd")
|
||||
slotInterval Int?
|
||||
|
@ -128,7 +128,8 @@ model User {
|
|||
credentials Credential[]
|
||||
teams Membership[]
|
||||
bookings Booking[]
|
||||
availability Availability[]
|
||||
schedules Schedule[]
|
||||
defaultScheduleId Int?
|
||||
selectedCalendars SelectedCalendar[]
|
||||
completedOnboarding Boolean @default(false)
|
||||
locale String?
|
||||
|
@ -137,9 +138,9 @@ model User {
|
|||
twoFactorEnabled Boolean @default(false)
|
||||
identityProvider IdentityProvider @default(CAL)
|
||||
identityProviderId String?
|
||||
availability Availability[]
|
||||
invitedTo Int?
|
||||
plan UserPlan @default(TRIAL)
|
||||
Schedule Schedule[]
|
||||
webhooks Webhook[]
|
||||
brandColor String @default("#292929")
|
||||
darkBrandColor String @default("#fafafa")
|
||||
|
@ -256,17 +257,17 @@ model Booking {
|
|||
|
||||
model Schedule {
|
||||
id Int @id @default(autoincrement())
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int?
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
eventType EventType? @relation(fields: [eventTypeId], references: [id])
|
||||
eventTypeId Int?
|
||||
title String?
|
||||
freeBusyTimes Json?
|
||||
eventTypeId Int? @unique
|
||||
name String
|
||||
timeZone String?
|
||||
availability Availability[]
|
||||
}
|
||||
|
||||
model Availability {
|
||||
id Int @id @default(autoincrement())
|
||||
label String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int?
|
||||
eventType EventType? @relation(fields: [eventTypeId], references: [id])
|
||||
|
@ -275,6 +276,8 @@ model Availability {
|
|||
startTime DateTime @db.Time
|
||||
endTime DateTime @db.Time
|
||||
date DateTime? @db.Date
|
||||
Schedule Schedule? @relation(fields: [scheduleId], references: [id])
|
||||
scheduleId Int?
|
||||
}
|
||||
|
||||
model SelectedCalendar {
|
||||
|
|
|
@ -29,11 +29,18 @@ async function createUserAndEventType(opts: {
|
|||
emailVerified: new Date(),
|
||||
completedOnboarding: opts.user.completedOnboarding ?? true,
|
||||
locale: "en",
|
||||
schedules: opts.user.completedOnboarding
|
||||
? {
|
||||
create: {
|
||||
name: "Working Hours",
|
||||
availability: {
|
||||
createMany: {
|
||||
data: getAvailabilityFromSchedule(DEFAULT_SCHEDULE),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: opts.user.email },
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { CheckCircleIcon, InformationCircleIcon, XCircleIcon } from "@heroicons/react/solid";
|
||||
import { CheckCircleIcon, ExclamationIcon, InformationCircleIcon, XCircleIcon } from "@heroicons/react/solid";
|
||||
import classNames from "classnames";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
|
@ -7,7 +7,7 @@ export interface AlertProps {
|
|||
message?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
severity: "success" | "warning" | "error";
|
||||
severity: "success" | "warning" | "error" | "info";
|
||||
}
|
||||
export function Alert(props: AlertProps) {
|
||||
const { severity } = props;
|
||||
|
@ -19,6 +19,7 @@ export function Alert(props: AlertProps) {
|
|||
props.className,
|
||||
severity === "error" && "border-red-900 bg-red-50 text-red-800",
|
||||
severity === "warning" && "border-yellow-700 bg-yellow-50 text-yellow-700",
|
||||
severity === "info" && "border-sky-700 bg-sky-50 text-sky-700",
|
||||
severity === "success" && "bg-gray-900 text-white"
|
||||
)}>
|
||||
<div className="flex">
|
||||
|
@ -27,7 +28,10 @@ export function Alert(props: AlertProps) {
|
|||
<XCircleIcon className={classNames("h-5 w-5 text-red-400")} aria-hidden="true" />
|
||||
)}
|
||||
{severity === "warning" && (
|
||||
<InformationCircleIcon className={classNames("h-5 w-5 text-yellow-400")} aria-hidden="true" />
|
||||
<ExclamationIcon className={classNames("h-5 w-5 text-yellow-400")} aria-hidden="true" />
|
||||
)}
|
||||
{severity === "info" && (
|
||||
<InformationCircleIcon className={classNames("h-5 w-5 text-sky-400")} aria-hidden="true" />
|
||||
)}
|
||||
{severity === "success" && (
|
||||
<CheckCircleIcon className={classNames("h-5 w-5 text-gray-400")} aria-hidden="true" />
|
||||
|
|
|
@ -1,37 +1,22 @@
|
|||
import { useId } from "@radix-ui/react-id";
|
||||
import * as Label from "@radix-ui/react-label";
|
||||
import * as PrimitiveSwitch from "@radix-ui/react-switch";
|
||||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
|
||||
type SwitchProps = React.ComponentProps<typeof PrimitiveSwitch.Root> & {
|
||||
const Switch = (
|
||||
props: React.ComponentProps<typeof PrimitiveSwitch.Root> & {
|
||||
label: string;
|
||||
};
|
||||
export default function Switch(props: SwitchProps) {
|
||||
const { label, onCheckedChange, ...primitiveProps } = props;
|
||||
const [checked, setChecked] = useState(props.defaultChecked || false);
|
||||
|
||||
const onPrimitiveCheckedChange = (change: boolean) => {
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(change);
|
||||
}
|
||||
setChecked(change);
|
||||
};
|
||||
) => {
|
||||
const { label, ...primitiveProps } = props;
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<div className="flex h-[20px] items-center">
|
||||
<PrimitiveSwitch.Root
|
||||
className={classNames(checked ? "bg-gray-900" : "bg-gray-400", "h-[20px] w-[36px] rounded-sm p-0.5")}
|
||||
checked={checked}
|
||||
onCheckedChange={onPrimitiveCheckedChange}
|
||||
{...primitiveProps}>
|
||||
<PrimitiveSwitch.Root className="h-[20px] w-[36px] rounded-sm bg-gray-400 p-0.5" {...primitiveProps}>
|
||||
<PrimitiveSwitch.Thumb
|
||||
id={id}
|
||||
className={classNames(
|
||||
"block h-[16px] w-[16px] bg-white transition-transform",
|
||||
checked ? "translate-x-[16px]" : "translate-x-0"
|
||||
)}
|
||||
className={"block h-[16px] w-[16px] translate-x-0 bg-white transition-transform"}
|
||||
/>
|
||||
</PrimitiveSwitch.Root>
|
||||
{label && (
|
||||
|
@ -43,4 +28,6 @@ export default function Switch(props: SwitchProps) {
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default Switch;
|
||||
|
|
Loading…
Reference in a new issue