diff --git a/apps/web/pages/availability/index.tsx b/apps/web/pages/availability/index.tsx index 172f2fa14f..f8b2b43cb2 100644 --- a/apps/web/pages/availability/index.tsx +++ b/apps/web/pages/availability/index.tsx @@ -1,14 +1,17 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams, usePathname } from "next/navigation"; +import { useCallback } from "react"; +import { useOrgBranding } from "@calcom/ee/organizations/context/provider"; import { getLayout } from "@calcom/features/MainLayout"; import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules"; import { ShellMain } from "@calcom/features/shell/Shell"; +import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { HttpError } from "@calcom/lib/http-error"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; -import { EmptyScreen, showToast } from "@calcom/ui"; +import { EmptyScreen, showToast, ToggleGroup } from "@calcom/ui"; import { Clock } from "@calcom/ui/components/icon"; import { withQuery } from "@lib/QueryCell"; @@ -129,14 +132,54 @@ const WithQuery = withQuery(trpc.viewer.availability.list as any); export default function AvailabilityPage() { const { t } = useLocale(); + const searchParams = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + const orgBranding = useOrgBranding(); + + // Get a new searchParams string by merging the current + // searchParams with a provided key/value pair + const createQueryString = useCallback( + (name: string, value: string) => { + const params = new URLSearchParams(searchParams ?? undefined); + params.set(name, value); + + return params.toString(); + }, + [searchParams] + ); return (
}> - } customLoader={} /> + CTA={ +
+ {orgBranding && ( + { + router.push(`${pathname}?${createQueryString("type", value)}`); + }} + options={[ + { value: "mine", label: t("my_availability") }, + { value: "team", label: t("team_availability") }, + ]} + /> + )} + +
+ }> + {searchParams?.get("type") === "team" && orgBranding ? ( + + ) : ( + } + customLoader={} + /> + )}
); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index c2888f9ce3..c77ca78645 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2007,5 +2007,7 @@ "attendee_last_name_info": "The person booking's last name", "me": "Me", "verify_team_tooltip": "Verify your team to enable sending messages to attendees", + "my_availability": "My Availability", + "team_availability": "Team Availability", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 56b611a2ac..ca5be263cd 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -8,7 +8,13 @@ "@lib/*": ["lib/*"], "@server/*": ["server/*"], "@prisma/client/*": ["@calcom/prisma/client/*"] - } + }, + "plugins": [ + { + "name": "next" + } + ], + "strictNullChecks": true }, "include": [ /* Find a way to not require this - App files don't belong here. */ @@ -17,7 +23,8 @@ "../../packages/types/*.d.ts", "../../packages/types/next-auth.d.ts", "**/*.ts", - "**/*.tsx" + "**/*.tsx", + ".next/types/**/*.ts" ], "exclude": ["node_modules"] } diff --git a/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx b/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx new file mode 100644 index 0000000000..0a1f2b1d70 --- /dev/null +++ b/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx @@ -0,0 +1,163 @@ +import type { ColumnDef } from "@tanstack/react-table"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import { useMemo, useRef, useCallback, useEffect, useState } from "react"; + +import dayjs from "@calcom/dayjs"; +import type { DateRange } from "@calcom/lib/date-ranges"; +import type { MembershipRole } from "@calcom/prisma/enums"; +import { trpc } from "@calcom/trpc"; +import { Avatar, Button, ButtonGroup, DataTable } from "@calcom/ui"; + +import { TBContext, createTimezoneBuddyStore } from "../store"; +import { TimeDial } from "./TimeDial"; + +export interface SliderUser { + id: number; + username: string | null; + email: string; + timeZone: string; + role: MembershipRole; + dateRanges: DateRange[]; +} + +export function AvailabilitySliderTable() { + const tableContainerRef = useRef(null); + const [browsingDate, setBrowsingDate] = useState(dayjs()); + + const { data, isLoading, fetchNextPage, isFetching } = trpc.viewer.availability.listTeam.useInfiniteQuery( + { + limit: 10, + loggedInUsersTz: dayjs.tz.guess(), + startDate: browsingDate.startOf("day").toISOString(), + endDate: browsingDate.endOf("day").toISOString(), + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + keepPreviousData: true, + } + ); + + const memorisedColumns = useMemo(() => { + const cols: ColumnDef[] = [ + { + id: "member", + accessorFn: (data) => data.email, + header: "Member", + cell: ({ row }) => { + const { username, email, timeZone } = row.original; + return ( +
+ +
+
+ {username || "No username"} +
+
{timeZone}
+
+
+ ); + }, + }, + { + id: "timezone", + accessorFn: (data) => data.timeZone, + header: "Timezone", + cell: ({ row }) => { + const { timeZone } = row.original; + const timeRaw = dayjs().tz(timeZone); + const time = timeRaw.format("HH:mm"); + const utcOffsetInMinutes = timeRaw.utcOffset(); + const hours = Math.abs(Math.floor(utcOffsetInMinutes / 60)); + const minutes = Math.abs(utcOffsetInMinutes % 60); + const offsetFormatted = `${utcOffsetInMinutes < 0 ? "-" : "+"}${hours + .toString() + .padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; + + return ( +
+ {time} + GMT {offsetFormatted} +
+ ); + }, + }, + { + id: "slider", + header: () => { + return ( +
+ +
+ ); + }, + cell: ({ row }) => { + const { timeZone, dateRanges } = row.original; + // return
{JSON.stringify(dateRanges, null, 2)}
; + return ; + }, + }, + ]; + + return cols; + }, [browsingDate]); + + //we must flatten the array of arrays from the useInfiniteQuery hook + const flatData = useMemo(() => data?.pages?.flatMap((page) => page.rows) ?? [], [data]) as SliderUser[]; + const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0; + const totalFetched = flatData.length; + + //called on scroll and possibly on mount to fetch more data as the user scrolls and reaches bottom of table + const fetchMoreOnBottomReached = useCallback( + (containerRefElement?: HTMLDivElement | null) => { + if (containerRefElement) { + const { scrollHeight, scrollTop, clientHeight } = containerRefElement; + //once the user has scrolled within 300px of the bottom of the table, fetch more data if there is any + if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && totalFetched < totalDBRowCount) { + fetchNextPage(); + } + } + }, + [fetchNextPage, isFetching, totalFetched, totalDBRowCount] + ); + + useEffect(() => { + fetchMoreOnBottomReached(tableContainerRef.current); + }, [fetchMoreOnBottomReached]); + + return ( + +
+ } + onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)} + /> +
+
+ ); +} diff --git a/packages/features/timezone-buddy/components/HoverOverview.tsx b/packages/features/timezone-buddy/components/HoverOverview.tsx new file mode 100644 index 0000000000..c100cd398b --- /dev/null +++ b/packages/features/timezone-buddy/components/HoverOverview.tsx @@ -0,0 +1,149 @@ +import { useState, useRef, useEffect, useMemo } from "react"; + +import { DAY_CELL_WIDTH } from "../constants"; + +function rounded(x: number, dayCellWidth: number) { + let n = Math.round(x / dayCellWidth); + n = Math.max(0, n); + n = Math.min(24, n); + return n * dayCellWidth; +} +function useElementBounding(ref: React.RefObject): DOMRect | null { + const [boundingRect, setBoundingRect] = useState(null); + const observer = useRef(null); + + useEffect(() => { + const target = ref.current; + + function updateBoundingRect() { + if (target) { + const rect = target.getBoundingClientRect(); + setBoundingRect(rect); + } + } + + updateBoundingRect(); + + observer.current = new ResizeObserver(updateBoundingRect); + observer.current.observe(target as Element); + + return () => { + if (observer.current) { + observer.current.disconnect(); + observer.current = null; + } + }; + }, [ref]); + + return boundingRect; +} + +function useMouse() { + const [x, setX] = useState(0); + const [y, setY] = useState(0); + const [isPressed, setIsPressed] = useState(false); + + useEffect(() => { + function handleMouseMove(event: MouseEvent) { + setX(event.clientX); + setY(event.clientY); + } + + function handleMouseDown() { + setIsPressed(true); + } + + function handleMouseUp() { + setIsPressed(false); + } + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mousedown", handleMouseDown); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mousedown", handleMouseDown); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, []); + + return { x, y, isPressed }; +} + +export function HoverOverview() { + const top = useRef(0); + const bottom = useRef(0); + const edgeStart = useRef(0); + const edgeEnd = useRef(0); + const [leftEdge, setLeftEdge] = useState(0); + const [rightEdge, setRightEdge] = useState(0); + const [width, setWidth] = useState(0); + + const { x, y, isPressed: pressed } = useMouse(); + const el = useRef(null); + const box = useElementBounding(el); + + useEffect(() => { + function updateEdge() { + const roundedX = rounded(x - (box?.left || 0), DAY_CELL_WIDTH); + edgeStart.current = roundedX; + edgeEnd.current = roundedX; + setLeftEdge(roundedX); + } + + if (pressed) { + updateEdge(); + } + }, [pressed, x, box]); + + useEffect(() => { + if (pressed) { + const roundedX = rounded(x - (box?.left || 0), DAY_CELL_WIDTH); + edgeEnd.current = roundedX; + setRightEdge(roundedX); + } + }, [pressed, x, box]); + + useEffect(() => { + setWidth(Math.abs(edgeStart.current - edgeEnd.current)); + }, [edgeStart, edgeEnd]); + + const position = useMemo( + () => ({ + left: `${leftEdge}px`, + top: `${top}px`, + bottom: `${bottom}px`, + width: `${width}px`, + }), + [leftEdge, top, bottom, width] + ); + + const leftWhiteout = useMemo( + () => ({ + left: "0", + top: `${top}px`, + bottom: `${bottom}px`, + width: `${leftEdge}px`, + }), + [leftEdge, top, bottom] + ); + + const rightWhiteout = useMemo( + () => ({ + right: "0", + top: `${top}px`, + bottom: `${bottom}px`, + left: `${rightEdge}px`, + }), + [rightEdge, top, bottom] + ); + + return ( +
+
+
+
+
+ ); +} diff --git a/packages/features/timezone-buddy/components/TimeDial.tsx b/packages/features/timezone-buddy/components/TimeDial.tsx new file mode 100644 index 0000000000..88b8e91069 --- /dev/null +++ b/packages/features/timezone-buddy/components/TimeDial.tsx @@ -0,0 +1,219 @@ +import { useContext } from "react"; +import { useStore } from "zustand"; + +import type { Dayjs } from "@calcom/dayjs"; +import dayjs from "@calcom/dayjs"; +import { classNames } from "@calcom/lib"; +import type { DateRange } from "@calcom/lib/date-ranges"; + +import { DAY_CELL_WIDTH } from "../constants"; +import { TBContext } from "../store"; + +interface TimeDialProps { + timezone: string; + dateRanges?: DateRange[]; +} + +function isMidnight(h: number) { + return h <= 5 || h >= 22; +} + +function isCurrentHourInRange({ + dateRanges, + cellDate, + offset, +}: { + dateRanges?: DateRange[]; + cellDate: Dayjs; + offset: number; +}): { + rangeOverlap?: number; + isInRange: boolean; +} { + if (!dateRanges) + return { + isInRange: false, + }; + const currentHour = cellDate.hour(); + + let rangeOverlap = 0; + + // yes or no answer whether it's in range. + const isFullyInRange = dateRanges.some((time) => { + if (!time) null; + + const startHour = dayjs(time.start).subtract(offset, "hour"); + const endHour = dayjs(time.end).subtract(offset, "hour"); + + // If not same day number then we don't care + + if (startHour.day() !== cellDate.day()) { + return false; + } + + // this is a weird way of doing this + const newDate = dayjs(time.start).set("hour", currentHour); + + const diffStart = newDate.diff(startHour, "minutes"); + if (Math.abs(diffStart) < 60 && diffStart != 0) { + rangeOverlap = + diffStart < 0 + ? -(Math.floor(startHour.minute() / 15) * 25) + : Math.floor(startHour.minute() / 15) * 25; + } + + const diffEnd = newDate.diff(endHour, "minutes"); + if (Math.abs(diffEnd) < 60 && diffEnd != 0) { + rangeOverlap = + diffEnd < 0 ? -(Math.floor(endHour.minute() / 15) * 25) : Math.floor(endHour.minute() / 15) * 25; + } + + return newDate.isBetween(startHour, endHour, undefined, "[)"); // smiley faces or something + }); + // most common situation, bail early. + if (isFullyInRange) { + return { + isInRange: isFullyInRange, + }; + } + + return { + isInRange: !!rangeOverlap, + rangeOverlap, // value from -75 to 75 to indicate range overlap + }; +} + +export function TimeDial({ timezone, dateRanges }: TimeDialProps) { + const store = useContext(TBContext); + if (!store) throw new Error("Missing TBContext.Provider in the tree"); + const browsingDate = useStore(store, (s) => s.browsingDate); + + const usersTimezoneDate = dayjs(browsingDate).tz(timezone); + + const nowDate = dayjs(browsingDate); + + const offset = (nowDate.utcOffset() - usersTimezoneDate.utcOffset()) / 60; + + const hours = Array.from({ length: 24 }, (_, i) => i - offset + 1); + + const days = [ + hours.filter((i) => i < 0).map((i) => (i + 24) % 24), + hours.filter((i) => i >= 0 && i < 24), + hours.filter((i) => i >= 24).map((i) => i % 24), + ]; + + let minuteOffsetApplied = false; + + return ( + <> +
+ {days.map((day, i) => { + if (!day.length) return null; + const dateWithDaySet = usersTimezoneDate.add(i - 1, "day"); + return ( +
+
+ {day.map((h) => { + const hours = Math.floor(h); // Whole number part + const fractionalHours = h - hours; // Decimal part + + // Convert the fractional hours to minutes + const minutes = fractionalHours * 60; + const hourSet = dateWithDaySet.set("hour", h).set("minute", minutes); + + const { isInRange, rangeOverlap = 0 } = isCurrentHourInRange({ + dateRanges, + offset, + cellDate: hourSet, + }); + + const rangeGradients: { + backgroundGradient?: string; + textGradient?: string; + darkTextGradient?: string; + } = {}; + + if (isInRange && rangeOverlap) { + if (rangeOverlap < 0) { + const gradientValue = Math.abs(rangeOverlap); + rangeGradients.backgroundGradient = `linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0) ${gradientValue}%, var(--cal-bg-success) ${gradientValue}%)`; + + rangeGradients.textGradient = `linear-gradient(90deg, rgba(0,212,255,1) 0%, rgba(2,0,36,1) 100%, rgba(9,108,121,1) 100%)`; + + rangeGradients.darkTextGradient = `linear-gradient(90deg, var(--cal-text-emphasis, #111827) ${ + gradientValue === 50 ? "50%" : Math.round(gradientValue / 100) * 100 + "%" + }, var(--cal-text-inverted, white) 0%, var(--cal-text-inverted, white) 0%)`; + } else { + rangeGradients.backgroundGradient = `linear-gradient(90deg, var(--cal-bg-success) ${rangeOverlap}%, rgba(0,0,0,0) 0%, rgba(0,0,0,0) 0%)`; + + rangeGradients.textGradient = `linear-gradient(90deg, rgba(0,212,255,1) 0%, rgba(2,0,36,1) 50%, rgba(9,108,121,1) 100%)`; + + rangeGradients.darkTextGradient = `linear-gradient(90deg, var(--cal-text-inverted, white) ${ + rangeOverlap === 50 ? "50%" : Math.round(rangeOverlap / 100) * 100 + "%" + }, var(--cal-text-emphasis, #111827) 0%, var(--cal-text-emphasis, #111827) 0%)`; + } + } + + const minuteOffsetStyles: { marginLeft?: string } = {}; + if (hours !== 0 && !minuteOffsetApplied) { + minuteOffsetApplied = true; + minuteOffsetStyles.marginLeft = `${DAY_CELL_WIDTH * (offset % 1)}px`; + } + + return ( +
+ {hours ? ( +
+
+ {rangeGradients.textGradient ? ( + <> + {/* light mode */} + + {hourSet.format("H")} + + {/* dark mode */} + + + ) : ( + {hourSet.format("H")} + )} +
+
+ ) : ( +
+ {hourSet.format("MMM")} + {hourSet.format("DD")} +
+ )} +
+ ); + })} +
+
+ ); + })} +
+ + ); +} diff --git a/packages/features/timezone-buddy/constants.ts b/packages/features/timezone-buddy/constants.ts new file mode 100644 index 0000000000..0ff1ff7843 --- /dev/null +++ b/packages/features/timezone-buddy/constants.ts @@ -0,0 +1 @@ +export const DAY_CELL_WIDTH = 30; diff --git a/packages/features/timezone-buddy/store.ts b/packages/features/timezone-buddy/store.ts new file mode 100644 index 0000000000..65e1b0ccce --- /dev/null +++ b/packages/features/timezone-buddy/store.ts @@ -0,0 +1,56 @@ +import { createContext } from "react"; +import { createStore } from "zustand"; + +export interface Timezone { + name: string; + offset: number; + isdst?: boolean; + abbr?: string; + city?: string; + location?: string; +} + +export interface TimezoneBuddyProps { + browsingDate: Date; + timeMode?: "12h" | "24h"; +} + +type TimezoneBuddyState = TimezoneBuddyProps & { + addToDate: (amount: number) => void; + subtractFromDate: (amount: number) => void; + setBrowseDate: (date: Date) => void; +}; + +export type TimezoneBuddyStore = ReturnType; + +/** + * Differnt type of zustand store compared to what we are used to + * This is a function that returns a store instead of a hook. This allows us to properly set the initial state of the store + * from the props passed in to the component. + */ +export const createTimezoneBuddyStore = (initProps?: Partial) => { + const DEFAULT_PROPS: TimezoneBuddyProps = { + timeMode: "24h", + browsingDate: new Date(), + }; + + return createStore()((set, get) => ({ + ...DEFAULT_PROPS, + ...initProps, + addToDate: (amount?: number) => { + const date = get().browsingDate; + date.setDate(date.getDate() + (amount || 1)); + set({ browsingDate: date }); + }, + subtractFromDate: (amount?: number) => { + const date = get().browsingDate; + date.setDate(date.getDate() - (amount || 1)); + set({ browsingDate: date }); + }, + setBrowseDate: (date: Date) => { + set({ browsingDate: date }); + }, + })); +}; + +export const TBContext = createContext(null); diff --git a/packages/trpc/server/routers/viewer/availability/_router.tsx b/packages/trpc/server/routers/viewer/availability/_router.tsx index c8a777ceac..1084dc5dc7 100644 --- a/packages/trpc/server/routers/viewer/availability/_router.tsx +++ b/packages/trpc/server/routers/viewer/availability/_router.tsx @@ -1,11 +1,13 @@ import authedProcedure from "../../../procedures/authedProcedure"; import { router } from "../../../trpc"; import { scheduleRouter } from "./schedule/_router"; +import { ZListTeamAvailaiblityScheme } from "./team/listTeamAvailability.schema"; import { ZUserInputSchema } from "./user.schema"; type AvailabilityRouterHandlerCache = { list?: typeof import("./list.handler").listHandler; user?: typeof import("./user.handler").userHandler; + listTeamAvailability?: typeof import("./team/listTeamAvailability.handler").listTeamAvailabilityHandler; }; const UNSTABLE_HANDLER_CACHE: AvailabilityRouterHandlerCache = {}; @@ -41,6 +43,23 @@ export const availabilityRouter = router({ input, }); }), + listTeam: authedProcedure.input(ZListTeamAvailaiblityScheme).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.listTeamAvailability) { + UNSTABLE_HANDLER_CACHE.listTeamAvailability = await import("./team/listTeamAvailability.handler").then( + (mod) => mod.listTeamAvailabilityHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.listTeamAvailability) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.listTeamAvailability({ + ctx, + input, + }); + }), schedule: scheduleRouter, }); diff --git a/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts b/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts new file mode 100644 index 0000000000..288aed71cb --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts @@ -0,0 +1,112 @@ +import dayjs from "@calcom/dayjs"; +import type { DateRange } from "@calcom/lib/date-ranges"; +import { buildDateRanges } from "@calcom/lib/date-ranges"; +import { prisma } from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../../trpc"; +import type { TListTeamAvailaiblityScheme } from "./listTeamAvailability.schema"; + +type GetOptions = { + ctx: { + user: NonNullable; + }; + input: TListTeamAvailaiblityScheme; +}; + +export const listTeamAvailabilityHandler = async ({ ctx, input }: GetOptions) => { + const organizationId = ctx.user.organizationId; + + if (!organizationId) { + throw new TRPCError({ code: "NOT_FOUND", message: "User is not part of any organization." }); + } + + const { cursor, limit } = input; + + const getTotalMembers = await prisma.membership.count({ + where: { + teamId: organizationId, + }, + }); + + // I couldnt get this query to work direct on membership table + const teamMembers = await prisma.membership.findMany({ + where: { + teamId: organizationId, + accepted: true, + }, + select: { + id: true, + role: true, + user: { + select: { + id: true, + username: true, + email: true, + timeZone: true, + defaultScheduleId: true, + }, + }, + }, + cursor: cursor ? { id: cursor } : undefined, + take: limit + 1, // We take +1 as itll be used for the next cursor + orderBy: { + id: "asc", + }, + }); + + let nextCursor: typeof cursor | undefined = undefined; + if (teamMembers && teamMembers.length > limit) { + const nextItem = teamMembers.pop(); + nextCursor = nextItem!.id; + } + + const dateFrom = dayjs(input.startDate).tz(input.loggedInUsersTz).subtract(1, "day"); + const dateTo = dayjs(input.endDate).tz(input.loggedInUsersTz).add(1, "day"); + + const buildMembers = teamMembers?.map(async (member) => { + if (!member.user.defaultScheduleId) { + return { + id: member.user.id, + username: member.user.username, + email: member.user.email, + timeZone: member.user.timeZone, + role: member.role, + dateRanges: [] as DateRange[], + }; + } + + const schedule = await prisma.schedule.findUnique({ + where: { id: member.user.defaultScheduleId }, + select: { availability: true, timeZone: true }, + }); + const timeZone = schedule?.timeZone || member.user.timeZone; + + const dateRanges = buildDateRanges({ + dateFrom, + dateTo, + timeZone, + availability: schedule?.availability ?? [], + }); + + return { + id: member.user.id, + username: member.user.username, + email: member.user.email, + timeZone, + role: member.role, + dateRanges, + }; + }); + + const members = await Promise.all(buildMembers); + + return { + rows: members || [], + nextCursor, + meta: { + totalRowCount: getTotalMembers || 0, + }, + }; +}; diff --git a/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.schema.ts b/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.schema.ts new file mode 100644 index 0000000000..d07e9401b8 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const ZListTeamAvailaiblityScheme = z.object({ + limit: z.number().min(1).max(100), + cursor: z.number().nullish(), + startDate: z.string(), + endDate: z.string(), + loggedInUsersTz: z.string(), +}); + +export type TListTeamAvailaiblityScheme = z.infer; diff --git a/packages/ui/components/data-table/index.tsx b/packages/ui/components/data-table/index.tsx index 0802baef44..0099beed16 100644 --- a/packages/ui/components/data-table/index.tsx +++ b/packages/ui/components/data-table/index.tsx @@ -34,6 +34,7 @@ export interface DataTableProps { isLoading?: boolean; onScroll?: (e: React.UIEvent) => void; CTA?: React.ReactNode; + tableOverlay?: React.ReactNode; } export function DataTable({ @@ -45,6 +46,7 @@ export function DataTable({ selectionOptions, tableContainerRef, isLoading, + tableOverlay, onScroll, }: DataTableProps) { const [rowSelection, setRowSelection] = useState({}); @@ -159,6 +161,7 @@ export function DataTable({ )} + {tableOverlay && tableOverlay}
{/* */}