Compare commits
40 Commits
main
...
techdebt/r
Author | SHA1 | Date | |
---|---|---|---|
|
53089686c5 | ||
|
f178650811 | ||
|
3004bbfbce | ||
|
52770c99e9 | ||
|
216695b69c | ||
|
3f9ca45ad1 | ||
|
1e11260b52 | ||
|
2ab0f34934 | ||
|
298a44eca3 | ||
|
2718cc7e87 | ||
|
51a3e52eb2 | ||
|
32ff4e0524 | ||
|
a9b926ed97 | ||
|
6789b225e9 | ||
|
17afc454fa | ||
|
b52e022634 | ||
|
1b6c7a668f | ||
|
b96250379c | ||
|
22d245e53e | ||
|
b979c3a0f2 | ||
|
c1e680f16b | ||
|
538e63956d | ||
|
dcd10f2f90 | ||
|
b76351e4a1 | ||
|
db938540a5 | ||
|
d0a6ea7ac9 | ||
|
4125265bae | ||
|
8d9a0378bd | ||
|
56d8da327e | ||
|
0f49a62e84 | ||
|
25df421200 | ||
|
140cd3f349 | ||
|
b66cd09e0d | ||
|
2abed498c4 | ||
|
0d23c5de4e | ||
|
7214d14b41 | ||
|
c5733c0c9d | ||
|
f361eaea9b | ||
|
1a25dfcbf8 | ||
|
d078378303 |
|
@ -1 +1 @@
|
||||||
Subproject commit ad226cef6e7d181ad61b850b24eb850d96d8d408
|
Subproject commit 564f9b2faa03bd4e7da6d978f990d53aebe3626f
|
|
@ -1,5 +1,13 @@
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import ReactSelect, { components, GroupBase, Props, InputProps, SingleValue, MultiValue } from "react-select";
|
import ReactSelect, {
|
||||||
|
components,
|
||||||
|
GroupBase,
|
||||||
|
Props,
|
||||||
|
InputProps,
|
||||||
|
SingleValue,
|
||||||
|
MultiValue,
|
||||||
|
OptionProps,
|
||||||
|
} from "react-select";
|
||||||
|
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||||
|
|
|
@ -5,9 +5,11 @@ import { Fragment } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
|
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
|
||||||
|
import BookingLayout from "@calcom/features/bookings/layout/BookingLayout";
|
||||||
|
import { filterQuerySchema, useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { RouterInputs, RouterOutputs, trpc } from "@calcom/trpc/react";
|
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||||
import { Alert, BookingLayout, Button, EmptyScreen, Icon } from "@calcom/ui";
|
import { Alert, Button, EmptyScreen, Icon } from "@calcom/ui";
|
||||||
|
|
||||||
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
|
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
|
||||||
|
|
||||||
|
@ -16,7 +18,7 @@ import SkeletonLoader from "@components/booking/SkeletonLoader";
|
||||||
|
|
||||||
import { ssgInit } from "@server/lib/ssg";
|
import { ssgInit } from "@server/lib/ssg";
|
||||||
|
|
||||||
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["status"];
|
type BookingListingStatus = z.infer<typeof filterQuerySchema>["status"];
|
||||||
type BookingOutput = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][0];
|
type BookingOutput = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][0];
|
||||||
|
|
||||||
type RecurringInfo = {
|
type RecurringInfo = {
|
||||||
|
@ -41,12 +43,19 @@ const querySchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function Bookings() {
|
export default function Bookings() {
|
||||||
|
const { data: filterQuery } = useFilterQuery();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { status } = router.isReady ? querySchema.parse(router.query) : { status: "upcoming" as const };
|
const { status } = router.isReady ? querySchema.parse(router.query) : { status: "upcoming" as const };
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
const query = trpc.viewer.bookings.get.useInfiniteQuery(
|
const query = trpc.viewer.bookings.get.useInfiniteQuery(
|
||||||
{ status, limit: 10 },
|
{
|
||||||
|
limit: 10,
|
||||||
|
filters: {
|
||||||
|
...filterQuery,
|
||||||
|
status: filterQuery.status ?? status,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// first render has status `undefined`
|
// first render has status `undefined`
|
||||||
enabled: router.isReady,
|
enabled: router.isReady,
|
||||||
|
@ -165,7 +174,7 @@ export default function Bookings() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{query.status === "success" && isEmpty && (
|
{query.status === "success" && isEmpty && (
|
||||||
<div className="flex items-center justify-center pt-2 xl:mx-6 xl:pt-0">
|
<div className="flex items-center justify-center pt-2 xl:pt-0">
|
||||||
<EmptyScreen
|
<EmptyScreen
|
||||||
Icon={Icon.FiCalendar}
|
Icon={Icon.FiCalendar}
|
||||||
headline={t("no_status_bookings_yet", { status: t(status).toLowerCase() })}
|
headline={t("no_status_bookings_yet", { status: t(status).toLowerCase() })}
|
||||||
|
|
|
@ -1442,6 +1442,9 @@
|
||||||
"enter_option": "Enter Option {{index}}",
|
"enter_option": "Enter Option {{index}}",
|
||||||
"add_an_option": "Add an option",
|
"add_an_option": "Add an option",
|
||||||
"radio": "Radio",
|
"radio": "Radio",
|
||||||
|
"individual":"Individual",
|
||||||
|
"all_bookings_filter_label":"All Bookings",
|
||||||
|
"all_users_filter_label":"All Users",
|
||||||
"meeting_url_workflow": "Meeting url",
|
"meeting_url_workflow": "Meeting url",
|
||||||
"meeting_url_info": "The event meeting conference url",
|
"meeting_url_info": "The event meeting conference url",
|
||||||
"date_overrides": "Date overrides",
|
"date_overrides": "Date overrides",
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
Subproject commit e25a57cf16380eece9b194bd8aeecb74f214e580
|
Subproject commit 794dda81932f6c0572941fb70cc38b599510d31f
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { Fragment, useState, useEffect } from "react";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { trpc, RouterOutputs } from "@calcom/trpc/react";
|
||||||
|
import { AnimatedPopover } from "@calcom/ui";
|
||||||
|
|
||||||
|
import { groupBy } from "../groupBy";
|
||||||
|
|
||||||
|
export type IEventTypesFilters = RouterOutputs["viewer"]["eventTypes"]["listWithTeam"];
|
||||||
|
export type IEventTypeFilter = IEventTypesFilters[0];
|
||||||
|
|
||||||
|
type GroupedEventTypeState = Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
team: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
} | null;
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
}[]
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const EventTypeFilter = () => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const { data: user } = useSession();
|
||||||
|
const eventTypes = trpc.viewer.eventTypes.listWithTeam.useQuery();
|
||||||
|
const [groupedEventTypes, setGroupedEventTypes] = useState<GroupedEventTypeState>();
|
||||||
|
// Will be handled up the tree to redirect
|
||||||
|
useEffect(() => {
|
||||||
|
if (!eventTypes.data) return;
|
||||||
|
// Group event types by team
|
||||||
|
const grouped = groupBy<IEventTypeFilter>(
|
||||||
|
eventTypes.data.filter((el) => el.team),
|
||||||
|
(item) => item?.team?.name || ""
|
||||||
|
); // Add the team name
|
||||||
|
const individualEvents = eventTypes.data.filter((el) => !el.team);
|
||||||
|
// push indivdual events to the start of grouped array
|
||||||
|
setGroupedEventTypes({ user_own_event_types: individualEvents, ...grouped });
|
||||||
|
}, [eventTypes.data, user]);
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedPopover text={t("event_type")}>
|
||||||
|
<div className="">
|
||||||
|
{groupedEventTypes &&
|
||||||
|
Object.keys(groupedEventTypes).map((teamName) => (
|
||||||
|
<Fragment key={teamName}>
|
||||||
|
<div className="p-4 text-xs font-medium uppercase leading-none text-gray-500">
|
||||||
|
{teamName === "user_own_event_types" ? t("individual") : teamName}
|
||||||
|
</div>
|
||||||
|
{groupedEventTypes[teamName].map((eventType) => (
|
||||||
|
<Fragment key={eventType.id}>
|
||||||
|
<div className="item-center flex px-4 py-[6px]">
|
||||||
|
<p className="block self-center truncate text-sm font-medium text-gray-700">
|
||||||
|
{eventType.title}
|
||||||
|
</p>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name=""
|
||||||
|
id=""
|
||||||
|
className="text-primary-600 focus:ring-primary-500 mr-2 h-4 w-4 rounded border-gray-300 "
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AnimatedPopover>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { Fragment, ReactNode } from "react";
|
||||||
|
|
||||||
|
import { PeopleFilter } from "./PeopleFilter";
|
||||||
|
import { TeamsMemberFilter } from "./TeamFilter";
|
||||||
|
|
||||||
|
type FilterTypes = "teams" | "people";
|
||||||
|
|
||||||
|
type Filter = {
|
||||||
|
name: FilterTypes;
|
||||||
|
controllingQueryParams?: string[]; // this is what the filter controls - but also we show the filter if any of these query params are present
|
||||||
|
component: ReactNode;
|
||||||
|
showByDefault?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filters: Filter[] = [
|
||||||
|
{
|
||||||
|
name: "teams",
|
||||||
|
component: <TeamsMemberFilter />,
|
||||||
|
controllingQueryParams: ["teamId"],
|
||||||
|
showByDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "people",
|
||||||
|
component: <PeopleFilter />,
|
||||||
|
controllingQueryParams: ["usersId"],
|
||||||
|
showByDefault: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function FiltersContainer() {
|
||||||
|
return (
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
{filters.map((filter) => {
|
||||||
|
if (!filter.showByDefault) {
|
||||||
|
// TODO: check if any of the controllingQueryParams are present in the query params and show the filter if so
|
||||||
|
// TODO: Also check state to see if the user has toggled the filter
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return <Fragment key={filter.name}>{filter.component}</Fragment>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
import { AnimatedPopover, Avatar, Icon } from "@calcom/ui";
|
||||||
|
|
||||||
|
import { useFilterQuery } from "../lib/useFilterQuery";
|
||||||
|
|
||||||
|
export const PeopleFilter = () => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const { data: query, pushItemToKey, removeItemByKeyAndValue, removeByKey } = useFilterQuery();
|
||||||
|
const { data } = trpc.viewer.teams.listMembers.useQuery({});
|
||||||
|
|
||||||
|
if (!data || !data.length) return null;
|
||||||
|
|
||||||
|
// Get user names from query
|
||||||
|
const userNames = data?.filter((user) => query.userIds?.includes(user.id)).map((user) => user.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedPopover
|
||||||
|
text={userNames && userNames.length > 0 ? `${userNames.join(", ")}` : t("all_users_filter_label")}>
|
||||||
|
<div className="item-center flex px-4 py-[6px] focus-within:bg-gray-100">
|
||||||
|
<div className="mr-2 flex h-6 w-6 items-center justify-center">
|
||||||
|
<Icon.FiUser className="h-full w-full" />
|
||||||
|
</div>
|
||||||
|
<label htmlFor="allUsers" className="mr-auto self-center truncate text-sm font-medium text-gray-700">
|
||||||
|
{t("all_users_filter_label")}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id="allUsers"
|
||||||
|
type="checkbox"
|
||||||
|
checked={!query.userIds}
|
||||||
|
onChange={() => {
|
||||||
|
// Always clear userIds on toggle as this is the toggle box for all users. No params means we are currently selecting all users
|
||||||
|
removeByKey("userIds");
|
||||||
|
}}
|
||||||
|
className="text-primary-600 focus:ring-primary-500 inline-flex h-4 w-4 place-self-center justify-self-end rounded border-gray-300 "
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{data &&
|
||||||
|
data.map((user) => (
|
||||||
|
<div className="item-center flex px-4 py-[6px] focus-within:bg-gray-100" key={`${user.id}`}>
|
||||||
|
<Avatar
|
||||||
|
imageSrc={user.avatar}
|
||||||
|
size="sm"
|
||||||
|
alt={`${user.name} Avatar`}
|
||||||
|
gravatarFallbackMd5="fallback"
|
||||||
|
className="self-center"
|
||||||
|
asChild
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={user.name ?? "NamelessUser"}
|
||||||
|
className="ml-2 mr-auto self-center truncate text-sm font-medium text-gray-700">
|
||||||
|
{user.name}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id={user.name ?? "NamelessUser"}
|
||||||
|
name={user.name ?? "NamelessUser"}
|
||||||
|
type="checkbox"
|
||||||
|
checked={query.userIds?.includes(user.id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
pushItemToKey("userIds", user.id);
|
||||||
|
} else if (!e.target.checked) {
|
||||||
|
removeItemByKeyAndValue("userIds", user.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-primary-600 focus:ring-primary-500 inline-flex h-4 w-4 place-self-center justify-self-end rounded border-gray-300 "
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</AnimatedPopover>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
import { AnimatedPopover, Avatar, Icon } from "@calcom/ui";
|
||||||
|
|
||||||
|
import { useFilterQuery } from "../lib/useFilterQuery";
|
||||||
|
|
||||||
|
export const TeamsMemberFilter = () => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const { data: query, pushItemToKey, removeItemByKeyAndValue, removeByKey } = useFilterQuery();
|
||||||
|
const { data } = trpc.viewer.teams.list.useQuery();
|
||||||
|
|
||||||
|
if (!data || !data.length) return null;
|
||||||
|
|
||||||
|
// get team names from query
|
||||||
|
const teamNames = data?.filter((team) => query.teamIds?.includes(team.id)).map((team) => team.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedPopover
|
||||||
|
text={teamNames && teamNames.length > 0 ? `${teamNames.join(", ")}` : t("all_bookings_filter_label")}>
|
||||||
|
<div className="item-center flex px-4 py-[6px] focus-within:bg-gray-100">
|
||||||
|
<div className="mr-2 flex h-6 w-6 items-center justify-center">
|
||||||
|
<Icon.FiLayers className="h-full w-full" />
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
htmlFor="allBookings"
|
||||||
|
className="mr-auto self-center truncate text-sm font-medium text-gray-700">
|
||||||
|
{t("all_bookings_filter_label")}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id="allBookings"
|
||||||
|
type="checkbox"
|
||||||
|
checked={!query.teamIds}
|
||||||
|
onChange={() => {
|
||||||
|
removeByKey("teamIds"); // Always clear on toggle or not toggle (seems weird but when you know the behviour it works well )
|
||||||
|
}}
|
||||||
|
className="text-primary-600 focus:ring-primary-500 inline-flex h-4 w-4 place-self-center justify-self-end rounded border-gray-300 "
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{data &&
|
||||||
|
data.map((team) => (
|
||||||
|
<div className="item-center flex px-4 py-[6px] focus-within:bg-gray-100" key={`${team.id}`}>
|
||||||
|
<Avatar
|
||||||
|
imageSrc={team.logo}
|
||||||
|
size="sm"
|
||||||
|
alt={`${team.name} Avatar`}
|
||||||
|
gravatarFallbackMd5="fallback"
|
||||||
|
className="self-center"
|
||||||
|
asChild
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={team.name}
|
||||||
|
className="ml-2 mr-auto self-center truncate text-sm font-medium text-gray-700">
|
||||||
|
{team.name}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id={team.name}
|
||||||
|
name={team.name}
|
||||||
|
type="checkbox"
|
||||||
|
checked={query.teamIds?.includes(team.id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
pushItemToKey("teamIds", team.id);
|
||||||
|
} else if (!e.target.checked) {
|
||||||
|
removeItemByKeyAndValue("teamIds", team.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-primary-600 focus:ring-primary-500 inline-flex h-4 w-4 place-self-center justify-self-end rounded border-gray-300 "
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</AnimatedPopover>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,18 @@
|
||||||
|
type KeySelector<T> = (item: T) => string;
|
||||||
|
|
||||||
|
export function groupBy<T>(array: Iterable<T>, keySelector: KeySelector<T>): Record<string, T[]> {
|
||||||
|
return Array.from(array).reduce(
|
||||||
|
(acc: Record<string, T[]>, item: T) => {
|
||||||
|
const key = keySelector(item);
|
||||||
|
if (key in acc) {
|
||||||
|
// found key, push new item into existing array
|
||||||
|
acc[key].push(item);
|
||||||
|
} else {
|
||||||
|
// did not find key, create new array
|
||||||
|
acc[key] = [item];
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} // start with empty object
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,36 +1,30 @@
|
||||||
import React, { ComponentProps } from "react";
|
import React, { ComponentProps } from "react";
|
||||||
|
|
||||||
import type { VerticalTabItemProps } from "../";
|
import { HorizontalTabs, Shell } from "@calcom/ui";
|
||||||
import { HorizontalTabs, VerticalTabs } from "../";
|
import { VerticalTabItemProps, HorizontalTabItemProps } from "@calcom/ui/v2";
|
||||||
import { Icon } from "../../../Icon";
|
|
||||||
import Shell from "../Shell";
|
import { FiltersContainer } from "../components/FiltersContainer";
|
||||||
import type { HorizontalTabItemProps } from "../navigation/tabs/HorizontalTabItem";
|
|
||||||
|
|
||||||
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = [
|
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = [
|
||||||
{
|
{
|
||||||
name: "upcoming",
|
name: "upcoming",
|
||||||
href: "/bookings/upcoming",
|
href: "/bookings/upcoming",
|
||||||
icon: Icon.FiCalendar,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "unconfirmed",
|
name: "unconfirmed",
|
||||||
href: "/bookings/unconfirmed",
|
href: "/bookings/unconfirmed",
|
||||||
icon: Icon.FiInbox,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "recurring",
|
name: "recurring",
|
||||||
href: "/bookings/recurring",
|
href: "/bookings/recurring",
|
||||||
icon: Icon.FiRotateCcw,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "past",
|
name: "past",
|
||||||
href: "/bookings/past",
|
href: "/bookings/past",
|
||||||
icon: Icon.FiSunset,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "cancelled",
|
name: "cancelled",
|
||||||
href: "/bookings/cancelled",
|
href: "/bookings/cancelled",
|
||||||
icon: Icon.FiSlash,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -40,12 +34,10 @@ export default function BookingLayout({
|
||||||
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
||||||
return (
|
return (
|
||||||
<Shell {...rest}>
|
<Shell {...rest}>
|
||||||
<div className="flex flex-col sm:space-x-2 xl:flex-row">
|
<div className="flex max-w-6xl flex-col sm:space-x-2">
|
||||||
<div className="hidden xl:block">
|
<div className="flex flex-col lg:flex-row">
|
||||||
<VerticalTabs tabs={tabs} sticky />
|
|
||||||
</div>
|
|
||||||
<div className="block xl:hidden">
|
|
||||||
<HorizontalTabs tabs={tabs} />
|
<HorizontalTabs tabs={tabs} />
|
||||||
|
<FiltersContainer />
|
||||||
</div>
|
</div>
|
||||||
<main className="w-full max-w-6xl">{children}</main>
|
<main className="w-full max-w-6xl">{children}</main>
|
||||||
</div>
|
</div>
|
|
@ -0,0 +1,15 @@
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
import { queryNumberArray, useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||||
|
|
||||||
|
// TODO: Move this to zod utils
|
||||||
|
export const filterQuerySchema = z.object({
|
||||||
|
teamIds: queryNumberArray.optional(),
|
||||||
|
userIds: queryNumberArray.optional(),
|
||||||
|
status: z.enum(["upcoming", "recurring", "past", "cancelled", "unconfirmed"]),
|
||||||
|
eventTypeIds: queryNumberArray.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useFilterQuery() {
|
||||||
|
return useTypedQuery(filterQuerySchema);
|
||||||
|
}
|
|
@ -24,7 +24,7 @@ export const queryStringArray = z
|
||||||
.preprocess((a) => z.string().parse(a).split(","), z.string().array())
|
.preprocess((a) => z.string().parse(a).split(","), z.string().array())
|
||||||
.or(z.string().array());
|
.or(z.string().array());
|
||||||
|
|
||||||
export function useTypedQuery<T extends z.ZodType>(schema: T) {
|
export function useTypedQuery<T extends z.AnyZodObject>(schema: T) {
|
||||||
type Output = z.infer<typeof schema>;
|
type Output = z.infer<typeof schema>;
|
||||||
type FullOutput = Required<Output>;
|
type FullOutput = Required<Output>;
|
||||||
type OutputKeys = Required<keyof FullOutput>;
|
type OutputKeys = Required<keyof FullOutput>;
|
||||||
|
@ -65,8 +65,10 @@ export function useTypedQuery<T extends z.ZodType>(schema: T) {
|
||||||
const existingValue = parsedQuery[key];
|
const existingValue = parsedQuery[key];
|
||||||
if (Array.isArray(existingValue)) {
|
if (Array.isArray(existingValue)) {
|
||||||
if (existingValue.includes(value)) return; // prevent adding the same value to the array
|
if (existingValue.includes(value)) return; // prevent adding the same value to the array
|
||||||
|
// @ts-expect-error this is too much for TS it seems
|
||||||
setQuery(key, [...existingValue, value]);
|
setQuery(key, [...existingValue, value]);
|
||||||
} else {
|
} else {
|
||||||
|
// @ts-expect-error this is too much for TS it seems
|
||||||
setQuery(key, [value]);
|
setQuery(key, [value]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -74,7 +76,7 @@ export function useTypedQuery<T extends z.ZodType>(schema: T) {
|
||||||
// Remove item by key and value
|
// Remove item by key and value
|
||||||
function removeItemByKeyAndValue<J extends ArrayOutputKeys>(key: J, value: ArrayOutput[J][number]) {
|
function removeItemByKeyAndValue<J extends ArrayOutputKeys>(key: J, value: ArrayOutput[J][number]) {
|
||||||
const existingValue = parsedQuery[key];
|
const existingValue = parsedQuery[key];
|
||||||
if (Array.isArray(existingValue)) {
|
if (Array.isArray(existingValue) && existingValue.length > 1) {
|
||||||
// @ts-expect-error this is too much for TS it seems
|
// @ts-expect-error this is too much for TS it seems
|
||||||
const newValue = existingValue.filter((item) => item !== value);
|
const newValue = existingValue.filter((item) => item !== value);
|
||||||
setQuery(key, newValue);
|
setQuery(key, newValue);
|
||||||
|
|
|
@ -93,7 +93,12 @@ export const bookingsRouter = router({
|
||||||
get: authedProcedure
|
get: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
status: z.enum(["upcoming", "recurring", "past", "cancelled", "unconfirmed"]),
|
filters: z.object({
|
||||||
|
teamIds: z.number().array().optional(),
|
||||||
|
userIds: z.number().array().optional(),
|
||||||
|
status: z.enum(["upcoming", "recurring", "past", "cancelled", "unconfirmed"]),
|
||||||
|
eventTypeIds: z.number().array().optional(),
|
||||||
|
}),
|
||||||
limit: z.number().min(1).max(100).nullish(),
|
limit: z.number().min(1).max(100).nullish(),
|
||||||
cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type
|
cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type
|
||||||
})
|
})
|
||||||
|
@ -104,7 +109,7 @@ export const bookingsRouter = router({
|
||||||
const take = input.limit ?? 10;
|
const take = input.limit ?? 10;
|
||||||
const skip = input.cursor ?? 0;
|
const skip = input.cursor ?? 0;
|
||||||
const { prisma, user } = ctx;
|
const { prisma, user } = ctx;
|
||||||
const bookingListingByStatus = input.status;
|
const bookingListingByStatus = input.filters.status;
|
||||||
const bookingListingFilters: Record<typeof bookingListingByStatus, Prisma.BookingWhereInput> = {
|
const bookingListingFilters: Record<typeof bookingListingByStatus, Prisma.BookingWhereInput> = {
|
||||||
upcoming: {
|
upcoming: {
|
||||||
endTime: { gte: new Date() },
|
endTime: { gte: new Date() },
|
||||||
|
@ -165,7 +170,46 @@ export const bookingsRouter = router({
|
||||||
cancelled: { startTime: "desc" },
|
cancelled: { startTime: "desc" },
|
||||||
unconfirmed: { startTime: "asc" },
|
unconfirmed: { startTime: "asc" },
|
||||||
};
|
};
|
||||||
const passedBookingsFilter = bookingListingFilters[bookingListingByStatus];
|
|
||||||
|
// TODO: Fix record typing
|
||||||
|
const bookingWhereInputFilters: Record<string, Prisma.BookingWhereInput> = {
|
||||||
|
teamIds: {
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
eventType: {
|
||||||
|
team: {
|
||||||
|
id: {
|
||||||
|
in: input.filters?.teamIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
userIds: {
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
eventType: {
|
||||||
|
users: {
|
||||||
|
some: {
|
||||||
|
id: {
|
||||||
|
in: input.filters?.userIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtersCombined: Prisma.BookingWhereInput[] =
|
||||||
|
input.filters &&
|
||||||
|
Object.keys(input.filters).map((key) => {
|
||||||
|
return bookingWhereInputFilters[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
const passedBookingsStatusFilter = bookingListingFilters[bookingListingByStatus];
|
||||||
const orderBy = bookingListingOrderby[bookingListingByStatus];
|
const orderBy = bookingListingOrderby[bookingListingByStatus];
|
||||||
|
|
||||||
const bookingsQuery = await prisma.booking.findMany({
|
const bookingsQuery = await prisma.booking.findMany({
|
||||||
|
@ -194,7 +238,7 @@ export const bookingsRouter = router({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
AND: [passedBookingsFilter],
|
AND: [passedBookingsStatusFilter, ...(filtersCombined ?? [])],
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
...bookingMinimalSelect,
|
...bookingMinimalSelect,
|
||||||
|
|
|
@ -356,6 +356,35 @@ export const eventTypesRouter = router({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
listWithTeam: authedProcedure.query(async ({ ctx }) => {
|
||||||
|
return await ctx.prisma.eventType.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ userId: ctx.user.id },
|
||||||
|
{
|
||||||
|
team: {
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId: ctx.user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
team: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: true,
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
create: authedProcedure.input(createEventTypeInput).mutation(async ({ ctx, input }) => {
|
create: authedProcedure.input(createEventTypeInput).mutation(async ({ ctx, input }) => {
|
||||||
const { schedulingType, teamId, ...rest } = input;
|
const { schedulingType, teamId, ...rest } = input;
|
||||||
const userId = ctx.user.id;
|
const userId = ctx.user.id;
|
||||||
|
|
|
@ -642,4 +642,45 @@ export const viewerTeamsRouter = router({
|
||||||
});
|
});
|
||||||
return teams;
|
return teams;
|
||||||
}),
|
}),
|
||||||
|
listMembers: authedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
teamIds: z.number().array().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const teams = await ctx.prisma.team.findMany({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: input.teamIds,
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
user: {
|
||||||
|
id: ctx.user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
members: {
|
||||||
|
select: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
type UserMap = Record<number, typeof teams[number]["members"][number]["user"]>;
|
||||||
|
// flattern users to be unique by id
|
||||||
|
const users = teams
|
||||||
|
.flatMap((t) => t.members)
|
||||||
|
.reduce((acc, m) => (m.user.id in acc ? acc : { ...acc, [m.user.id]: m.user }), {} as UserMap);
|
||||||
|
return Object.values(users);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
import rateLimit from "@calcom/lib/rateLimit";
|
import rateLimit from "@calcom/lib/rateLimit";
|
||||||
|
|
||||||
|
|
|
@ -9,48 +9,56 @@ import { Maybe } from "@trpc/server";
|
||||||
|
|
||||||
export type AvatarProps = {
|
export type AvatarProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
size: "sm" | "md" | "mdLg" | "lg";
|
size: "xs" | "sm" | "md" | "mdLg" | "lg";
|
||||||
imageSrc?: Maybe<string>;
|
imageSrc?: Maybe<string>;
|
||||||
title?: string;
|
title?: string;
|
||||||
alt: string;
|
alt: string;
|
||||||
gravatarFallbackMd5?: string;
|
gravatarFallbackMd5?: string;
|
||||||
|
fallback?: React.ReactNode;
|
||||||
accepted?: boolean;
|
accepted?: boolean;
|
||||||
|
asChild?: boolean; // Added to ignore the outer span on the fallback component - messes up styling
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizesPropsBySize = {
|
const sizesPropsBySize = {
|
||||||
sm: "w-6", // 24px
|
xs: "w-4 h-4", // 16px
|
||||||
md: "w-8", // 32px
|
sm: "w-6 h-6", // 24px
|
||||||
mdLg: "w-10", //40px
|
md: "w-8 h-8", // 32px
|
||||||
lg: "w-16", // 64px
|
mdLg: "w-10 h-10", //40px
|
||||||
|
lg: "w-16 h-16", // 64px
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function Avatar(props: AvatarProps) {
|
export function Avatar(props: AvatarProps) {
|
||||||
const { imageSrc, gravatarFallbackMd5, size, alt, title } = props;
|
const { imageSrc, gravatarFallbackMd5, size, alt, title } = props;
|
||||||
const sizeClassname = sizesPropsBySize[size];
|
const sizeClassname = sizesPropsBySize[size];
|
||||||
const rootClass = classNames("rounded-full aspect-square", sizeClassname, "h-auto");
|
const rootClass = classNames("rounded-full aspect-square ", sizeClassname);
|
||||||
const avatar = (
|
const avatar = (
|
||||||
<AvatarPrimitive.Root
|
<AvatarPrimitive.Root
|
||||||
className={classNames(
|
className={classNames(
|
||||||
sizeClassname,
|
sizeClassname,
|
||||||
"dark:bg-darkgray-300 relative inline-block aspect-square overflow-hidden rounded-full"
|
"dark:bg-darkgray-300 item-center relative inline-flex aspect-square justify-center overflow-hidden rounded-full"
|
||||||
)}>
|
)}>
|
||||||
<AvatarPrimitive.Image src={imageSrc ?? undefined} alt={alt} className={rootClass} />
|
<>
|
||||||
<AvatarPrimitive.Fallback delayMs={600}>
|
<AvatarPrimitive.Image src={imageSrc ?? undefined} alt={alt} className={rootClass} />
|
||||||
{gravatarFallbackMd5 && (
|
<AvatarPrimitive.Fallback delayMs={600} asChild={props.asChild}>
|
||||||
<img src={defaultAvatarSrc({ md5: gravatarFallbackMd5 })} alt={alt} className={rootClass} />
|
<>
|
||||||
)}
|
{props.fallback && !gravatarFallbackMd5 && props.fallback}
|
||||||
</AvatarPrimitive.Fallback>
|
{gravatarFallbackMd5 && (
|
||||||
{props.accepted && (
|
<img src={defaultAvatarSrc({ md5: gravatarFallbackMd5 })} alt={alt} className={rootClass} />
|
||||||
<div
|
)}
|
||||||
className={classNames(
|
</>
|
||||||
"absolute bottom-0 right-0 block rounded-full bg-green-400 text-white ring-2 ring-white",
|
</AvatarPrimitive.Fallback>
|
||||||
size === "lg" ? "h-5 w-5" : "h-2 w-2"
|
{props.accepted && (
|
||||||
)}>
|
<div
|
||||||
<div className="flex h-full items-center justify-center p-[2px]">
|
className={classNames(
|
||||||
{size === "lg" && <Icon.FiCheck className="" />}
|
"absolute bottom-0 right-0 block rounded-full bg-green-400 text-white ring-2 ring-white",
|
||||||
|
size === "lg" ? "h-5 w-5" : "h-2 w-2"
|
||||||
|
)}>
|
||||||
|
<div className="flex h-full items-center justify-center p-[2px]">
|
||||||
|
{size === "lg" && <Icon.FiCheck />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</>
|
||||||
</AvatarPrimitive.Root>
|
</AvatarPrimitive.Root>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -29,3 +29,4 @@ export {
|
||||||
} from "./form";
|
} from "./form";
|
||||||
export { TopBanner } from "./top-banner";
|
export { TopBanner } from "./top-banner";
|
||||||
export type { TopBannerProps } from "./top-banner";
|
export type { TopBannerProps } from "./top-banner";
|
||||||
|
export { AnimatedPopover } from "./popover/index";
|
||||||
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { classNames } from "@calcom/lib";
|
||||||
|
import { Icon } from "@calcom/ui";
|
||||||
|
|
||||||
|
export const AnimatedPopover = ({
|
||||||
|
text,
|
||||||
|
count,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
count?: number;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
return (
|
||||||
|
<Popover.Root onOpenChange={setOpen} modal={true}>
|
||||||
|
<Popover.Trigger asChild>
|
||||||
|
<div
|
||||||
|
className="item-center mb-2 flex h-9 justify-between whitespace-nowrap rounded-md border border-gray-300 py-2 px-3
|
||||||
|
text-sm placeholder:text-gray-400 hover:cursor-pointer hover:border-gray-400
|
||||||
|
focus:border-neutral-300 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-1">
|
||||||
|
<div className="max-w-36 flex items-center">
|
||||||
|
<div className="truncate">
|
||||||
|
{text}
|
||||||
|
{count && count > 0 && (
|
||||||
|
<div className="flex h-4 w-4 items-center justify-center rounded-full">{count}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Icon.FiChevronDown
|
||||||
|
className={classNames("mt-auto ml-2 transition-transform duration-150", open && "rotate-180")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Content side="bottom" align="end" asChild>
|
||||||
|
<div className="absolute z-50 mt-2 w-56 -translate-x-[228px] rounded-md bg-white shadow-sm ring-1 ring-black ring-opacity-5 focus-within:outline-none">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
|
);
|
||||||
|
};
|
|
@ -24,6 +24,7 @@ export {
|
||||||
TextAreaField,
|
TextAreaField,
|
||||||
TextField,
|
TextField,
|
||||||
TopBanner,
|
TopBanner,
|
||||||
|
AnimatedPopover,
|
||||||
Select,
|
Select,
|
||||||
SelectField,
|
SelectField,
|
||||||
SelectWithValidation,
|
SelectWithValidation,
|
||||||
|
@ -98,7 +99,6 @@ export { ToggleGroup } from "./v2/core/form/ToggleGroup";
|
||||||
export { default as ImageUploader } from "./v2/core/ImageUploader";
|
export { default as ImageUploader } from "./v2/core/ImageUploader";
|
||||||
export { default as AdminLayout, getLayout as getAdminLayout } from "./v2/core/layouts/AdminLayout";
|
export { default as AdminLayout, getLayout as getAdminLayout } from "./v2/core/layouts/AdminLayout";
|
||||||
export { default as AppsLayout } from "./v2/core/layouts/AppsLayout";
|
export { default as AppsLayout } from "./v2/core/layouts/AppsLayout";
|
||||||
export { default as BookingLayout } from "./v2/core/layouts/BookingLayout";
|
|
||||||
export { default as InstalledAppsLayout } from "./v2/core/layouts/InstalledAppsLayout";
|
export { default as InstalledAppsLayout } from "./v2/core/layouts/InstalledAppsLayout";
|
||||||
export { default as SettingsLayout, getLayout as getSettingsLayout } from "./v2/core/layouts/SettingsLayout";
|
export { default as SettingsLayout, getLayout as getSettingsLayout } from "./v2/core/layouts/SettingsLayout";
|
||||||
export { default as WizardLayout, getLayout as getWizardLayout } from "./v2/core/layouts/WizardLayout";
|
export { default as WizardLayout, getLayout as getWizardLayout } from "./v2/core/layouts/WizardLayout";
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
"@calcom/trpc": "*",
|
"@calcom/trpc": "*",
|
||||||
"@formkit/auto-animate": "^1.0.0-beta.5",
|
"@formkit/auto-animate": "^1.0.0-beta.5",
|
||||||
"@radix-ui/react-dialog": "^1.0.0",
|
"@radix-ui/react-dialog": "^1.0.0",
|
||||||
|
"@radix-ui/react-popover": "^1.0.2",
|
||||||
"@radix-ui/react-portal": "^1.0.0",
|
"@radix-ui/react-portal": "^1.0.0",
|
||||||
"@radix-ui/react-select": "^0.1.1",
|
"@radix-ui/react-select": "^0.1.1",
|
||||||
"@tanstack/react-query": "^4.3.9",
|
"@tanstack/react-query": "^4.3.9",
|
||||||
|
|
Loading…
Reference in New Issue
Block a user