feat: ooo-v2 (#13621)
Co-authored-by: zomars <zomars@me.com> Co-authored-by: Peer Richelsen <peer@cal.com> Co-authored-by: CarinaWolli <wollencarina@gmail.com> Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
co-authored by
zomars
Peer Richelsen
CarinaWolli
Alex van Andel
parent
f00849b8d1
commit
880ba8f1dd
@@ -3,7 +3,6 @@ import type { GetServerSideProps } from "next";
|
||||
import { encode } from "querystring";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { handleUserRedirection } from "@calcom/features/booking-redirect/handle-user";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR } from "@calcom/lib/constants";
|
||||
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
@@ -73,21 +72,12 @@ export type UserPageProps = {
|
||||
export const getServerSideProps: GetServerSideProps<UserPageProps> = async (context) => {
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
|
||||
const usernameList = getUsernameList(context.query.user as string);
|
||||
const isARedirectFromNonOrgLink = context.query.orgRedirection === "true";
|
||||
const isOrgContext = isValidOrgDomain && currentOrgDomain;
|
||||
const dataFetchStart = Date.now();
|
||||
let outOfOffice = false;
|
||||
const isOrgContext = isValidOrgDomain && !!currentOrgDomain;
|
||||
|
||||
if (usernameList.length === 1) {
|
||||
const result = await handleUserRedirection({ username: usernameList[0] });
|
||||
if (result && result.outOfOffice) {
|
||||
outOfOffice = true;
|
||||
}
|
||||
if (result && result.redirect?.destination) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
const dataFetchStart = Date.now();
|
||||
|
||||
if (!isOrgContext) {
|
||||
// If there is no org context, see if some redirect is setup due to org migration
|
||||
@@ -176,7 +166,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
||||
const eventTypes = await getEventTypesPublic(user.id);
|
||||
|
||||
// if profile only has one public event-type, redirect to it
|
||||
if (eventTypes.length === 1 && context.query.redirect !== "false" && !outOfOffice) {
|
||||
if (eventTypes.length === 1 && context.query.redirect !== "false") {
|
||||
// Redirect but don't change the URL
|
||||
const urlDestination = `/${user.profile.username}/${eventTypes[0].slug}`;
|
||||
const { query } = context;
|
||||
@@ -202,9 +192,9 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
||||
username: user.username,
|
||||
bio: user.bio,
|
||||
avatarUrl: user.avatarUrl,
|
||||
away: usernameList.length === 1 ? outOfOffice : user.away,
|
||||
verified: user.verified,
|
||||
profile: user.profile,
|
||||
away: user.away,
|
||||
})),
|
||||
entity: {
|
||||
considerUnpublished: !isARedirectFromNonOrgLink && org?.slug === null,
|
||||
|
||||
@@ -87,25 +87,6 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
||||
isEmbed ? "border-booker border-booker-width bg-default rounded-md border" : "",
|
||||
"max-w-3xl px-4 py-24"
|
||||
)}>
|
||||
{isRedirect && (
|
||||
<div className="mb-8 rounded-md bg-blue-100 p-4 dark:border dark:bg-transparent">
|
||||
<h2 className="text-default mb-2 text-sm font-semibold dark:text-white">
|
||||
{t("user_redirect_title", {
|
||||
username: fromUserNameRedirected,
|
||||
})}{" "}
|
||||
🏝️
|
||||
</h2>
|
||||
<p className="text-default text-sm">
|
||||
{t("user_redirect_description", {
|
||||
profile: {
|
||||
username: user.username,
|
||||
},
|
||||
username: fromUserNameRedirected,
|
||||
})}{" "}
|
||||
😄
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-8 text-center">
|
||||
<UserAvatar
|
||||
size="xl"
|
||||
|
||||
@@ -2,7 +2,6 @@ import { type GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { handleTypeRedirection } from "@calcom/features/booking-redirect/handle-type";
|
||||
import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
@@ -105,7 +104,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
const username = usernames[0];
|
||||
const { rescheduleUid, bookingUid } = context.query;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
let outOfOffice = false;
|
||||
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
if (!isOrgContext) {
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
@@ -132,18 +131,6 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
// If user is found, quickly verify bookingRedirects
|
||||
const result = await handleTypeRedirection({
|
||||
userId: user.id,
|
||||
username,
|
||||
slug,
|
||||
});
|
||||
if (result && result.outOfOffice) {
|
||||
outOfOffice = true;
|
||||
}
|
||||
if (result && result.redirect?.destination) {
|
||||
return result;
|
||||
}
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
@@ -176,9 +163,9 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
length: eventData.length,
|
||||
metadata: eventData.metadata,
|
||||
},
|
||||
away: outOfOffice,
|
||||
user: username,
|
||||
slug,
|
||||
away: false,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isBrandingHidden: user?.hideBranding,
|
||||
isSEOIndexable: user?.allowSEOIndexing,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState } from "react";
|
||||
import { Trans } from "next-i18next";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm, useFormState } from "react-hook-form";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -11,6 +12,12 @@ import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import {
|
||||
Button,
|
||||
DateRangePicker,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
EmptyScreen,
|
||||
Icon,
|
||||
Meta,
|
||||
Select,
|
||||
showToast,
|
||||
@@ -18,10 +25,9 @@ import {
|
||||
Switch,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableNew,
|
||||
TableRow,
|
||||
TextArea,
|
||||
UpgradeTeamsBadge,
|
||||
} from "@calcom/ui";
|
||||
|
||||
@@ -31,12 +37,21 @@ export type BookingRedirectForm = {
|
||||
dateRange: { startDate: Date; endDate: Date };
|
||||
offset: number;
|
||||
toTeamUserId: number | null;
|
||||
reasonId: number;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
const OutOfOfficeSection = () => {
|
||||
const CreateOutOfOfficeEntryModal = ({
|
||||
openModal,
|
||||
closeModal,
|
||||
}: {
|
||||
openModal: boolean;
|
||||
closeModal: () => void;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const [selectedReason, setSelectedReason] = useState<{ label: string; value: number } | null>(null);
|
||||
const [profileRedirect, setProfileRedirect] = useState(false);
|
||||
const [selectedMember, setSelectedMember] = useState<{ label: string; value: number | null } | null>(null);
|
||||
|
||||
@@ -45,28 +60,6 @@ const OutOfOfficeSection = () => {
|
||||
endDate: dayjs().add(1, "d").endOf("d").toDate(),
|
||||
});
|
||||
|
||||
const { handleSubmit, setValue, getValues, control } = useForm<BookingRedirectForm>({
|
||||
defaultValues: {
|
||||
dateRange: {
|
||||
startDate: dateRange.startDate,
|
||||
endDate: dateRange.endDate,
|
||||
},
|
||||
offset: dayjs().utcOffset(),
|
||||
toTeamUserId: null,
|
||||
},
|
||||
});
|
||||
|
||||
const createOutOfOfficeEntry = trpc.viewer.outOfOfficeCreate.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("success_entry_created"), "success");
|
||||
utils.viewer.outOfOfficeEntriesList.invalidate();
|
||||
setProfileRedirect(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(t(error.message), "error");
|
||||
},
|
||||
});
|
||||
|
||||
const { hasTeamPlan } = useHasTeamPlan();
|
||||
const { data: listMembers } = trpc.viewer.teams.listMembers.useQuery({});
|
||||
const me = useMeQuery();
|
||||
@@ -81,57 +74,59 @@ const OutOfOfficeSection = () => {
|
||||
label: member.name || "",
|
||||
})) || [];
|
||||
|
||||
const { handleSubmit, setValue, getValues, control, register } = useForm<BookingRedirectForm>({
|
||||
defaultValues: {
|
||||
dateRange: {
|
||||
startDate: dateRange.startDate,
|
||||
endDate: dateRange.endDate,
|
||||
},
|
||||
offset: dayjs().utcOffset(),
|
||||
toTeamUserId: null,
|
||||
reasonId: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const createOutOfOfficeEntry = trpc.viewer.outOfOfficeCreate.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("success_entry_created"), "success");
|
||||
utils.viewer.outOfOfficeEntriesList.invalidate();
|
||||
setProfileRedirect(false);
|
||||
closeModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(t(error.message), "error");
|
||||
},
|
||||
});
|
||||
|
||||
const { data: outOfOfficeReasonList } = trpc.viewer.outOfOfficeReasonList.useQuery();
|
||||
|
||||
const reasonList = [
|
||||
...(outOfOfficeReasonList || []).map((reason) => ({
|
||||
label: `${reason.emoji} ${reason.userId === null ? t(reason.reason) : reason.reason}`,
|
||||
value: reason.id,
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
onSubmit={handleSubmit((data) => {
|
||||
createOutOfOfficeEntry.mutate(data);
|
||||
setValue("toTeamUserId", null);
|
||||
setSelectedMember(null);
|
||||
})}>
|
||||
<div className="border-subtle flex flex-col border border-b-0 border-t-0 p-6 px-6 py-8 text-sm">
|
||||
{/* Add startDate and end date inputs */}
|
||||
{/* Add toggle to enable/disable redirect */}
|
||||
<div className="flex flex-row">
|
||||
<Switch
|
||||
disabled={!hasTeamPlan}
|
||||
data-testid="profile-redirect-switch"
|
||||
checked={profileRedirect}
|
||||
id="profile-redirect-switch"
|
||||
onCheckedChange={(state) => {
|
||||
setProfileRedirect(state);
|
||||
}}
|
||||
label={hasTeamPlan ? t("redirect_team_enabled") : t("redirect_team_disabled")}
|
||||
/>
|
||||
{!hasTeamPlan && (
|
||||
<div className="mx-2" data-testid="upgrade-team-badge">
|
||||
<UpgradeTeamsBadge />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 grid grid-rows-2 gap-2 md:grid-cols-2">
|
||||
{profileRedirect && (
|
||||
<div>
|
||||
<p className="text-emphasis block text-sm font-medium">{t("team_member")}</p>
|
||||
<Select
|
||||
className="mt-1 h-4 text-white"
|
||||
name="toTeamUsername"
|
||||
data-testid="team_username_select"
|
||||
value={selectedMember}
|
||||
placeholder={t("select_team_member")}
|
||||
isSearchable
|
||||
options={memberListOptions}
|
||||
onChange={(selectedOption) => {
|
||||
if (selectedOption?.value) {
|
||||
setSelectedMember(selectedOption);
|
||||
setValue("toTeamUserId", selectedOption?.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Dialog open={openModal}>
|
||||
<DialogContent
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
}}>
|
||||
<form
|
||||
id="create-ooo-form"
|
||||
className="h-full"
|
||||
onSubmit={handleSubmit((data) => {
|
||||
createOutOfOfficeEntry.mutate(data);
|
||||
setValue("toTeamUserId", null);
|
||||
setValue("notes", "");
|
||||
setSelectedReason(null);
|
||||
setSelectedMember(null);
|
||||
})}>
|
||||
<div className="px-1">
|
||||
<DialogHeader title={t("create_an_out_of_office")} />
|
||||
<div>
|
||||
<p className="text-emphasis mb-1 block text-sm font-medium">{t("time_range")}</p>
|
||||
<p className="text-emphasis mb-1 block text-sm font-medium capitalize">{t("dates")}</p>
|
||||
<div>
|
||||
<Controller
|
||||
name="dateRange"
|
||||
@@ -152,25 +147,109 @@ const OutOfOfficeSection = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reason Select */}
|
||||
<div className="mt-4 w-full">
|
||||
<div className="">
|
||||
<p className="text-emphasis block text-sm font-medium">{t("reason")}</p>
|
||||
<Select
|
||||
className="mb-0 mt-1 text-white"
|
||||
name="reason"
|
||||
data-testid="reason_select"
|
||||
value={selectedReason}
|
||||
placeholder={t("ooo_select_reason")}
|
||||
options={reasonList}
|
||||
onChange={(selectedOption) => {
|
||||
if (selectedOption?.value) {
|
||||
setSelectedReason(selectedOption);
|
||||
setValue("reasonId", selectedOption?.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes input */}
|
||||
<div className="mt-4">
|
||||
<p className="text-emphasis block text-sm font-medium">{t("notes")}</p>
|
||||
<TextArea
|
||||
data-testid="notes_input"
|
||||
className="border-subtle mt-1 h-10 w-full rounded-lg border px-2"
|
||||
placeholder={t("additional_notes")}
|
||||
{...register("notes")}
|
||||
onChange={(e) => {
|
||||
setValue("notes", e?.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted my-4 rounded-xl p-5">
|
||||
<div className="flex flex-row">
|
||||
<Switch
|
||||
disabled={!hasTeamPlan}
|
||||
data-testid="profile-redirect-switch"
|
||||
checked={profileRedirect}
|
||||
id="profile-redirect-switch"
|
||||
onCheckedChange={(state) => {
|
||||
setProfileRedirect(state);
|
||||
}}
|
||||
label={hasTeamPlan ? t("redirect_team_enabled") : t("redirect_team_disabled")}
|
||||
/>
|
||||
{!hasTeamPlan && (
|
||||
<div className="mx-2" data-testid="upgrade-team-badge">
|
||||
<UpgradeTeamsBadge />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{profileRedirect && (
|
||||
<div className="mt-4">
|
||||
<div className="h-16">
|
||||
<p className="text-emphasis block text-sm font-medium">{t("team_member")}</p>
|
||||
<Select
|
||||
className="mt-1 h-4 text-white"
|
||||
name="toTeamUsername"
|
||||
data-testid="team_username_select"
|
||||
value={selectedMember}
|
||||
placeholder={t("select_team_member")}
|
||||
isSearchable
|
||||
options={memberListOptions}
|
||||
onChange={(selectedOption) => {
|
||||
if (selectedOption?.value) {
|
||||
setSelectedMember(selectedOption);
|
||||
setValue("toTeamUserId", selectedOption?.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SectionBottomActions className="mb-6" align="end">
|
||||
<Button
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={createOutOfOfficeEntry.isPending}
|
||||
data-testid="create-entry-ooo-redirect">
|
||||
{t("create_entry")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
</form>
|
||||
</>
|
||||
</form>
|
||||
<DialogFooter showDivider noSticky>
|
||||
<div className="flex">
|
||||
<Button color="minimal" type="button" onClick={() => closeModal()} className="mr-1">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
form="create-ooo-form"
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={createOutOfOfficeEntry.isPending}
|
||||
data-testid="create-entry-ooo-redirect">
|
||||
{t("create")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const OutOfOfficeEntriesList = () => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const utils = trpc.useUtils();
|
||||
const { data, isPending } = trpc.viewer.outOfOfficeEntriesList.useQuery();
|
||||
const deleteOutOfOfficeEntryMutation = trpc.viewer.outOfOfficeEntryDelete.useMutation({
|
||||
onSuccess: () => {
|
||||
@@ -182,31 +261,75 @@ const OutOfOfficeEntriesList = () => {
|
||||
showToast(`An error ocurred`, "error");
|
||||
},
|
||||
});
|
||||
if (data === null || data?.length === 0 || data === undefined) return null;
|
||||
if (data === null || data?.length === 0 || (data === undefined && !isPending))
|
||||
return (
|
||||
<EmptyScreen
|
||||
className="mt-6"
|
||||
headline={t("ooo_empty_title")}
|
||||
description={t("ooo_empty_description")}
|
||||
customIcon={
|
||||
<div className="mt-4 h-[102px]">
|
||||
<div className="flex h-full flex-col items-center justify-center p-2 md:mt-0 md:p-0">
|
||||
<div className="relative">
|
||||
<div className="dark:bg-darkgray-50 absolute -left-3 -top-3 -z-20 h-[70px] w-[70px] -rotate-[24deg] rounded-3xl border-2 border-[#e5e7eb] p-8 opacity-40 dark:opacity-80">
|
||||
<div className="w-12" />
|
||||
</div>
|
||||
<div className="dark:bg-darkgray-50 absolute -top-3 left-3 -z-10 h-[70px] w-[70px] rotate-[24deg] rounded-3xl border-2 border-[#e5e7eb] p-8 opacity-60 dark:opacity-90">
|
||||
<div className="w-12" />
|
||||
</div>
|
||||
<div className="dark:bg-darkgray-50 relative z-0 flex h-[70px] w-[70px] items-center justify-center rounded-3xl border-2 border-[#e5e7eb] bg-white">
|
||||
<Icon name="clock" size={28} />
|
||||
<div className="dark:bg-darkgray-50 absolute right-4 top-5 h-[12px] w-[12px] rotate-[56deg] bg-white text-lg font-bold" />
|
||||
<span className="absolute right-4 top-3 font-sans text-sm font-extrabold">z</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div className="border-subtle mt-6 rounded-lg border">
|
||||
<TableNew className="border-0">
|
||||
<TableHeader className="md:z-1">
|
||||
<TableRow>
|
||||
<TableHead className="rounded-tl-lg font-normal capitalize">{t("time_range")}</TableHead>
|
||||
<TableHead className="font-normal">{t("redirect_to")}</TableHead>
|
||||
|
||||
<TableHead className="rounded-tr-lg font-normal">{t("action")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.map((item) => (
|
||||
<TableRow key={item.id} data-testid={`table-redirect-${item.toUser?.username || "n-a"}`}>
|
||||
<TableCell>
|
||||
<p className="px-2">
|
||||
{dayjs.utc(item.start).format("ll")} - {dayjs.utc(item.end).format("ll")}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="px-2">{item.toUser?.username || "N/A"}</p>
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<TableCell className="flex flex-row justify-between p-4">
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gray-50">
|
||||
{item?.reason?.emoji || "🏝️"}
|
||||
</div>
|
||||
|
||||
<div className="ml-2 flex flex-col">
|
||||
<p className="px-2 font-bold">
|
||||
{dayjs.utc(item.start).format("ll")} - {dayjs.utc(item.end).format("ll")}
|
||||
</p>
|
||||
<p className="px-2">
|
||||
{item.toUser?.username ? (
|
||||
<Trans
|
||||
i18nKey="ooo_forwarding_to"
|
||||
values={{
|
||||
username: item.toUser?.username,
|
||||
}}
|
||||
components={{
|
||||
span: <span className="text-subtle font-bold" />,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>{t("ooo_not_forwarding")}</>
|
||||
)}
|
||||
</p>
|
||||
{item.notes && (
|
||||
<p className="px-2">
|
||||
<span className="text-subtle">{t("notes")}: </span>
|
||||
{item.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="self-center rounded-lg border"
|
||||
type="button"
|
||||
color="minimal"
|
||||
variant="icon"
|
||||
@@ -220,13 +343,15 @@ const OutOfOfficeEntriesList = () => {
|
||||
</TableRow>
|
||||
))}
|
||||
{isPending && (
|
||||
<TableRow>
|
||||
{new Array(6).fill(0).map((_, index) => (
|
||||
<TableCell key={index}>
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
</TableCell>
|
||||
<>
|
||||
{new Array(2).fill(0).map((_, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<SkeletonText className="h-12 w-full" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableRow>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isPending && (data === undefined || data.length === 0) && (
|
||||
@@ -245,10 +370,32 @@ const OutOfOfficeEntriesList = () => {
|
||||
const OutOfOfficePage = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const params = useSearchParams();
|
||||
const openModalOnStart = !!params?.get("om");
|
||||
useEffect(() => {
|
||||
if (openModalOnStart) {
|
||||
setOpenModal(true);
|
||||
}
|
||||
}, [openModalOnStart]);
|
||||
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("out_of_office")} description={t("out_of_office_description")} borderInShellHeader />
|
||||
<OutOfOfficeSection />
|
||||
<Meta
|
||||
title={t("out_of_office")}
|
||||
description={t("out_of_office_description")}
|
||||
borderInShellHeader={false}
|
||||
CTA={
|
||||
<Button
|
||||
color="primary"
|
||||
className="flex w-20 items-center justify-between px-4"
|
||||
onClick={() => setOpenModal(true)}
|
||||
data-testid="add_entry_ooo">
|
||||
<Icon name="plus" size={16} /> {t("add")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CreateOutOfOfficeEntryModal openModal={openModal} closeModal={() => setOpenModal(false)} />
|
||||
<OutOfOfficeEntriesList />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,14 @@ test.describe("Out of office", () => {
|
||||
|
||||
await page.goto("/settings/my-account/out-of-office");
|
||||
|
||||
await page.locator("data-testid=create-entry-ooo-redirect").click();
|
||||
await page.getByTestId("add_entry_ooo").click();
|
||||
await page.getByTestId("reason_select").click();
|
||||
|
||||
await page.getByTestId("select-option-4").click();
|
||||
|
||||
await page.getByTestId("notes_input").click();
|
||||
await page.getByTestId("notes_input").fill("Demo notes");
|
||||
await page.getByTestId("create-entry-ooo-redirect").click();
|
||||
|
||||
await expect(page.locator(`data-testid=table-redirect-n-a`)).toBeVisible();
|
||||
});
|
||||
@@ -56,14 +63,20 @@ test.describe("Out of office", () => {
|
||||
|
||||
await page.goto(`/settings/my-account/out-of-office`);
|
||||
|
||||
await page.getByTestId("add_entry_ooo").click();
|
||||
await page.getByTestId("reason_select").click();
|
||||
|
||||
await page.getByTestId("select-option-4").click();
|
||||
|
||||
await page.getByTestId("notes_input").click();
|
||||
await page.getByTestId("notes_input").fill("Demo notes");
|
||||
|
||||
await page.getByTestId("profile-redirect-switch").click();
|
||||
await page
|
||||
.getByTestId("team_username_select")
|
||||
.locator("div")
|
||||
.filter({ hasText: "Select team member" })
|
||||
.first()
|
||||
.click();
|
||||
await page.locator("#react-select-2-option-0 div").click();
|
||||
|
||||
await page.getByTestId("team_username_select").click();
|
||||
|
||||
await page.locator("#react-select-3-input").fill("user");
|
||||
await page.locator("#react-select-3-input").press("Enter");
|
||||
|
||||
// send request
|
||||
await page.getByTestId("create-entry-ooo-redirect").click();
|
||||
@@ -84,18 +97,19 @@ test.describe("Out of office", () => {
|
||||
user: { connect: { id: user.id } },
|
||||
toUser: { connect: { id: userTo.id } },
|
||||
createdAt: new Date(),
|
||||
reason: {
|
||||
connect: {
|
||||
id: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/${user.username}`);
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
const eventTypeLink = page.locator('[data-testid="event-type-link"]').first();
|
||||
await eventTypeLink.click();
|
||||
|
||||
// regex to match username
|
||||
expect(page.url()).toMatch(new RegExp(`/${userTo.username}`));
|
||||
|
||||
await page.goto(`/${userTo.username}/30-min`);
|
||||
|
||||
expect(page.url()).toMatch(new RegExp(`/${userTo.username}/30-min`));
|
||||
await expect(page.getByTestId("away-emoji")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2276,7 +2276,7 @@
|
||||
"user_redirect_title": "{{username}} is currently away for a brief period of time.",
|
||||
"user_redirect_description": "In the meantime, {{profile.username}} will be in charge of all the new scheduled meetings on behalf of {{username}}.",
|
||||
"out_of_office": "Out of office",
|
||||
"out_of_office_description": "Configure actions in your profile while you are away.",
|
||||
"out_of_office_description": "Let your bookers know when you're OOO.",
|
||||
"send_request": "Send request",
|
||||
"start_date_and_end_date_required": "Start date and end date are required",
|
||||
"start_date_must_be_before_end_date": "Start date must be before end date",
|
||||
@@ -2295,8 +2295,8 @@
|
||||
"booking_redirect_request_title": "Booking Redirect Request",
|
||||
"select_team_member": "Select team member",
|
||||
"going_away_title": "Going away? Simply mark your profile link as unavailable for a period of time.",
|
||||
"redirect_team_enabled": "Redirect your profile to another team member",
|
||||
"redirect_team_disabled": "Redirect your profile to another team member (Team plan required)",
|
||||
"redirect_team_enabled": "Provide a link to a team member when OOO",
|
||||
"redirect_team_disabled": "Provide a link to a team member when OOO (Team plan required)",
|
||||
"out_of_office_unavailable_list": "Out of office unavailability list",
|
||||
"success_deleted_entry_out_of_office": "Successfully deleted entry",
|
||||
"temporarily_out_of_office": "Temporarily Out-Of-Office?",
|
||||
@@ -2366,6 +2366,21 @@
|
||||
"lock_org_users_eventtypes_description": "Prevent members from creating their own event types.",
|
||||
"cookie_consent_checkbox": "I consent to our privacy policy and cookie usage",
|
||||
"make_a_call": "Make a Call",
|
||||
"ooo_reasons_unspecified": "Unspecified",
|
||||
"ooo_reasons_vacation": "Vacation",
|
||||
"ooo_reasons_travel": "Travel",
|
||||
"ooo_reasons_sick_leave": "Sick leave",
|
||||
"ooo_reasons_public_holiday": "Public holiday",
|
||||
"ooo_forwarding_to": "Forwarding to <span>{{username}}</span>",
|
||||
"ooo_not_forwarding": "No forwarding",
|
||||
"ooo_empty_title": "Create an OOO",
|
||||
"ooo_empty_description": "Communicate to your bookers when you're not available to take bookings. They can still book you upon your return or you can forward them to a team member.",
|
||||
"ooo_user_is_ooo": "{{displayName}} is OOO",
|
||||
"ooo_slots_returning": "<0>{{displayName}}</0> can take their meetings while they are away.",
|
||||
"ooo_slots_book_with": "Book {{displayName}}",
|
||||
"ooo_create_entry_modal": "Create an Out of Office",
|
||||
"ooo_select_reason": "Select reason",
|
||||
"create_an_out_of_office": "Create an Out of Office",
|
||||
"submit_feedback": "Submit Feedback",
|
||||
"host_no_show": "Your host didn't show up",
|
||||
"no_show_description": "You can reschedule another meeting with them",
|
||||
|
||||
@@ -12,6 +12,7 @@ const HAWAII_AND_NEWYORK_TEAM = [
|
||||
workingHours: [{ userId: 1, days: [1, 2, 3, 4, 5], startTime: 780, endTime: 1260 }],
|
||||
busy: [],
|
||||
dateOverrides: [],
|
||||
datesOutOfOffice: {},
|
||||
},
|
||||
{
|
||||
timeZone: "Pacific/Honolulu", // GMT -10 per 22th of Aug, 2022
|
||||
@@ -23,6 +24,7 @@ const HAWAII_AND_NEWYORK_TEAM = [
|
||||
],
|
||||
busy: [],
|
||||
dateOverrides: [],
|
||||
datesOutOfOffice: {},
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { parseBookingLimit, parseDurationLimit } from "@calcom/lib";
|
||||
import { getWorkingHours } from "@calcom/lib/availability";
|
||||
import type { DateOverride, WorkingHours } from "@calcom/lib/date-ranges";
|
||||
import { buildDateRanges, subtract } from "@calcom/lib/date-ranges";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -343,6 +344,13 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
|
||||
end: dayjs(busy.end),
|
||||
}));
|
||||
|
||||
const datesOutOfOffice = await getOutOfOfficeDays({
|
||||
userId: user.id,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
availability,
|
||||
});
|
||||
|
||||
const dateRangesInWhichUserIsAvailable = subtract(dateRanges, formattedBusyTimes);
|
||||
|
||||
log.debug(
|
||||
@@ -363,6 +371,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
|
||||
workingHours,
|
||||
dateOverrides,
|
||||
currentSeats,
|
||||
datesOutOfOffice,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -626,3 +635,146 @@ const _getBusyTimesFromDurationLimits = async (
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
interface GetUserAvailabilityParamsDTO {
|
||||
userId: number;
|
||||
dateFrom: Dayjs;
|
||||
dateTo: Dayjs;
|
||||
availability: (DateOverride | WorkingHours)[];
|
||||
}
|
||||
|
||||
export interface IFromUser {
|
||||
id: number;
|
||||
displayName: string | null;
|
||||
}
|
||||
|
||||
export interface IToUser {
|
||||
id: number;
|
||||
username: string | null;
|
||||
displayName: string | null;
|
||||
}
|
||||
|
||||
export interface IOutOfOfficeData {
|
||||
[key: string]: {
|
||||
fromUser: IFromUser | null;
|
||||
toUser?: IToUser | null;
|
||||
reason?: string | null;
|
||||
emoji?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const getOutOfOfficeDays = async (
|
||||
...args: Parameters<typeof _getOutOfOfficeDays>
|
||||
): Promise<ReturnType<typeof _getOutOfOfficeDays>> => {
|
||||
return monitorCallbackAsync(_getOutOfOfficeDays, ...args);
|
||||
};
|
||||
|
||||
const _getOutOfOfficeDays = async ({
|
||||
userId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
availability,
|
||||
}: GetUserAvailabilityParamsDTO): Promise<IOutOfOfficeData> => {
|
||||
const outOfOfficeDays = await prisma.outOfOfficeEntry.findMany({
|
||||
where: {
|
||||
userId,
|
||||
OR: [
|
||||
// outside of range
|
||||
// (start <= 'dateTo' AND end >= 'dateFrom')
|
||||
{
|
||||
start: {
|
||||
lte: dateTo.toISOString(),
|
||||
},
|
||||
end: {
|
||||
gte: dateFrom.toISOString(),
|
||||
},
|
||||
},
|
||||
// start is between dateFrom and dateTo but end is outside of range
|
||||
// (start <= 'dateTo' AND end >= 'dateTo')
|
||||
{
|
||||
start: {
|
||||
lte: dateTo.toISOString(),
|
||||
},
|
||||
|
||||
end: {
|
||||
gte: dateTo.toISOString(),
|
||||
},
|
||||
},
|
||||
// end is between dateFrom and dateTo but start is outside of range
|
||||
// (start <= 'dateFrom' OR end <= 'dateTo')
|
||||
{
|
||||
start: {
|
||||
lte: dateFrom.toISOString(),
|
||||
},
|
||||
|
||||
end: {
|
||||
lte: dateTo.toISOString(),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
start: true,
|
||||
end: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
toUser: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
reason: {
|
||||
select: {
|
||||
id: true,
|
||||
emoji: true,
|
||||
reason: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!outOfOfficeDays.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return outOfOfficeDays.reduce((acc: IOutOfOfficeData, { start, end, toUser, user, reason }) => {
|
||||
// here we should use startDate or today if start is before today
|
||||
// consider timezone in start and end date range
|
||||
const startDateRange = dayjs(start).utc().isBefore(dayjs().startOf("day").utc())
|
||||
? dayjs().utc().startOf("day")
|
||||
: dayjs(start).utc().startOf("day");
|
||||
|
||||
// get number of day in the week and see if it's on the availability
|
||||
const flattenDays = Array.from(new Set(availability.flatMap((a) => ("days" in a ? a.days : [])))).sort(
|
||||
(a, b) => a - b
|
||||
);
|
||||
|
||||
const endDateRange = dayjs(end).utc().endOf("day");
|
||||
|
||||
for (let date = startDateRange; date.isBefore(endDateRange); date = date.add(1, "day")) {
|
||||
const dayNumberOnWeek = date.day();
|
||||
|
||||
if (!flattenDays?.includes(dayNumberOnWeek)) {
|
||||
continue; // Skip to the next iteration if day not found in flattenDays
|
||||
}
|
||||
|
||||
acc[date.format("YYYY-MM-DD")] = {
|
||||
// @TODO: would be good having start and end availability time here, but for now should be good
|
||||
// you can obtain that from user availability defined outside of here
|
||||
fromUser: { id: user.id, displayName: user.name },
|
||||
// optional chaining destructuring toUser
|
||||
toUser: !!toUser ? { id: toUser.id, displayName: toUser.name, username: toUser.username } : null,
|
||||
reason: !!reason ? reason.reason : null,
|
||||
emoji: !!reason ? reason.emoji : null,
|
||||
};
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ export default class BookingRedirectNotification extends BaseEmail {
|
||||
|
||||
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
|
||||
return {
|
||||
to: `${this.bookingRedirect.toEmail} <${this.bookingRedirect.toName}>`,
|
||||
to: `${this.bookingRedirect.toName} <${this.bookingRedirect.toEmail}>`,
|
||||
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
|
||||
subject: this.bookingRedirect.language("booking_redirect_email_subject"),
|
||||
html: await renderEmail("BookingRedirectEmailNotification", {
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
interface HandleTypeRedirectionProps {
|
||||
userId: number;
|
||||
slug: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export const handleTypeRedirection = async (props: HandleTypeRedirectionProps) => {
|
||||
const { userId, slug, username } = props;
|
||||
const outOfOfficeEntryActive = await prisma.outOfOfficeEntry.findFirst({
|
||||
select: {
|
||||
userId: true,
|
||||
start: true,
|
||||
end: true,
|
||||
toUserId: true,
|
||||
toUser: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
userId: userId,
|
||||
start: {
|
||||
lte: new Date(),
|
||||
},
|
||||
end: {
|
||||
gte: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let redirectToSameEventSlug = false;
|
||||
|
||||
// @TODO: Should I validate if new user has same type as slug?
|
||||
if (outOfOfficeEntryActive) {
|
||||
if (outOfOfficeEntryActive.toUserId === null) {
|
||||
return {
|
||||
outOfOffice: true,
|
||||
};
|
||||
}
|
||||
if (outOfOfficeEntryActive?.toUser) {
|
||||
const eventType = await prisma.eventType.findFirst({
|
||||
select: {
|
||||
slug: true,
|
||||
userId: true,
|
||||
},
|
||||
where: {
|
||||
slug,
|
||||
userId: outOfOfficeEntryActive.toUserId,
|
||||
},
|
||||
});
|
||||
|
||||
if (eventType) {
|
||||
redirectToSameEventSlug = true;
|
||||
}
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
destination: `/${outOfOfficeEntryActive.toUser.username}${
|
||||
redirectToSameEventSlug ? `/${slug}` : ""
|
||||
}?redirected=true&username=${username}`,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
interface HandleUserRedirectionProps {
|
||||
username: string;
|
||||
}
|
||||
|
||||
export const handleUserRedirection = async (props: HandleUserRedirectionProps) => {
|
||||
const { username } = props;
|
||||
const fromUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
username,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (fromUser) {
|
||||
// If user is found quickly verify bookingRedirect
|
||||
const outOfOfficeEntryActive = await prisma.outOfOfficeEntry.findFirst({
|
||||
select: {
|
||||
toUser: {
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
toUserId: true,
|
||||
userId: true,
|
||||
start: true,
|
||||
end: true,
|
||||
},
|
||||
where: {
|
||||
userId: fromUser.id,
|
||||
start: {
|
||||
lte: new Date(),
|
||||
},
|
||||
end: {
|
||||
gte: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (outOfOfficeEntryActive && outOfOfficeEntryActive.toUserId === null) {
|
||||
return {
|
||||
outOfOffice: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (outOfOfficeEntryActive && outOfOfficeEntryActive.toUser?.username) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: `/${outOfOfficeEntryActive.toUser.username}?redirected=true&username=${username}`,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
};
|
||||
@@ -50,8 +50,6 @@ const BookerComponent = ({
|
||||
onClickOverlayContinue,
|
||||
onOverlaySwitchStateChange,
|
||||
sessionUsername,
|
||||
isRedirect,
|
||||
fromUserNameRedirected,
|
||||
rescheduleUid,
|
||||
hasSession,
|
||||
extraOptions,
|
||||
@@ -93,6 +91,7 @@ const BookerComponent = ({
|
||||
const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.data?.slots).filter(
|
||||
(slot) => dayjs(selectedDate).diff(slot, "day") <= 0
|
||||
);
|
||||
|
||||
const totalWeekDays = 7;
|
||||
const addonDays =
|
||||
nonEmptyScheduleDays.length < extraDays
|
||||
@@ -266,26 +265,6 @@ const BookerComponent = ({
|
||||
"text-default flex min-h-full w-full flex-col items-center",
|
||||
layout === BookerLayouts.MONTH_VIEW ? "overflow-visible" : "overflow-clip"
|
||||
)}>
|
||||
{/* redirect from other user profile */}
|
||||
{isRedirect && (
|
||||
<div className="mb-8 rounded-md bg-blue-100 p-4 dark:border dark:bg-transparent">
|
||||
<h2 className="text-default mb-2 text-sm font-semibold">
|
||||
{t("user_redirect_title", {
|
||||
username: fromUserNameRedirected,
|
||||
})}{" "}
|
||||
🏝️
|
||||
</h2>
|
||||
<p className="text-default text-sm">
|
||||
{t("user_redirect_description", {
|
||||
profile: {
|
||||
username: username,
|
||||
},
|
||||
username: fromUserNameRedirected,
|
||||
})}{" "}
|
||||
😄
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={animationScope}
|
||||
className={classNames(
|
||||
@@ -403,7 +382,6 @@ const BookerComponent = ({
|
||||
event={event}
|
||||
/>
|
||||
</BookerSection>
|
||||
|
||||
<BookerSection
|
||||
key="timeslots"
|
||||
area={{ default: "main", month_view: "timeslots" }}
|
||||
|
||||
@@ -100,20 +100,20 @@ export const AvailableTimeSlots = ({
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={classNames(
|
||||
limitHeight && "scroll-bar flex-grow overflow-auto md:h-[400px]",
|
||||
!limitHeight && "flex h-full w-full flex-row gap-4"
|
||||
)}>
|
||||
{isLoading
|
||||
? // Shows exact amount of days as skeleton.
|
||||
Array.from({ length: 1 + (extraDays ?? 0) }).map((_, i) => <AvailableTimesSkeleton key={i} />)
|
||||
: slotsPerDay.length > 0 &&
|
||||
slotsPerDay.map((slots) => (
|
||||
{isLoading && // Shows exact amount of days as skeleton.
|
||||
Array.from({ length: 1 + (extraDays ?? 0) }).map((_, i) => <AvailableTimesSkeleton key={i} />)}
|
||||
{!isLoading &&
|
||||
slotsPerDay.length > 0 &&
|
||||
slotsPerDay.map((slots) => (
|
||||
<div key={slots.date} className="scroll-bar h-full w-full overflow-y-auto overflow-x-hidden">
|
||||
<AvailableTimes
|
||||
className="scroll-bar w-full overflow-y-auto overflow-x-hidden"
|
||||
key={slots.date}
|
||||
showTimeFormatToggle={!isColumnView}
|
||||
onTimeSelect={onTimeSelect}
|
||||
slots={slots.slots}
|
||||
@@ -121,7 +121,8 @@ export const AvailableTimeSlots = ({
|
||||
showAvailableSeatsCount={showAvailableSeatsCount}
|
||||
event={event}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -41,6 +41,7 @@ export const DatePicker = ({
|
||||
browsingDate={month ? dayjs(month) : undefined}
|
||||
selected={dayjs(selectedDate)}
|
||||
weekStart={weekdayToWeekIndex(event?.data?.users?.[0]?.weekStart)}
|
||||
slots={schedule?.data?.slots}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -37,10 +37,14 @@ export const LargeCalendar = ({
|
||||
if (!schedule.slots) return availableTimeslots;
|
||||
|
||||
for (const day in schedule.slots) {
|
||||
availableTimeslots[day] = schedule.slots[day].map((slot) => ({
|
||||
start: dayjs(slot.time).toDate(),
|
||||
end: dayjs(slot.time).add(eventDuration, "minutes").toDate(),
|
||||
}));
|
||||
availableTimeslots[day] = schedule.slots[day].map((slot) => {
|
||||
const { time, ...rest } = slot;
|
||||
return {
|
||||
start: dayjs(time).toDate(),
|
||||
end: dayjs(time).add(eventDuration, "minutes").toDate(),
|
||||
...rest,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return availableTimeslots;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Trans } from "next-i18next";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import type { IOutOfOfficeData } from "@calcom/core/getUserAvailability";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
interface IOutOfOfficeInSlotsProps {
|
||||
date: string;
|
||||
fromUser?: IOutOfOfficeData["anyDate"]["fromUser"];
|
||||
toUser?: IOutOfOfficeData["anyDate"]["toUser"];
|
||||
emoji?: string;
|
||||
reason?: string;
|
||||
borderDashed?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const OutOfOfficeInSlots = (props: IOutOfOfficeInSlotsProps) => {
|
||||
const { t } = useLocale();
|
||||
const { fromUser, toUser, emoji = "🏝️", borderDashed = true, date, className } = props;
|
||||
const searchParams = useCompatSearchParams();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
if (!fromUser || !toUser) return null;
|
||||
return (
|
||||
<div className={classNames("relative h-full pb-5", className)}>
|
||||
<div
|
||||
className={classNames(
|
||||
"flex h-full flex-col items-center justify-start rounded-md border bg-white px-4 py-4 font-normal dark:bg-transparent",
|
||||
borderDashed && "border-dashed"
|
||||
)}>
|
||||
<div className="bg-emphasis flex h-14 w-14 flex-col items-center justify-center rounded-full">
|
||||
<span className="m-auto text-center text-lg">{emoji}</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-center">
|
||||
<p className="mt-2 text-base font-bold">
|
||||
{t("ooo_user_is_ooo", { displayName: fromUser.displayName })}
|
||||
</p>
|
||||
|
||||
{fromUser?.displayName && toUser?.displayName && (
|
||||
<p className="text-center text-sm">
|
||||
<Trans
|
||||
i18nKey="ooo_slots_returning"
|
||||
values={{ displayName: toUser.displayName }}
|
||||
default="<1>{{ displayName }}</1> can take their meetings while they are away."
|
||||
components={[<strong key="username">username</strong>]}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{toUser?.id && (
|
||||
<Button
|
||||
className="mt-8 max-w-[90%]"
|
||||
variant="button"
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
// grab current dates and query params from URL
|
||||
|
||||
const month = searchParams.get("month");
|
||||
const layout = searchParams.get("layout");
|
||||
const targetDate = searchParams.get("date") || date;
|
||||
// go to the booking page with the selected user and correct search param
|
||||
// While being an org push will maintain the org context and just change the user in params
|
||||
router.push(
|
||||
`/${toUser.username}?${month ? `month=${month}&` : ""}date=${targetDate}${
|
||||
layout ? `&layout=${layout}` : ""
|
||||
}`
|
||||
);
|
||||
}}>
|
||||
<span className="block overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{t("ooo_slots_book_with", { displayName: toUser.displayName })}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -13,7 +13,7 @@ export type useScheduleForEventReturnType = ReturnType<typeof useScheduleForEven
|
||||
|
||||
/**
|
||||
* Wrapper hook around the trpc query that fetches
|
||||
* the event curently viewed in the booker. It will get
|
||||
* the event currently viewed in the booker. It will get
|
||||
* the current event slug and username from the booker store.
|
||||
*
|
||||
* Using this hook means you only need to use one hook, instead
|
||||
|
||||
@@ -3,11 +3,14 @@ import * as HoverCard from "@radix-ui/react-hover-card";
|
||||
import { AnimatePresence, m } from "framer-motion";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import type { IOutOfOfficeData } from "@calcom/core/getUserAvailability";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { OutOfOfficeInSlots } from "@calcom/features/bookings/Booker/components/OutOfOfficeInSlots";
|
||||
import type { Slots } from "@calcom/features/schedules";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import type { IGetAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util";
|
||||
import { Button, Icon, SkeletonText } from "@calcom/ui";
|
||||
|
||||
import { useBookerStore } from "../Booker/store";
|
||||
@@ -25,7 +28,7 @@ type TOnTimeSelect = (
|
||||
) => void;
|
||||
|
||||
type AvailableTimesProps = {
|
||||
slots: Slots[string];
|
||||
slots: IGetAvailableSlots["slots"][string];
|
||||
onTimeSelect: TOnTimeSelect;
|
||||
seatsPerTimeSlot?: number | null;
|
||||
showAvailableSeatsCount?: boolean | null;
|
||||
@@ -154,9 +157,7 @@ const SlotItem = ({
|
||||
<m.div initial={{ width: 0 }} animate={{ width: "auto" }} exit={{ width: 0 }}>
|
||||
<Button
|
||||
variant={layout === "column_view" ? "icon" : "button"}
|
||||
StartIcon={
|
||||
layout === "column_view" ? "chevron-right" : undefined
|
||||
}
|
||||
StartIcon={layout === "column_view" ? "chevron-right" : undefined}
|
||||
onClick={() =>
|
||||
onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid)
|
||||
}>
|
||||
@@ -166,7 +167,7 @@ const SlotItem = ({
|
||||
</HoverCard.Trigger>
|
||||
<HoverCard.Portal>
|
||||
<HoverCard.Content side="top" align="end" sideOffset={2}>
|
||||
<div className="text-emphasis bg-inverted text-inverted w-[var(--booker-timeslots-width)] rounded-md p-3">
|
||||
<div className="text-emphasis bg-inverted w-[var(--booker-timeslots-width)] rounded-md p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<p>Busy</p>
|
||||
</div>
|
||||
@@ -195,6 +196,15 @@ export const AvailableTimes = ({
|
||||
}: AvailableTimesProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const oooAllDay = slots.every((slot) => slot.away);
|
||||
if (oooAllDay) {
|
||||
return <OOOSlot {...slots[0]} />;
|
||||
}
|
||||
|
||||
// Display ooo in slots once but after or before slots
|
||||
const oooBeforeSlots = slots[0] && slots[0].away;
|
||||
const oooAfterSlots = slots[slots.length - 1] && slots[slots.length - 1].away;
|
||||
|
||||
return (
|
||||
<div className={classNames("text-default flex flex-col", className)}>
|
||||
<div className="h-full pb-4">
|
||||
@@ -208,22 +218,52 @@ export const AvailableTimes = ({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{slots.map((slot) => (
|
||||
<SlotItem
|
||||
key={slot.time}
|
||||
onTimeSelect={onTimeSelect}
|
||||
slot={slot}
|
||||
selectedSlots={selectedSlots}
|
||||
seatsPerTimeSlot={seatsPerTimeSlot}
|
||||
showAvailableSeatsCount={showAvailableSeatsCount}
|
||||
event={event}
|
||||
/>
|
||||
))}
|
||||
{oooBeforeSlots && !oooAfterSlots && <OOOSlot {...slots[0]} />}
|
||||
{slots.map((slot) => {
|
||||
if (slot.away) return null;
|
||||
return (
|
||||
<SlotItem
|
||||
key={slot.time}
|
||||
onTimeSelect={onTimeSelect}
|
||||
slot={slot}
|
||||
selectedSlots={selectedSlots}
|
||||
seatsPerTimeSlot={seatsPerTimeSlot}
|
||||
showAvailableSeatsCount={showAvailableSeatsCount}
|
||||
event={event}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{oooAfterSlots && !oooBeforeSlots && <OOOSlot {...slots[slots.length - 1]} className="pb-0" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IOOOSlotProps {
|
||||
fromUser?: IOutOfOfficeData["anyDate"]["fromUser"];
|
||||
toUser?: IOutOfOfficeData["anyDate"]["toUser"];
|
||||
reason?: string;
|
||||
emoji?: string;
|
||||
time?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const OOOSlot: React.FC<IOOOSlotProps> = (props) => {
|
||||
const { fromUser, toUser, reason, emoji, time, className = "" } = props;
|
||||
|
||||
return (
|
||||
<OutOfOfficeInSlots
|
||||
fromUser={fromUser}
|
||||
toUser={toUser}
|
||||
date={dayjs(time).format("YYYY-MM-DD")}
|
||||
reason={reason}
|
||||
emoji={emoji}
|
||||
borderDashed
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const AvailableTimesSkeleton = () => (
|
||||
<div className="flex w-[20%] flex-col only:w-full">
|
||||
{/* Random number of elements between 1 and 6. */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from "react";
|
||||
import { shallow } from "zustand/shallow";
|
||||
|
||||
import type { IFromUser, IToUser } from "@calcom/core/getUserAvailability";
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useEmbedStyles } from "@calcom/embed-core/embed-iframe";
|
||||
@@ -37,20 +38,38 @@ export type DatePickerProps = {
|
||||
isPending?: boolean;
|
||||
/** used to query the multiple selected dates */
|
||||
eventSlug?: string;
|
||||
/** To identify days that are not available and should display OOO and redirect if toUser exists */
|
||||
slots?: Record<
|
||||
string,
|
||||
{
|
||||
time: string;
|
||||
userIds?: number[];
|
||||
away?: boolean;
|
||||
fromUser?: IFromUser;
|
||||
toUser?: IToUser;
|
||||
reason?: string;
|
||||
emoji?: string;
|
||||
}[]
|
||||
>;
|
||||
};
|
||||
|
||||
export const Day = ({
|
||||
date,
|
||||
active,
|
||||
disabled,
|
||||
away,
|
||||
emoji,
|
||||
...props
|
||||
}: JSX.IntrinsicElements["button"] & {
|
||||
active: boolean;
|
||||
date: Dayjs;
|
||||
away?: boolean;
|
||||
emoji?: string | null;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const enabledDateButtonEmbedStyles = useEmbedStyles("enabledDateButton");
|
||||
const disabledDateButtonEmbedStyles = useEmbedStyles("disabledDateButton");
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -67,7 +86,8 @@ export const Day = ({
|
||||
data-disabled={disabled}
|
||||
disabled={disabled}
|
||||
{...props}>
|
||||
{date.date()}
|
||||
{away && <span data-testid="away-emoji">{emoji}</span>}
|
||||
{!away && date.date()}
|
||||
{date.isToday() && (
|
||||
<span
|
||||
className={classNames(
|
||||
@@ -110,6 +130,7 @@ const Days = ({
|
||||
month,
|
||||
nextMonthButton,
|
||||
eventSlug,
|
||||
slots,
|
||||
...props
|
||||
}: Omit<DatePickerProps, "locale" | "className" | "weekStart"> & {
|
||||
DayComponent?: React.FC<React.ComponentProps<typeof Day>>;
|
||||
@@ -162,10 +183,20 @@ const Days = ({
|
||||
|
||||
const daysToRenderForTheMonth = days.map((day) => {
|
||||
if (!day) return { day: null, disabled: true };
|
||||
const dateKey = yyyymmdd(day);
|
||||
const oooInfo = slots && slots?.[dateKey] ? slots?.[dateKey]?.find((slot) => slot.away) : null;
|
||||
const included = includedDates?.includes(dateKey);
|
||||
const excluded = excludedDates.includes(dateKey);
|
||||
|
||||
const isOOOAllDay = !!(slots && slots[dateKey] && slots[dateKey].every((slot) => slot.away));
|
||||
const away = isOOOAllDay;
|
||||
const disabled = away ? !oooInfo?.toUser : !included || excluded;
|
||||
|
||||
return {
|
||||
day: day,
|
||||
disabled:
|
||||
(includedDates && !includedDates.includes(yyyymmdd(day))) || excludedDates.includes(yyyymmdd(day)),
|
||||
disabled,
|
||||
away,
|
||||
emoji: oooInfo?.emoji,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -200,7 +231,7 @@ const Days = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{daysToRenderForTheMonth.map(({ day, disabled }, idx) => (
|
||||
{daysToRenderForTheMonth.map(({ day, disabled, away, emoji }, idx) => (
|
||||
<div key={day === null ? `e-${idx}` : `day-${day.format()}`} className="relative w-full pt-[100%]">
|
||||
{day === null ? (
|
||||
<div key={`e-${idx}`} />
|
||||
@@ -219,6 +250,8 @@ const Days = ({
|
||||
}}
|
||||
disabled={disabled}
|
||||
active={isActive(day)}
|
||||
away={away}
|
||||
emoji={emoji}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -237,6 +270,7 @@ const DatePicker = ({
|
||||
locale,
|
||||
selected,
|
||||
onMonthChange,
|
||||
slots,
|
||||
...passThroughProps
|
||||
}: DatePickerProps & Partial<React.ComponentProps<typeof Days>>) => {
|
||||
const browsingDate = passThroughProps.browsingDate || dayjs().startOf("month");
|
||||
@@ -307,6 +341,7 @@ const DatePicker = ({
|
||||
browsingDate={browsingDate}
|
||||
month={month}
|
||||
nextMonthButton={() => changeMonth(+1)}
|
||||
slots={slots}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,14 +15,14 @@ import { SchedulerColumns } from "./grid";
|
||||
import { SchedulerHeading } from "./heading/SchedulerHeading";
|
||||
import { HorizontalLines } from "./horizontalLines";
|
||||
import { Spinner } from "./spinner/Spinner";
|
||||
import { VeritcalLines } from "./verticalLines";
|
||||
import { VerticalLines } from "./verticalLines";
|
||||
|
||||
export function Calendar(props: CalendarComponentProps) {
|
||||
const container = useRef<HTMLDivElement | null>(null);
|
||||
const containerNav = useRef<HTMLDivElement | null>(null);
|
||||
const containerOffset = useRef<HTMLDivElement | null>(null);
|
||||
const schedulerGrid = useRef<HTMLOListElement | null>(null);
|
||||
const initalState = useCalendarStore((state) => state.initState);
|
||||
const initialState = useCalendarStore((state) => state.initState);
|
||||
const { timezone } = useTimePreferences();
|
||||
|
||||
const startDate = useCalendarStore((state) => state.startDate);
|
||||
@@ -42,10 +42,10 @@ export function Calendar(props: CalendarComponentProps) {
|
||||
const numberOfGridStopsPerDay = hours.length * usersCellsStopsPerHour;
|
||||
const hourSize = 58;
|
||||
|
||||
// Initalise State on inital mount
|
||||
// Initalise State on initial mount
|
||||
useEffect(() => {
|
||||
initalState(props);
|
||||
}, [props, initalState]);
|
||||
initialState(props);
|
||||
}, [props, initialState]);
|
||||
|
||||
return (
|
||||
<MobileNotSupported>
|
||||
@@ -81,7 +81,7 @@ export function Calendar(props: CalendarComponentProps) {
|
||||
numberOfGridStopsPerCell={usersCellsStopsPerHour}
|
||||
containerOffsetRef={containerOffset}
|
||||
/>
|
||||
<VeritcalLines days={days} />
|
||||
<VerticalLines days={days} />
|
||||
|
||||
<SchedulerColumns
|
||||
offsetHeight={containerOffset.current?.offsetHeight}
|
||||
|
||||
@@ -6,6 +6,7 @@ import dayjs from "@calcom/dayjs";
|
||||
import { useTimePreferences } from "@calcom/features/bookings/lib";
|
||||
import { classNames } from "@calcom/lib";
|
||||
|
||||
import { OutOfOfficeInSlots } from "../../../../bookings/Booker/components/OutOfOfficeInSlots";
|
||||
import { useCalendarStore } from "../../state/store";
|
||||
import type { CalendarAvailableTimeslots } from "../../types/state";
|
||||
import type { GridCellToDateProps } from "../../utils";
|
||||
@@ -26,9 +27,9 @@ export function EmptyCell(props: EmptyCellProps) {
|
||||
timezone: props.timezone,
|
||||
});
|
||||
|
||||
const minuesFromStart = (cellToDate.hour() - props.startHour) * 60 + cellToDate.minute();
|
||||
const minutesFromStart = (cellToDate.hour() - props.startHour) * 60 + cellToDate.minute();
|
||||
|
||||
return <Cell topOffsetMinutes={minuesFromStart} timeSlot={dayjs(cellToDate).tz(props.timezone)} />;
|
||||
return <Cell topOffsetMinutes={minutesFromStart} timeSlot={dayjs(cellToDate).tz(props.timezone)} />;
|
||||
}
|
||||
|
||||
type AvailableCellProps = {
|
||||
@@ -39,32 +40,91 @@ type AvailableCellProps = {
|
||||
|
||||
export function AvailableCellsForDay({ availableSlots, day, startHour }: AvailableCellProps) {
|
||||
const { timezone } = useTimePreferences();
|
||||
const date = dayjs(day);
|
||||
const dateFormatted = date.format("YYYY-MM-DD");
|
||||
const slotsForToday = availableSlots && availableSlots[dateFormatted];
|
||||
|
||||
const slotsForToday = availableSlots && availableSlots[dayjs(day).format("YYYY-MM-DD")];
|
||||
const slots = useMemo(() => {
|
||||
const calculatedSlots: {
|
||||
slot: CalendarAvailableTimeslots[string][number];
|
||||
topOffsetMinutes: number;
|
||||
firstSlot?: CalendarAvailableTimeslots[string][number];
|
||||
timezone?: string;
|
||||
}[] = [];
|
||||
|
||||
const slots = useMemo(
|
||||
() =>
|
||||
slotsForToday?.map((slot) => ({
|
||||
slot,
|
||||
topOffsetMinutes:
|
||||
(dayjs(slot.start).tz(timezone).hour() - startHour) * 60 + dayjs(slot.start).tz(timezone).minute(),
|
||||
})),
|
||||
[slotsForToday, startHour, timezone]
|
||||
);
|
||||
// first and last slot for ooo to display range correctly in week view
|
||||
let firstSlotIndex = -1;
|
||||
let lastSlotIndex = -1;
|
||||
let areAllSlotsAway = true;
|
||||
let startEndTimeDuration = 0;
|
||||
|
||||
if (!availableSlots) return null;
|
||||
slotsForToday?.forEach((slot, index) => {
|
||||
const startTime = dayjs(slot.start).tz(timezone);
|
||||
const topOffsetMinutes = (startTime.hour() - startHour) * 60 + startTime.minute();
|
||||
calculatedSlots.push({ slot, topOffsetMinutes });
|
||||
|
||||
if (!slot.away) {
|
||||
areAllSlotsAway = false;
|
||||
} else {
|
||||
if (firstSlotIndex === -1) {
|
||||
firstSlotIndex = index;
|
||||
}
|
||||
lastSlotIndex = index;
|
||||
}
|
||||
});
|
||||
|
||||
if (areAllSlotsAway && firstSlotIndex !== -1) {
|
||||
const firstSlot = slotsForToday[firstSlotIndex];
|
||||
const lastSlot = slotsForToday[lastSlotIndex];
|
||||
startEndTimeDuration = dayjs(lastSlot.end).diff(dayjs(firstSlot.start), "minutes");
|
||||
|
||||
if (firstSlot.toUser == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// This will return null if all slots are away and the first slot has no user
|
||||
return {
|
||||
slots: calculatedSlots,
|
||||
startEndTimeDuration,
|
||||
firstSlot,
|
||||
timezone,
|
||||
};
|
||||
}
|
||||
|
||||
return { slots: calculatedSlots, startEndTimeDuration };
|
||||
}, [slotsForToday, startHour, timezone]);
|
||||
|
||||
if (slots === null) return null;
|
||||
|
||||
if (slots.startEndTimeDuration) {
|
||||
const { firstSlot, startEndTimeDuration } = slots;
|
||||
return (
|
||||
<CustomCell
|
||||
timeSlot={dayjs(firstSlot?.start).tz(slots.timezone)}
|
||||
topOffsetMinutes={slots.slots[0]?.topOffsetMinutes}
|
||||
startEndTimeDuration={startEndTimeDuration}>
|
||||
<OutOfOfficeInSlots
|
||||
fromUser={firstSlot?.fromUser}
|
||||
toUser={firstSlot?.toUser}
|
||||
reason={firstSlot?.reason}
|
||||
emoji={firstSlot?.emoji}
|
||||
borderDashed={false}
|
||||
date={dateFormatted}
|
||||
className="pb-0"
|
||||
/>
|
||||
</CustomCell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{slots?.map((slot, index) => {
|
||||
return (
|
||||
<Cell
|
||||
key={index}
|
||||
timeSlot={dayjs(slot.slot.start).tz(timezone)}
|
||||
topOffsetMinutes={slot.topOffsetMinutes}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{slots.slots.map((slot, index) => (
|
||||
<Cell
|
||||
key={index}
|
||||
timeSlot={dayjs(slot.slot.start).tz(timezone)}
|
||||
topOffsetMinutes={slot.topOffsetMinutes}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -125,3 +185,33 @@ function Cell({ isDisabled, topOffsetMinutes, timeSlot }: CellProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomCell({
|
||||
timeSlot,
|
||||
children,
|
||||
topOffsetMinutes,
|
||||
startEndTimeDuration,
|
||||
}: CellProps & { children: React.ReactNode; startEndTimeDuration?: number }) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"bg-default dark:bg-muted group absolute z-[65] flex w-[calc(100%-1px)] items-center justify-center"
|
||||
)}
|
||||
data-slot={timeSlot.toISOString()}
|
||||
style={{
|
||||
top: topOffsetMinutes ? `calc(${topOffsetMinutes}*var(--one-minute-height))` : undefined,
|
||||
overflow: "visible",
|
||||
}}>
|
||||
<div
|
||||
className={classNames(
|
||||
"dark:border-emphasis bg-default dark:bg-muted cursor-pointer rounded-[4px] p-[6px] text-xs font-semibold dark:text-white"
|
||||
)}
|
||||
style={{
|
||||
height: `calc(${startEndTimeDuration}*var(--one-minute-height) - 2px)`,
|
||||
width: "calc(100% - 2px)",
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type dayjs from "@calcom/dayjs";
|
||||
|
||||
export const VeritcalLines = ({ days }: { days: dayjs.Dayjs[] }) => {
|
||||
export const VerticalLines = ({ days }: { days: dayjs.Dayjs[] }) => {
|
||||
const isRTL = () => {
|
||||
let userLanguage = "en"; // Default to 'en' if navigator is not defined
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { IFromUser, IToUser } from "@calcom/core/getUserAvailability";
|
||||
import type { TimeRange } from "@calcom/types/schedule";
|
||||
|
||||
import type { CalendarEvent } from "./events";
|
||||
@@ -50,11 +51,18 @@ export type CalendarPrivateActions = {
|
||||
setSelectedEvent: (event: CalendarEvent) => void;
|
||||
handleDateChange: (payload: "INCREMENT" | "DECREMENT") => void;
|
||||
};
|
||||
type TimeRangeExtended = TimeRange & {
|
||||
away?: boolean;
|
||||
fromUser?: IFromUser;
|
||||
toUser?: IToUser;
|
||||
reason?: string;
|
||||
emoji?: string;
|
||||
};
|
||||
|
||||
export type CalendarAvailableTimeslots = {
|
||||
// Key is the date in YYYY-MM-DD format
|
||||
// start and end are ISOstring
|
||||
[key: string]: TimeRange[];
|
||||
[key: string]: TimeRangeExtended[];
|
||||
};
|
||||
|
||||
export type CalendarState = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { useSchedule } from "./useSchedule";
|
||||
export { useSlotsForDate } from "./useSlotsForDate";
|
||||
export { useNonEmptyScheduleDays } from "./useNonEmptyScheduleDays";
|
||||
export type { Slots } from "./types";
|
||||
export type { Slots, GetSchedule } from "./types";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
export type Slots = RouterOutputs["viewer"]["public"]["slots"]["getSchedule"]["slots"];
|
||||
|
||||
export type GetSchedule = RouterOutputs["viewer"]["public"]["slots"]["getSchedule"];
|
||||
|
||||
@@ -4,7 +4,16 @@ import type { Slots } from "../use-schedule";
|
||||
|
||||
export const getNonEmptyScheduleDays = (slots?: Slots) => {
|
||||
if (typeof slots === "undefined") return [];
|
||||
return Object.keys(slots).filter((day) => slots[day].length > 0);
|
||||
|
||||
const nonEmptyDays: string[] = [];
|
||||
|
||||
Object.keys(slots).forEach((date) => {
|
||||
if (slots[date].some((slot) => !(slot?.away && !slot.toUser) && slots[date].length > 0)) {
|
||||
nonEmptyDays.push(date);
|
||||
}
|
||||
});
|
||||
|
||||
return nonEmptyDays;
|
||||
};
|
||||
|
||||
export const useNonEmptyScheduleDays = (slots?: Slots) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMemo } from "react";
|
||||
import type { Slots } from "./types";
|
||||
|
||||
/**
|
||||
* Get's slots for a specific date from the schedul cache.
|
||||
* Get's slots for a specific date from the schedule cache.
|
||||
* @param date Format YYYY-MM-DD
|
||||
* @param scheduleCache Instance of useScheduleWithCache
|
||||
*/
|
||||
|
||||
@@ -645,7 +645,7 @@ export default function SettingsLayout({
|
||||
<MobileSettingsContainer onSideContainerOpen={() => setSideContainerOpen(!sideContainerOpen)} />
|
||||
}>
|
||||
<div className="flex flex-1 [&>*]:flex-1">
|
||||
<div className="mx-auto max-w-full justify-center lg:max-w-4xl">
|
||||
<div className="mx-auto max-w-full justify-center lg:max-w-3xl">
|
||||
<ShellHeader />
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<Icon name="loader" />}>{children}</Suspense>
|
||||
|
||||
+41
-3
@@ -1,3 +1,4 @@
|
||||
import type { IFromUser, IOutOfOfficeData, IToUser } from "@calcom/core/getUserAvailability";
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type { WorkingHours, TimeRange as DateOverride } from "@calcom/types/schedule";
|
||||
@@ -16,6 +17,7 @@ export type GetSlots = {
|
||||
eventLength: number;
|
||||
offsetStart?: number;
|
||||
organizerTimeZone: string;
|
||||
datesOutOfOffice?: IOutOfOfficeData;
|
||||
};
|
||||
export type TimeFrame = { userIds?: number[]; startTime: number; endTime: number };
|
||||
|
||||
@@ -147,6 +149,7 @@ function buildSlotsWithDateRanges({
|
||||
minimumBookingNotice,
|
||||
organizerTimeZone,
|
||||
offsetStart,
|
||||
datesOutOfOffice,
|
||||
}: {
|
||||
dateRanges: DateRange[];
|
||||
frequency: number;
|
||||
@@ -155,12 +158,21 @@ function buildSlotsWithDateRanges({
|
||||
minimumBookingNotice: number;
|
||||
organizerTimeZone: string;
|
||||
offsetStart?: number;
|
||||
datesOutOfOffice?: IOutOfOfficeData;
|
||||
}) {
|
||||
// keep the old safeguards in; may be needed.
|
||||
frequency = minimumOfOne(frequency);
|
||||
eventLength = minimumOfOne(eventLength);
|
||||
offsetStart = offsetStart ? minimumOfOne(offsetStart) : 0;
|
||||
const slots: { time: Dayjs; userIds?: number[] }[] = [];
|
||||
const slots: {
|
||||
time: Dayjs;
|
||||
userIds?: number[];
|
||||
away?: boolean;
|
||||
fromUser?: IFromUser;
|
||||
toUser?: IToUser;
|
||||
reason?: string;
|
||||
emoji?: string;
|
||||
}[] = [];
|
||||
|
||||
let interval = Number(process.env.NEXT_PUBLIC_AVAILABILITY_SCHEDULE_INTERVAL) || 1;
|
||||
const intervalsWithDefinedStartTimes = [60, 30, 20, 15, 10, 5];
|
||||
@@ -173,6 +185,7 @@ function buildSlotsWithDateRanges({
|
||||
}
|
||||
|
||||
dateRanges.forEach((range) => {
|
||||
const dateYYYYMMDD = range.start.format("YYYY-MM-DD");
|
||||
const startTimeWithMinNotice = dayjs.utc().add(minimumBookingNotice, "minute");
|
||||
|
||||
let slotStartTime = range.start.utc().isAfter(startTimeWithMinNotice)
|
||||
@@ -194,10 +207,33 @@ function buildSlotsWithDateRanges({
|
||||
slotStartTime = slotStartTime.add(offsetStart ?? 0, "minutes").tz(timeZone);
|
||||
|
||||
while (!slotStartTime.add(eventLength, "minutes").subtract(1, "second").utc().isAfter(rangeEnd)) {
|
||||
slots.push({
|
||||
const dateOutOfOfficeExists = datesOutOfOffice?.[dateYYYYMMDD];
|
||||
let slotData: {
|
||||
time: Dayjs;
|
||||
userIds?: number[];
|
||||
away?: boolean;
|
||||
fromUser?: IFromUser;
|
||||
toUser?: IToUser;
|
||||
reason?: string;
|
||||
emoji?: string;
|
||||
} = {
|
||||
time: slotStartTime,
|
||||
});
|
||||
};
|
||||
|
||||
if (dateOutOfOfficeExists) {
|
||||
const { toUser, fromUser, reason, emoji } = dateOutOfOfficeExists;
|
||||
|
||||
slotData = {
|
||||
time: slotStartTime,
|
||||
away: true,
|
||||
...(fromUser && { fromUser }),
|
||||
...(toUser && { toUser }),
|
||||
...(reason && { reason }),
|
||||
...(emoji && { emoji }),
|
||||
};
|
||||
}
|
||||
|
||||
slots.push(slotData);
|
||||
slotStartTime = slotStartTime.add(frequency + (offsetStart ?? 0), "minutes");
|
||||
}
|
||||
});
|
||||
@@ -221,6 +257,7 @@ const getSlots = ({
|
||||
eventLength,
|
||||
offsetStart = 0,
|
||||
organizerTimeZone,
|
||||
datesOutOfOffice,
|
||||
}: GetSlots) => {
|
||||
if (dateRanges) {
|
||||
const slots = buildSlotsWithDateRanges({
|
||||
@@ -231,6 +268,7 @@ const getSlots = ({
|
||||
minimumBookingNotice,
|
||||
organizerTimeZone,
|
||||
offsetStart,
|
||||
datesOutOfOffice,
|
||||
});
|
||||
return slots;
|
||||
}
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "OutOfOfficeEntry" ADD COLUMN "reasonId" INTEGER;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OutOfOfficeReason" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"emoji" TEXT NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"userId" INTEGER,
|
||||
|
||||
CONSTRAINT "OutOfOfficeReason_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OutOfOfficeReason_reason_key" ON "OutOfOfficeReason"("reason");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OutOfOfficeEntry" ADD CONSTRAINT "OutOfOfficeEntry_reasonId_fkey" FOREIGN KEY ("reasonId") REFERENCES "OutOfOfficeReason"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OutOfOfficeReason" ADD CONSTRAINT "OutOfOfficeReason_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Seed data for stock reasons
|
||||
INSERT INTO "OutOfOfficeReason" ("emoji", "reason", "enabled") VALUES ('🕒', 'ooo_reasons_unspecified', true);
|
||||
INSERT INTO "OutOfOfficeReason" ("emoji", "reason", "enabled") VALUES ('🏝️', 'ooo_reasons_vacation', true);
|
||||
INSERT INTO "OutOfOfficeReason" ("emoji", "reason", "enabled") VALUES ('🛫', 'ooo_reasons_travel', true);
|
||||
INSERT INTO "OutOfOfficeReason" ("emoji", "reason", "enabled") VALUES ('🤒', 'ooo_reasons_sick_leave', true);
|
||||
INSERT INTO "OutOfOfficeReason" ("emoji", "reason", "enabled") VALUES ('📅', 'ooo_reasons_public_holiday', true);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "OutOfOfficeEntry" ADD COLUMN "notes" TEXT;
|
||||
@@ -303,6 +303,8 @@ model User {
|
||||
secondaryEmails SecondaryEmail[]
|
||||
isPlatformManaged Boolean @default(false)
|
||||
|
||||
OutOfOfficeReasons OutOfOfficeReason[]
|
||||
|
||||
@@unique([email])
|
||||
@@unique([email, username])
|
||||
@@unique([username, organizationId])
|
||||
@@ -1177,14 +1179,17 @@ model Avatar {
|
||||
}
|
||||
|
||||
model OutOfOfficeEntry {
|
||||
id Int @id @default(autoincrement())
|
||||
uuid String @unique
|
||||
id Int @id @default(autoincrement())
|
||||
uuid String @unique
|
||||
start DateTime
|
||||
end DateTime
|
||||
notes String?
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
toUserId Int?
|
||||
toUser User? @relation(name: "toUser", fields: [toUserId], references: [id], onDelete: Cascade)
|
||||
toUser User? @relation(name: "toUser", fields: [toUserId], references: [id], onDelete: Cascade)
|
||||
reasonId Int?
|
||||
reason OutOfOfficeReason? @relation(fields: [reasonId], references: [id], onDelete: SetNull)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -1195,6 +1200,17 @@ model OutOfOfficeEntry {
|
||||
@@index([start, end])
|
||||
}
|
||||
|
||||
model OutOfOfficeReason {
|
||||
id Int @id @default(autoincrement())
|
||||
emoji String
|
||||
reason String @unique
|
||||
enabled Boolean @default(true)
|
||||
userId Int?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
entries OutOfOfficeEntry[]
|
||||
}
|
||||
|
||||
// Platform
|
||||
model PlatformOAuthClient {
|
||||
id String @id @default(cuid())
|
||||
|
||||
@@ -55,6 +55,7 @@ type AppsRouterHandlerCache = {
|
||||
outOfOfficeEntriesList?: typeof import("./outOfOffice.handler").outOfOfficeEntriesList;
|
||||
outOfOfficeEntryDelete?: typeof import("./outOfOffice.handler").outOfOfficeEntryDelete;
|
||||
addSecondaryEmail?: typeof import("./addSecondaryEmail.handler").addSecondaryEmailHandler;
|
||||
outOfOfficeReasonList?: typeof import("./outOfOfficeReasons.handler").outOfOfficeReasonList;
|
||||
};
|
||||
|
||||
const UNSTABLE_HANDLER_CACHE: AppsRouterHandlerCache = {};
|
||||
@@ -484,4 +485,18 @@ export const loggedInViewerRouter = router({
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.addSecondaryEmail({ ctx, input });
|
||||
}),
|
||||
outOfOfficeReasonList: authedProcedure.query(async () => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeReasonList) {
|
||||
UNSTABLE_HANDLER_CACHE.outOfOfficeReasonList = (
|
||||
await import("./outOfOfficeReasons.handler")
|
||||
).outOfOfficeReasonList;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeReasonList) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.outOfOfficeReasonList();
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -101,6 +101,10 @@ export const outOfOfficeCreate = async ({ ctx, input }: TBookingRedirect) => {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "out_of_office_entry_already_exists" });
|
||||
}
|
||||
|
||||
if (!input.reasonId) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "reason_id_required" });
|
||||
}
|
||||
|
||||
// Prevent infinite redirects but consider time ranges
|
||||
const existingOutOfOfficeEntry = await prisma.outOfOfficeEntry.findFirst({
|
||||
select: {
|
||||
@@ -142,7 +146,9 @@ export const outOfOfficeCreate = async ({ ctx, input }: TBookingRedirect) => {
|
||||
uuid: uuidv4(),
|
||||
start: startDateUtc.startOf("day").toISOString(),
|
||||
end: endDateUtc.endOf("day").toISOString(),
|
||||
notes: input.notes,
|
||||
userId: ctx.user.id,
|
||||
reasonId: input.reasonId,
|
||||
toUserId: toUserId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -222,7 +228,7 @@ export const outOfOfficeEntriesList = async ({ ctx }: { ctx: { user: NonNullable
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
start: "desc",
|
||||
start: "asc",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -235,6 +241,15 @@ export const outOfOfficeEntriesList = async ({ ctx }: { ctx: { user: NonNullable
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
reason: {
|
||||
select: {
|
||||
id: true,
|
||||
emoji: true,
|
||||
reason: true,
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
notes: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ export const ZOutOfOfficeInputSchema = z.object({
|
||||
}),
|
||||
offset: z.number(),
|
||||
toTeamUserId: z.number().nullable(),
|
||||
reasonId: z.number(),
|
||||
notes: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export type TOutOfOfficeInputSchema = z.infer<typeof ZOutOfOfficeInputSchema>;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export const outOfOfficeReasonList = async () => {
|
||||
const outOfOfficeReasons = await prisma.outOfOfficeReason.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
return outOfOfficeReasons;
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import { v4 as uuid } from "uuid";
|
||||
|
||||
import { getAggregatedAvailability } from "@calcom/core/getAggregatedAvailability";
|
||||
import { getBusyTimesForLimitChecks } from "@calcom/core/getBusyTimes";
|
||||
import type { CurrentSeats } from "@calcom/core/getUserAvailability";
|
||||
import type { CurrentSeats, IFromUser, IToUser } from "@calcom/core/getUserAvailability";
|
||||
import { getUserAvailability } from "@calcom/core/getUserAvailability";
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
@@ -296,7 +296,23 @@ function applyOccupiedSeatsToCurrentSeats(currentSeats: CurrentSeats, occupiedSe
|
||||
return currentSeats;
|
||||
}
|
||||
|
||||
export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) {
|
||||
export interface IGetAvailableSlots {
|
||||
slots: Record<
|
||||
string,
|
||||
{
|
||||
time: string;
|
||||
attendees?: number | undefined;
|
||||
bookingUid?: string | undefined;
|
||||
away?: boolean | undefined;
|
||||
fromUser?: IFromUser | undefined;
|
||||
toUser?: IToUser | undefined;
|
||||
reason?: string | undefined;
|
||||
emoji?: string | undefined;
|
||||
}[]
|
||||
>;
|
||||
}
|
||||
|
||||
export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<IGetAvailableSlots> {
|
||||
const orgDetails = input?.orgSlug
|
||||
? {
|
||||
currentOrgDomain: input.orgSlug,
|
||||
@@ -447,6 +463,7 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) {
|
||||
dateRanges,
|
||||
currentSeats: _currentSeats,
|
||||
timeZone,
|
||||
datesOutOfOffice,
|
||||
} = await getUserAvailability(
|
||||
{
|
||||
userId: currentUser.id,
|
||||
@@ -481,6 +498,7 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) {
|
||||
dateRanges,
|
||||
busy,
|
||||
user: currentUser,
|
||||
datesOutOfOffice,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -514,6 +532,7 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) {
|
||||
frequency: eventType.slotInterval || input.duration || eventType.length,
|
||||
organizerTimeZone:
|
||||
eventType.timeZone || eventType?.schedule?.timeZone || allUsersAvailability?.[0]?.timeZone,
|
||||
datesOutOfOffice: allUsersAvailability[0]?.datesOutOfOffice,
|
||||
});
|
||||
|
||||
let availableTimeSlots: typeof timeSlots = [];
|
||||
|
||||
@@ -175,11 +175,12 @@ export function DialogHeader(props: DialogHeaderProps) {
|
||||
type DialogFooterProps = {
|
||||
children: React.ReactNode;
|
||||
showDivider?: boolean;
|
||||
noSticky?: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export function DialogFooter(props: DialogFooterProps) {
|
||||
return (
|
||||
<div className={classNames("bg-default sticky bottom-0", props.className)}>
|
||||
<div className={classNames("bg-default bottom-0", props?.noSticky ? "" : "sticky", props.className)}>
|
||||
{props.showDivider && (
|
||||
// TODO: the -mx-8 is causing overflow in the dialog buttons
|
||||
<hr data-testid="divider" className="border-subtle -mx-8" />
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Button } from "../../components/button";
|
||||
|
||||
export function EmptyScreen({
|
||||
Icon: icon,
|
||||
customIcon,
|
||||
avatar,
|
||||
headline,
|
||||
description,
|
||||
@@ -22,6 +23,7 @@ export function EmptyScreen({
|
||||
iconWrapperClassName,
|
||||
}: {
|
||||
Icon?: IconName;
|
||||
customIcon?: React.ReactElement;
|
||||
avatar?: React.ReactElement;
|
||||
headline: string | React.ReactElement;
|
||||
description?: string | React.ReactElement;
|
||||
@@ -59,6 +61,7 @@ export function EmptyScreen({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!customIcon ? null : <>{customIcon}</>}
|
||||
<div className="flex max-w-[420px] flex-col items-center">
|
||||
<h2
|
||||
className={classNames(
|
||||
|
||||
Reference in New Issue
Block a user