Merge branch 'main' into test-datepicker
This commit is contained in:
commit
3b0e896be6
|
@ -40,7 +40,7 @@ export async function getHandler(req: NextApiRequest) {
|
|||
// If user is not ADMIN, return only his data.
|
||||
if (!isAdmin) where.id = userId;
|
||||
const [total, data] = await prisma.$transaction([
|
||||
prisma.user.count(),
|
||||
prisma.user.count({ where }),
|
||||
prisma.user.findMany({ where, take, skip }),
|
||||
]);
|
||||
const users = schemaUsersReadPublic.parse(data);
|
||||
|
|
|
@ -3,7 +3,7 @@ import { useRouter } from "next/navigation";
|
|||
import type { ComponentProps } from "react";
|
||||
import React from "react";
|
||||
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { EmptyScreen } from "@calcom/ui";
|
||||
import { AlertCircle } from "@calcom/ui/components/icon";
|
||||
|
@ -12,7 +12,7 @@ type AppsLayoutProps = {
|
|||
children: React.ReactNode;
|
||||
actions?: (className?: string) => JSX.Element;
|
||||
emptyStore?: boolean;
|
||||
} & Omit<ComponentProps<typeof Shell>, "actions">;
|
||||
} & Omit<ComponentProps<typeof ShellMain>, "actions">;
|
||||
|
||||
export default function AppsLayout({ children, actions, emptyStore, ...rest }: AppsLayoutProps) {
|
||||
const { t } = useLocale();
|
||||
|
@ -22,7 +22,7 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
|
|||
|
||||
if (session.status === "loading") return <></>;
|
||||
return (
|
||||
<Shell {...rest} actions={actions?.("block")} hideHeadingOnMobile>
|
||||
<ShellMain {...rest} actions={actions?.("block")} hideHeadingOnMobile>
|
||||
<div className="flex flex-col xl:flex-row">
|
||||
<main className="w-full">
|
||||
{emptyStore ? (
|
||||
|
@ -38,7 +38,6 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
|
|||
)}
|
||||
</main>
|
||||
</div>
|
||||
</Shell>
|
||||
</ShellMain>
|
||||
);
|
||||
}
|
||||
export const getLayout = (page: React.ReactElement) => <AppsLayout>{page}</AppsLayout>;
|
||||
|
|
|
@ -13,6 +13,7 @@ import type { DurationType } from "@calcom/lib/convertToNewDurationType";
|
|||
import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
|
||||
import findDurationType from "@calcom/lib/findDurationType";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { ascendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit";
|
||||
import type { PeriodType } from "@calcom/prisma/enums";
|
||||
import type { IntervalLimit } from "@calcom/types/Calendar";
|
||||
import { Button, DateRangePicker, InputField, Label, Select, SettingsToggle, TextField } from "@calcom/ui";
|
||||
|
@ -462,7 +463,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||
type="number"
|
||||
{...offsetStartLockedProps}
|
||||
label={t("offset_start")}
|
||||
defaultValue={eventType.offsetStart}
|
||||
{...formMethods.register("offsetStart")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
hint={t("offset_start_description", {
|
||||
|
@ -477,11 +477,9 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||
|
||||
type IntervalLimitsKey = keyof IntervalLimit;
|
||||
|
||||
const intervalOrderKeys = ["PER_DAY", "PER_WEEK", "PER_MONTH", "PER_YEAR"] as const;
|
||||
|
||||
const INTERVAL_LIMIT_OPTIONS = intervalOrderKeys.map((key) => ({
|
||||
const INTERVAL_LIMIT_OPTIONS = ascendingLimitKeys.map((key) => ({
|
||||
value: key as keyof IntervalLimit,
|
||||
label: `Per ${key.split("_")[1].toLocaleLowerCase()}`,
|
||||
label: `Per ${intervalLimitKeyToUnit(key)}`,
|
||||
}));
|
||||
|
||||
type IntervalLimitItemProps = {
|
||||
|
@ -590,8 +588,8 @@ const IntervalLimitsManager = <K extends "durationLimits" | "bookingLimits">({
|
|||
Object.entries(currentIntervalLimits)
|
||||
.sort(([limitKeyA], [limitKeyB]) => {
|
||||
return (
|
||||
intervalOrderKeys.indexOf(limitKeyA as IntervalLimitsKey) -
|
||||
intervalOrderKeys.indexOf(limitKeyB as IntervalLimitsKey)
|
||||
ascendingLimitKeys.indexOf(limitKeyA as IntervalLimitsKey) -
|
||||
ascendingLimitKeys.indexOf(limitKeyB as IntervalLimitsKey)
|
||||
);
|
||||
})
|
||||
.map(([key, value]) => {
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
@ -99,6 +101,7 @@ const CheckedHostField = ({
|
|||
isFixed,
|
||||
value,
|
||||
onChange,
|
||||
helperText,
|
||||
...rest
|
||||
}: {
|
||||
labelText: string;
|
||||
|
@ -107,6 +110,7 @@ const CheckedHostField = ({
|
|||
value: { isFixed: boolean; userId: number }[];
|
||||
onChange?: (options: { isFixed: boolean; userId: number }[]) => void;
|
||||
options?: Options<CheckedSelectOption>;
|
||||
helperText?: React.ReactNode | string;
|
||||
} & Omit<Partial<ComponentProps<typeof CheckedTeamSelect>>, "onChange" | "value">) => {
|
||||
return (
|
||||
<div className="bg-muted flex flex-col space-y-5 p-4">
|
||||
|
@ -136,11 +140,24 @@ const CheckedHostField = ({
|
|||
placeholder={placeholder}
|
||||
{...rest}
|
||||
/>
|
||||
{helperText && <p className="text-subtle text-sm">{helperText}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FixedHostHelper = (
|
||||
<Trans i18nKey="fixed_host_helper">
|
||||
Add anyone who needs to attend the event.
|
||||
<Link
|
||||
className="underline underline-offset-2"
|
||||
target="_blank"
|
||||
href="https://cal.com/docs/enterprise-features/teams/round-robin-scheduling#fixed-hosts">
|
||||
Learn more
|
||||
</Link>
|
||||
</Trans>
|
||||
);
|
||||
|
||||
const RoundRobinHosts = ({
|
||||
teamMembers,
|
||||
value,
|
||||
|
@ -167,6 +184,7 @@ const RoundRobinHosts = ({
|
|||
value={value}
|
||||
placeholder={t("add_fixed_hosts")}
|
||||
labelText={t("fixed_hosts")}
|
||||
helperText={FixedHostHelper}
|
||||
/>
|
||||
<CheckedHostField
|
||||
options={teamMembers.sort(sortByLabel)}
|
||||
|
@ -175,6 +193,7 @@ const RoundRobinHosts = ({
|
|||
isFixed={false}
|
||||
placeholder={t("add_attendees")}
|
||||
labelText={t("round_robin_hosts")}
|
||||
helperText={t("round_robin_helper")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -2,7 +2,7 @@ import { usePathname, useSearchParams } from "next/navigation";
|
|||
|
||||
export default function useIsBookingPage() {
|
||||
const pathname = usePathname();
|
||||
const isBookingPage = ["/booking", "/cancel", "/reschedule"].some((route) => pathname?.startsWith(route));
|
||||
const isBookingPage = ["/booking/", "/cancel", "/reschedule"].some((route) => pathname?.startsWith(route));
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const userParam = searchParams.get("user");
|
||||
|
|
|
@ -127,7 +127,10 @@ const matcherConfigUserTypeEmbedRoute = {
|
|||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
i18n,
|
||||
i18n: {
|
||||
...i18n,
|
||||
localeDetection: false,
|
||||
},
|
||||
productionBrowserSourceMaps: true,
|
||||
/* We already do type check on GH actions */
|
||||
typescript: {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import Head from "next/head";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, showToast } from "@calcom/ui";
|
||||
import { Copy } from "@calcom/ui/components/icon";
|
||||
|
@ -21,14 +21,11 @@ export default function Error500() {
|
|||
<div className="rtl: bg-default m-auto rounded-md p-10 text-right ltr:text-left">
|
||||
<h1 className="font-cal text-emphasis text-6xl">500</h1>
|
||||
<h2 className="text-emphasis mt-6 text-2xl font-medium">It's not you, it's us.</h2>
|
||||
<p className="text-default mb-6 mt-4 max-w-2xl text-sm">
|
||||
Something went wrong on our end. Get in touch with our support team, and we’ll get it fixed right
|
||||
away for you.
|
||||
</p>
|
||||
<p className="text-default mb-6 mt-4 max-w-2xl text-sm">{t("something_went_wrong_on_our_end")}</p>
|
||||
{searchParams?.get("error") && (
|
||||
<div className="mb-8 flex flex-col">
|
||||
<p className="text-default mb-4 max-w-2xl text-sm">
|
||||
Please provide the following text when contacting support to better help you:
|
||||
{t("please_provide_following_text_to_suppport")}:
|
||||
</p>
|
||||
<pre className="bg-emphasis text-emphasis w-full max-w-2xl whitespace-normal break-words rounded-md p-4">
|
||||
{searchParams?.get("error")}
|
||||
|
@ -46,9 +43,9 @@ export default function Error500() {
|
|||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<Button href={`${WEBSITE_URL}/support`}>{t("contact_support")}</Button>
|
||||
<Button href="mailto:support@cal.com">{t("contact_support")}</Button>
|
||||
<Button color="secondary" href="javascript:history.back()" className="ml-2">
|
||||
Go back
|
||||
{t("go_back")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import React from "react";
|
||||
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import type { AppProps } from "@lib/app-providers";
|
||||
|
|
|
@ -3,6 +3,7 @@ import type { ChangeEventHandler } from "react";
|
|||
import { useState } from "react";
|
||||
|
||||
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import getUserAdminTeams from "@calcom/features/ee/teams/lib/getUserAdminTeams";
|
||||
import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams";
|
||||
|
@ -94,6 +95,7 @@ export default function Apps({
|
|||
}
|
||||
|
||||
Apps.PageWrapper = PageWrapper;
|
||||
Apps.getLayout = getLayout;
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { req, res } = context;
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
@ -130,15 +131,17 @@ export default function AvailabilityPage() {
|
|||
const { t } = useLocale();
|
||||
return (
|
||||
<div>
|
||||
<Shell
|
||||
<ShellMain
|
||||
heading={t("availability")}
|
||||
hideHeadingOnMobile
|
||||
subtitle={t("configure_availability")}
|
||||
CTA={<NewScheduleButton />}>
|
||||
<WithQuery success={({ data }) => <AvailabilityList {...data} />} customLoader={<SkeletonLoader />} />
|
||||
</Shell>
|
||||
</ShellMain>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AvailabilityPage.getLayout = getLayout;
|
||||
|
||||
AvailabilityPage.PageWrapper = PageWrapper;
|
||||
|
|
|
@ -1,16 +1,21 @@
|
|||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import type { GetStaticPaths, GetStaticProps } from "next";
|
||||
import { Fragment } from "react";
|
||||
import React from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
|
||||
import BookingLayout from "@calcom/features/bookings/layout/BookingLayout";
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { FiltersContainer } from "@calcom/features/bookings/components/FiltersContainer";
|
||||
import type { filterQuerySchema } from "@calcom/features/bookings/lib/useFilterQuery";
|
||||
import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { HorizontalTabs } from "@calcom/ui";
|
||||
import type { VerticalTabItemProps, HorizontalTabItemProps } from "@calcom/ui";
|
||||
import { Alert, Button, EmptyScreen } from "@calcom/ui";
|
||||
import { Calendar } from "@calcom/ui/components/icon";
|
||||
|
||||
|
@ -32,6 +37,28 @@ type RecurringInfo = {
|
|||
bookings: { [key: string]: Date[] };
|
||||
};
|
||||
|
||||
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = [
|
||||
{
|
||||
name: "upcoming",
|
||||
href: "/bookings/upcoming",
|
||||
},
|
||||
{
|
||||
name: "unconfirmed",
|
||||
href: "/bookings/unconfirmed",
|
||||
},
|
||||
{
|
||||
name: "recurring",
|
||||
href: "/bookings/recurring",
|
||||
},
|
||||
{
|
||||
name: "past",
|
||||
href: "/bookings/past",
|
||||
},
|
||||
{
|
||||
name: "cancelled",
|
||||
href: "/bookings/cancelled",
|
||||
},
|
||||
];
|
||||
const validStatuses = ["upcoming", "recurring", "past", "cancelled", "unconfirmed"] as const;
|
||||
|
||||
const descriptionByStatus: Record<NonNullable<BookingListingStatus>, string> = {
|
||||
|
@ -112,7 +139,15 @@ export default function Bookings() {
|
|||
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<BookingLayout heading={t("bookings")} subtitle={t("bookings_description")}>
|
||||
<ShellMain hideHeadingOnMobile heading={t("bookings")} subtitle={t("bookings_description")}>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col flex-wrap lg:flex-row">
|
||||
<HorizontalTabs tabs={tabs} />
|
||||
<div className="max-w-full overflow-x-auto xl:ml-auto">
|
||||
<FiltersContainer />
|
||||
</div>
|
||||
</div>
|
||||
<main className="w-full">
|
||||
<div className="flex w-full flex-col" ref={animationParentRef}>
|
||||
{query.status === "error" && (
|
||||
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
|
||||
|
@ -191,11 +226,14 @@ export default function Bookings() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BookingLayout>
|
||||
</main>
|
||||
</div>
|
||||
</ShellMain>
|
||||
);
|
||||
}
|
||||
|
||||
Bookings.PageWrapper = PageWrapper;
|
||||
Bookings.getLayout = getLayout;
|
||||
|
||||
export const getStaticProps: GetStaticProps = async (ctx) => {
|
||||
const params = querySchema.safeParse(ctx.params);
|
||||
|
|
|
@ -258,6 +258,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
|||
startDate: periodDates.startDate,
|
||||
endDate: periodDates.endDate,
|
||||
},
|
||||
offsetStart: eventType.offsetStart,
|
||||
bookingFields: eventType.bookingFields,
|
||||
periodType: eventType.periodType,
|
||||
periodCountCalendarDays: eventType.periodCountCalendarDays ? "1" : "0",
|
||||
|
|
|
@ -7,6 +7,7 @@ import type { FC } from "react";
|
|||
import { memo, useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import useIntercom from "@calcom/features/ee/support/lib/intercom/useIntercom";
|
||||
import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed";
|
||||
|
@ -15,7 +16,7 @@ import CreateEventTypeDialog from "@calcom/features/eventtypes/components/Create
|
|||
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
|
||||
import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter";
|
||||
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { APP_NAME, CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
@ -932,12 +933,7 @@ const EventTypesPage = () => {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<HeadSeo
|
||||
title="Event Types"
|
||||
description="Create events to share for people to book on your calendar."
|
||||
/>
|
||||
<Shell
|
||||
<ShellMain
|
||||
withoutSeo
|
||||
heading={t("event_types_page_title")}
|
||||
hideHeadingOnMobile
|
||||
|
@ -945,12 +941,17 @@ const EventTypesPage = () => {
|
|||
afterHeading={showProfileBanner && <SetupProfileBanner closeAction={closeBanner} />}
|
||||
beforeCTAactions={<Actions />}
|
||||
CTA={<CTA data={data} />}>
|
||||
<HeadSeo
|
||||
title="Event Types"
|
||||
description="Create events to share for people to book on your calendar."
|
||||
/>
|
||||
<Main data={data} status={status} error={error} filters={filters} />
|
||||
</Shell>
|
||||
</div>
|
||||
</ShellMain>
|
||||
);
|
||||
};
|
||||
|
||||
EventTypesPage.getLayout = getLayout;
|
||||
|
||||
EventTypesPage.PageWrapper = PageWrapper;
|
||||
|
||||
export default EventTypesPage;
|
||||
|
|
|
@ -121,10 +121,10 @@ const OnboardingPage = () => {
|
|||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<div className="mx-auto px-4 py-6 md:py-24">
|
||||
<div className="mx-auto py-6 sm:px-4 md:py-24">
|
||||
<div className="relative">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-[600px]">
|
||||
<div className="mx-auto sm:max-w-[520px]">
|
||||
<div className="mx-auto px-4 sm:max-w-[520px]">
|
||||
<header>
|
||||
<p className="font-cal mb-3 text-[28px] font-medium leading-7">
|
||||
{headers[currentStepIndex]?.title || "Undefined title"}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||
import {
|
||||
AverageEventDurationChart,
|
||||
|
@ -9,7 +10,7 @@ import {
|
|||
} from "@calcom/features/insights/components";
|
||||
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
|
||||
import { Filters } from "@calcom/features/insights/filters";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { UpgradeTip } from "@calcom/features/tips";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
@ -56,7 +57,7 @@ export default function InsightsPage() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<Shell hideHeadingOnMobile>
|
||||
<ShellMain hideHeadingOnMobile>
|
||||
<UpgradeTip
|
||||
title={t("make_informed_decisions")}
|
||||
description={t("make_informed_decisions_description")}
|
||||
|
@ -111,12 +112,13 @@ export default function InsightsPage() {
|
|||
</FiltersProvider>
|
||||
)}
|
||||
</UpgradeTip>
|
||||
</Shell>
|
||||
</ShellMain>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
InsightsPage.PageWrapper = PageWrapper;
|
||||
InsightsPage.getLayout = getLayout;
|
||||
|
||||
// If feature flag is disabled, return not found on getServerSideProps
|
||||
export const getServerSideProps = async () => {
|
||||
|
|
|
@ -272,6 +272,9 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
|||
username = available ? username : suggestion || username;
|
||||
}
|
||||
|
||||
// Transform all + to - in username
|
||||
username = username.replace(/\+/g, "-");
|
||||
|
||||
return {
|
||||
props: {
|
||||
...props,
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { TeamsListing } from "@calcom/features/ee/teams/components";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
@ -17,7 +18,7 @@ function Teams() {
|
|||
const [user] = trpc.viewer.me.useSuspenseQuery();
|
||||
|
||||
return (
|
||||
<Shell
|
||||
<ShellMain
|
||||
heading={t("teams")}
|
||||
hideHeadingOnMobile
|
||||
subtitle={t("create_manage_teams_collaborative")}
|
||||
|
@ -33,7 +34,7 @@ function Teams() {
|
|||
)
|
||||
}>
|
||||
<TeamsListing />
|
||||
</Shell>
|
||||
</ShellMain>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -46,5 +47,5 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
|
||||
Teams.requiresLicense = false;
|
||||
Teams.PageWrapper = PageWrapper;
|
||||
|
||||
Teams.getLayout = getLayout;
|
||||
export default Teams;
|
||||
|
|
|
@ -204,7 +204,7 @@
|
|||
"blog": "Blog",
|
||||
"blog_description": "Lesen Sie unsere Neuigkeiten und Artikel",
|
||||
"join_our_community": "Treten Sie unserer Community bei",
|
||||
"join_our_discord": "Treten Sie unserem Discord-Server bei",
|
||||
"join_our_discord": "Treffe uns im Discord",
|
||||
"404_claim_entity_user": "Benutzername registrieren und Termine vergeben",
|
||||
"popular_pages": "Beliebte Seiten",
|
||||
"register_now": "Jetzt registrieren",
|
||||
|
|
|
@ -256,6 +256,8 @@
|
|||
"available_apps": "Available Apps",
|
||||
"available_apps_lower_case": "Available apps",
|
||||
"available_apps_desc": "You have no apps installed. View popular apps below and explore more in our <1>App Store</1>",
|
||||
"fixed_host_helper": "Add anyone who needs to attend the event. <1>Learn more</1>",
|
||||
"round_robin_helper":"People in the group take turns and only one person will show up for the event.",
|
||||
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
|
||||
"finish": "Finish",
|
||||
"organization_general_description": "Manage settings for your team language and timezone",
|
||||
|
@ -1850,6 +1852,8 @@
|
|||
"insights_no_data_found_for_filter": "No data found for the selected filter or selected dates.",
|
||||
"acknowledge_booking_no_show_fee": "I acknowledge that if I do not attend this event that a {{amount, currency}} no show fee will be applied to my card.",
|
||||
"card_details": "Card details",
|
||||
"something_went_wrong_on_our_end":"Something went wrong on our end. Get in touch with our support team, and we’ll get it fixed right away for you.",
|
||||
"please_provide_following_text_to_suppport":"Please provide the following text when contacting support to better help you",
|
||||
"seats_and_no_show_fee_error": "Currently cannot enable seats and charge a no-show fee",
|
||||
"complete_your_booking": "Complete your booking",
|
||||
"complete_your_booking_subject": "Complete your booking: {{title}} on {{date}}",
|
||||
|
|
|
@ -129,7 +129,7 @@
|
|||
"team_upgrade_banner_description": "אנחנו מודים לך על כך שניסית את החבילה החדשה שלנו לצוותים. שמנו לב שהצוות שלך, \"{{teamName}}\", זקוק לשדרוג.",
|
||||
"upgrade_banner_action": "כאן משדרגים",
|
||||
"team_upgraded_successfully": "הצוות שלך שודרג בהצלחה!",
|
||||
"org_upgrade_banner_description": "אנחנו מודים לך על כך שניסית את החבילה החדשה שלנו לארגונים. שמנו לב שהארגון שלך, \"{{teamName}}\", זקוק לשדרוג.",
|
||||
"org_upgrade_banner_description": "אנחנו מודים לך על כך שניסית את החבילה החדשה שלנו לארגונים. שמנו לב שה-Organization שלך, \"{{teamName}}\", זקוק לשדרוג.",
|
||||
"org_upgraded_successfully": "השדרוג של הארגון שלך בוצע בהצלחה!",
|
||||
"use_link_to_reset_password": "נא להשתמש בקישור הבא כדי לאפס את הסיסמה",
|
||||
"hey_there": "שלום,",
|
||||
|
@ -551,7 +551,7 @@
|
|||
"team_description": "מספר משפטים אודות הצוות. המידע הזה יופיע בדף ה-URL של הצוות.",
|
||||
"org_description": "מספר משפטים אודות הארגון. הם יופיעו בדף עם כתובת ה-URL של הארגון.",
|
||||
"members": "חברים",
|
||||
"organization_members": "חברי הארגון",
|
||||
"organization_members": "חברי Organization",
|
||||
"member": "חבר/ת",
|
||||
"number_member_one": "חבר {{count}}",
|
||||
"number_member_other": "{{count}} חברים",
|
||||
|
@ -699,7 +699,7 @@
|
|||
"create_team_to_get_started": "צור צוות כדי להתחיל",
|
||||
"teams": "צוותים",
|
||||
"team": "צוות",
|
||||
"organization": "ארגון",
|
||||
"organization": "Organization",
|
||||
"team_billing": "חיוב צוותים",
|
||||
"team_billing_description": "ניהול החיוב עבור הצוות",
|
||||
"upgrade_to_flexible_pro_title": "שינינו את החיוב בעבור צוותים",
|
||||
|
@ -1151,7 +1151,7 @@
|
|||
"custom": "התאמה אישית",
|
||||
"reminder": "תזכורת",
|
||||
"rescheduled": "נקבע מועד חדש",
|
||||
"completed": "הושלם",
|
||||
"completed": "הושלמה",
|
||||
"reminder_email": "תזכורת: {{eventType}} עם {{name}} בתאריך {{date}}",
|
||||
"not_triggering_existing_bookings": "לא יופעל עבור הזמנות קיימות מאחר שהמשתמש יתבקש למסור מספר טלפון בעת הזמנת האירוע.",
|
||||
"minute_one": "{{count}} דקה",
|
||||
|
@ -1938,7 +1938,7 @@
|
|||
"insights_all_org_filter": "כל האפליקציות",
|
||||
"insights_team_filter": "צוות: {{teamName}}",
|
||||
"insights_user_filter": "משתמש: {{userName}}",
|
||||
"insights_subtitle": "הצגת תובנות הזמנה מתוך האירועים שלך",
|
||||
"insights_subtitle": "הצגת insights לגבי הזמנות מתוך האירועים שלך",
|
||||
"custom_plan": "חבילה בהתאמה אישית",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
|
|
@ -118,7 +118,7 @@
|
|||
"team_info": "Informazioni sul team",
|
||||
"request_another_invitation_email": "Se preferisci non usare {{toEmail}} come email {{appName}} o già hai un account {{appName}}, per favore richiedi un altro invito a quella email.",
|
||||
"you_have_been_invited": "Sei stato invitato ad unirti al team {{teamName}}",
|
||||
"user_invited_you": "{{user}} ti ha invitato a unirti al {{team}} {{entity}} su {{appName}}",
|
||||
"user_invited_you": "{{user}} ti ha invitato a unirti a {{entity}} {{team}} su {{appName}}",
|
||||
"hidden_team_member_title": "Sei nascosto in questo team",
|
||||
"hidden_team_member_message": "Il tuo posto non è pagato. Passa a Pro oppure informa il proprietario del team che può pagare il tuo posto.",
|
||||
"hidden_team_owner_message": "Per utilizzare i team ti occorre un account Pro. Sarai nascosto finché non cambierai piano.",
|
||||
|
@ -129,8 +129,8 @@
|
|||
"team_upgrade_banner_description": "Grazie per aver provato il nostro piano team. Abbiamo notato che è necessario effettuare l'upgrade del tuo team \"{{teamName}}\".",
|
||||
"upgrade_banner_action": "Effettua l'upgrade",
|
||||
"team_upgraded_successfully": "Il tuo team è stato aggiornato!",
|
||||
"org_upgrade_banner_description": "Grazie per aver provato il nostro piano Organization. Abbiamo notato che è necessario eseguire l'upgrade per la tua organizzazione \"{{teamName}}\".",
|
||||
"org_upgraded_successfully": "È stato eseguito l'upgrade della tua organizzazione!",
|
||||
"org_upgrade_banner_description": "Grazie per aver provato il nostro piano Organization. Abbiamo notato che è necessario eseguire l'upgrade del piano Organization per \"{{teamName}}\".",
|
||||
"org_upgraded_successfully": "È stato eseguito l'upgrade del piano Organization!",
|
||||
"use_link_to_reset_password": "Usa il link qui sotto per reimpostare la tua password",
|
||||
"hey_there": "Ciao,",
|
||||
"forgot_your_password_calcom": "Hai dimenticato la password? - {{appName}}",
|
||||
|
@ -555,7 +555,7 @@
|
|||
"member": "Membri",
|
||||
"number_member_one": "{{count}} membro",
|
||||
"number_member_other": "{{count}} membri",
|
||||
"number_selected": "{{count}} selezionati",
|
||||
"number_selected": "{{count}} selezionato/i",
|
||||
"owner": "Proprietario",
|
||||
"admin": "Amministratore",
|
||||
"administrator_user": "Utente amministratore",
|
||||
|
@ -1912,7 +1912,7 @@
|
|||
"organization_admin_invited_body": "Unisciti al tuo team su {{orgName}} per concentrarti sulle riunioni, non sulla loro pianificazione!",
|
||||
"duplicated_slugs_warning": "Non è stato possibile creare i seguenti team a causa di slug duplicati: {{slugs}}",
|
||||
"team_names_empty": "I nomi di team non possono essere vuoti",
|
||||
"team_names_repeated": "I nomi di team non possono essere ripetuti",
|
||||
"team_names_repeated": "I nomi di team devono essere unici",
|
||||
"user_belongs_organization": "L'utente appartiene a un'organizzazione",
|
||||
"org_no_teams_yet": "Questa organizzazione non ha ancora nessun team",
|
||||
"org_no_teams_yet_description": "Se sei un amministratore, assicurati di creare dei team da mostrare qui.",
|
||||
|
@ -1934,7 +1934,7 @@
|
|||
"404_the_org": "L'organizzazione",
|
||||
"404_the_team": "Il team",
|
||||
"404_claim_entity_org": "Richiedi il sottodominio per la tua organizzazione",
|
||||
"404_claim_entity_team": "Richiedi questo team e inizia a gestire le pianificazioni collettivamente",
|
||||
"404_claim_entity_team": "Richiedi l'accesso a questo team per iniziare a gestire le pianificazioni collettivamente",
|
||||
"insights_all_org_filter": "Tutte le app",
|
||||
"insights_team_filter": "Team: {{teamName}}",
|
||||
"insights_user_filter": "Utente: {{userName}}",
|
||||
|
|
|
@ -13,8 +13,8 @@
|
|||
"reset_password_subject": "{{appName}}: パスワードのリセット手順",
|
||||
"verify_email_subject": "{{appName}}:アカウントを確認",
|
||||
"check_your_email": "メールを確認してください",
|
||||
"verify_email_page_body": "{{email}} にメールを送信しました。{{appName}} からのメール配信とカレンダーが確実に連携するようにするには、メールアドレスの確認が重要です。",
|
||||
"verify_email_banner_body": "メール配信とカレンダーが確実に連携するようにするには、メールアドレスを確認してください。",
|
||||
"verify_email_page_body": "{{email}} にメールを送信しました。{{appName}} からのメール配信とカレンダー通知が確実に連携するようにするには、メールアドレスの確認が重要です。",
|
||||
"verify_email_banner_body": "メール配信とカレンダー通知が確実に連携するようにするには、メールアドレスを確認してください。",
|
||||
"verify_email_email_header": "メールアドレスの確認",
|
||||
"verify_email_email_button": "メールを確認",
|
||||
"verify_email_email_body": "以下のボタンをクリックして、メールアドレスを確認してください。",
|
||||
|
@ -1934,7 +1934,7 @@
|
|||
"404_the_org": "組織",
|
||||
"404_the_team": "チーム",
|
||||
"404_claim_entity_org": "組織のサブドメインを取得",
|
||||
"404_claim_entity_team": "このチームを取得して、これからはスケジュールをまとめて管理しましょう",
|
||||
"404_claim_entity_team": "このチームの一員になって、これからはスケジュールをまとめて管理しましょう",
|
||||
"insights_all_org_filter": "すべて",
|
||||
"insights_team_filter": "チーム:{{teamName}}",
|
||||
"insights_user_filter": "ユーザー:{{userName}}",
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
"reset_password_subject": "{{appName}}: Instruções para redefinir sua senha",
|
||||
"verify_email_subject": "{{appName}}: Verificar sua conta",
|
||||
"check_your_email": "Verifique seu e-mail",
|
||||
"verify_email_page_body": "Enviamos um e-mail para {{email}}. É importante verificar seu endereço de e-mail para garantir o fornecimento do melhor e-mail e calendário de {{appName}}.",
|
||||
"verify_email_page_body": "Enviamos um e-mail para {{email}}. É importante verificar seu endereço de e-mail para garantir o melhor envio de e-mail e calendário do {{appName}}.",
|
||||
"verify_email_banner_body": "Verifique seu endereço de e-mail para garantir o fornecimento do melhor e-mail e calendário",
|
||||
"verify_email_email_header": "Verifique seu endereço de e-mail",
|
||||
"verify_email_email_button": "Verificar e-mail",
|
||||
|
|
|
@ -310,7 +310,7 @@
|
|||
"bookerlayout_user_settings_title": "Disposição da reserva",
|
||||
"bookerlayout_user_settings_description": "Pode selecionar várias e os clientes podem alternar entre vistas. Isto pode ser sobreposto para cada evento.",
|
||||
"bookerlayout_month_view": "Mês",
|
||||
"bookerlayout_week_view": "Semanal",
|
||||
"bookerlayout_week_view": "Semana",
|
||||
"bookerlayout_column_view": "Coluna",
|
||||
"bookerlayout_error_min_one_enabled": "Tem de estar ativa pelo menos uma disposição.",
|
||||
"bookerlayout_error_default_not_enabled": "A disposição selecionada como a vista predefinida não faz parte das disposições ativas.",
|
||||
|
|
|
@ -118,7 +118,7 @@
|
|||
"team_info": "Informații echipă",
|
||||
"request_another_invitation_email": "Dacă preferați să nu utilizați {{toEmail}} ca e-mail {{appName}} sau aveți deja un cont {{appName}}, vă rugăm să solicitați o altă invitație la acel e-mail.",
|
||||
"you_have_been_invited": "Ați fost invitat să vă alăturați echipei {{teamName}}",
|
||||
"user_invited_you": "{{user}} v-a invitat să vă alăturați echipei {{entity}} {{team}} pe {{appName}}",
|
||||
"user_invited_you": "{{user}} v-a invitat să vă alăturați la {{entity}} {{team}} de pe {{appName}}",
|
||||
"hidden_team_member_title": "Sunteți ascuns în această echipă",
|
||||
"hidden_team_member_message": "Licența dvs. nu este plătită. Fie faceți upgrade la Pro, fie anunțați proprietarul echipei că vă poate plăti licența.",
|
||||
"hidden_team_owner_message": "Aveți nevoie de un cont Pro pentru a utiliza echipe. Sunteți ascuns până când faceți upgrade.",
|
||||
|
@ -129,8 +129,8 @@
|
|||
"team_upgrade_banner_description": "Vă mulțumim că ați încercat noul nostru plan pentru echipe. Am observat că planul echipei dvs. „{{teamName}}” necesită un upgrade.",
|
||||
"upgrade_banner_action": "Realizați upgrade aici",
|
||||
"team_upgraded_successfully": "Echipa dvs. a fost actualizată cu succes!",
|
||||
"org_upgrade_banner_description": "Vă mulțumim că ați încercat planul nostru Organizație. Am observat că organizația dvs. „{{teamName}}” necesită un upgrade.",
|
||||
"org_upgraded_successfully": "Upgrade-ul la organizația dvs. a fost realizat cu succes!",
|
||||
"org_upgrade_banner_description": "Vă mulțumim că ați încercat planul nostru Organization. Am observat că organizația dvs. „{{teamName}}” necesită un upgrade.",
|
||||
"org_upgraded_successfully": "Upgrade-ul pentru Organization a fost realizat cu succes!",
|
||||
"use_link_to_reset_password": "Utilizați linkul de mai jos pentru a vă reseta parola",
|
||||
"hey_there": "Bună,",
|
||||
"forgot_your_password_calcom": "Ți-ai uitat parola? - {{appName}}",
|
||||
|
|
|
@ -131,7 +131,7 @@
|
|||
"upgrade_banner_action": "Nadogradite ovde",
|
||||
"team_upgraded_successfully": "Vaš tim je uspešno pretplaćen!",
|
||||
"org_upgrade_banner_description": "Hvala što isprobavate plan naše organizacije. Primetili smo da tim vaše organizacije „{{teamName}}” treba da se nadogradi.",
|
||||
"org_upgraded_successfully": "Vaša organizacija je uspešno nadograđena!",
|
||||
"org_upgraded_successfully": "Vaš Organization je uspešno nadograđen!",
|
||||
"use_link_to_reset_password": "Resetujte lozinku koristeći link ispod",
|
||||
"hey_there": "Zdravo,",
|
||||
"forgot_your_password_calcom": "Zaboravili ste lozinku? - {{appName}}",
|
||||
|
|
|
@ -129,8 +129,8 @@
|
|||
"team_upgrade_banner_description": "Tack för att du testade vår nya teamplan. Vi märkte att ditt team \"{{teamName}}\" måste uppgraderas.",
|
||||
"upgrade_banner_action": "Uppgradera här",
|
||||
"team_upgraded_successfully": "Ditt team har uppgraderats!",
|
||||
"org_upgrade_banner_description": "Tack för att du testade vår nya organisationsplan. Vi märkte att din organisation \"{{teamName}}\" måste uppgraderas.",
|
||||
"org_upgraded_successfully": "Din organisation har uppgraderats!",
|
||||
"org_upgrade_banner_description": "Tack för att du testade vår nya Organization-plan. Vi märkte att din Organization \"{{teamName}}\" måste uppgraderas.",
|
||||
"org_upgraded_successfully": "Din Organization har uppgraderats!",
|
||||
"use_link_to_reset_password": "Använd länken nedan för att återställa ditt lösenord",
|
||||
"hey_there": "Hallå där,",
|
||||
"forgot_your_password_calcom": "Glömt ditt lösenord? - {{appName}}",
|
||||
|
@ -551,7 +551,7 @@
|
|||
"team_description": "Några meningar om ditt team. Detta visas på ditt teams URL-sida.",
|
||||
"org_description": "Några meningar om din organisation. Detta kommer att visas på din organisations webbsida.",
|
||||
"members": "Medlemmar",
|
||||
"organization_members": "Organisationens medlemmar",
|
||||
"organization_members": "Organization-medlemmar",
|
||||
"member": "Medlem",
|
||||
"number_member_one": "{{count}} medlem",
|
||||
"number_member_other": "{{count}} medlemmar",
|
||||
|
@ -699,7 +699,7 @@
|
|||
"create_team_to_get_started": "Skapa ett team för att komma igång",
|
||||
"teams": "Teams",
|
||||
"team": "Team",
|
||||
"organization": "Organisation",
|
||||
"organization": "Organization",
|
||||
"team_billing": "Team-fakturering",
|
||||
"team_billing_description": "Hantera fakturering för ditt team",
|
||||
"upgrade_to_flexible_pro_title": "Vi har ändrat fakturering för team",
|
||||
|
@ -1938,7 +1938,7 @@
|
|||
"insights_all_org_filter": "Alla",
|
||||
"insights_team_filter": "Team: {{teamName}}",
|
||||
"insights_user_filter": "Användare: {{userName}}",
|
||||
"insights_subtitle": "Visa bokningsinsikter för dina händelser",
|
||||
"insights_subtitle": "Visa Insights-bokningar för dina händelser",
|
||||
"custom_plan": "Anpassad plan",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Lägg till dina nya strängar här ovanför ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
|
|
@ -1938,7 +1938,7 @@
|
|||
"insights_all_org_filter": "Tümü",
|
||||
"insights_team_filter": "Ekip: {{teamName}}",
|
||||
"insights_user_filter": "Kullanıcı: {{userName}}",
|
||||
"insights_subtitle": "Etkinlikleriniz için rezervasyon analizlerini görüntüleyin",
|
||||
"insights_subtitle": "Etkinlikleriniz için rezervasyon Insights'lerini görüntüleyin",
|
||||
"custom_plan": "Özel Plan",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Yeni dizelerinizi yukarıya ekleyin ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
|
|
@ -118,7 +118,7 @@
|
|||
"team_info": "Про команду",
|
||||
"request_another_invitation_email": "Якщо ви не хочете використовувати {{toEmail}} в {{appName}} або вже маєте обліковий запис {{appName}}, надішліть запит на запрошення на потрібну електронну адресу.",
|
||||
"you_have_been_invited": "Вас запрошено приєднатися до команди «{{teamName}}»",
|
||||
"user_invited_you": "{{user}} запросив(-ла) вас приєднатися до команди «{{team}}» користувача {{entity}} в {{appName}}",
|
||||
"user_invited_you": "{{user}} запросив(-ла) вас приєднатися сюди: {{entity}} «{{team}}» в {{appName}}",
|
||||
"hidden_team_member_title": "У цій команді вас приховано",
|
||||
"hidden_team_member_message": "Ваше місце не оплачено. Перейдіть на версію Pro або попросіть власника команди оплатити ваше місце.",
|
||||
"hidden_team_owner_message": "Щоб користуватися функціями команди, потрібен обліковий запис версії Pro. До переходу на цю версію вас буде приховано.",
|
||||
|
|
|
@ -131,7 +131,7 @@
|
|||
"upgrade_banner_action": "在此处升级",
|
||||
"team_upgraded_successfully": "您的团队升级成功!",
|
||||
"org_upgrade_banner_description": "感谢您试用我们的 Organization 计划。我们注意到您的组织“{{teamName}}”需要升级。",
|
||||
"org_upgraded_successfully": "您的组织已成功升级!",
|
||||
"org_upgraded_successfully": "您的 Organization 已成功升级!",
|
||||
"use_link_to_reset_password": "使用下面的链接重置您的密码",
|
||||
"hey_there": "嘿,您好!",
|
||||
"forgot_your_password_calcom": "忘记密码?- {{appName}}",
|
||||
|
@ -555,7 +555,7 @@
|
|||
"team_description": "请写一段简单的团队介绍,该介绍将会显示在您的团队链接页面上。",
|
||||
"org_description": "关于您的组织的几句话。这将显示在您组织的链接页面上。",
|
||||
"members": "成员",
|
||||
"organization_members": "组织成员",
|
||||
"organization_members": "Organization 成员",
|
||||
"member": "成员",
|
||||
"number_member_one": "{{count}} 个成员",
|
||||
"number_member_other": "{{count}} 个成员",
|
||||
|
@ -1331,7 +1331,7 @@
|
|||
"download_responses_description": "以 CSV 格式下载对表格的所有回复。",
|
||||
"download": "下载",
|
||||
"download_recording": "下载录制内容",
|
||||
"recording_from_your_recent_call": "您最近在 {{appName}} 上的通话录音可供下载",
|
||||
"recording_from_your_recent_call": "您最近在 {{appName}} 上的通话录制内容可供下载",
|
||||
"create_your_first_form": "创建您的第一个表格",
|
||||
"create_your_first_form_description": "利用途径表格,您可以提出符合条件的问题,并可根据途径找到正确的人或活动类型。",
|
||||
"create_your_first_webhook": "创建您的第一个 Webhook",
|
||||
|
@ -1691,7 +1691,7 @@
|
|||
"attendee_no_longer_attending": "一名参与者不再参加您的活动",
|
||||
"attendee_no_longer_attending_subtitle": "{{name}} 已取消。这意味着该时段有一个位置空出来",
|
||||
"create_event_on": "创建活动于",
|
||||
"create_routing_form_on": "创建途径表格于",
|
||||
"create_routing_form_on": "创建途径表格的账户",
|
||||
"default_app_link_title": "设置默认应用链接",
|
||||
"default_app_link_description": "设置默认应用链接可以让所有新创建的活动类型使用您设置的应用链接。",
|
||||
"organizer_default_conferencing_app": "组织者的默认应用",
|
||||
|
@ -1880,7 +1880,7 @@
|
|||
"connect_google_workspace": "连接 Google Workspace",
|
||||
"google_workspace_admin_tooltip": "您必须是 Workspace 管理员才能使用此功能",
|
||||
"first_event_type_webhook_description": "为此活动类型创建第一个 Webhook",
|
||||
"install_app_on": "安装应用在",
|
||||
"install_app_on": "安装应用的账户",
|
||||
"create_for": "创建",
|
||||
"setup_organization": "设置组织",
|
||||
"organization_banner_description": "创建一个环境,让团队可以在其中通过轮流和集体日程安排来创建共享的应用、工作流程和活动类型。",
|
||||
|
@ -1942,7 +1942,7 @@
|
|||
"insights_all_org_filter": "所有应用",
|
||||
"insights_team_filter": "团队:{{teamName}}",
|
||||
"insights_user_filter": "用户:{{userName}}",
|
||||
"insights_subtitle": "查看您的活动的预约洞察",
|
||||
"insights_subtitle": "查看您的活动的预约 insights",
|
||||
"custom_plan": "自定义计划",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 在此上方添加您的新字符串 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
|
|
@ -81,21 +81,6 @@ describe("Check Booking Limits Tests", () => {
|
|||
})
|
||||
).rejects.toThrowError();
|
||||
});
|
||||
it("Should return busyTimes when set", async () => {
|
||||
prismaMock.booking.count.mockResolvedValue(2);
|
||||
expect(
|
||||
checkBookingLimit({
|
||||
key: "PER_DAY",
|
||||
limitingNumber: 2,
|
||||
eventStartDate: MOCK_DATA.startDate,
|
||||
eventId: MOCK_DATA.id,
|
||||
returnBusyTimes: true,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
start: dayjs(MOCK_DATA.startDate).startOf("day").toDate(),
|
||||
end: dayjs(MOCK_DATA.startDate).endOf("day").toDate(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Booking limit validation", () => {
|
||||
|
|
|
@ -97,21 +97,6 @@ describe("Check Duration Limit Tests", () => {
|
|||
})
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
it("Should return busyTimes when set and limit is reached", async () => {
|
||||
prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 60 }]);
|
||||
await expect(
|
||||
checkDurationLimit({
|
||||
key: "PER_DAY",
|
||||
limitingNumber: 60,
|
||||
eventStartDate: MOCK_DATA.startDate,
|
||||
eventId: MOCK_DATA.id,
|
||||
returnBusyTimes: true,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
start: dayjs(MOCK_DATA.startDate).startOf("day").toDate(),
|
||||
end: dayjs(MOCK_DATA.startDate).endOf("day").toDate(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Duration limit validation", () => {
|
||||
|
|
|
@ -207,4 +207,63 @@ export async function getBusyTimes(params: {
|
|||
return busyTimes;
|
||||
}
|
||||
|
||||
export async function getBusyTimesForLimitChecks(params: {
|
||||
userId: number;
|
||||
eventTypeId: number;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}) {
|
||||
const { userId, eventTypeId, startDate, endDate } = params;
|
||||
logger.silly(
|
||||
`Fetch limit checks bookings in range ${startDate} to ${endDate} for input ${JSON.stringify({
|
||||
userId,
|
||||
eventTypeId,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
})}`
|
||||
);
|
||||
performance.mark("getBusyTimesForLimitChecksStart");
|
||||
|
||||
const bookings = await prisma.booking.findMany({
|
||||
where: {
|
||||
userId,
|
||||
eventTypeId,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
// FIXME: bookings that overlap on one side will never be counted
|
||||
startTime: {
|
||||
gte: startDate,
|
||||
},
|
||||
endTime: {
|
||||
lte: endDate,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
eventType: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
title: true,
|
||||
},
|
||||
});
|
||||
|
||||
const busyTimes = bookings.map(({ id, startTime, endTime, eventType, title }) => ({
|
||||
start: dayjs(startTime).toDate(),
|
||||
end: dayjs(endTime).toDate(),
|
||||
title,
|
||||
source: `eventType-${eventType?.id}-booking-${id}`,
|
||||
}));
|
||||
|
||||
logger.silly(`Fetch limit checks bookings for eventId: ${eventTypeId} ${JSON.stringify(busyTimes)}`);
|
||||
performance.mark("getBusyTimesForLimitChecksEnd");
|
||||
performance.measure(
|
||||
`prisma booking get for limits took $1'`,
|
||||
"getBusyTimesForLimitChecksStart",
|
||||
"getBusyTimesForLimitChecksEnd"
|
||||
);
|
||||
return busyTimes;
|
||||
}
|
||||
|
||||
export default getBusyTimes;
|
||||
|
|
|
@ -7,6 +7,7 @@ import { parseBookingLimit, parseDurationLimit } from "@calcom/lib";
|
|||
import { getWorkingHours } from "@calcom/lib/availability";
|
||||
import { buildDateRanges, subtract } from "@calcom/lib/date-ranges";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { descendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { checkBookingLimit } from "@calcom/lib/server";
|
||||
import { performance } from "@calcom/lib/server/perfObserver";
|
||||
|
@ -14,9 +15,14 @@ import { getTotalBookingDuration } from "@calcom/lib/server/queries";
|
|||
import prisma, { availabilityUserSelect } from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { EventTypeMetaDataSchema, stringToDayjs } from "@calcom/prisma/zod-utils";
|
||||
import type { EventBusyDetails, IntervalLimit } from "@calcom/types/Calendar";
|
||||
import type {
|
||||
EventBusyDate,
|
||||
EventBusyDetails,
|
||||
IntervalLimit,
|
||||
IntervalLimitUnit,
|
||||
} from "@calcom/types/Calendar";
|
||||
|
||||
import { getBusyTimes } from "./getBusyTimes";
|
||||
import { getBusyTimes, getBusyTimesForLimitChecks } from "./getBusyTimes";
|
||||
|
||||
const availabilitySchema = z
|
||||
.object({
|
||||
|
@ -105,7 +111,7 @@ export const getCurrentSeats = (eventTypeId: number, dateFrom: Dayjs, dateTo: Da
|
|||
export type CurrentSeats = Awaited<ReturnType<typeof getCurrentSeats>>;
|
||||
|
||||
/** This should be called getUsersWorkingHoursAndBusySlots (...and remaining seats, and final timezone) */
|
||||
export async function getUserAvailability(
|
||||
export const getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseAndEverythingElse(
|
||||
query: {
|
||||
withSource?: boolean;
|
||||
username?: string;
|
||||
|
@ -147,11 +153,30 @@ export async function getUserAvailability(
|
|||
currentSeats = await getCurrentSeats(eventType.id, dateFrom, dateTo);
|
||||
}
|
||||
|
||||
const bookingLimits = parseBookingLimit(eventType?.bookingLimits);
|
||||
const durationLimits = parseDurationLimit(eventType?.durationLimits);
|
||||
|
||||
const busyTimesFromLimits =
|
||||
eventType && (bookingLimits || durationLimits)
|
||||
? await getBusyTimesFromLimits(
|
||||
bookingLimits,
|
||||
durationLimits,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
duration,
|
||||
eventType,
|
||||
user.id
|
||||
)
|
||||
: [];
|
||||
|
||||
// TODO: only query what we need after applying limits (shrink date range)
|
||||
const getBusyTimesStart = dateFrom.toISOString();
|
||||
const getBusyTimesEnd = dateTo.toISOString();
|
||||
|
||||
const busyTimes = await getBusyTimes({
|
||||
credentials: user.credentials,
|
||||
// needed to correctly apply limits (weeks can be part of two months)
|
||||
startTime: dateFrom.startOf("week").toISOString(),
|
||||
endTime: dateTo.endOf("week").toISOString(),
|
||||
startTime: getBusyTimesStart,
|
||||
endTime: getBusyTimesEnd,
|
||||
eventTypeId,
|
||||
userId: user.id,
|
||||
username: `${user.username}`,
|
||||
|
@ -161,40 +186,16 @@ export async function getUserAvailability(
|
|||
seatedEvent: !!eventType?.seatsPerTimeSlot,
|
||||
});
|
||||
|
||||
let bufferedBusyTimes: EventBusyDetails[] = busyTimes.map((a) => ({
|
||||
const detailedBusyTimes: EventBusyDetails[] = [
|
||||
...busyTimes.map((a) => ({
|
||||
...a,
|
||||
start: dayjs(a.start).toISOString(),
|
||||
end: dayjs(a.end).toISOString(),
|
||||
title: a.title,
|
||||
source: query.withSource ? a.source : undefined,
|
||||
}));
|
||||
|
||||
const bookings = busyTimes.filter((busyTime) => busyTime.source?.startsWith(`eventType-${eventType?.id}`));
|
||||
|
||||
const bookingLimits = parseBookingLimit(eventType?.bookingLimits);
|
||||
if (bookingLimits) {
|
||||
const bookingBusyTimes = await getBusyTimesFromBookingLimits(
|
||||
bookings,
|
||||
bookingLimits,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
eventType
|
||||
);
|
||||
bufferedBusyTimes = bufferedBusyTimes.concat(bookingBusyTimes);
|
||||
}
|
||||
|
||||
const durationLimits = parseDurationLimit(eventType?.durationLimits);
|
||||
if (durationLimits) {
|
||||
const durationBusyTimes = await getBusyTimesFromDurationLimits(
|
||||
bookings,
|
||||
durationLimits,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
duration,
|
||||
eventType
|
||||
);
|
||||
bufferedBusyTimes = bufferedBusyTimes.concat(durationBusyTimes);
|
||||
}
|
||||
})),
|
||||
...busyTimesFromLimits,
|
||||
];
|
||||
|
||||
const userSchedule = user.schedules.filter(
|
||||
(schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId
|
||||
|
@ -239,22 +240,22 @@ export async function getUserAvailability(
|
|||
timeZone,
|
||||
});
|
||||
|
||||
const formattedBusyTimes = bufferedBusyTimes.map((busy) => ({
|
||||
const formattedBusyTimes = detailedBusyTimes.map((busy) => ({
|
||||
start: dayjs(busy.start),
|
||||
end: dayjs(busy.end),
|
||||
}));
|
||||
|
||||
return {
|
||||
busy: bufferedBusyTimes,
|
||||
busy: detailedBusyTimes,
|
||||
timeZone,
|
||||
dateRanges: subtract(dateRanges, formattedBusyTimes),
|
||||
workingHours,
|
||||
dateOverrides,
|
||||
currentSeats,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getDatesBetween = (dateFrom: Dayjs, dateTo: Dayjs, period: "day" | "week" | "month" | "year") => {
|
||||
const getPeriodStartDatesBetween = (dateFrom: Dayjs, dateTo: Dayjs, period: IntervalLimitUnit) => {
|
||||
const dates = [];
|
||||
let startDate = dayjs(dateFrom).startOf(period);
|
||||
const endDate = dayjs(dateTo).endOf(period);
|
||||
|
@ -265,65 +266,191 @@ const getDatesBetween = (dateFrom: Dayjs, dateTo: Dayjs, period: "day" | "week"
|
|||
return dates;
|
||||
};
|
||||
|
||||
type BusyMapKey = `${IntervalLimitUnit}-${ReturnType<Dayjs["toISOString"]>}`;
|
||||
|
||||
/**
|
||||
* Helps create, check, and return busy times from limits (with parallel support)
|
||||
*/
|
||||
class LimitManager {
|
||||
private busyMap: Map<BusyMapKey, EventBusyDate> = new Map();
|
||||
|
||||
/**
|
||||
* Creates a busy map key
|
||||
*/
|
||||
private static createKey(start: Dayjs, unit: IntervalLimitUnit): BusyMapKey {
|
||||
return `${unit}-${start.startOf(unit).toISOString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if already marked busy by ancestors or siblings
|
||||
*/
|
||||
isAlreadyBusy(start: Dayjs, unit: IntervalLimitUnit) {
|
||||
if (this.busyMap.has(LimitManager.createKey(start, "year"))) return true;
|
||||
|
||||
if (unit === "month" && this.busyMap.has(LimitManager.createKey(start, "month"))) {
|
||||
return true;
|
||||
} else if (
|
||||
unit === "week" &&
|
||||
// weeks can be part of two months
|
||||
((this.busyMap.has(LimitManager.createKey(start, "month")) &&
|
||||
this.busyMap.has(LimitManager.createKey(start.endOf("week"), "month"))) ||
|
||||
this.busyMap.has(LimitManager.createKey(start, "week")))
|
||||
) {
|
||||
return true;
|
||||
} else if (
|
||||
unit === "day" &&
|
||||
(this.busyMap.has(LimitManager.createKey(start, "month")) ||
|
||||
this.busyMap.has(LimitManager.createKey(start, "week")) ||
|
||||
this.busyMap.has(LimitManager.createKey(start, "day")))
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new busy time
|
||||
*/
|
||||
addBusyTime(start: Dayjs, unit: IntervalLimitUnit) {
|
||||
this.busyMap.set(`${unit}-${start.toISOString()}`, {
|
||||
start: start.toISOString(),
|
||||
end: start.endOf(unit).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all busy times
|
||||
*/
|
||||
getBusyTimes() {
|
||||
return Array.from(this.busyMap.values());
|
||||
}
|
||||
}
|
||||
|
||||
const getBusyTimesFromLimits = async (
|
||||
bookingLimits: IntervalLimit | null,
|
||||
durationLimits: IntervalLimit | null,
|
||||
dateFrom: Dayjs,
|
||||
dateTo: Dayjs,
|
||||
duration: number | undefined,
|
||||
eventType: NonNullable<EventType>,
|
||||
userId: number
|
||||
) => {
|
||||
performance.mark("limitsStart");
|
||||
|
||||
// shared amongst limiters to prevent processing known busy periods
|
||||
const limitManager = new LimitManager();
|
||||
|
||||
let limitDateFrom = dayjs(dateFrom);
|
||||
let limitDateTo = dayjs(dateTo);
|
||||
|
||||
// expand date ranges by absolute minimum required to apply limits
|
||||
// (yearly limits are handled separately for performance)
|
||||
for (const key of ["PER_MONTH", "PER_WEEK", "PER_DAY"] as Exclude<keyof IntervalLimit, "PER_YEAR">[]) {
|
||||
if (bookingLimits?.[key] || durationLimits?.[key]) {
|
||||
const unit = intervalLimitKeyToUnit(key);
|
||||
limitDateFrom = dayjs.min(limitDateFrom, dateFrom.startOf(unit));
|
||||
limitDateTo = dayjs.max(limitDateTo, dateTo.endOf(unit));
|
||||
}
|
||||
}
|
||||
|
||||
// fetch only the data we need to check limits
|
||||
const bookings = await getBusyTimesForLimitChecks({
|
||||
userId,
|
||||
eventTypeId: eventType.id,
|
||||
startDate: limitDateFrom.toDate(),
|
||||
endDate: limitDateTo.toDate(),
|
||||
});
|
||||
|
||||
// run this first, as counting bookings should always run faster..
|
||||
if (bookingLimits) {
|
||||
performance.mark("bookingLimitsStart");
|
||||
await getBusyTimesFromBookingLimits(
|
||||
bookings,
|
||||
bookingLimits,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
eventType.id,
|
||||
limitManager
|
||||
);
|
||||
performance.mark("bookingLimitsEnd");
|
||||
performance.measure(`checking booking limits took $1'`, "bookingLimitsStart", "bookingLimitsEnd");
|
||||
}
|
||||
|
||||
// ..than adding up durations (especially for the whole year)
|
||||
if (durationLimits) {
|
||||
performance.mark("durationLimitsStart");
|
||||
await getBusyTimesFromDurationLimits(
|
||||
bookings,
|
||||
durationLimits,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
duration,
|
||||
eventType,
|
||||
limitManager
|
||||
);
|
||||
performance.mark("durationLimitsEnd");
|
||||
performance.measure(`checking duration limits took $1'`, "durationLimitsStart", "durationLimitsEnd");
|
||||
}
|
||||
|
||||
performance.mark("limitsEnd");
|
||||
performance.measure(`checking all limits took $1'`, "limitsStart", "limitsEnd");
|
||||
|
||||
return limitManager.getBusyTimes();
|
||||
};
|
||||
|
||||
const getBusyTimesFromBookingLimits = async (
|
||||
bookings: EventBusyDetails[],
|
||||
bookingLimits: IntervalLimit,
|
||||
dateFrom: Dayjs,
|
||||
dateTo: Dayjs,
|
||||
eventType: EventType | undefined
|
||||
eventTypeId: number,
|
||||
limitManager: LimitManager
|
||||
) => {
|
||||
const busyTimes: EventBusyDetails[] = [];
|
||||
for (const key of descendingLimitKeys) {
|
||||
const limit = bookingLimits?.[key];
|
||||
if (!limit) continue;
|
||||
|
||||
// Apply booking limit filter against our bookings
|
||||
for (const [key, limit] of Object.entries(bookingLimits)) {
|
||||
const limitKey = key as keyof IntervalLimit;
|
||||
const unit = intervalLimitKeyToUnit(key);
|
||||
const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit);
|
||||
|
||||
if (limitKey === "PER_YEAR") {
|
||||
const yearlyBusyTime = await checkBookingLimit({
|
||||
eventStartDate: dateFrom.toDate(),
|
||||
for (const periodStart of periodStartDates) {
|
||||
if (limitManager.isAlreadyBusy(periodStart, unit)) continue;
|
||||
|
||||
// special handling of yearly limits to improve performance
|
||||
if (unit === "year") {
|
||||
try {
|
||||
await checkBookingLimit({
|
||||
eventStartDate: periodStart.toDate(),
|
||||
limitingNumber: limit,
|
||||
eventId: eventType?.id as number,
|
||||
key: "PER_YEAR",
|
||||
returnBusyTimes: true,
|
||||
eventId: eventTypeId,
|
||||
key,
|
||||
});
|
||||
if (!yearlyBusyTime) break;
|
||||
busyTimes.push({
|
||||
start: yearlyBusyTime.start.toISOString(),
|
||||
end: yearlyBusyTime.end.toISOString(),
|
||||
});
|
||||
break;
|
||||
} catch (_) {
|
||||
limitManager.addBusyTime(periodStart, unit);
|
||||
if (periodStartDates.every((start) => limitManager.isAlreadyBusy(start, unit))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Take PER_DAY and turn it into day and PER_WEEK into week etc.
|
||||
const filter = key.split("_")[1].toLowerCase() as "day" | "week" | "month" | "year";
|
||||
const dates = getDatesBetween(dateFrom, dateTo, filter);
|
||||
|
||||
// loop through all dates and check if we have reached the limit
|
||||
for (const date of dates) {
|
||||
let total = 0;
|
||||
const startDate = date.startOf(filter);
|
||||
// this is parsed above with parseBookingLimit so we know it's safe.
|
||||
const endDate = date.endOf(filter);
|
||||
for (const booking of bookings) {
|
||||
const bookingEventTypeId = parseInt(booking.source?.split("-")[1] as string, 10);
|
||||
if (
|
||||
// Only check OUR booking that matches the current eventTypeId
|
||||
// we don't care about another event type in this case as we dont need to know their booking limits
|
||||
!(bookingEventTypeId == eventType?.id && dayjs(booking.start).isBetween(startDate, endDate))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// increment total and check against the limit, adding a busy time if condition is met.
|
||||
total++;
|
||||
if (total >= limit) {
|
||||
busyTimes.push({ start: startDate.toISOString(), end: endDate.toISOString() });
|
||||
|
||||
const periodEnd = periodStart.endOf(unit);
|
||||
let totalBookings = 0;
|
||||
|
||||
for (const booking of bookings) {
|
||||
// consider booking part of period independent of end date
|
||||
if (!dayjs(booking.start).isBetween(periodStart, periodEnd)) {
|
||||
continue;
|
||||
}
|
||||
totalBookings++;
|
||||
if (totalBookings >= limit) {
|
||||
limitManager.addBusyTime(periodStart, unit);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return busyTimes;
|
||||
};
|
||||
|
||||
const getBusyTimesFromDurationLimits = async (
|
||||
|
@ -332,60 +459,56 @@ const getBusyTimesFromDurationLimits = async (
|
|||
dateFrom: Dayjs,
|
||||
dateTo: Dayjs,
|
||||
duration: number | undefined,
|
||||
eventType: EventType | undefined
|
||||
eventType: NonNullable<EventType>,
|
||||
limitManager: LimitManager
|
||||
) => {
|
||||
const busyTimes: EventBusyDetails[] = [];
|
||||
// Start check from larger time periods to smaller time periods, to skip unnecessary checks
|
||||
for (const [key, limit] of Object.entries(durationLimits).reverse()) {
|
||||
// Use aggregate sql query if we are checking PER_YEAR
|
||||
if (key === "PER_YEAR") {
|
||||
const totalBookingDuration = await getTotalBookingDuration({
|
||||
eventId: eventType?.id as number,
|
||||
startDate: dateFrom.startOf("year").toDate(),
|
||||
endDate: dateFrom.endOf("year").toDate(),
|
||||
});
|
||||
if (totalBookingDuration + (duration ?? 0) > limit) {
|
||||
busyTimes.push({
|
||||
start: dateFrom.startOf("year").toISOString(),
|
||||
end: dateFrom.endOf("year").toISOString(),
|
||||
for (const key of descendingLimitKeys) {
|
||||
const limit = durationLimits?.[key];
|
||||
if (!limit) continue;
|
||||
|
||||
const unit = intervalLimitKeyToUnit(key);
|
||||
const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit);
|
||||
|
||||
for (const periodStart of periodStartDates) {
|
||||
if (limitManager.isAlreadyBusy(periodStart, unit)) continue;
|
||||
|
||||
const selectedDuration = (duration || eventType.length) ?? 0;
|
||||
|
||||
if (selectedDuration > limit) {
|
||||
limitManager.addBusyTime(periodStart, unit);
|
||||
continue;
|
||||
}
|
||||
|
||||
// special handling of yearly limits to improve performance
|
||||
if (unit === "year") {
|
||||
const totalYearlyDuration = await getTotalBookingDuration({
|
||||
eventId: eventType.id,
|
||||
startDate: periodStart.toDate(),
|
||||
endDate: periodStart.endOf(unit).toDate(),
|
||||
});
|
||||
if (totalYearlyDuration + selectedDuration > limit) {
|
||||
limitManager.addBusyTime(periodStart, unit);
|
||||
if (periodStartDates.every((start) => limitManager.isAlreadyBusy(start, unit))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const filter = key.split("_")[1].toLowerCase() as "day" | "week" | "month" | "year";
|
||||
const dates = getDatesBetween(dateFrom, dateTo, filter);
|
||||
|
||||
// loop through all dates and check if we have reached the limit
|
||||
for (const date of dates) {
|
||||
let total = (duration || eventType?.length) ?? 0;
|
||||
const startDate = date.startOf(filter);
|
||||
const endDate = date.endOf(filter);
|
||||
|
||||
// add busy time if we have already reached the limit with just the selected duration
|
||||
if (total > limit) {
|
||||
busyTimes.push({ start: startDate.toISOString(), end: endDate.toISOString() });
|
||||
continue;
|
||||
}
|
||||
const periodEnd = periodStart.endOf(unit);
|
||||
let totalDuration = selectedDuration;
|
||||
|
||||
for (const booking of bookings) {
|
||||
const bookingEventTypeId = parseInt(booking.source?.split("-")[1] as string, 10);
|
||||
if (
|
||||
// Only check OUR booking that matches the current eventTypeId
|
||||
// we don't care about another event type in this case as we dont need to know their booking limits
|
||||
!(bookingEventTypeId == eventType?.id && dayjs(booking.start).isBetween(startDate, endDate))
|
||||
) {
|
||||
// consider booking part of period independent of end date
|
||||
if (!dayjs(booking.start).isBetween(periodStart, periodEnd)) {
|
||||
continue;
|
||||
}
|
||||
// Add current booking duration to total and check against the limit, adding a busy time if condition is met.
|
||||
total += dayjs(booking.end).diff(dayjs(booking.start), "minute");
|
||||
if (total > limit) {
|
||||
busyTimes.push({ start: startDate.toISOString(), end: endDate.toISOString() });
|
||||
totalDuration += dayjs(booking.end).diff(dayjs(booking.start), "minute");
|
||||
if (totalDuration > limit) {
|
||||
limitManager.addBusyTime(periodStart, unit);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return busyTimes;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
import { useRef } from "react";
|
||||
|
||||
/**
|
||||
* Updates in document the number of times a component has been rendered. Helps in 2 ways. Using it doesn't cause any additional renders.
|
||||
* - Did the component render when it shouldn't have?
|
||||
* - Did the component reset its state when it shouldn't have?
|
||||
*/
|
||||
export const RenderCounter = ({ label }: { label: string }) => {
|
||||
const counterRef = useRef(0);
|
||||
counterRef.current++;
|
||||
return (
|
||||
<span>
|
||||
<span>{label}:</span>
|
||||
<span>{counterRef.current} </span>
|
||||
</span>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { RenderCounter } from "./components/RenderCounter";
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "@calcom/debugging",
|
||||
"description": "Debugging utilities",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"main": "./index.ts"
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"extends": "@calcom/tsconfig/react-library.json",
|
||||
"compilerOptions": {
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"../../apps/web/next-env.d.ts",
|
||||
"../types/*.d.ts",
|
||||
"../types/next-auth.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import type { ComponentProps } from "react";
|
||||
import React from "react";
|
||||
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
|
||||
export default function MainLayout({
|
||||
children,
|
||||
...rest
|
||||
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
||||
return (
|
||||
<Shell withoutMain={true} {...rest}>
|
||||
{children}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
export const getLayout = (page: React.ReactElement) => <MainLayout>{page}</MainLayout>;
|
|
@ -298,7 +298,7 @@ const BookerComponent = ({
|
|||
<m.span
|
||||
key="logo"
|
||||
className={classNames(
|
||||
"mb-6 mt-auto pt-6 [&_img]:h-[15px]",
|
||||
"-z-10 mb-6 mt-auto pt-6 [&_img]:h-[15px]",
|
||||
hasDarkBackground ? "dark" : "",
|
||||
layout === BookerLayouts.MONTH_VIEW ? "block" : "hidden"
|
||||
)}>
|
||||
|
|
|
@ -1,51 +0,0 @@
|
|||
import type { ComponentProps } from "react";
|
||||
import React from "react";
|
||||
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { HorizontalTabs } from "@calcom/ui";
|
||||
import type { VerticalTabItemProps, HorizontalTabItemProps } from "@calcom/ui";
|
||||
|
||||
import { FiltersContainer } from "../components/FiltersContainer";
|
||||
|
||||
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = [
|
||||
{
|
||||
name: "upcoming",
|
||||
href: "/bookings/upcoming",
|
||||
},
|
||||
{
|
||||
name: "unconfirmed",
|
||||
href: "/bookings/unconfirmed",
|
||||
},
|
||||
{
|
||||
name: "recurring",
|
||||
href: "/bookings/recurring",
|
||||
},
|
||||
{
|
||||
name: "past",
|
||||
href: "/bookings/past",
|
||||
},
|
||||
{
|
||||
name: "cancelled",
|
||||
href: "/bookings/cancelled",
|
||||
},
|
||||
];
|
||||
|
||||
export default function BookingLayout({
|
||||
children,
|
||||
...rest
|
||||
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
||||
return (
|
||||
<Shell {...rest} hideHeadingOnMobile>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col flex-wrap lg:flex-row">
|
||||
<HorizontalTabs tabs={tabs} />
|
||||
<div className="max-w-full overflow-x-auto xl:ml-auto">
|
||||
<FiltersContainer />
|
||||
</div>
|
||||
</div>
|
||||
<main className="w-full">{children}</main>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
export const getLayout = (page: React.ReactElement) => <BookingLayout>{page}</BookingLayout>;
|
|
@ -652,7 +652,6 @@ async function handler(
|
|||
|
||||
const fullName = getFullName(bookerName);
|
||||
|
||||
const tAttendees = await getTranslation(language ?? "en", "common");
|
||||
const tGuests = await getTranslation("en", "common");
|
||||
log.debug(`Booking eventType ${eventTypeId} started`);
|
||||
const dynamicUserList = Array.isArray(reqBody.user) ? reqBody.user : getUsernameList(reqBody.user);
|
||||
|
@ -848,6 +847,46 @@ async function handler(
|
|||
|
||||
const allCredentials = await getAllCredentials(organizerUser, eventType);
|
||||
|
||||
let rescheduleUid = reqBody.rescheduleUid;
|
||||
let bookingSeat: Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null = null;
|
||||
type BookingType = Prisma.PromiseReturnType<typeof getOriginalRescheduledBooking>;
|
||||
let originalRescheduledBooking: BookingType = null;
|
||||
|
||||
if (rescheduleUid) {
|
||||
// rescheduleUid can be bookingUid and bookingSeatUid
|
||||
bookingSeat = await prisma.bookingSeat.findUnique({
|
||||
where: {
|
||||
referenceUid: rescheduleUid,
|
||||
},
|
||||
include: {
|
||||
booking: true,
|
||||
attendee: true,
|
||||
},
|
||||
});
|
||||
if (bookingSeat) {
|
||||
rescheduleUid = bookingSeat.booking.uid;
|
||||
}
|
||||
originalRescheduledBooking = await getOriginalRescheduledBooking(
|
||||
rescheduleUid,
|
||||
!!eventType.seatsPerTimeSlot
|
||||
);
|
||||
if (!originalRescheduledBooking) {
|
||||
throw new HttpError({ statusCode: 404, message: "Could not find original booking" });
|
||||
}
|
||||
}
|
||||
|
||||
const isOrganizerRescheduling = organizerUser.id === userId;
|
||||
|
||||
const attendeeInfoOnReschedule =
|
||||
isOrganizerRescheduling && originalRescheduledBooking
|
||||
? originalRescheduledBooking.attendees.find((attendee) => attendee.email === bookerEmail)
|
||||
: null;
|
||||
|
||||
const attendeeLanguage = attendeeInfoOnReschedule ? attendeeInfoOnReschedule.locale : language;
|
||||
const attendeeTimezone = attendeeInfoOnReschedule ? attendeeInfoOnReschedule.timeZone : reqBody.timeZone;
|
||||
|
||||
const tAttendees = await getTranslation(attendeeLanguage ?? "en", "common");
|
||||
|
||||
// use host default
|
||||
if (isTeamEventType && locationBodyString === OrganizerDefaultConferencingAppType) {
|
||||
const metadataParseResult = userMetadataSchema.safeParse(organizerUser.metadata);
|
||||
|
@ -868,8 +907,8 @@ async function handler(
|
|||
name: fullName,
|
||||
firstName: (typeof bookerName === "object" && bookerName.firstName) || "",
|
||||
lastName: (typeof bookerName === "object" && bookerName.lastName) || "",
|
||||
timeZone: reqBody.timeZone,
|
||||
language: { translate: tAttendees, locale: language ?? "en" },
|
||||
timeZone: attendeeTimezone,
|
||||
language: { translate: tAttendees, locale: attendeeLanguage ?? "en" },
|
||||
},
|
||||
];
|
||||
|
||||
|
@ -883,7 +922,7 @@ async function handler(
|
|||
name: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
timeZone: reqBody.timeZone,
|
||||
timeZone: attendeeTimezone,
|
||||
language: { translate: tGuests, locale: "en" },
|
||||
});
|
||||
return guestArray;
|
||||
|
@ -982,34 +1021,6 @@ async function handler(
|
|||
seatsPerTimeSlot: eventType.seatsPerTimeSlot,
|
||||
};
|
||||
|
||||
let rescheduleUid = reqBody.rescheduleUid;
|
||||
let bookingSeat: Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null = null;
|
||||
type BookingType = Prisma.PromiseReturnType<typeof getOriginalRescheduledBooking>;
|
||||
let originalRescheduledBooking: BookingType = null;
|
||||
|
||||
if (rescheduleUid) {
|
||||
// rescheduleUid can be bookingUid and bookingSeatUid
|
||||
bookingSeat = await prisma.bookingSeat.findUnique({
|
||||
where: {
|
||||
referenceUid: rescheduleUid,
|
||||
},
|
||||
include: {
|
||||
booking: true,
|
||||
attendee: true,
|
||||
},
|
||||
});
|
||||
if (bookingSeat) {
|
||||
rescheduleUid = bookingSeat.booking.uid;
|
||||
}
|
||||
originalRescheduledBooking = await getOriginalRescheduledBooking(
|
||||
rescheduleUid,
|
||||
!!eventType.seatsPerTimeSlot
|
||||
);
|
||||
if (!originalRescheduledBooking) {
|
||||
throw new HttpError({ statusCode: 404, message: "Could not find original booking" });
|
||||
}
|
||||
}
|
||||
|
||||
/* Used for seats bookings to update evt object with video data */
|
||||
const addVideoCallDataToEvt = (bookingReferences: BookingReference[]) => {
|
||||
const videoCallReference = bookingReferences.find((reference) => reference.type.includes("_video"));
|
||||
|
@ -1397,7 +1408,7 @@ async function handler(
|
|||
const updatedBookingAttendees = updatedNewBooking.attendees.map((attendee) => {
|
||||
const evtAttendee = {
|
||||
...attendee,
|
||||
language: { translate: tAttendees, locale: language ?? "en" },
|
||||
language: { translate: tAttendees, locale: attendeeLanguage ?? "en" },
|
||||
};
|
||||
return evtAttendee;
|
||||
});
|
||||
|
@ -1529,7 +1540,7 @@ async function handler(
|
|||
} else {
|
||||
// Need to add translation for attendees to pass type checks. Since these values are never written to the db we can just use the new attendee language
|
||||
const bookingAttendees = booking.attendees.map((attendee) => {
|
||||
return { ...attendee, language: { translate: tAttendees, locale: language ?? "en" } };
|
||||
return { ...attendee, language: { translate: tAttendees, locale: attendeeLanguage ?? "en" } };
|
||||
});
|
||||
|
||||
evt = { ...evt, attendees: [...bookingAttendees, invitee[0]] };
|
||||
|
|
|
@ -7,6 +7,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||
import type { DestinationCalendar } from "@calcom/prisma/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Select } from "@calcom/ui";
|
||||
import { Check } from "@calcom/ui/components/icon";
|
||||
|
||||
interface Props {
|
||||
onChange: (value: { externalId: string; integration: string }) => void;
|
||||
|
@ -37,7 +38,10 @@ const OptionComponent = ({ ...props }: OptionProps<Option>) => {
|
|||
const { label } = props.data;
|
||||
return (
|
||||
<components.Option {...props}>
|
||||
<span>{label}</span>
|
||||
<div className="flex">
|
||||
<span className="mr-auto">{label}</span>
|
||||
{props.isSelected && <Check className="ml-2 h-4 w-4" />}
|
||||
</div>
|
||||
</components.Option>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -34,14 +34,28 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||
where: { metadata: { path: ["paymentId"], equals: checkoutSession.id } },
|
||||
});
|
||||
|
||||
let metadata;
|
||||
|
||||
if (!team) {
|
||||
const prevTeam = await prisma.team.findFirstOrThrow({ where: { id } });
|
||||
const metadata = teamMetadataSchema.parse(prevTeam.metadata);
|
||||
|
||||
metadata = teamMetadataSchema.safeParse(prevTeam.metadata);
|
||||
if (!metadata.success) throw new HttpError({ statusCode: 400, message: "Invalid team metadata" });
|
||||
|
||||
if (!metadata.data?.requestedSlug) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Can't publish team/org without `requestedSlug`",
|
||||
});
|
||||
}
|
||||
|
||||
const { requestedSlug, ...newMetadata } = metadata.data;
|
||||
/** We save the metadata first to prevent duplicate payments */
|
||||
team = await prisma.team.update({
|
||||
where: { id },
|
||||
data: {
|
||||
metadata: {
|
||||
...newMetadata,
|
||||
paymentId: checkoutSession.id,
|
||||
subscriptionId: subscription.id || null,
|
||||
subscriptionItemId: subscription.items.data[0].id || null,
|
||||
|
@ -49,7 +63,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||
},
|
||||
});
|
||||
/** Legacy teams already have a slug, this will allow them to upgrade as well */
|
||||
const slug = prevTeam.slug || metadata?.requestedSlug;
|
||||
const slug = prevTeam.slug || requestedSlug;
|
||||
if (slug) {
|
||||
try {
|
||||
/** Then we try to upgrade the slug, which may fail if a conflict came up since team creation */
|
||||
|
@ -64,12 +78,21 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||
closeComUpdateTeam(prevTeam, team);
|
||||
}
|
||||
|
||||
if (!metadata) {
|
||||
metadata = teamMetadataSchema.safeParse(team.metadata);
|
||||
if (!metadata.success) throw new HttpError({ statusCode: 400, message: "Invalid team metadata" });
|
||||
}
|
||||
|
||||
const session = await getServerSession({ req, res });
|
||||
|
||||
if (!session) return { message: "Team upgraded successfully" };
|
||||
|
||||
const redirectUrl = metadata?.data?.isOrganization
|
||||
? `${WEBAPP_URL}/settings/organizations/profile?upgraded=true`
|
||||
: `${WEBAPP_URL}/settings/teams/${team.id}/profile?upgraded=true`;
|
||||
|
||||
// redirect to team screen
|
||||
res.redirect(302, `${WEBAPP_URL}/settings/teams/${team.id}/profile?upgraded=true`);
|
||||
res.redirect(302, redirectUrl);
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
|
|
|
@ -29,7 +29,6 @@ import {
|
|||
} from "@calcom/ui";
|
||||
import { ExternalLink, MoreHorizontal, Edit2, Lock, UserX } from "@calcom/ui/components/icon";
|
||||
|
||||
import { useOrgBranding } from "../../organizations/context/provider";
|
||||
import MemberChangeRoleModal from "./MemberChangeRoleModal";
|
||||
import TeamAvailabilityModal from "./TeamAvailabilityModal";
|
||||
import TeamPill, { TeamRole } from "./TeamPill";
|
||||
|
@ -54,7 +53,6 @@ const checkIsOrg = (team: Props["team"]) => {
|
|||
|
||||
export default function MemberListItem(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const orgBranding = useOrgBranding();
|
||||
|
||||
const utils = trpc.useContext();
|
||||
const [showChangeMemberRoleModal, setShowChangeMemberRoleModal] = useState(false);
|
||||
|
@ -115,7 +113,7 @@ export default function MemberListItem(props: Props) {
|
|||
return (
|
||||
<li className="divide-subtle divide-y px-5">
|
||||
<div className="my-4 flex justify-between">
|
||||
<div className="flex w-full flex-col justify-between sm:flex-row">
|
||||
<div className="flex w-full flex-col justify-between truncate sm:flex-row">
|
||||
<div className="flex">
|
||||
<Avatar
|
||||
size="sm"
|
||||
|
|
|
@ -109,7 +109,7 @@ function UsersTableBare() {
|
|||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<div
|
||||
className="rounded-md border"
|
||||
className="border-subtle rounded-md border"
|
||||
ref={tableContainerRef}
|
||||
onScroll={() => fetchMoreOnBottomReached()}
|
||||
style={{
|
||||
|
|
|
@ -3,7 +3,8 @@ import { useRouter } from "next/navigation";
|
|||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
@ -49,7 +50,7 @@ function WorkflowsPage() {
|
|||
});
|
||||
|
||||
return (
|
||||
<Shell
|
||||
<ShellMain
|
||||
heading={t("workflows")}
|
||||
title={t("workflows")}
|
||||
subtitle={t("workflows_to_automate_notifications")}
|
||||
|
@ -92,7 +93,7 @@ function WorkflowsPage() {
|
|||
</FilterResults>
|
||||
</>
|
||||
</LicenseRequired>
|
||||
</Shell>
|
||||
</ShellMain>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -221,4 +222,6 @@ const Filter = (props: {
|
|||
);
|
||||
};
|
||||
|
||||
WorkflowsPage.getLayout = getLayout;
|
||||
|
||||
export default WorkflowsPage;
|
||||
|
|
|
@ -39,7 +39,7 @@ export const CheckedTeamSelect = ({
|
|||
{/* This class name conditional looks a bit odd but it allows a seemless transition when using autoanimate
|
||||
- Slides down from the top instead of just teleporting in from nowhere*/}
|
||||
<ul
|
||||
className={classNames("mt-3 rounded-md", value.length >= 1 && "border-subtle border")}
|
||||
className={classNames("mb-1 mt-3 rounded-md", value.length >= 1 && "border-subtle border")}
|
||||
ref={animationRef}>
|
||||
{value.map((option, index) => (
|
||||
<li
|
||||
|
|
|
@ -69,7 +69,6 @@ const ScheduleDay = <TFieldValues extends FieldValues>({
|
|||
/>
|
||||
</div>
|
||||
<span className="inline-block min-w-[88px] text-sm capitalize">{weekday}</span>
|
||||
{watchDayRange && !!watchDayRange.length && <div className="sm:hidden">{CopyButton}</div>}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -77,7 +76,7 @@ const ScheduleDay = <TFieldValues extends FieldValues>({
|
|||
{watchDayRange ? (
|
||||
<div className="flex sm:ml-2">
|
||||
<DayRanges control={control} name={name} />
|
||||
{!!watchDayRange.length && <div className="hidden sm:block">{CopyButton}</div>}
|
||||
{!!watchDayRange.length && <div className="block">{CopyButton}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<SkeletonText className="ml-1 mt-2.5 h-6 w-48" />
|
||||
|
@ -244,7 +243,7 @@ const RemoveTimeButton = ({
|
|||
const TimeRangeField = ({ className, value, onChange }: { className?: string } & ControllerRenderProps) => {
|
||||
// this is a controlled component anyway given it uses LazySelect, so keep it RHF agnostic.
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={classNames("flex flex-row gap-1",className)}>
|
||||
<LazySelect
|
||||
className="inline-block w-[100px]"
|
||||
value={value.start}
|
||||
|
|
|
@ -12,6 +12,7 @@ import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
|||
import UnconfirmedBookingBadge from "@calcom/features/bookings/UnconfirmedBookingBadge";
|
||||
import ImpersonatingBanner from "@calcom/features/ee/impersonation/components/ImpersonatingBanner";
|
||||
import { OrgUpgradeBanner } from "@calcom/features/ee/organizations/components/OrgUpgradeBanner";
|
||||
import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import HelpMenuItem from "@calcom/features/ee/support/components/HelpMenuItem";
|
||||
import { TeamsUpgradeBanner } from "@calcom/features/ee/teams/components";
|
||||
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||
|
@ -788,9 +789,6 @@ function SideBarContainer({ bannersHeight }: SideBarContainerProps) {
|
|||
return <SideBar bannersHeight={bannersHeight} user={data?.user} />;
|
||||
}
|
||||
|
||||
const getOrganizationUrl = (slug: string) =>
|
||||
`${slug}.${process.env.NEXT_PUBLIC_WEBSITE_URL?.replace?.(/http(s*):\/\//, "")}`;
|
||||
|
||||
function SideBar({ bannersHeight, user }: SideBarProps) {
|
||||
const { t, isLocaleReady } = useLocale();
|
||||
const orgBranding = useOrgBranding();
|
||||
|
@ -798,7 +796,7 @@ function SideBar({ bannersHeight, user }: SideBarProps) {
|
|||
|
||||
const publicPageUrl = useMemo(() => {
|
||||
if (!user?.organizationId) return `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user?.username}`;
|
||||
const publicPageUrl = orgBranding?.slug ? getOrganizationUrl(orgBranding?.slug) : "";
|
||||
const publicPageUrl = orgBranding?.slug ? getOrgFullDomain(orgBranding.slug) : "";
|
||||
return publicPageUrl;
|
||||
}, [orgBranding?.slug, user?.organizationId, user?.username]);
|
||||
|
||||
|
|
|
@ -4,6 +4,8 @@ import { prisma } from "@calcom/prisma";
|
|||
|
||||
import type { Prisma } from ".prisma/client";
|
||||
|
||||
type EnabledApp = ReturnType<typeof getApps>[number] & { enabled: boolean };
|
||||
|
||||
/**
|
||||
*
|
||||
* @param credentials - Can be user or team credentials
|
||||
|
@ -39,14 +41,13 @@ const getEnabledApps = async (credentials: CredentialDataWithTeamName[], filterO
|
|||
select: { slug: true, enabled: true },
|
||||
});
|
||||
const apps = getApps(credentials, filterOnCredentials);
|
||||
|
||||
const filteredApps = enabledApps.reduce((reducedArray, app) => {
|
||||
const appMetadata = apps.find((metadata) => metadata.slug === app.slug);
|
||||
if (appMetadata) {
|
||||
reducedArray.push({ ...appMetadata, enabled: app.enabled });
|
||||
const filteredApps = apps.reduce((reducedArray, app) => {
|
||||
const appDbQuery = enabledApps.find((metadata) => metadata.slug === app.slug);
|
||||
if (appDbQuery?.enabled || app.isGlobal) {
|
||||
reducedArray.push({ ...app, enabled: true });
|
||||
}
|
||||
return reducedArray;
|
||||
}, [] as (ReturnType<typeof getApps>[number] & { enabled: boolean })[]);
|
||||
}, [] as EnabledApp[]);
|
||||
|
||||
return filteredApps;
|
||||
};
|
||||
|
|
|
@ -29,6 +29,36 @@ describe("processWorkingHours", () => {
|
|||
end: dayjs(`${dateTo.tz(timeZone).format("YYYY-MM-DD")}T21:00:00Z`).tz(timeZone),
|
||||
});
|
||||
});
|
||||
it("should return the correct working hours in the month were DST ends", () => {
|
||||
const item = {
|
||||
days: [0, 1, 2, 3, 4, 5, 6], // Monday to Sunday
|
||||
startTime: new Date(Date.UTC(2023, 5, 12, 8, 0)), // 8 AM
|
||||
endTime: new Date(Date.UTC(2023, 5, 12, 17, 0)), // 5 PM
|
||||
};
|
||||
|
||||
// in America/New_York DST ends on first Sunday of November
|
||||
const timeZone = "America/New_York";
|
||||
|
||||
let firstSundayOfNovember = dayjs().startOf("day").month(10).date(1);
|
||||
while (firstSundayOfNovember.day() !== 0) {
|
||||
firstSundayOfNovember = firstSundayOfNovember.add(1, "day");
|
||||
}
|
||||
|
||||
const dateFrom = dayjs().month(10).date(1).startOf("day");
|
||||
const dateTo = dayjs().month(10).endOf("month");
|
||||
|
||||
const results = processWorkingHours({ item, timeZone, dateFrom, dateTo });
|
||||
|
||||
const allDSTStartAt12 = results
|
||||
.filter((res) => res.start.isBefore(firstSundayOfNovember))
|
||||
.every((result) => result.start.utc().hour() === 12);
|
||||
const allNotDSTStartAt13 = results
|
||||
.filter((res) => res.start.isAfter(firstSundayOfNovember))
|
||||
.every((result) => result.start.utc().hour() === 13);
|
||||
|
||||
expect(allDSTStartAt12).toBeTruthy();
|
||||
expect(allNotDSTStartAt13).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("processDateOverrides", () => {
|
||||
|
|
|
@ -23,12 +23,21 @@ export function processWorkingHours({
|
|||
}) {
|
||||
const results = [];
|
||||
for (let date = dateFrom.tz(timeZone).startOf("day"); dateTo.isAfter(date); date = date.add(1, "day")) {
|
||||
if (!item.days.includes(date.day())) {
|
||||
const dateInTz = date.tz(timeZone);
|
||||
|
||||
if (!item.days.includes(dateInTz.day())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const start = date.hour(item.startTime.getUTCHours()).minute(item.startTime.getUTCMinutes()).second(0);
|
||||
const end = date.hour(item.endTime.getUTCHours()).minute(item.endTime.getUTCMinutes()).second(0);
|
||||
let start = dateInTz.hour(item.startTime.getUTCHours()).minute(item.startTime.getUTCMinutes()).second(0);
|
||||
|
||||
let end = dateInTz.hour(item.endTime.getUTCHours()).minute(item.endTime.getUTCMinutes()).second(0);
|
||||
|
||||
const offsetBeginningOfDay = dayjs(start.format("YYYY-MM-DD hh:mm")).tz(timeZone).utcOffset();
|
||||
const offsetDiff = start.utcOffset() - offsetBeginningOfDay; // there will be 60 min offset on the day day of DST change
|
||||
|
||||
start = start.add(offsetDiff, "minute");
|
||||
end = end.add(offsetDiff, "minute");
|
||||
|
||||
const startResult = dayjs.max(start, dateFrom.tz(timeZone));
|
||||
const endResult = dayjs.min(end, dateTo.tz(timeZone));
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
import type { IntervalLimit, IntervalLimitUnit } from "@calcom/types/Calendar";
|
||||
|
||||
export const ascendingLimitKeys: (keyof IntervalLimit)[] = ["PER_DAY", "PER_WEEK", "PER_MONTH", "PER_YEAR"];
|
||||
|
||||
export const descendingLimitKeys = [...ascendingLimitKeys].reverse();
|
||||
|
||||
/**
|
||||
* Turns `PER_DAY` into `day`, `PER_WEEK` into `week` etc.
|
||||
*/
|
||||
export function intervalLimitKeyToUnit(key: keyof IntervalLimit) {
|
||||
return key.split("_")[1].toLowerCase() as IntervalLimitUnit;
|
||||
}
|
|
@ -1,34 +1,31 @@
|
|||
import dayjs from "@calcom/dayjs";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import type { IntervalLimit } from "@calcom/types/Calendar";
|
||||
|
||||
import { getErrorFromUnknown } from "../errors";
|
||||
import { HttpError } from "../http-error";
|
||||
import { ascendingLimitKeys, intervalLimitKeyToUnit } from "../intervalLimit";
|
||||
import { parseBookingLimit } from "../isBookingLimits";
|
||||
|
||||
export async function checkBookingLimits(
|
||||
bookingLimits: IntervalLimit,
|
||||
eventStartDate: Date,
|
||||
eventId: number,
|
||||
returnBusyTimes?: boolean
|
||||
eventId: number
|
||||
) {
|
||||
const parsedBookingLimits = parseBookingLimit(bookingLimits);
|
||||
if (parsedBookingLimits) {
|
||||
const limitCalculations = Object.entries(parsedBookingLimits).map(
|
||||
async ([key, limitingNumber]) =>
|
||||
await checkBookingLimit({ key, limitingNumber, eventStartDate, eventId, returnBusyTimes })
|
||||
if (!parsedBookingLimits) return false;
|
||||
|
||||
// not iterating entries to preserve types
|
||||
const limitCalculations = ascendingLimitKeys.map((key) =>
|
||||
checkBookingLimit({ key, limitingNumber: parsedBookingLimits[key], eventStartDate, eventId })
|
||||
);
|
||||
await Promise.all(limitCalculations)
|
||||
.then((res) => {
|
||||
if (returnBusyTimes) {
|
||||
return res;
|
||||
|
||||
try {
|
||||
return !!(await Promise.all(limitCalculations));
|
||||
} catch (error) {
|
||||
throw new HttpError({ message: getErrorFromUnknown(error).message, statusCode: 401 });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
throw new HttpError({ message: error.message, statusCode: 401 });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function checkBookingLimit({
|
||||
|
@ -36,33 +33,25 @@ export async function checkBookingLimit({
|
|||
eventId,
|
||||
key,
|
||||
limitingNumber,
|
||||
returnBusyTimes = false,
|
||||
}: {
|
||||
eventStartDate: Date;
|
||||
eventId: number;
|
||||
key: string;
|
||||
limitingNumber: number;
|
||||
returnBusyTimes?: boolean;
|
||||
key: keyof IntervalLimit;
|
||||
limitingNumber: number | undefined;
|
||||
}) {
|
||||
{
|
||||
const limitKey = key as keyof IntervalLimit;
|
||||
// Take PER_DAY and turn it into day and PER_WEEK into week etc.
|
||||
const filter = limitKey.split("_")[1].toLocaleLowerCase() as "day" | "week" | "month" | "year"; // Have to cast here
|
||||
const startDate = dayjs(eventStartDate).startOf(filter).toDate();
|
||||
// this is parsed above with parseBookingLimit so we know it's safe.
|
||||
if (!limitingNumber) return;
|
||||
|
||||
const endDate = dayjs(startDate).endOf(filter).toDate();
|
||||
const unit = intervalLimitKeyToUnit(key);
|
||||
|
||||
const startDate = dayjs(eventStartDate).startOf(unit).toDate();
|
||||
const endDate = dayjs(eventStartDate).endOf(unit).toDate();
|
||||
|
||||
// This allows us to easily add it within dayjs
|
||||
const bookingsInPeriod = await prisma.booking.count({
|
||||
where: {
|
||||
status: "ACCEPTED",
|
||||
eventType: {
|
||||
AND: [
|
||||
{
|
||||
id: eventId,
|
||||
bookings: {
|
||||
every: {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
eventTypeId: eventId,
|
||||
// FIXME: bookings that overlap on one side will never be counted
|
||||
startTime: {
|
||||
gte: startDate,
|
||||
},
|
||||
|
@ -70,20 +59,9 @@ export async function checkBookingLimit({
|
|||
lte: endDate,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
if (bookingsInPeriod >= limitingNumber) {
|
||||
// This is used when getting availability
|
||||
if (returnBusyTimes) {
|
||||
return {
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
};
|
||||
}
|
||||
|
||||
if (bookingsInPeriod < limitingNumber) return;
|
||||
|
||||
throw new HttpError({
|
||||
message: `booking_limit_reached`,
|
||||
|
@ -91,4 +69,3 @@ export async function checkBookingLimit({
|
|||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import dayjs from "@calcom/dayjs";
|
||||
import type { IntervalLimit } from "@calcom/types/Calendar";
|
||||
|
||||
import { getErrorFromUnknown } from "../errors";
|
||||
import { HttpError } from "../http-error";
|
||||
import { ascendingLimitKeys, intervalLimitKeyToUnit } from "../intervalLimit";
|
||||
import { parseDurationLimit } from "../isDurationLimits";
|
||||
import { getTotalBookingDuration } from "./queries";
|
||||
|
||||
|
@ -11,20 +13,18 @@ export async function checkDurationLimits(
|
|||
eventId: number
|
||||
) {
|
||||
const parsedDurationLimits = parseDurationLimit(durationLimits);
|
||||
if (!parsedDurationLimits) {
|
||||
return false;
|
||||
}
|
||||
if (!parsedDurationLimits) return false;
|
||||
|
||||
const limitCalculations = Object.entries(parsedDurationLimits).map(
|
||||
async ([key, limitingNumber]) =>
|
||||
await checkDurationLimit({ key, limitingNumber, eventStartDate, eventId })
|
||||
// not iterating entries to preserve types
|
||||
const limitCalculations = ascendingLimitKeys.map((key) =>
|
||||
checkDurationLimit({ key, limitingNumber: parsedDurationLimits[key], eventStartDate, eventId })
|
||||
);
|
||||
|
||||
await Promise.all(limitCalculations).catch((error) => {
|
||||
throw new HttpError({ message: error.message, statusCode: 401 });
|
||||
});
|
||||
|
||||
return true;
|
||||
try {
|
||||
return !!(await Promise.all(limitCalculations));
|
||||
} catch (error) {
|
||||
throw new HttpError({ message: getErrorFromUnknown(error).message, statusCode: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkDurationLimit({
|
||||
|
@ -32,29 +32,23 @@ export async function checkDurationLimit({
|
|||
eventId,
|
||||
key,
|
||||
limitingNumber,
|
||||
returnBusyTimes = false,
|
||||
}: {
|
||||
eventStartDate: Date;
|
||||
eventId: number;
|
||||
key: string;
|
||||
limitingNumber: number;
|
||||
returnBusyTimes?: boolean;
|
||||
key: keyof IntervalLimit;
|
||||
limitingNumber: number | undefined;
|
||||
}) {
|
||||
{
|
||||
// Take PER_DAY and turn it into day and PER_WEEK into week etc.
|
||||
const filter = key.split("_")[1].toLocaleLowerCase() as "day" | "week" | "month" | "year";
|
||||
const startDate = dayjs(eventStartDate).startOf(filter).toDate();
|
||||
const endDate = dayjs(startDate).endOf(filter).toDate();
|
||||
if (!limitingNumber) return;
|
||||
|
||||
const unit = intervalLimitKeyToUnit(key);
|
||||
|
||||
const startDate = dayjs(eventStartDate).startOf(unit).toDate();
|
||||
const endDate = dayjs(eventStartDate).endOf(unit).toDate();
|
||||
|
||||
const totalBookingDuration = await getTotalBookingDuration({ eventId, startDate, endDate });
|
||||
if (totalBookingDuration >= limitingNumber) {
|
||||
// This is used when getting availability
|
||||
if (returnBusyTimes) {
|
||||
return {
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
};
|
||||
}
|
||||
|
||||
if (totalBookingDuration < limitingNumber) return;
|
||||
|
||||
throw new HttpError({
|
||||
message: `duration_limit_reached`,
|
||||
|
@ -62,4 +56,3 @@ export async function checkDurationLimit({
|
|||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,14 +10,15 @@ export const getTotalBookingDuration = async ({
|
|||
endDate: Date;
|
||||
}) => {
|
||||
// Aggregates the total booking time for a given event in a given time period
|
||||
const [totalBookingTime] = (await prisma.$queryRaw`
|
||||
// FIXME: bookings that overlap on one side will never be counted
|
||||
const [totalBookingTime] = await prisma.$queryRaw<[{ totalMinutes: number | null }]>`
|
||||
SELECT SUM(EXTRACT(EPOCH FROM ("endTime" - "startTime")) / 60) as "totalMinutes"
|
||||
FROM "Booking"
|
||||
WHERE "status" = 'accepted'
|
||||
AND "id" = ${eventId}
|
||||
AND "eventTypeId" = ${eventId}
|
||||
AND "startTime" >= ${startDate}
|
||||
AND "endTime" <= ${endDate};
|
||||
`) as { totalMinutes: number }[];
|
||||
`;
|
||||
|
||||
return totalBookingTime.totalMinutes;
|
||||
return totalBookingTime.totalMinutes ?? 0;
|
||||
};
|
||||
|
|
|
@ -99,7 +99,7 @@ export async function getTeamWithMembers(args: {
|
|||
const where: Prisma.TeamFindFirstArgs["where"] = {};
|
||||
|
||||
if (userId) where.members = { some: { userId } };
|
||||
if (orgSlug) {
|
||||
if (orgSlug && orgSlug !== slug) {
|
||||
where.parent = getSlugOrRequestedSlug(orgSlug);
|
||||
}
|
||||
if (id) where.id = id;
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import type { IntervalLimit } from "@calcom/types/Calendar";
|
||||
|
||||
const validationOrderKeys = ["PER_DAY", "PER_WEEK", "PER_MONTH", "PER_YEAR"];
|
||||
import { ascendingLimitKeys } from "./intervalLimit";
|
||||
|
||||
export const validateIntervalLimitOrder = (input: IntervalLimit) => {
|
||||
// Sort limits by validationOrder
|
||||
const sorted = Object.entries(input)
|
||||
|
@ -9,7 +10,7 @@ export const validateIntervalLimitOrder = (input: IntervalLimit) => {
|
|||
})
|
||||
.map(([key]) => key);
|
||||
|
||||
const validationOrderWithoutMissing = validationOrderKeys.filter((key) => sorted.includes(key));
|
||||
const validationOrderWithoutMissing = ascendingLimitKeys.filter((key) => sorted.includes(key));
|
||||
|
||||
return sorted.every((key, index) => validationOrderWithoutMissing[index] === key);
|
||||
};
|
||||
|
|
|
@ -120,12 +120,9 @@ export interface RecurringEvent {
|
|||
tzid?: string | undefined;
|
||||
}
|
||||
|
||||
export interface IntervalLimit {
|
||||
PER_DAY?: number | undefined;
|
||||
PER_WEEK?: number | undefined;
|
||||
PER_MONTH?: number | undefined;
|
||||
PER_YEAR?: number | undefined;
|
||||
}
|
||||
export type IntervalLimitUnit = "day" | "week" | "month" | "year";
|
||||
|
||||
export type IntervalLimit = Partial<Record<`PER_${Uppercase<IntervalLimitUnit>}`, number | undefined>>;
|
||||
|
||||
export type AppsStatus = {
|
||||
appName: string;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const StepCard: React.FC<{ children: React.ReactNode }> = (props) => {
|
||||
return (
|
||||
<div className="border-subtle bg-default mt-10 rounded-md border p-4 dark:bg-black sm:p-8">
|
||||
<div className="sm:border-subtle bg-default mt-10 border p-4 dark:bg-black sm:rounded-md sm:p-8">
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -101,7 +101,7 @@ export function DataTable<TData, TValue>({
|
|||
tableCTA={tableCTA}
|
||||
/>
|
||||
<div
|
||||
className="rounded-md border"
|
||||
className="border-subtle rounded-md border"
|
||||
ref={tableContainerRef}
|
||||
onScroll={onScroll}
|
||||
style={{
|
||||
|
|
|
@ -10,7 +10,7 @@ export interface NavTabProps {
|
|||
|
||||
const HorizontalTabs = function ({ tabs, linkShallow, linkScroll, actions, ...props }: NavTabProps) {
|
||||
return (
|
||||
<div className="mb-4 h-9 max-w-[calc(100%+40px)] lg:mb-5">
|
||||
<div className="mb-4 h-9 max-w-full lg:mb-5">
|
||||
<nav
|
||||
className="no-scrollbar flex max-h-9 space-x-1 overflow-scroll rounded-md"
|
||||
aria-label="Tabs"
|
||||
|
|
|
@ -36,7 +36,10 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
|
|||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={classNames("hover:bg-subtle data-[state=selected]:bg-subtle border-b", className)}
|
||||
className={classNames(
|
||||
"hover:bg-subtle data-[state=selected]:bg-subtle border-subtle border-b",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -4026,6 +4026,12 @@ __metadata:
|
|||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@calcom/debugging@workspace:packages/debugging":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@calcom/debugging@workspace:packages/debugging"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@calcom/discord@workspace:packages/app-store/discord":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@calcom/discord@workspace:packages/app-store/discord"
|
||||
|
|
Loading…
Reference in New Issue
Block a user