2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge, Button, Switch } from "@calcom/ui";
import { TroubleshooterListItemContainer } from "./TroubleshooterListItemContainer";
function AvailabiltyItem() {
const { t } = useLocale();
return (
<TroubleshooterListItemContainer
title="Office Hours"
subtitle="Mon-Fri; 9:00 AM - 5:00 PM"
suffixSlot={
<div>
<Badge variant="green" withDot size="sm">
Connected
</Badge>
</div>
}>
<div className="flex flex-col gap-3">
<p className="text-subtle text-sm font-medium leading-none">{t("date_overrides")}</p>
<Switch label="google@calendar.com" />
</div>
</TroubleshooterListItemContainer>
);
}
export function AvailabiltySchedulesContainer() {
const { t } = useLocale();
return (
<div className="flex flex-col space-y-3">
<p className="text-sm font-medium leading-none">{t("availabilty_schedules")}</p>
<AvailabiltyItem />
<Button color="secondary" className="justify-center gap-2">
{t("manage_availabilty_schedules")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,122 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Badge, Button, Switch } from "@calcom/ui";
import { TroubleshooterListItemContainer } from "./TroubleshooterListItemContainer";
const SELECTION_COLORS = ["#f97316", "#84cc16", "#06b6d4", "#8b5cf6", "#ec4899", "#f43f5e"];
interface CalendarToggleItemProps {
title: string;
subtitle: string;
colorDot?: string;
status: "connected" | "not_found";
calendars?: {
active?: boolean;
name?: string;
}[];
}
function CalendarToggleItem(props: CalendarToggleItemProps) {
const badgeStatus = props.status === "connected" ? "green" : "orange";
const badgeText = props.status === "connected" ? "Connected" : "Not found";
return (
<TroubleshooterListItemContainer
title={props.title}
subtitle={props.subtitle}
prefixSlot={
<>
<div
className="h-4 w-4 self-center rounded-[4px]"
style={{
backgroundColor: props.colorDot,
}}
/>
</>
}
suffixSlot={
<div>
<Badge variant={badgeStatus} withDot size="sm">
{badgeText}
</Badge>
</div>
}>
<div className="[&>*]:text-emphasis flex flex-col gap-3">
{props.calendars?.map((calendar) => {
return <Switch key={calendar.name} checked={calendar.active} label={calendar.name} disabled />;
})}
</div>
</TroubleshooterListItemContainer>
);
}
function EmptyCalendarToggleItem() {
const { t } = useLocale();
return (
<TroubleshooterListItemContainer
title={t("installed", { count: 0 })}
subtitle={t("please_install_a_calendar")}
prefixSlot={
<>
<div className="h-4 w-4 self-center rounded-[4px] bg-blue-500" />
</>
}
suffixSlot={
<div>
<Badge variant="orange" withDot size="sm">
{t("unavailable")}
</Badge>
</div>
}>
<div className="flex flex-col gap-3">
<Button color="secondary" className="justify-center gap-2" href="/apps/categories/calendar">
{t("install_calendar")}
</Button>
</div>
</TroubleshooterListItemContainer>
);
}
export function CalendarToggleContainer() {
const { t } = useLocale();
const { data, isLoading } = trpc.viewer.connectedCalendars.useQuery();
const hasConnectedCalendars = data && data?.connectedCalendars.length > 0;
return (
<div className="flex flex-col space-y-3">
<p className="text-sm font-medium leading-none">{t("calendars_were_checking_for_conflicts")}</p>
{hasConnectedCalendars && !isLoading ? (
<>
{data.connectedCalendars.map((calendar) => {
const foundPrimary = calendar.calendars?.find((item) => item.primary);
// Will be used when getAvailbility is modified to use externalId instead of appId for source.
// const color = SELECTION_COLORS[idx] || "#000000";
// // Add calendar to color map using externalId (what we use on the backend to determine source)
// addToColorMap(foundPrimary?.externalId, color);
return (
<CalendarToggleItem
key={calendar.credentialId}
title={calendar.integration.name}
colorDot="#000000"
subtitle={foundPrimary?.name ?? "Nameless Calendar"}
status={calendar.error ? "not_found" : "connected"}
calendars={calendar.calendars?.map((item) => {
return {
active: item.isSelected,
name: item.name,
};
})}
/>
);
})}
<Button color="secondary" className="justify-center gap-2" href="/settings/my-account/calendars">
{t("manage_calendars")}
</Button>
</>
) : (
<EmptyCalendarToggleItem />
)}
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge } from "@calcom/ui";
import { TroubleshooterListItemHeader } from "./TroubleshooterListItemContainer";
function ConnectedAppsItem() {
return (
<TroubleshooterListItemHeader
title="Google Cal"
subtitle="google@calendar.com"
prefixSlot={
<>
<div className="h-4 w-4 self-center rounded-[4px] bg-blue-500" />
</>
}
suffixSlot={
<div>
<Badge variant="green" withDot size="sm">
Connected
</Badge>
</div>
}
/>
);
}
export function ConnectedAppsContainer() {
const { t } = useLocale();
return (
<div className="flex flex-col space-y-3">
<p className="text-sm font-medium leading-none">{t("other_apps")}</p>
<div className="[&>*:first-child]:rounded-t-md [&>*:last-child]:rounded-b-md [&>*:last-child]:border-b">
<ConnectedAppsItem />
<ConnectedAppsItem />
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import Link from "next/link";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Badge, Label } from "@calcom/ui";
import { useTroubleshooterStore } from "../store";
import { TroubleshooterListItemHeader } from "./TroubleshooterListItemContainer";
export function EventScheduleItem() {
const { t } = useLocale();
const selectedEventType = useTroubleshooterStore((state) => state.event);
const { data: schedule } = trpc.viewer.availability.schedule.getScheduleByEventSlug.useQuery(
{
eventSlug: selectedEventType?.slug as string,
},
{
enabled: !!selectedEventType?.slug,
}
);
return (
<div>
<Label>Availability Schedule</Label>
<TroubleshooterListItemHeader
className="group rounded-md border-b"
prefixSlot={<div className="w-4 rounded-[4px] bg-black" />}
title={schedule?.name ?? "Loading"}
suffixSlot={
schedule && (
<Link href={`/availability/${schedule.id}`} className="inline-flex">
<Badge color="orange" size="sm" className="hidden hover:cursor-pointer group-hover:inline-flex">
{t("edit")}
</Badge>
</Link>
)
}
/>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { useMemo, useEffect, startTransition } from "react";
import { trpc } from "@calcom/trpc";
import { SelectField } from "@calcom/ui";
import { getQueryParam } from "../../bookings/Booker/utils/query-param";
import { useTroubleshooterStore } from "../store";
export function EventTypeSelect() {
const { data: eventTypes, isPending } = trpc.viewer.eventTypes.list.useQuery();
const selectedEventType = useTroubleshooterStore((state) => state.event);
const setSelectedEventType = useTroubleshooterStore((state) => state.setEvent);
const selectedEventQueryParam = getQueryParam("eventType");
const options = useMemo(() => {
if (!eventTypes) return [];
return eventTypes.map((e) => ({
label: e.title,
value: e.slug,
id: e.id,
duration: e.length,
}));
}, [eventTypes]);
useEffect(() => {
if (!selectedEventType && eventTypes && eventTypes[0] && !selectedEventQueryParam) {
const { id, slug, length } = eventTypes[0];
setSelectedEventType({
id,
slug,
duration: length,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventTypes]);
useEffect(() => {
if (selectedEventQueryParam) {
// ensure that the update is deferred until the Suspense boundary has finished hydrating
startTransition(() => {
const foundEventType = eventTypes?.find((et) => et.slug === selectedEventQueryParam);
if (foundEventType) {
const { id, slug, length } = foundEventType;
setSelectedEventType({ id, slug, duration: length });
} else if (eventTypes && eventTypes[0]) {
const { id, slug, length } = eventTypes[0];
setSelectedEventType({
id,
slug,
duration: length,
});
}
});
}
}, [eventTypes, selectedEventQueryParam, setSelectedEventType]);
return (
<SelectField
label="Event Type"
options={options}
isDisabled={isPending || options.length === 0}
value={options.find((option) => option.value === selectedEventType?.slug) || options[0]}
onChange={(option) => {
if (!option) return;
setSelectedEventType({
slug: option.value,
id: option.id,
duration: option.duration,
});
}}
/>
);
}

View File

@@ -0,0 +1,143 @@
import { useSession } from "next-auth/react";
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import { Calendar } from "@calcom/features/calendars/weeklyview";
import type { CalendarAvailableTimeslots } from "@calcom/features/calendars/weeklyview/types/state";
import { BookingStatus } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
import { useTimePreferences } from "../../bookings/lib/timePreferences";
import { useSchedule } from "../../schedules/lib/use-schedule";
import { useTroubleshooterStore } from "../store";
export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
const { timezone } = useTimePreferences();
const selectedDate = useTroubleshooterStore((state) => state.selectedDate);
const event = useTroubleshooterStore((state) => state.event);
const calendarToColorMap = useTroubleshooterStore((state) => state.calendarToColorMap);
const { data: session } = useSession();
const startDate = selectedDate ? dayjs(selectedDate) : dayjs();
const { data: busyEvents } = trpc.viewer.availability.user.useQuery(
{
username: session?.user?.username || "",
dateFrom: startDate.startOf("day").utc().format(),
dateTo: startDate
.endOf("day")
.add(extraDays - 1, "day")
.utc()
.format(),
withSource: true,
},
{
enabled: !!session?.user?.username,
}
);
const { data: schedule } = useSchedule({
username: session?.user.username || "",
eventSlug: event?.slug,
eventId: event?.id,
timezone,
month: startDate.format("YYYY-MM"),
orgSlug: session?.user.org?.slug,
});
const endDate = dayjs(startDate)
.add(extraDays - 1, "day")
.toDate();
const availableSlots = useMemo(() => {
const availableTimeslots: CalendarAvailableTimeslots = {};
if (!schedule) return availableTimeslots;
if (!schedule?.slots) return availableTimeslots;
for (const day in schedule.slots) {
availableTimeslots[day] = schedule.slots[day].map((slot) => ({
start: dayjs(slot.time).toDate(),
end: dayjs(slot.time)
.add(event?.duration ?? 30, "minutes")
.toDate(),
}));
}
return availableTimeslots;
}, [schedule, event]);
const events = useMemo(() => {
if (!busyEvents?.busy) return [];
// TODO: Add buffer times in here as well just requires a bit of logic for fetching event type and then adding buffer time
// start: dayjs(startTime)
// .subtract((eventType?.beforeEventBuffer || 0) + (afterEventBuffer || 0), "minute")
// .toDate(),
// end: dayjs(endTime)
// .add((eventType?.afterEventBuffer || 0) + (beforeEventBuffer || 0), "minute")
// .toDate(),
const calendarEvents = busyEvents?.busy.map((event, idx) => {
return {
id: idx,
title: event.title ?? `Busy`,
start: new Date(event.start),
end: new Date(event.end),
options: {
borderColor:
event.source && calendarToColorMap[event.source] ? calendarToColorMap[event.source] : "black",
status: BookingStatus.ACCEPTED,
"data-test-id": "troubleshooter-busy-event",
},
};
});
if (busyEvents.dateOverrides) {
busyEvents.dateOverrides.forEach((dateOverride) => {
const dateOverrideStart = dayjs(dateOverride.start);
const dateOverrideEnd = dayjs(dateOverride.end);
if (!dateOverrideStart.isSame(dateOverrideEnd)) {
return;
}
const dayOfWeekNum = dateOverrideStart.day();
const workingHoursForDay = busyEvents.workingHours.find((workingHours) =>
workingHours.days.includes(dayOfWeekNum)
);
if (!workingHoursForDay) return;
calendarEvents.push({
id: calendarEvents.length,
title: "Date Override",
start: dateOverrideStart.add(workingHoursForDay.startTime, "minutes").toDate(),
end: dateOverrideEnd.add(workingHoursForDay.endTime, "minutes").toDate(),
options: {
borderColor: "black",
status: BookingStatus.ACCEPTED,
"data-test-id": "troubleshooter-busy-time",
},
});
});
}
return calendarEvents;
}, [busyEvents, calendarToColorMap]);
return (
<div className="h-full [--calendar-dates-sticky-offset:66px]">
<Calendar
sortEvents
startHour={0}
endHour={23}
events={events}
availableTimeslots={availableSlots}
startDate={startDate.toDate()}
endDate={endDate}
gridCellsPerHour={60 / (event?.duration || 15)}
hoverEventDuration={30}
hideHeader
/>
</div>
);
};

View File

@@ -0,0 +1,79 @@
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, ButtonGroup } from "@calcom/ui";
import { useTroubleshooterStore } from "../store";
export function TroubleshooterHeader({ extraDays, isMobile }: { extraDays: number; isMobile: boolean }) {
const { t, i18n } = useLocale();
const selectedDateString = useTroubleshooterStore((state) => state.selectedDate);
const setSelectedDate = useTroubleshooterStore((state) => state.setSelectedDate);
const addToSelectedDate = useTroubleshooterStore((state) => state.addToSelectedDate);
const selectedDate = selectedDateString ? dayjs(selectedDateString) : dayjs();
const today = dayjs();
const selectedDateMin3DaysDifference = useMemo(() => {
const diff = today.diff(selectedDate, "days");
return diff > 3 || diff < -3;
}, [today, selectedDate]);
if (isMobile) return null;
const endDate = selectedDate.add(extraDays - 1, "days");
const isSameMonth = () => {
return selectedDate.format("MMM") === endDate.format("MMM");
};
const isSameYear = () => {
return selectedDate.format("YYYY") === endDate.format("YYYY");
};
const formattedMonth = new Intl.DateTimeFormat(i18n.language, { month: "short" });
const FormattedSelectedDateRange = () => {
return (
<h3 className="min-w-[150px] text-base font-semibold leading-4">
{formattedMonth.format(selectedDate.toDate())} {selectedDate.format("D")}
{!isSameYear() && <span className="text-subtle">, {selectedDate.format("YYYY")} </span>}-{" "}
{!isSameMonth() && formattedMonth.format(endDate.toDate())} {endDate.format("D")},{" "}
<span className="text-subtle">
{isSameYear() ? selectedDate.format("YYYY") : endDate.format("YYYY")}
</span>
</h3>
);
};
return (
<div className="border-default relative z-10 flex border-b px-5 py-4 ltr:border-l rtl:border-r">
<div className="flex items-center gap-5 rtl:flex-grow">
<FormattedSelectedDateRange />
<ButtonGroup>
<Button
className="group rtl:ml-1 rtl:rotate-180"
variant="icon"
color="minimal"
StartIcon="chevron-left"
aria-label="Previous Day"
onClick={() => addToSelectedDate(-extraDays)}
/>
<Button
className="group rtl:mr-1 rtl:rotate-180"
variant="icon"
color="minimal"
StartIcon="chevron-right"
aria-label="Next Day"
onClick={() => addToSelectedDate(extraDays)}
/>
{selectedDateMin3DaysDifference && (
<Button
className="capitalize ltr:ml-2 rtl:mr-2"
color="secondary"
onClick={() => setSelectedDate(today.format("YYYY-MM-DD"))}>
{t("today")}
</Button>
)}
</ButtonGroup>
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import type { PropsWithChildren } from "react";
import classNames from "@calcom/lib/classNames";
interface TroubleshooterListItemContainerProps {
title: string;
subtitle?: string;
suffixSlot?: React.ReactNode;
prefixSlot?: React.ReactNode;
className?: string;
}
export function TroubleshooterListItemHeader({
prefixSlot,
title,
subtitle,
suffixSlot,
className,
}: TroubleshooterListItemContainerProps) {
return (
<div className={classNames("border-subtle flex max-w-full gap-3 border border-b-0 px-4 py-2", className)}>
{prefixSlot}
<div className="flex h-full max-w-full flex-1 flex-col flex-nowrap truncate text-sm leading-4">
<p className="font-medium">{title}</p>
{subtitle && <p className="font-normal">{subtitle}</p>}
</div>
{suffixSlot}
</div>
);
}
export function TroubleshooterListItemContainer({
children,
...rest
}: PropsWithChildren<TroubleshooterListItemContainerProps>) {
return (
<div className="[&>*:first-child]:rounded-t-md ">
<TroubleshooterListItemHeader {...rest} />
<div className="border-subtle flex flex-col space-y-3 rounded-b-md border p-4">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import Link from "next/link";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon, Skeleton } from "@calcom/ui";
import { CalendarToggleContainer } from "./CalendarToggleContainer";
import { EventScheduleItem } from "./EventScheduleItem";
import { EventTypeSelect } from "./EventTypeSelect";
const BackButtonInSidebar = ({ name }: { name: string }) => {
return (
<Link
href="/availability"
className="hover:bg-subtle group-hover:text-default text-emphasis group flex h-6 max-h-6 w-full flex-row items-center rounded-md px-3 py-2">
<Icon
name="arrow-left"
className="h-4 w-4 stroke-[2px] ltr:mr-[10px] rtl:ml-[10px] rtl:rotate-180 md:mt-0"
/>
<Skeleton
title={name}
as="p"
className="max-w-36 min-h-4 truncate font-semibold"
loadingClassName="ms-3">
{name}
</Skeleton>
</Link>
);
};
export const TroubleshooterSidebar = () => {
const { t } = useLocale();
return (
<div className="relative z-10 hidden h-screen w-full flex-col gap-6 overflow-y-auto py-6 pl-4 pr-6 sm:flex md:pl-0">
<BackButtonInSidebar name={t("troubleshooter")} />
<EventTypeSelect />
<EventScheduleItem />
<CalendarToggleContainer />
</div>
);
};