feat: Manage Team ooo or crud (#16456)
* feature for team ooo crud and past records * added tests for ooo handler, combined list components for mine,team * nit: removed commented code * added current-previous tab , not to show TeamOOO tab for nonadmin * update for type checks * updated tests after merge * updated outofoffice handler after merge * for alignment with outofoffice header column * update after resolving merge conflicts * nit/chore: spacing * update after changes in router migration * ui improvements rounded corner * updated props * Update apps/web/public/static/locales/en/common.json Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> * Update apps/web/public/static/locales/en/common.json Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> * updated dialog subtitles, review comments * Update packages/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal.tsx Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com> * Update packages/trpc/server/routers/loggedInViewer/outOfOffice.schema.ts Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com> * refactored to split ooo handlers, review comments * updated test.utils * chore * chore: unrelated changes may be merge * optimized query * update due to latest changes and merge conflicts * updates to changes in people filter * chore * as now taken care in DataTable * updates after merge * updates after merge due tp changes in datatable and others * update import due to file splitting and new name * update for typecheck * update for new add button when empty entries * nit * replace tests with e2e tests * chore * include only manage team ooo * fix type check * Update safe-parse.ts * use invalidate instead of props, leaving main server components as it is * nit * update forMember div ht in edit mode --------- Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
This commit is contained in:
co-authored by
Carina Wollendorfer
Amit Sharma
Anik Dhabal Babu
Tushar Bhatt
parent
caaecf2f94
commit
8612fa71c7
+7
-1
@@ -4,6 +4,7 @@ import { getTranslate } from "app/_utils";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import CreateNewOutOfOfficeEntryButton from "@calcom/features/settings/outOfOffice/CreateNewOutOfOfficeEntryButton";
|
||||
import { OutOfOfficeEntriesList } from "@calcom/features/settings/outOfOffice/OutOfOfficeEntriesList";
|
||||
import { OutOfOfficeToggleGroup } from "@calcom/features/settings/outOfOffice/OutOfOfficeToggleGroup";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -18,7 +19,12 @@ const Page = async () => {
|
||||
<SettingsHeader
|
||||
title={t("out_of_office")}
|
||||
description={t("out_of_office_description")}
|
||||
CTA={<CreateNewOutOfOfficeEntryButton data-testid="add_entry_ooo" />}>
|
||||
CTA={
|
||||
<div className="flex gap-2">
|
||||
<OutOfOfficeToggleGroup />
|
||||
<CreateNewOutOfOfficeEntryButton data-testid="add_entry_ooo" />
|
||||
</div>
|
||||
}>
|
||||
<OutOfOfficeEntriesList />
|
||||
</SettingsHeader>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { randomString } from "@calcom/lib/random";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { submitAndWaitForResponse, localize } from "./lib/testUtils";
|
||||
@@ -417,7 +418,7 @@ test.describe("Out of office", () => {
|
||||
|
||||
//As owner,OOO is created on Next month 1st - 3rd, forwarding to 'member-1'
|
||||
await dateButton.click();
|
||||
await selectDateAndCreateOOO(page, "1", "3", "member-1");
|
||||
await selectDateAndCreateOOO(page, "1", "3", member1User?.id);
|
||||
await expect(
|
||||
page.locator(`data-testid=table-redirect-${member1User?.username ?? "n-a"}`).nth(0)
|
||||
).toBeVisible();
|
||||
@@ -430,7 +431,7 @@ test.describe("Out of office", () => {
|
||||
await addOOOButton.click();
|
||||
await reasonListRespPromise;
|
||||
await dateButton.click();
|
||||
await selectDateAndCreateOOO(page, "4", "5", "owner");
|
||||
await selectDateAndCreateOOO(page, "4", "5", owner.id);
|
||||
await expect(page.locator(`data-testid=table-redirect-${owner.username ?? "n-a"}`).nth(0)).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -461,26 +462,111 @@ test.describe("Out of office", () => {
|
||||
const reasonListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200
|
||||
);
|
||||
await addOOOButton.click();
|
||||
await reasonListRespPromise;
|
||||
await test.step("As owner,OOO is created on Next month 1st - 3rd, forwarding to 'member-1'", async () => {
|
||||
await addOOOButton.click();
|
||||
await reasonListRespPromise;
|
||||
await dateButton.click();
|
||||
await selectDateAndCreateOOO(page, "1", "3", member1User?.id);
|
||||
await expect(
|
||||
page.locator(`data-testid=table-redirect-${member1User?.username ?? "n-a"}`).nth(0)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
//As owner,OOO is created on Next month 1st - 3rd, forwarding to 'member-1'
|
||||
await dateButton.click();
|
||||
await selectDateAndCreateOOO(page, "1", "3", "member-1");
|
||||
await expect(
|
||||
page.locator(`data-testid=table-redirect-${member1User?.username ?? "n-a"}`).nth(0)
|
||||
).toBeVisible();
|
||||
await test.step("As member1, expect error while OOO is created on Next month 4th - 5th, forwarding to 'owner'", async () => {
|
||||
await member1User?.apiLogin();
|
||||
await page.goto("/settings/my-account/out-of-office");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await entriesListRespPromise;
|
||||
await addOOOButton.click();
|
||||
await reasonListRespPromise;
|
||||
await dateButton.click();
|
||||
await selectDateAndCreateOOO(page, "2", "5", owner.id, 400);
|
||||
await expect(page.locator(`text=${t("booking_redirect_infinite_not_allowed")}`)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
//As member1, expect error while OOO is created on Next month 2nd - 5th, forwarding to 'owner'
|
||||
await member1User?.apiLogin();
|
||||
await page.goto("/settings/my-account/out-of-office");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await entriesListRespPromise;
|
||||
await addOOOButton.click();
|
||||
await reasonListRespPromise;
|
||||
await dateButton.click();
|
||||
await selectDateAndCreateOOO(page, "2", "5", "owner", 400);
|
||||
await expect(page.locator(`text=${t("booking_redirect_infinite_not_allowed")}`)).toBeTruthy();
|
||||
test.describe("Team OOO", () => {
|
||||
test("Create, edit and delete", async ({ page, users }) => {
|
||||
const t = await localize("en");
|
||||
const teamMatesObj = [{ name: "member-1" }, { name: "member-2" }, { name: "member-3" }];
|
||||
const teamAdmin = await users.create(
|
||||
{ name: `team-owner-${Date.now()}` },
|
||||
{
|
||||
hasTeam: true,
|
||||
isOrg: true,
|
||||
teamRole: MembershipRole.ADMIN,
|
||||
teammates: teamMatesObj,
|
||||
}
|
||||
);
|
||||
const member1User = users.get().find((user) => user.name === "member-1");
|
||||
const member2User = users.get().find((user) => user.name === "member-2");
|
||||
const member3User = users.get().find((user) => user.name === "member-3");
|
||||
await teamAdmin.apiLogin();
|
||||
|
||||
const entriesListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||
);
|
||||
await page.goto("/settings/my-account/out-of-office?type=team");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await entriesListRespPromise;
|
||||
|
||||
const addOOOButton = page.getByTestId("add_entry_ooo");
|
||||
const dateButton = page.locator('[data-testid="date-range"]');
|
||||
const reasonListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200
|
||||
);
|
||||
const legacyListMembersRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("legacyListMembers") && response.status() === 200
|
||||
);
|
||||
await addOOOButton.click();
|
||||
await reasonListRespPromise;
|
||||
await legacyListMembersRespPromise;
|
||||
await legacyListMembersRespPromise;
|
||||
|
||||
await test.step("Admin can create OOO for team member and add redirect", async () => {
|
||||
//OOO is created for 'member-1' on Next month 1st - 3rd, forwarding to 'member-2'
|
||||
await page.getByTestId(`ooofor_username_select_${member1User?.id}`).click();
|
||||
await dateButton.click();
|
||||
|
||||
await selectDateAndCreateOOO(page, "1", "3", member2User?.id, 200, true);
|
||||
await expect(
|
||||
page.locator(`data-testid=table-redirect-${member2User?.username ?? "n-a"}`).nth(0)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("Reverse redirect not allowed for team member", async () => {
|
||||
//Try to create OOO for 'member-2' on Next month 1st - 3rd, forwarding to 'member-1'
|
||||
await page.getByTestId("add_entry_ooo").click();
|
||||
await reasonListRespPromise;
|
||||
await legacyListMembersRespPromise;
|
||||
await legacyListMembersRespPromise;
|
||||
|
||||
await page.getByTestId(`ooofor_username_select_${member2User?.id}`).click();
|
||||
await dateButton.click();
|
||||
await selectDateAndCreateOOO(page, "1", "3", member1User?.id, 400, true);
|
||||
expect(page.locator(`text=${t("booking_redirect_infinite_not_allowed")}`)).toBeTruthy();
|
||||
await page.locator(`text=${t("cancel")}`).click();
|
||||
});
|
||||
|
||||
await test.step("Edit OOO and change redirect member", async () => {
|
||||
//Change redirect member to 'member-3' for OOO created in step 1
|
||||
await page.getByTestId(`ooo-edit-${member2User?.username}`).click();
|
||||
await reasonListRespPromise;
|
||||
await legacyListMembersRespPromise;
|
||||
await legacyListMembersRespPromise;
|
||||
|
||||
await page.getByTestId(`team_username_select_${member3User?.id}`).click();
|
||||
await saveAndWaitForResponse(page);
|
||||
await expect(
|
||||
page.locator(`data-testid=table-redirect-${member3User?.username ?? "n-a"}`).nth(0)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("Delete OOO successfully", async () => {
|
||||
await page.getByTestId(`ooo-delete-${member3User?.username}`).click();
|
||||
expect(page.locator(`text=${t("success_deleted_entry_out_of_office")}`)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -504,21 +590,28 @@ async function selectDateAndCreateOOO(
|
||||
page: Page,
|
||||
fromDate: string,
|
||||
toDate: string,
|
||||
redirectToUser?: string,
|
||||
expectedStatusCode = 200
|
||||
redirectToUserId?: number,
|
||||
expectedStatusCode = 200,
|
||||
forTeamMember = false,
|
||||
month: "previous-month" | "next-month" = "next-month",
|
||||
editMode = false
|
||||
) {
|
||||
const t = await localize("en");
|
||||
await page.locator(`button[name="next-month"]`).click();
|
||||
await page.locator(`button[name="${month}"]`).click();
|
||||
await page.locator(`button[name="day"]:text-is("${fromDate}")`).nth(0).click();
|
||||
await page.locator(`button[name="day"]:text-is("${toDate}")`).nth(0).click();
|
||||
await page.locator(`text=${t("create_an_out_of_office")}`).click();
|
||||
editMode
|
||||
? await page.locator(`text=${t("edit_an_out_of_office")}`).click()
|
||||
: forTeamMember
|
||||
? await page.locator(`text=${t("create_ooo_dialog_team_title")}`).click()
|
||||
: await page.locator(`text=${t("create_an_out_of_office")}`).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");
|
||||
if (redirectToUser) {
|
||||
if (redirectToUserId) {
|
||||
await page.getByTestId("profile-redirect-switch").click();
|
||||
await page.locator(`text=${redirectToUser}`).click();
|
||||
await page.getByTestId(`team_username_select_${redirectToUserId}`).click();
|
||||
}
|
||||
await saveAndWaitForResponse(page, expectedStatusCode);
|
||||
}
|
||||
|
||||
@@ -2964,5 +2964,17 @@
|
||||
"verify_email": "Verify email",
|
||||
"verify_email_change": "Verify email change",
|
||||
"end_date_not_selected": "You didn't set an end date for your OOO",
|
||||
"my_ooo": "My OOO",
|
||||
"team_ooo": "Team OOO",
|
||||
"out_of_office_team_description": "Manage you're team's OOO.",
|
||||
"create_ooo_dialog_team_title": "Create Out of Office",
|
||||
"create_ooo_dialog_team_subtitle": "Create an OOO for a team member",
|
||||
"edit_ooo_dialog_team_subtitle": "Edit an OOO for team member",
|
||||
"ooo_team_empty_title": "Create OOO for your team",
|
||||
"ooo_team_empty_description": "Communicate to bookers when your team members are not available to take bookings. They can still be booked upon their return or you can forward them to another team member.",
|
||||
"forward_to_team_member_only": "OOO Forwarding is only allowed to a member of the same team.",
|
||||
"ooo_team_redirect_infinite_not_allowed": "There is already a reverse forwarding between these users.",
|
||||
"previous_ooo_empty_title": "No previous OOO found",
|
||||
"previous": "Previous",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@ function EventTypeSingleLayout({
|
||||
<div className="flex flex-col xl:flex-row xl:space-x-6">
|
||||
<div className="hidden xl:block">
|
||||
<VerticalTabs
|
||||
className="primary-navigation w-full max-w-64"
|
||||
className="primary-navigation max-w-64 w-full"
|
||||
tabs={EventTypeTabs}
|
||||
sticky
|
||||
linkShallow
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
||||
import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
||||
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
|
||||
@@ -25,6 +26,8 @@ import {
|
||||
Input,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { OutOfOfficeTab } from "./OutOfOfficeToggleGroup";
|
||||
|
||||
export type BookingRedirectForm = {
|
||||
dateRange: { startDate: Date; endDate: Date };
|
||||
offset: number;
|
||||
@@ -32,6 +35,10 @@ export type BookingRedirectForm = {
|
||||
reasonId: number;
|
||||
notes?: string;
|
||||
uuid?: string | null;
|
||||
forUserId: number | null;
|
||||
forUserName?: string;
|
||||
forUserAvatar?: string;
|
||||
toUserName?: string;
|
||||
};
|
||||
|
||||
type Option = { value: number; label: string };
|
||||
@@ -47,42 +54,78 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const me = useMeQuery();
|
||||
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const debouncedSearch = useDebounce(searchText, 500);
|
||||
const searchParams = useCompatSearchParams();
|
||||
const oooType = searchParams?.get("type") ?? OutOfOfficeTab.MINE;
|
||||
|
||||
const members = trpc.viewer.teams.legacyListMembers.useInfiniteQuery(
|
||||
{ limit: 10, searchText: debouncedSearch },
|
||||
const [searchMember, setSearchMember] = useState("");
|
||||
const debouncedSearchMember = useDebounce(searchMember, 500);
|
||||
const oooForMembers = trpc.viewer.teams.legacyListMembers.useInfiniteQuery(
|
||||
{ limit: 10, searchText: debouncedSearchMember },
|
||||
{
|
||||
enabled: true,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
}
|
||||
);
|
||||
const oooMemberListOptions: {
|
||||
value: number;
|
||||
label: string;
|
||||
avatarUrl: string | null;
|
||||
}[] = !!currentlyEditingOutOfOfficeEntry
|
||||
? [
|
||||
{
|
||||
value: currentlyEditingOutOfOfficeEntry.forUserId || -1,
|
||||
label: currentlyEditingOutOfOfficeEntry.forUserName || "",
|
||||
avatarUrl: currentlyEditingOutOfOfficeEntry.forUserAvatar || "",
|
||||
},
|
||||
]
|
||||
: oooForMembers?.data?.pages
|
||||
.flatMap((page) => page.members)
|
||||
?.filter((member) => me?.data?.id !== member.id)
|
||||
.map((member) => ({
|
||||
value: member.id,
|
||||
label: member.name || member.username || "",
|
||||
avatarUrl: member.avatarUrl,
|
||||
})) || [];
|
||||
const { ref: observerRefMember } = useInViewObserver(() => {
|
||||
if (oooForMembers.hasNextPage && !oooForMembers.isFetching) {
|
||||
oooForMembers.fetchNextPage();
|
||||
}
|
||||
}, document.querySelector('[role="dialog"]'));
|
||||
|
||||
const me = useMeQuery();
|
||||
|
||||
const memberListOptions: {
|
||||
const [searchRedirectMember, setSearchRedirectMember] = useState("");
|
||||
const debouncedSearchRedirect = useDebounce(searchRedirectMember, 500);
|
||||
const redirectMembers = trpc.viewer.teams.legacyListMembers.useInfiniteQuery(
|
||||
{ limit: 10, searchText: debouncedSearchRedirect },
|
||||
{
|
||||
enabled: true,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
}
|
||||
);
|
||||
const redirectToMemberListOptions: {
|
||||
value: number;
|
||||
label: string;
|
||||
avatarUrl: string | null;
|
||||
}[] =
|
||||
members?.data?.pages
|
||||
redirectMembers?.data?.pages
|
||||
.flatMap((page) => page.members)
|
||||
?.filter((member) => me?.data?.id !== member.id)
|
||||
?.filter((member) =>
|
||||
oooType === OutOfOfficeTab.MINE ? me?.data?.id !== member.id : oooType === OutOfOfficeTab.TEAM
|
||||
)
|
||||
.map((member) => ({
|
||||
value: member.id,
|
||||
label: member.name || member.username || "",
|
||||
avatarUrl: member.avatarUrl,
|
||||
})) || [];
|
||||
|
||||
const { ref: observerRef } = useInViewObserver(() => {
|
||||
if (members.hasNextPage && !members.isFetching) {
|
||||
members.fetchNextPage();
|
||||
const { ref: observerRefRedirect } = useInViewObserver(() => {
|
||||
if (redirectMembers.hasNextPage && !redirectMembers.isFetching) {
|
||||
redirectMembers.fetchNextPage();
|
||||
}
|
||||
}, document.querySelector('[role="dialog"]'));
|
||||
|
||||
const { data: outOfOfficeReasonList } = trpc.viewer.outOfOfficeReasonList.useQuery();
|
||||
|
||||
const { data: outOfOfficeReasonList, isPending: isReasonListPending } =
|
||||
trpc.viewer.outOfOfficeReasonList.useQuery();
|
||||
const reasonList = (outOfOfficeReasonList || []).map((reason) => ({
|
||||
label: `${reason.emoji} ${reason.userId === null ? t(reason.reason) : reason.reason}`,
|
||||
value: reason.id,
|
||||
@@ -99,6 +142,7 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
|
||||
register,
|
||||
watch,
|
||||
formState: { isSubmitting },
|
||||
getValues,
|
||||
} = useForm<BookingRedirectForm>({
|
||||
defaultValues: currentlyEditingOutOfOfficeEntry
|
||||
? currentlyEditingOutOfOfficeEntry
|
||||
@@ -110,10 +154,12 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
|
||||
offset: dayjs().utcOffset(),
|
||||
toTeamUserId: null,
|
||||
reasonId: 1,
|
||||
forUserId: null,
|
||||
},
|
||||
});
|
||||
|
||||
const watchedTeamUserId = watch("toTeamUserId");
|
||||
const watchForUserId = watch("forUserId");
|
||||
|
||||
const createOrEditOutOfOfficeEntry = trpc.viewer.outOfOfficeCreateOrUpdate.useMutation({
|
||||
onSuccess: () => {
|
||||
@@ -156,9 +202,87 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
|
||||
<div className="h-full px-1">
|
||||
<DialogHeader
|
||||
title={
|
||||
currentlyEditingOutOfOfficeEntry ? t("edit_an_out_of_office") : t("create_an_out_of_office")
|
||||
currentlyEditingOutOfOfficeEntry
|
||||
? t("edit_an_out_of_office")
|
||||
: oooType === "team"
|
||||
? t("create_ooo_dialog_team_title")
|
||||
: t("create_an_out_of_office")
|
||||
}
|
||||
subtitle={
|
||||
oooType === "team"
|
||||
? currentlyEditingOutOfOfficeEntry
|
||||
? t("edit_ooo_dialog_team_subtitle")
|
||||
: t("create_ooo_dialog_team_subtitle")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* In case of Team, Select Member for whom OOO is created */}
|
||||
{oooType === "team" && (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<Label className="text-emphasis mt-6">{t("select_team_member")}</Label>
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("search")}
|
||||
onChange={(e) => setSearchMember(e.target.value)}
|
||||
value={searchMember}
|
||||
disabled={!!currentlyEditingOutOfOfficeEntry}
|
||||
/>
|
||||
<div
|
||||
className={`scroll-bar bg-default mt-2 flex ${
|
||||
!!currentlyEditingOutOfOfficeEntry ? "h-[45px]" : "h-[150px]"
|
||||
} flex-col gap-0.5 overflow-y-scroll rounded-[10px] border p-1`}>
|
||||
{oooMemberListOptions.map((member) => (
|
||||
<label
|
||||
key={member.value}
|
||||
aria-disabled={!!currentlyEditingOutOfOfficeEntry}
|
||||
data-testid={`ooofor_username_select_${member.value}`}
|
||||
tabIndex={watchForUserId === member.value ? -1 : 0}
|
||||
role="radio"
|
||||
aria-checked={watchForUserId === member.value}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setValue("forUserId", member.value, { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
className={classNames(
|
||||
"cursor-not-allowed items-center justify-between gap-0.5 rounded-sm py-2 outline-none",
|
||||
watchForUserId === member.value && "bg-subtle",
|
||||
!currentlyEditingOutOfOfficeEntry &&
|
||||
"hover:bg-subtle focus:bg-subtle focus:ring-emphasis cursor-pointer focus:ring-2"
|
||||
)}>
|
||||
<div className="flex flex-1 items-center space-x-3">
|
||||
<input
|
||||
type="radio"
|
||||
disabled={!!currentlyEditingOutOfOfficeEntry}
|
||||
className="hidden"
|
||||
checked={watchForUserId === member.value}
|
||||
onChange={() => setValue("forUserId", member.value, { shouldDirty: true })}
|
||||
/>
|
||||
<span className="text-emphasis w-full px-2 text-sm">{member.label}</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
{!currentlyEditingOutOfOfficeEntry && (
|
||||
<div className="text-default text-center" ref={observerRefMember}>
|
||||
<Button
|
||||
color="minimal"
|
||||
loading={oooForMembers.isFetchingNextPage}
|
||||
disabled={!oooForMembers.hasNextPage}
|
||||
onClick={() => oooForMembers.fetchNextPage()}>
|
||||
{oooForMembers.hasNextPage ? t("load_more_results") : t("no_more_results")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-emphasis mb-1 block text-sm font-medium capitalize">{t("dates")}</p>
|
||||
<div>
|
||||
@@ -247,45 +371,47 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("search")}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchRedirectMember(e.target.value)}
|
||||
value={searchRedirectMember}
|
||||
/>
|
||||
<div className="scroll-bar bg-default mt-2 flex h-[150px] flex-col gap-0.5 overflow-y-scroll rounded-[10px] border p-1">
|
||||
{memberListOptions.map((member) => (
|
||||
<label
|
||||
key={member.value}
|
||||
data-testid={`team_username_select_${member.value}`}
|
||||
tabIndex={watchedTeamUserId === member.value ? -1 : 0}
|
||||
role="radio"
|
||||
aria-checked={watchedTeamUserId === member.value}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setValue("toTeamUserId", member.value, { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
className={classNames(
|
||||
"hover:bg-subtle focus:bg-subtle focus:ring-emphasis cursor-pointer items-center justify-between gap-0.5 rounded-sm py-2 outline-none focus:ring-2",
|
||||
watchedTeamUserId === member.value && "bg-subtle"
|
||||
)}>
|
||||
<div className="flex flex-1 items-center space-x-3">
|
||||
<input
|
||||
type="radio"
|
||||
className="hidden"
|
||||
checked={watchedTeamUserId === member.value}
|
||||
onChange={() => setValue("toTeamUserId", member.value, { shouldDirty: true })}
|
||||
/>
|
||||
<span className="text-emphasis w-full px-2 text-sm">{member.label}</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
<div className="text-default text-center" ref={observerRef}>
|
||||
{redirectToMemberListOptions
|
||||
.filter((member) => member.value !== getValues("forUserId"))
|
||||
.map((member) => (
|
||||
<label
|
||||
key={member.value}
|
||||
data-testid={`team_username_select_${member.value}`}
|
||||
tabIndex={watchedTeamUserId === member.value ? -1 : 0}
|
||||
role="radio"
|
||||
aria-checked={watchedTeamUserId === member.value}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setValue("toTeamUserId", member.value, { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
className={classNames(
|
||||
"hover:bg-subtle focus:bg-subtle focus:ring-emphasis cursor-pointer items-center justify-between gap-0.5 rounded-sm py-2 outline-none focus:ring-2",
|
||||
watchedTeamUserId === member.value && "bg-subtle"
|
||||
)}>
|
||||
<div className="flex flex-1 items-center space-x-3">
|
||||
<input
|
||||
type="radio"
|
||||
className="hidden"
|
||||
checked={watchedTeamUserId === member.value}
|
||||
onChange={() => setValue("toTeamUserId", member.value, { shouldDirty: true })}
|
||||
/>
|
||||
<span className="text-emphasis w-full px-2 text-sm">{member.label}</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
<div className="text-default text-center" ref={observerRefRedirect}>
|
||||
<Button
|
||||
color="minimal"
|
||||
loading={members.isFetchingNextPage}
|
||||
disabled={!members.hasNextPage}
|
||||
onClick={() => members.fetchNextPage()}>
|
||||
{members.hasNextPage ? t("load_more_results") : t("no_more_results")}
|
||||
loading={redirectMembers.isFetchingNextPage}
|
||||
disabled={!redirectMembers.hasNextPage}
|
||||
onClick={() => redirectMembers.fetchNextPage()}>
|
||||
{redirectMembers.hasNextPage ? t("load_more_results") : t("no_more_results")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -309,7 +435,7 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
|
||||
form="create-or-edit-ooo-form"
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || isReasonListPending}
|
||||
data-testid="create-or-edit-entry-ooo-redirect">
|
||||
{currentlyEditingOutOfOfficeEntry ? t("save") : t("create")}
|
||||
</Button>
|
||||
|
||||
@@ -1,88 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import { getCoreRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
|
||||
import { Trans } from "next-i18next";
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useFormState } from "react-hook-form";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { DataTable, DataTableToolbar } from "@calcom/features/data-table";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Button,
|
||||
EmptyScreen,
|
||||
Icon,
|
||||
showToast,
|
||||
SkeletonText,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableNew,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
} from "@calcom/ui";
|
||||
import { Avatar, Button, EmptyScreen, Icon, showToast, SkeletonText, Tooltip } from "@calcom/ui";
|
||||
|
||||
import CreateNewOutOfOfficeEntryButton from "./CreateNewOutOfOfficeEntryButton";
|
||||
import { CreateOrEditOutOfOfficeEntryModal } from "./CreateOrEditOutOfOfficeModal";
|
||||
import type { BookingRedirectForm } from "./CreateOrEditOutOfOfficeModal";
|
||||
import { OutOfOfficeTab } from "./OutOfOfficeToggleGroup";
|
||||
|
||||
interface OutOfOfficeEntry {
|
||||
id: number;
|
||||
uuid: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
toUserId: number | null;
|
||||
toUser: {
|
||||
username: string;
|
||||
name: string;
|
||||
} | null;
|
||||
reason: {
|
||||
id: number;
|
||||
emoji: string;
|
||||
reason: string;
|
||||
userId: number;
|
||||
} | null;
|
||||
notes: string | null;
|
||||
user: { id: number; avatarUrl: string; username: string; email: string; name: string } | null;
|
||||
}
|
||||
|
||||
export const OutOfOfficeEntriesList = () => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const { data, isPending } = trpc.viewer.outOfOfficeEntriesList.useQuery();
|
||||
const deleteOutOfOfficeEntryMutation = trpc.viewer.outOfOfficeEntryDelete.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("success_deleted_entry_out_of_office"), "success");
|
||||
utils.viewer.outOfOfficeEntriesList.invalidate();
|
||||
useFormState;
|
||||
},
|
||||
onError: () => {
|
||||
showToast(`An error occurred`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [deletedEntry, setDeletedEntry] = useState(0);
|
||||
const [currentlyEditingOutOfOfficeEntry, setCurrentlyEditingOutOfOfficeEntry] =
|
||||
useState<BookingRedirectForm | null>(null);
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
|
||||
const editOutOfOfficeEntry = (entry: BookingRedirectForm) => {
|
||||
setCurrentlyEditingOutOfOfficeEntry(entry);
|
||||
setOpenModal(true);
|
||||
};
|
||||
|
||||
if (data === null || data?.length === 0 || (data === undefined && !isPending))
|
||||
return (
|
||||
<EmptyScreen
|
||||
className="mt-6"
|
||||
headline={t("ooo_empty_title")}
|
||||
description={t("ooo_empty_description")}
|
||||
buttonRaw={<CreateNewOutOfOfficeEntryButton size="sm" />}
|
||||
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" />
|
||||
const searchParams = useCompatSearchParams();
|
||||
const selectedTab = searchParams?.get("type") ?? OutOfOfficeTab.MINE;
|
||||
|
||||
const { data, isPending, fetchNextPage, isFetching, refetch } =
|
||||
trpc.viewer.outOfOfficeEntriesList.useInfiniteQuery(
|
||||
{
|
||||
limit: 10,
|
||||
fetchTeamMembersEntries: selectedTab === OutOfOfficeTab.TEAM,
|
||||
searchTerm: selectedTab === OutOfOfficeTab.TEAM ? searchTerm : undefined,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
if (selectedTab === OutOfOfficeTab.MINE) {
|
||||
setSearchTerm("");
|
||||
}
|
||||
}, [deletedEntry, selectedTab, refetch]);
|
||||
|
||||
const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0;
|
||||
const flatData = useMemo(
|
||||
() =>
|
||||
isPending || isFetching ? new Array(5).fill(null) : data?.pages?.flatMap((page) => page.rows) ?? [],
|
||||
[data, selectedTab, isPending, isFetching, searchTerm]
|
||||
) as OutOfOfficeEntry[];
|
||||
const totalFetched = flatData.length;
|
||||
|
||||
const memoColumns = useMemo(() => {
|
||||
const columns: ColumnDef<OutOfOfficeEntry>[] = [];
|
||||
if (selectedTab === OutOfOfficeTab.TEAM) {
|
||||
columns.push({
|
||||
id: "member",
|
||||
header: `Member`,
|
||||
size: 300,
|
||||
cell: ({ row }) => {
|
||||
if (!row.original || !row.original.user || isPending || isFetching) {
|
||||
return <SkeletonText className="h-8 w-full" />;
|
||||
}
|
||||
const { avatarUrl, username, email, name } = row.original.user;
|
||||
const memberName =
|
||||
name ||
|
||||
(() => {
|
||||
const emailName = email.split("@")[0];
|
||||
return emailName.charAt(0).toUpperCase() + emailName.slice(1);
|
||||
})();
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
size="sm"
|
||||
alt={username || email}
|
||||
imageSrc={getUserAvatarUrl({
|
||||
avatarUrl,
|
||||
})}
|
||||
/>
|
||||
<div className="">
|
||||
<div
|
||||
data-testid={`ooo-member-${username}-username`}
|
||||
className="text-emphasis text-sm font-medium leading-none">
|
||||
{memberName}
|
||||
</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 text-inverted 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
|
||||
data-testid={`ooo-member-${username}-email`}
|
||||
className="text-subtle mt-1 text-sm leading-none">
|
||||
{email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div className="border-subtle mt-6 rounded-lg border">
|
||||
<TableNew className="border-0">
|
||||
<TableBody>
|
||||
{data?.map((item) => (
|
||||
<TableRow key={item.id} data-testid={`table-redirect-${item.toUser?.username || "n-a"}`}>
|
||||
<TableCell className="flex flex-row justify-between p-4">
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
columns.push({
|
||||
id: "outOfOffice",
|
||||
header: `${t("out_of_office")} (${totalDBRowCount})`,
|
||||
size: selectedTab === OutOfOfficeTab.TEAM ? 370 : 660,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
return (
|
||||
<>
|
||||
{row.original && !isPending && !isFetching ? (
|
||||
<div
|
||||
className="flex flex-row justify-between p-2"
|
||||
data-testid={`table-redirect-${item.toUser?.username || "n-a"}`}>
|
||||
<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 || "🏝️"}
|
||||
@@ -117,81 +173,186 @@ export const OutOfOfficeEntriesList = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
columns.push({
|
||||
id: "actions",
|
||||
size: 90,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
return (
|
||||
<>
|
||||
{row.original && !isPending && !isFetching ? (
|
||||
<div className="flex flex-row items-center justify-end gap-x-2">
|
||||
<Tooltip content={t("edit")}>
|
||||
<Button
|
||||
className="self-center rounded-lg border"
|
||||
type="button"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
data-testid={`ooo-edit-${item.toUser?.username || "n-a"}`}
|
||||
StartIcon="pencil"
|
||||
onClick={() => {
|
||||
const offset = dayjs().utcOffset();
|
||||
const outOfOfficeEntryData: BookingRedirectForm = {
|
||||
uuid: item.uuid,
|
||||
dateRange: {
|
||||
startDate: dayjs(item.start).subtract(offset, "minute").toDate(),
|
||||
endDate: dayjs(item.end).subtract(offset, "minute").startOf("d").toDate(),
|
||||
},
|
||||
offset,
|
||||
toTeamUserId: item.toUserId,
|
||||
reasonId: item.reason?.id ?? 1,
|
||||
notes: item.notes ?? undefined,
|
||||
forUserId: item.user?.id || null,
|
||||
forUserName:
|
||||
item.user?.name ||
|
||||
(item.user?.email &&
|
||||
(() => {
|
||||
const emailName = item.user?.email.split("@")[0];
|
||||
return emailName.charAt(0).toUpperCase() + emailName.slice(1);
|
||||
})()),
|
||||
forUserAvatar: item.user?.avatarUrl,
|
||||
toUserName: item.toUser?.name || item.toUser?.username,
|
||||
};
|
||||
editOutOfOfficeEntry(outOfOfficeEntryData);
|
||||
}}
|
||||
disabled={isPending || isFetching}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("delete")}>
|
||||
<Button
|
||||
className="self-center rounded-lg border"
|
||||
type="button"
|
||||
color="destructive"
|
||||
variant="icon"
|
||||
disabled={deleteOutOfOfficeEntryMutation.isPending || isPending || isFetching}
|
||||
StartIcon="trash-2"
|
||||
data-testid={`ooo-delete-${item.toUser?.username || "n-a"}`}
|
||||
onClick={() => {
|
||||
deleteOutOfOfficeEntryMutation.mutate({
|
||||
outOfOfficeUid: item.uuid,
|
||||
userId: selectedTab === OutOfOfficeTab.TEAM ? item.user?.id : undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
return columns;
|
||||
}, [selectedTab, isPending, isFetching]);
|
||||
|
||||
<div className="flex flex-row items-center gap-x-2">
|
||||
<Tooltip content={t("edit")}>
|
||||
<Button
|
||||
className="self-center rounded-lg border"
|
||||
type="button"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
data-testid={`ooo-edit-${item.toUser?.username || "n-a"}`}
|
||||
StartIcon="pencil"
|
||||
onClick={() => {
|
||||
const offset = dayjs().utcOffset();
|
||||
const outOfOfficeEntryData: BookingRedirectForm = {
|
||||
uuid: item.uuid,
|
||||
dateRange: {
|
||||
startDate: dayjs(item.start).subtract(offset, "minute").toDate(),
|
||||
endDate: dayjs(item.end).subtract(offset, "minute").startOf("d").toDate(),
|
||||
},
|
||||
offset,
|
||||
toTeamUserId: item.toUserId,
|
||||
reasonId: item.reason?.id ?? 1,
|
||||
notes: item.notes ?? undefined,
|
||||
};
|
||||
editOutOfOfficeEntry(outOfOfficeEntryData);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("delete")}>
|
||||
<Button
|
||||
className="self-center rounded-lg border"
|
||||
type="button"
|
||||
color="destructive"
|
||||
variant="icon"
|
||||
disabled={deleteOutOfOfficeEntryMutation.isPending}
|
||||
StartIcon="trash-2"
|
||||
onClick={() => {
|
||||
deleteOutOfOfficeEntryMutation.mutate({ outOfOfficeUid: item.uuid });
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
const fetchMoreOnBottomReached = useCallback(
|
||||
(containerRefElement?: HTMLDivElement | null) => {
|
||||
if (containerRefElement) {
|
||||
const { scrollHeight, scrollTop, clientHeight } = containerRefElement;
|
||||
if (scrollHeight - scrollTop - clientHeight < 100 && !isFetching && totalFetched < totalDBRowCount) {
|
||||
fetchNextPage();
|
||||
}
|
||||
if (isFetching) {
|
||||
containerRefElement.classList.add("cursor-wait");
|
||||
} else {
|
||||
containerRefElement.classList.remove("cursor-wait");
|
||||
}
|
||||
}
|
||||
},
|
||||
[fetchNextPage, isFetching, totalFetched, totalDBRowCount]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: flatData,
|
||||
columns: memoColumns,
|
||||
enableRowSelection: false,
|
||||
debugTable: true,
|
||||
manualPagination: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchMoreOnBottomReached(tableContainerRef.current);
|
||||
}, [fetchMoreOnBottomReached]);
|
||||
|
||||
const deleteOutOfOfficeEntryMutation = trpc.viewer.outOfOfficeEntryDelete.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("success_deleted_entry_out_of_office"), "success");
|
||||
setDeletedEntry((previousValue) => previousValue + 1);
|
||||
useFormState;
|
||||
},
|
||||
onError: () => {
|
||||
showToast(`An error occurred`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{data === null ||
|
||||
(data?.pages?.length !== 0 && data?.pages[0].meta.totalRowCount === 0 && searchTerm === "") ||
|
||||
(data === undefined && !isPending) ? (
|
||||
<EmptyScreen
|
||||
className="mt-6"
|
||||
headline={selectedTab === OutOfOfficeTab.TEAM ? t("ooo_team_empty_title") : t("ooo_empty_title")}
|
||||
description={
|
||||
selectedTab === OutOfOfficeTab.TEAM ? t("ooo_team_empty_description") : t("ooo_empty_description")
|
||||
}
|
||||
buttonRaw={<CreateNewOutOfOfficeEntryButton size="sm" />}
|
||||
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 text-inverted 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>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{isPending && (
|
||||
<>
|
||||
{new Array(2).fill(0).map((_, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<SkeletonText className="h-12 w-full" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isPending && (data === undefined || data.length === 0) && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center">
|
||||
<p className="text-subtle text-sm">{t("no_redirects_found")}</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</TableNew>
|
||||
{openModal && (
|
||||
<CreateOrEditOutOfOfficeEntryModal
|
||||
openModal={openModal}
|
||||
closeModal={() => {
|
||||
setOpenModal(false);
|
||||
setCurrentlyEditingOutOfOfficeEntry(null);
|
||||
}}
|
||||
currentlyEditingOutOfOfficeEntry={currentlyEditingOutOfOfficeEntry}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<DataTable
|
||||
hideHeader={selectedTab === OutOfOfficeTab.MINE}
|
||||
table={table}
|
||||
tableContainerRef={tableContainerRef}
|
||||
isPending={isPending}
|
||||
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
|
||||
{selectedTab === OutOfOfficeTab.TEAM && (
|
||||
<DataTableToolbar.Root>
|
||||
<DataTableToolbar.SearchBar table={table} onSearch={(value) => setSearchTerm(value)} />
|
||||
</DataTableToolbar.Root>
|
||||
)}
|
||||
</DataTable>
|
||||
{openModal && (
|
||||
<CreateOrEditOutOfOfficeEntryModal
|
||||
openModal={openModal}
|
||||
closeModal={() => {
|
||||
setOpenModal(false);
|
||||
setCurrentlyEditingOutOfOfficeEntry(null);
|
||||
}}
|
||||
currentlyEditingOutOfOfficeEntry={currentlyEditingOutOfOfficeEntry}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useCompatSearchParams } from "@calcom/embed-core/src/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { ToggleGroup } from "@calcom/ui";
|
||||
|
||||
export enum OutOfOfficeTab {
|
||||
MINE = "mine",
|
||||
TEAM = "team",
|
||||
}
|
||||
|
||||
export const OutOfOfficeToggleGroup = () => {
|
||||
const { t } = useLocale();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const createQueryString = useCallback(
|
||||
(name: string, value: string) => {
|
||||
const params = new URLSearchParams(searchParams ?? undefined);
|
||||
params.set(name, value);
|
||||
|
||||
return params.toString();
|
||||
},
|
||||
[searchParams]
|
||||
);
|
||||
|
||||
const selectedTab = searchParams?.get("type") ?? OutOfOfficeTab.MINE;
|
||||
|
||||
const { data: user } = trpc.viewer.me.useQuery();
|
||||
const { data } = trpc.viewer.organizations.listCurrent.useQuery();
|
||||
const isOrgAdminOrOwner =
|
||||
data && (data.user.role === MembershipRole.OWNER || data.user.role === MembershipRole.ADMIN);
|
||||
const toggleGroupOptions = [{ value: OutOfOfficeTab.MINE, label: t("my_ooo") }];
|
||||
const hasTeamOOOAccess = isOrgAdminOrOwner || user?.isTeamAdminOrOwner;
|
||||
if (hasTeamOOOAccess) {
|
||||
toggleGroupOptions.push({ value: OutOfOfficeTab.TEAM, label: t("team_ooo") });
|
||||
}
|
||||
|
||||
return (
|
||||
<ToggleGroup
|
||||
className="hidden md:block"
|
||||
defaultValue={selectedTab}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
const newQuery = createQueryString("type", value);
|
||||
router.push(`${pathname}?${newQuery}`);
|
||||
}}
|
||||
options={toggleGroupOptions}
|
||||
disabled={!hasTeamOOOAccess}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { vi } from "vitest";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { outOfOfficeCreateOrUpdate } from "../outOfOffice.handler";
|
||||
import { outOfOfficeCreateOrUpdate } from "../outOfOfficeCreateOrUpdate.handler";
|
||||
|
||||
const prismaMock = {
|
||||
outOfOfficeEntry: {
|
||||
|
||||
@@ -14,7 +14,9 @@ import { ZGetDownloadLinkOfCalVideoRecordingsInputSchema } from "./getDownloadLi
|
||||
import { ZIntegrationsInputSchema } from "./integrations.schema";
|
||||
import { ZLocationOptionsInputSchema } from "./locationOptions.schema";
|
||||
import { ZNoShowInputSchema } from "./markNoShow.schema";
|
||||
import { ZOutOfOfficeInputSchema, ZOutOfOfficeDelete } from "./outOfOffice.schema";
|
||||
import { ZOutOfOfficeInputSchema } from "./outOfOfficeCreateOrUpdate.schema";
|
||||
import { ZOutOfOfficeEntriesListSchema } from "./outOfOfficeEntriesList.schema";
|
||||
import { ZOutOfOfficeDelete } from "./outOfOfficeEntryDelete.schema";
|
||||
import { me } from "./procedures/me";
|
||||
import { myStats } from "./procedures/myStats";
|
||||
import { platformMe } from "./procedures/platformMe";
|
||||
@@ -59,9 +61,9 @@ type AppsRouterHandlerCache = {
|
||||
teamsAndUserProfilesQuery?: typeof import("./teamsAndUserProfilesQuery.handler").teamsAndUserProfilesQuery;
|
||||
getUserTopBanners?: typeof import("./getUserTopBanners.handler").getUserTopBannersHandler;
|
||||
connectAndJoin?: typeof import("./connectAndJoin.handler").Handler;
|
||||
outOfOfficeCreateOrUpdate?: typeof import("./outOfOffice.handler").outOfOfficeCreateOrUpdate;
|
||||
outOfOfficeEntriesList?: typeof import("./outOfOffice.handler").outOfOfficeEntriesList;
|
||||
outOfOfficeEntryDelete?: typeof import("./outOfOffice.handler").outOfOfficeEntryDelete;
|
||||
outOfOfficeCreateOrUpdate?: typeof import("./outOfOfficeCreateOrUpdate.handler").outOfOfficeCreateOrUpdate;
|
||||
outOfOfficeEntriesList?: typeof import("./outOfOfficeEntriesList.handler").outOfOfficeEntriesList;
|
||||
outOfOfficeEntryDelete?: typeof import("./outOfOfficeEntryDelete.handler").outOfOfficeEntryDelete;
|
||||
addSecondaryEmail?: typeof import("./addSecondaryEmail.handler").addSecondaryEmailHandler;
|
||||
getTravelSchedules?: typeof import("./getTravelSchedules.handler").getTravelSchedulesHandler;
|
||||
outOfOfficeReasonList?: typeof import("./outOfOfficeReasons.handler").outOfOfficeReasonList;
|
||||
@@ -432,7 +434,7 @@ export const loggedInViewerRouter = router({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeCreateOrUpdate) {
|
||||
UNSTABLE_HANDLER_CACHE.outOfOfficeCreateOrUpdate = (
|
||||
await import("./outOfOffice.handler")
|
||||
await import("./outOfOfficeCreateOrUpdate.handler")
|
||||
).outOfOfficeCreateOrUpdate;
|
||||
}
|
||||
|
||||
@@ -443,10 +445,10 @@ export const loggedInViewerRouter = router({
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.outOfOfficeCreateOrUpdate({ ctx, input });
|
||||
}),
|
||||
outOfOfficeEntriesList: authedProcedure.query(async ({ ctx }) => {
|
||||
outOfOfficeEntriesList: authedProcedure.input(ZOutOfOfficeEntriesListSchema).query(async (opts) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeEntriesList) {
|
||||
UNSTABLE_HANDLER_CACHE.outOfOfficeEntriesList = (
|
||||
await import("./outOfOffice.handler")
|
||||
await import("./outOfOfficeEntriesList.handler")
|
||||
).outOfOfficeEntriesList;
|
||||
}
|
||||
|
||||
@@ -455,12 +457,12 @@ export const loggedInViewerRouter = router({
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.outOfOfficeEntriesList({ ctx });
|
||||
return UNSTABLE_HANDLER_CACHE.outOfOfficeEntriesList(opts);
|
||||
}),
|
||||
outOfOfficeEntryDelete: authedProcedure.input(ZOutOfOfficeDelete).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeEntryDelete) {
|
||||
UNSTABLE_HANDLER_CACHE.outOfOfficeEntryDelete = (
|
||||
await import("./outOfOffice.handler")
|
||||
await import("./outOfOfficeEntryDelete.handler")
|
||||
).outOfOfficeEntryDelete;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
@@ -94,6 +94,18 @@ export const meHandler = async ({ ctx, input }: MeOptions) => {
|
||||
organizationSettings: user?.profile?.organization?.organizationSettings,
|
||||
};
|
||||
|
||||
const isTeamAdminOrOwner =
|
||||
(await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
accepted: true,
|
||||
role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})) !== null;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
@@ -132,5 +144,6 @@ export const meHandler = async ({ ctx, input }: MeOptions) => {
|
||||
secondaryEmails,
|
||||
isPremium: userMetadataPrased?.isPremium,
|
||||
...(passwordAdded ? { passwordAdded } : {}),
|
||||
isTeamAdminOrOwner,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
export const isAdminForUser = async (adminUserId: number, memberUserId: number) => {
|
||||
const adminTeams = await prisma.membership.findMany({
|
||||
where: {
|
||||
userId: adminUserId,
|
||||
accepted: true,
|
||||
role: {
|
||||
in: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
teamId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const adminTeamIds = adminTeams?.map((team) => team.teamId);
|
||||
|
||||
const member = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: memberUserId,
|
||||
accepted: true,
|
||||
teamId: {
|
||||
in: adminTeamIds,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return !!member?.id;
|
||||
};
|
||||
+63
-121
@@ -15,7 +15,8 @@ import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TOutOfOfficeDelete, TOutOfOfficeInputSchema } from "./outOfOffice.schema";
|
||||
import { isAdminForUser } from "./outOfOffice.utils";
|
||||
import { type TOutOfOfficeInputSchema } from "./outOfOfficeCreateOrUpdate.schema";
|
||||
|
||||
type TBookingRedirect = {
|
||||
ctx: {
|
||||
@@ -38,9 +39,36 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "start_date_must_be_before_end_date" });
|
||||
}
|
||||
|
||||
let oooUserId = ctx.user.id;
|
||||
let oooUserName = ctx.user.username;
|
||||
let oooUserEmail = ctx.user.email;
|
||||
let oooUserTimeZone = ctx.user.timeZone;
|
||||
let oooUserOrgId = ctx.user.organizationId;
|
||||
let oooUserFullName = ctx.user.name;
|
||||
|
||||
let isAdmin;
|
||||
if (!!input.forUserId) {
|
||||
isAdmin = await isAdminForUser(ctx.user.id, input.forUserId);
|
||||
if (!isAdmin) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "only_admin_can_create_ooo" });
|
||||
}
|
||||
oooUserId = input.forUserId;
|
||||
const oooForUser = await prisma.user.findUnique({
|
||||
where: { id: input.forUserId },
|
||||
select: { username: true, email: true, timeZone: true, organizationId: true, name: true },
|
||||
});
|
||||
if (oooForUser) {
|
||||
oooUserEmail = oooForUser.email;
|
||||
oooUserName = oooForUser.username;
|
||||
oooUserFullName = oooForUser.name;
|
||||
oooUserTimeZone = oooForUser.timeZone;
|
||||
oooUserOrgId = oooForUser.organizationId;
|
||||
}
|
||||
}
|
||||
|
||||
let toUserId: number | null = null;
|
||||
|
||||
if (input.toTeamUserId === ctx.user.id) {
|
||||
if (input.toTeamUserId === oooUserId) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "cannot_redirect_to_self" });
|
||||
}
|
||||
|
||||
@@ -48,13 +76,13 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: input.toTeamUserId,
|
||||
/** You can only create OOO for members of teams you belong to */
|
||||
/** You can only redirect OOO for members of teams you belong to */
|
||||
teams: {
|
||||
some: {
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId: ctx.user.id,
|
||||
userId: oooUserId,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
@@ -67,7 +95,10 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "user_not_found" });
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: input.forUserId ? "forward_to_team_member_only" : "user_not_found",
|
||||
});
|
||||
}
|
||||
toUserId = user?.id;
|
||||
}
|
||||
@@ -84,7 +115,7 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
|
||||
},
|
||||
where: {
|
||||
...(toUserId && { userId: toUserId }),
|
||||
toUserId: ctx.user.id,
|
||||
toUserId: oooUserId,
|
||||
// Check for time overlap or collision
|
||||
OR: [
|
||||
// Outside of range
|
||||
@@ -101,12 +132,17 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
|
||||
|
||||
// don't allow infinite redirects
|
||||
if (existingOutOfOfficeEntry) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "booking_redirect_infinite_not_allowed" });
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: input.forUserId
|
||||
? "ooo_team_redirect_infinite_not_allowed"
|
||||
: "booking_redirect_infinite_not_allowed",
|
||||
});
|
||||
}
|
||||
|
||||
const isDuplicateOutOfOfficeEntry = await prisma.outOfOfficeEntry.findFirst({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
userId: oooUserId,
|
||||
start: startTimeUtc.toISOString(),
|
||||
end: endTimeUtc.toISOString(),
|
||||
},
|
||||
@@ -136,14 +172,14 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
|
||||
const createdOrUpdatedOutOfOffice = await prisma.outOfOfficeEntry.upsert({
|
||||
where: {
|
||||
uuid: input.uuid ?? "",
|
||||
userId: ctx.user.id,
|
||||
userId: oooUserId,
|
||||
},
|
||||
create: {
|
||||
uuid: uuidv4(),
|
||||
start: startTimeUtc.toISOString(),
|
||||
end: endTimeUtc.toISOString(),
|
||||
notes: input.notes,
|
||||
userId: ctx.user.id,
|
||||
userId: oooUserId,
|
||||
reasonId: input.reasonId,
|
||||
toUserId: toUserId,
|
||||
createdAt: new Date(),
|
||||
@@ -153,7 +189,7 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
|
||||
start: startTimeUtc.toISOString(),
|
||||
end: endTimeUtc.toISOString(),
|
||||
notes: input.notes,
|
||||
userId: ctx.user.id,
|
||||
userId: oooUserId,
|
||||
reasonId: input.reasonId,
|
||||
toUserId: toUserId ? toUserId : null,
|
||||
},
|
||||
@@ -225,8 +261,8 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
|
||||
if (existingRedirectedUser && existingRedirectedUser?.email !== userToNotify?.email) {
|
||||
await sendBookingRedirectNotification({
|
||||
language: t,
|
||||
fromEmail: ctx.user.email,
|
||||
eventOwner: ctx.user.username || ctx.user.email,
|
||||
fromEmail: oooUserEmail,
|
||||
eventOwner: oooUserName || oooUserEmail,
|
||||
toEmail: existingRedirectedUser.email,
|
||||
toName: existingRedirectedUser.username || "",
|
||||
dates: `${existingFormattedStartDate} - ${existingFormattedEndDate}`,
|
||||
@@ -243,8 +279,8 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
|
||||
) {
|
||||
await sendBookingRedirectNotification({
|
||||
language: t,
|
||||
fromEmail: ctx.user.email,
|
||||
eventOwner: ctx.user.username || ctx.user.email,
|
||||
fromEmail: oooUserEmail,
|
||||
eventOwner: oooUserName || oooUserEmail,
|
||||
toEmail: userToNotify.email,
|
||||
toName: userToNotify.username || "",
|
||||
oldDates: `${existingFormattedStartDate} - ${existingFormattedEndDate}`,
|
||||
@@ -258,8 +294,8 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
|
||||
) {
|
||||
await sendBookingRedirectNotification({
|
||||
language: t,
|
||||
fromEmail: ctx.user.email,
|
||||
eventOwner: ctx.user.username || ctx.user.email,
|
||||
fromEmail: oooUserEmail,
|
||||
eventOwner: oooUserName || oooUserEmail,
|
||||
toEmail: userToNotify.email,
|
||||
toName: userToNotify.username || "",
|
||||
dates: `${formattedStartDate} - ${formattedEndDate}`,
|
||||
@@ -271,7 +307,7 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
|
||||
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
userId: oooUserId,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
@@ -280,9 +316,9 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
|
||||
|
||||
// Send webhook to notify other services
|
||||
const subscriberOptions: GetSubscriberOptions = {
|
||||
userId: ctx.user.id,
|
||||
userId: oooUserId,
|
||||
teamId: teamIds,
|
||||
orgId: ctx.user.organizationId,
|
||||
orgId: oooUserOrgId,
|
||||
triggerEvent: WebhookTriggerEvents.OOO_CREATED,
|
||||
};
|
||||
|
||||
@@ -292,9 +328,9 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
|
||||
oooEntry: {
|
||||
id: createdOrUpdatedOutOfOffice.id,
|
||||
start: dayjs(createdOrUpdatedOutOfOffice.start)
|
||||
.tz(ctx.user.timeZone, true)
|
||||
.tz(oooUserTimeZone, true)
|
||||
.format("YYYY-MM-DDTHH:mm:ssZ"),
|
||||
end: dayjs(createdOrUpdatedOutOfOffice.end).tz(ctx.user.timeZone, true).format("YYYY-MM-DDTHH:mm:ssZ"),
|
||||
end: dayjs(createdOrUpdatedOutOfOffice.end).tz(oooUserTimeZone, true).format("YYYY-MM-DDTHH:mm:ssZ"),
|
||||
createdAt: createdOrUpdatedOutOfOffice.createdAt.toISOString(),
|
||||
updatedAt: createdOrUpdatedOutOfOffice.updatedAt.toISOString(),
|
||||
notes: createdOrUpdatedOutOfOffice.notes,
|
||||
@@ -304,11 +340,11 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
|
||||
},
|
||||
reasonId: input.reasonId,
|
||||
user: {
|
||||
id: ctx.user.id,
|
||||
name: ctx.user.name,
|
||||
username: ctx.user.username,
|
||||
email: ctx.user.email,
|
||||
timeZone: ctx.user.timeZone,
|
||||
id: oooUserId,
|
||||
name: oooUserFullName,
|
||||
username: oooUserName,
|
||||
email: oooUserEmail,
|
||||
timeZone: oooUserTimeZone,
|
||||
},
|
||||
toUser: toUserId
|
||||
? {
|
||||
@@ -341,97 +377,3 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
type TBookingRedirectDelete = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TOutOfOfficeDelete;
|
||||
};
|
||||
|
||||
export const outOfOfficeEntryDelete = async ({ ctx, input }: TBookingRedirectDelete) => {
|
||||
if (!input.outOfOfficeUid) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "out_of_office_id_required" });
|
||||
}
|
||||
|
||||
const deletedOutOfOfficeEntry = await prisma.outOfOfficeEntry.delete({
|
||||
where: {
|
||||
uuid: input.outOfOfficeUid,
|
||||
/** Validate outOfOfficeEntry belongs to the user deleting it */
|
||||
userId: ctx.user.id,
|
||||
},
|
||||
select: {
|
||||
start: true,
|
||||
end: true,
|
||||
toUser: {
|
||||
select: {
|
||||
email: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!deletedOutOfOfficeEntry) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "booking_redirect_not_found" });
|
||||
}
|
||||
|
||||
// Return early if no redirect user is set, and no email needs to be send.
|
||||
if (!deletedOutOfOfficeEntry.toUser) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const t = await getTranslation(ctx.user.locale ?? "en", "common");
|
||||
|
||||
const formattedStartDate = new Intl.DateTimeFormat("en-US").format(deletedOutOfOfficeEntry.start);
|
||||
const formattedEndDate = new Intl.DateTimeFormat("en-US").format(deletedOutOfOfficeEntry.end);
|
||||
|
||||
await sendBookingRedirectNotification({
|
||||
language: t,
|
||||
fromEmail: ctx.user.email,
|
||||
eventOwner: ctx.user.username || ctx.user.email,
|
||||
toEmail: deletedOutOfOfficeEntry.toUser.email,
|
||||
toName: deletedOutOfOfficeEntry.toUser.username || "",
|
||||
dates: `${formattedStartDate} - ${formattedEndDate}`,
|
||||
action: "cancel",
|
||||
});
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
export const outOfOfficeEntriesList = async ({ ctx }: { ctx: { user: NonNullable<TrpcSessionUser> } }) => {
|
||||
const outOfOfficeEntries = await prisma.outOfOfficeEntry.findMany({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
end: {
|
||||
gte: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
start: "asc",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
uuid: true,
|
||||
start: true,
|
||||
end: true,
|
||||
toUserId: true,
|
||||
toUser: {
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
reason: {
|
||||
select: {
|
||||
id: true,
|
||||
emoji: true,
|
||||
reason: true,
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
notes: true,
|
||||
},
|
||||
});
|
||||
|
||||
return outOfOfficeEntries;
|
||||
};
|
||||
+1
-6
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
|
||||
export const ZOutOfOfficeInputSchema = z.object({
|
||||
uuid: z.string().nullish(),
|
||||
forUserId: z.number().nullish(),
|
||||
dateRange: z.object({
|
||||
startDate: z.date(),
|
||||
endDate: z.date(),
|
||||
@@ -13,9 +14,3 @@ export const ZOutOfOfficeInputSchema = z.object({
|
||||
});
|
||||
|
||||
export type TOutOfOfficeInputSchema = z.infer<typeof ZOutOfOfficeInputSchema>;
|
||||
|
||||
export const ZOutOfOfficeDelete = z.object({
|
||||
outOfOfficeUid: z.string(),
|
||||
});
|
||||
|
||||
export type TOutOfOfficeDelete = z.infer<typeof ZOutOfOfficeDelete>;
|
||||
@@ -0,0 +1,147 @@
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { type TOutOfOfficeEntriesListSchema } from "./outOfOfficeEntriesList.schema";
|
||||
|
||||
type GetOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TOutOfOfficeEntriesListSchema;
|
||||
};
|
||||
|
||||
export const outOfOfficeEntriesList = async ({ ctx, input }: GetOptions) => {
|
||||
const { cursor, limit, fetchTeamMembersEntries, searchTerm } = input;
|
||||
let fetchOOOEntriesForIds = [ctx.user.id];
|
||||
|
||||
if (fetchTeamMembersEntries) {
|
||||
// Get teams where context user is admin or owner
|
||||
const teams = await prisma.membership.findMany({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
role: {
|
||||
in: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
teamId: true,
|
||||
},
|
||||
});
|
||||
if (teams.length === 0) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "user_not_admin_nor_owner" });
|
||||
}
|
||||
|
||||
// Fetch team member userIds
|
||||
const teamMembers = await prisma.team.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: teams.map((team) => team.teamId),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
members: {
|
||||
select: {
|
||||
userId: true,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const userIds = teamMembers
|
||||
.flatMap((team) => team.members.filter((member) => member.accepted).map((member) => member.userId))
|
||||
.filter((id) => id !== ctx.user.id);
|
||||
if (userIds.length === 0) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "no_team_members" });
|
||||
}
|
||||
fetchOOOEntriesForIds = userIds;
|
||||
}
|
||||
|
||||
const whereClause = {
|
||||
userId: {
|
||||
in: fetchOOOEntriesForIds,
|
||||
},
|
||||
end: {
|
||||
gte: new Date().toISOString(),
|
||||
},
|
||||
...(searchTerm && {
|
||||
user: {
|
||||
OR: [
|
||||
{
|
||||
email: {
|
||||
contains: searchTerm,
|
||||
},
|
||||
},
|
||||
{
|
||||
username: {
|
||||
contains: searchTerm,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const getTotalEntries = await prisma.outOfOfficeEntry.count({
|
||||
where: whereClause,
|
||||
});
|
||||
|
||||
const outOfOfficeEntries = await prisma.outOfOfficeEntry.findMany({
|
||||
where: whereClause,
|
||||
select: {
|
||||
id: true,
|
||||
uuid: true,
|
||||
start: true,
|
||||
end: true,
|
||||
toUserId: true,
|
||||
toUser: {
|
||||
select: {
|
||||
username: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
reason: {
|
||||
select: {
|
||||
id: true,
|
||||
emoji: true,
|
||||
reason: true,
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
notes: true,
|
||||
user: fetchTeamMembersEntries
|
||||
? {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
avatarUrl: true,
|
||||
name: true,
|
||||
},
|
||||
}
|
||||
: false,
|
||||
},
|
||||
orderBy: {
|
||||
start: "asc",
|
||||
},
|
||||
cursor: cursor ? { id: cursor } : undefined,
|
||||
take: limit + 1,
|
||||
});
|
||||
|
||||
let nextCursor: number | undefined = undefined;
|
||||
if (outOfOfficeEntries && outOfOfficeEntries.length > limit) {
|
||||
const nextItem = outOfOfficeEntries.pop();
|
||||
nextCursor = nextItem?.id;
|
||||
}
|
||||
|
||||
return {
|
||||
rows: outOfOfficeEntries || [],
|
||||
nextCursor,
|
||||
meta: {
|
||||
totalRowCount: getTotalEntries || 0,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZOutOfOfficeEntriesListSchema = z.object({
|
||||
limit: z.number().min(1).max(100),
|
||||
cursor: z.number().nullish(),
|
||||
fetchTeamMembersEntries: z.boolean().optional().default(false),
|
||||
searchTerm: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TOutOfOfficeEntriesListSchema = z.infer<typeof ZOutOfOfficeEntriesListSchema>;
|
||||
@@ -0,0 +1,86 @@
|
||||
import { sendBookingRedirectNotification } from "@calcom/emails";
|
||||
import { getTranslation } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { isAdminForUser } from "./outOfOffice.utils";
|
||||
import { type TOutOfOfficeDelete } from "./outOfOfficeEntryDelete.schema";
|
||||
|
||||
type TBookingRedirectDelete = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TOutOfOfficeDelete;
|
||||
};
|
||||
|
||||
export const outOfOfficeEntryDelete = async ({ ctx, input }: TBookingRedirectDelete) => {
|
||||
if (!input.outOfOfficeUid) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "out_of_office_id_required" });
|
||||
}
|
||||
|
||||
let oooUserId = ctx.user.id;
|
||||
let oooUserEmail = ctx.user.email;
|
||||
let oooUserName = ctx.user.username;
|
||||
|
||||
if (input.userId && input.userId !== ctx.user.id) {
|
||||
const isAdmin = await isAdminForUser(ctx.user.id, input.userId);
|
||||
if (!isAdmin) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "only_admin_can_delete_ooo" });
|
||||
}
|
||||
oooUserId = input.userId;
|
||||
const oooUser = await prisma.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
select: { username: true, email: true },
|
||||
});
|
||||
if (oooUser) {
|
||||
oooUserEmail = oooUser.email;
|
||||
oooUserName = oooUser.username;
|
||||
}
|
||||
}
|
||||
|
||||
const deletedOutOfOfficeEntry = await prisma.outOfOfficeEntry.delete({
|
||||
where: {
|
||||
uuid: input.outOfOfficeUid,
|
||||
/** Validate outOfOfficeEntry belongs to the user deleting it or is admin*/
|
||||
userId: oooUserId,
|
||||
},
|
||||
select: {
|
||||
start: true,
|
||||
end: true,
|
||||
toUser: {
|
||||
select: {
|
||||
email: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!deletedOutOfOfficeEntry) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "booking_redirect_not_found" });
|
||||
}
|
||||
|
||||
// Return early if no redirect user is set, and no email needs to be send.
|
||||
if (!deletedOutOfOfficeEntry.toUser) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const t = await getTranslation(ctx.user.locale ?? "en", "common");
|
||||
|
||||
const formattedStartDate = new Intl.DateTimeFormat("en-US").format(deletedOutOfOfficeEntry.start);
|
||||
const formattedEndDate = new Intl.DateTimeFormat("en-US").format(deletedOutOfOfficeEntry.end);
|
||||
|
||||
await sendBookingRedirectNotification({
|
||||
language: t,
|
||||
fromEmail: oooUserEmail,
|
||||
eventOwner: oooUserName || oooUserEmail,
|
||||
toEmail: deletedOutOfOfficeEntry.toUser.email,
|
||||
toName: deletedOutOfOfficeEntry.toUser.username || "",
|
||||
dates: `${formattedStartDate} - ${formattedEndDate}`,
|
||||
action: "cancel",
|
||||
});
|
||||
|
||||
return {};
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZOutOfOfficeDelete = z.object({
|
||||
outOfOfficeUid: z.string(),
|
||||
userId: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export type TOutOfOfficeDelete = z.infer<typeof ZOutOfOfficeDelete>;
|
||||
@@ -68,7 +68,8 @@ export function EmptyScreen({
|
||||
<h2
|
||||
className={classNames(
|
||||
"text-semibold font-cal text-emphasis text-center text-xl normal-nums",
|
||||
icon && "mt-6"
|
||||
icon && "mt-6",
|
||||
!description && "mb-8"
|
||||
)}>
|
||||
{headline}
|
||||
</h2>
|
||||
|
||||
@@ -85,7 +85,9 @@ export function DatePickerWithRange({
|
||||
<Popover.Content
|
||||
className="bg-default text-emphasis z-50 w-auto rounded-md border p-0 outline-none"
|
||||
align="start"
|
||||
sideOffset={4}>
|
||||
sideOffset={4}
|
||||
side="bottom"
|
||||
avoidCollisions={false}>
|
||||
{calendar}
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
Reference in New Issue
Block a user