feat: Availability slider for orgs (#10740)
* Init + get timezone + offset data agh * Add 12/24h mode - style correctly * User users timezone + working hours. Still some stuff to figure out * Multiple working hours * move calc to once per day * Demo with two users and differnt timezones * availabillity control tab via search params * WIP hover overlay * THIS WORKS ISH * fix: multiple duration getSchedule calls [CAL-2336] (#10709) Co-authored-by: Omar López <zomars@me.com> * New Crowdin translations by Github Action * fix: If the input type "Name" is selected, the label can't be changed from our default label "Your Name" (#10618) Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> * New Crowdin translations by Github Action * test: Create unit tests for react components in packages/ui/components/form/step (#10442) Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com> Co-authored-by: Keith Williams <keithwillcode@gmail.com> * feat: element call app added (#10585) Co-authored-by: Hariom Balhara <hariombalhara@gmail.com> * New Crowdin translations by Github Action * New Crowdin translations by Github Action * fix: e2e test for rescheduling overlapping time (#10721) Co-authored-by: CarinaWolli <wollencarina@gmail.com> * feat: mailhog fixture (#10606) * feat: mailhog fixture * fix: nodemailer to dispatch emails with e2e env * fix: remove space from email subject * feat: fixture getFirstEventAsOwner * feat: assert email subjects * fix: and enable dynamic booking test (#10642) * fix and enable dynamic booking test * remove page pause --------- Co-authored-by: Alex van Andel <me@alexvanandel.com> * fix: Broken team events if a user with the same name exists (#10724) * fix: Broken team events if a user with the same name exists * Fix tests + fix usernameList optionality * Try to list calendars, if not continue (#10720) Co-authored-by: Omar López <zomars@me.com> * v3.1.9 * link to org settings (#10718) * feat: app paypal payment (#8797) Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Co-authored-by: Omar López <zomars@me.com> * fix: RTL issues on booking pages + email confirmation (#10526) Co-authored-by: Peer Richelsen <peeroke@gmail.com> * fix: merge conflict * Fixing org slug (#10538) * fix: paypal build fixes * Fix avatar for org in Shell top (#10712) * feat: add range of dates for availability over-ride (#10462) * feat: add range of dates for availability over-ride * chore: changed range select to multiple select --------- Co-authored-by: Alex van Andel <me@alexvanandel.com> * fix: border issue for time slots (#10577) Co-authored-by: Raghul D <v-raghuld@microsoft.com> * style: Fix text wrapping issue in button (#10725) Co-authored-by: Omar López <zomars@me.com> * New Crowdin translations by Github Action * fix: App Install Dropdown Sort Properly [CAL-2285] (#10672) Co-authored-by: Peer Richelsen <peeroke@gmail.com> * fix: link escaping in booking page (#10360) Co-authored-by: Hariom Balhara <hariombalhara@gmail.com> * chore: fix refund i18n message (#10731) * chore: remove tailwind-scrollbar warning (#10523) Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> * New Crowdin translations by Github Action * chore: Simplified date overrides (#10728) * chore: Simplified date overrides * Fixed a test that had a date override that wasn't at midnight utc * Wrote test that showed a fixed Europe/Brussels * Lint fix * New Crowdin translations by Github Action * Fix offset time + fetching correct dates * Deal with awkward minute offsets * remove store overhead * Format H based on tz * Cleanup store logic * Cleanup * Remove comments * Remove comments * Remove yarn.lock * Dark mode & v-align text fixes * Move ButtonGroup to the left to prevent chevron from jumping * Shift based on timezone (non-hour) and have 15min granularity in hour display background color --------- Co-authored-by: Leo Giovanetti <hello@leog.me> Co-authored-by: Omar López <zomars@me.com> Co-authored-by: Crowdin Bot <support+bot@crowdin.com> Co-authored-by: Zain Gulbaz <zaingulbaz8@gmail.com> Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com> Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com> Co-authored-by: Keith Williams <keithwillcode@gmail.com> Co-authored-by: Suyash Srivastava <suyashsrivastava5053@gmail.com> Co-authored-by: Hariom Balhara <hariombalhara@gmail.com> Co-authored-by: CarinaWolli <wollencarina@gmail.com> Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com> Co-authored-by: alannnc <alannnc@gmail.com> Co-authored-by: Alex van Andel <me@alexvanandel.com> Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Co-authored-by: Pradumn Kumar <pradumn@tealfeed.com> Co-authored-by: Raghul <123321540+Raghul18@users.noreply.github.com> Co-authored-by: Raghul D <v-raghuld@microsoft.com> Co-authored-by: ABDERRAHMANI IDRISSI HAMZA <97639117+idrissi-hamza@users.noreply.github.com> Co-authored-by: Nafees Nazik <84864519+G3root@users.noreply.github.com> Co-authored-by: Danila <daniil.demidovich@gmail.com> Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>pull/10840/head
parent
ea4618a244
commit
67bb2ed798
|
@ -1,14 +1,17 @@
|
||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
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 { getLayout } from "@calcom/features/MainLayout";
|
||||||
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
|
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
|
||||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||||
|
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
import { trpc } 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 { Clock } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { withQuery } from "@lib/QueryCell";
|
import { withQuery } from "@lib/QueryCell";
|
||||||
|
@ -129,14 +132,54 @@ const WithQuery = withQuery(trpc.viewer.availability.list as any);
|
||||||
|
|
||||||
export default function AvailabilityPage() {
|
export default function AvailabilityPage() {
|
||||||
const { t } = useLocale();
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ShellMain
|
<ShellMain
|
||||||
heading={t("availability")}
|
heading={t("availability")}
|
||||||
hideHeadingOnMobile
|
hideHeadingOnMobile
|
||||||
subtitle={t("configure_availability")}
|
subtitle={t("configure_availability")}
|
||||||
CTA={<NewScheduleButton />}>
|
CTA={
|
||||||
<WithQuery success={({ data }) => <AvailabilityList {...data} />} customLoader={<SkeletonLoader />} />
|
<div className="flex gap-2">
|
||||||
|
{orgBranding && (
|
||||||
|
<ToggleGroup
|
||||||
|
className="hidden md:block"
|
||||||
|
defaultValue={searchParams?.get("type") ?? "mine"}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
router.push(`${pathname}?${createQueryString("type", value)}`);
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: "mine", label: t("my_availability") },
|
||||||
|
{ value: "team", label: t("team_availability") },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<NewScheduleButton />
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
{searchParams?.get("type") === "team" && orgBranding ? (
|
||||||
|
<AvailabilitySliderTable />
|
||||||
|
) : (
|
||||||
|
<WithQuery
|
||||||
|
success={({ data }) => <AvailabilityList {...data} />}
|
||||||
|
customLoader={<SkeletonLoader />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</ShellMain>
|
</ShellMain>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -2007,5 +2007,7 @@
|
||||||
"attendee_last_name_info": "The person booking's last name",
|
"attendee_last_name_info": "The person booking's last name",
|
||||||
"me": "Me",
|
"me": "Me",
|
||||||
"verify_team_tooltip": "Verify your team to enable sending messages to attendees",
|
"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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,13 @@
|
||||||
"@lib/*": ["lib/*"],
|
"@lib/*": ["lib/*"],
|
||||||
"@server/*": ["server/*"],
|
"@server/*": ["server/*"],
|
||||||
"@prisma/client/*": ["@calcom/prisma/client/*"]
|
"@prisma/client/*": ["@calcom/prisma/client/*"]
|
||||||
}
|
},
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"strictNullChecks": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
/* Find a way to not require this - App files don't belong here. */
|
/* Find a way to not require this - App files don't belong here. */
|
||||||
|
@ -17,7 +23,8 @@
|
||||||
"../../packages/types/*.d.ts",
|
"../../packages/types/*.d.ts",
|
||||||
"../../packages/types/next-auth.d.ts",
|
"../../packages/types/next-auth.d.ts",
|
||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
"**/*.tsx"
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<HTMLDivElement>(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<SliderUser>[] = [
|
||||||
|
{
|
||||||
|
id: "member",
|
||||||
|
accessorFn: (data) => data.email,
|
||||||
|
header: "Member",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { username, email, timeZone } = row.original;
|
||||||
|
return (
|
||||||
|
<div className="max-w-64 flex flex-shrink-0 items-center gap-2 overflow-hidden">
|
||||||
|
<Avatar
|
||||||
|
size="sm"
|
||||||
|
alt={username || email}
|
||||||
|
imageSrc={"/" + username + "/avatar.png"}
|
||||||
|
gravatarFallbackMd5="fallback"
|
||||||
|
/>
|
||||||
|
<div className="">
|
||||||
|
<div className="text-emphasis max-w-64 truncate text-sm font-medium" title={email}>
|
||||||
|
{username || "No username"}
|
||||||
|
</div>
|
||||||
|
<div className="text-subtle text-xs leading-none">{timeZone}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-default text-sm font-medium">{time}</span>
|
||||||
|
<span className="text-subtle text-xs leading-none">GMT {offsetFormatted}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "slider",
|
||||||
|
header: () => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<ButtonGroup containerProps={{ className: "space-x-0" }}>
|
||||||
|
<Button
|
||||||
|
color="minimal"
|
||||||
|
variant="icon"
|
||||||
|
StartIcon={ChevronLeftIcon}
|
||||||
|
onClick={() => setBrowsingDate(browsingDate.subtract(1, "day"))}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => setBrowsingDate(browsingDate.add(1, "day"))}
|
||||||
|
color="minimal"
|
||||||
|
StartIcon={ChevronRightIcon}
|
||||||
|
variant="icon"
|
||||||
|
/>
|
||||||
|
</ButtonGroup>
|
||||||
|
<span>{browsingDate.format("DD dddd MMM, YYYY")}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { timeZone, dateRanges } = row.original;
|
||||||
|
// return <pre>{JSON.stringify(dateRanges, null, 2)}</pre>;
|
||||||
|
return <TimeDial timezone={timeZone} dateRanges={dateRanges} />;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<TBContext.Provider
|
||||||
|
value={createTimezoneBuddyStore({
|
||||||
|
browsingDate: browsingDate.toDate(),
|
||||||
|
})}>
|
||||||
|
<div className="relative">
|
||||||
|
<DataTable
|
||||||
|
tableContainerRef={tableContainerRef}
|
||||||
|
columns={memorisedColumns}
|
||||||
|
data={flatData}
|
||||||
|
isLoading={isLoading}
|
||||||
|
// tableOverlay={<HoverOverview />}
|
||||||
|
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TBContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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<T extends HTMLDivElement>(ref: React.RefObject<T>): DOMRect | null {
|
||||||
|
const [boundingRect, setBoundingRect] = useState<DOMRect | null>(null);
|
||||||
|
const observer = useRef<ResizeObserver | null>(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<HTMLDivElement | null>(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 (
|
||||||
|
<div ref={el} className="absoulte inset-0 w-full">
|
||||||
|
<div className="bg-default/80 absolute" style={leftWhiteout} />
|
||||||
|
<div className="bg-default/80 absolute" style={rightWhiteout} />
|
||||||
|
<div className="border-emphasis border border-dashed" style={position} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="flex items-end overflow-auto text-sm">
|
||||||
|
{days.map((day, i) => {
|
||||||
|
if (!day.length) return null;
|
||||||
|
const dateWithDaySet = usersTimezoneDate.add(i - 1, "day");
|
||||||
|
return (
|
||||||
|
<div key={i} className={classNames("border-subtle overflow-hidden rounded-lg border-2")}>
|
||||||
|
<div className="flex flex-none">
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={h}
|
||||||
|
className={classNames(
|
||||||
|
"flex h-8 flex-col items-center justify-center",
|
||||||
|
isInRange ? "text-emphasis dark:text-inverted" : "",
|
||||||
|
isInRange && !rangeOverlap ? "bg-success" : "",
|
||||||
|
hours ? "" : "bg-subtle font-medium"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
...minuteOffsetStyles,
|
||||||
|
width: `${DAY_CELL_WIDTH}px`,
|
||||||
|
backgroundImage: rangeGradients.backgroundGradient,
|
||||||
|
}}>
|
||||||
|
{hours ? (
|
||||||
|
<div title={hourSet.format("DD/MM HH:mm")}>
|
||||||
|
<div className="flex flex-col text-center text-xs leading-3">
|
||||||
|
{rangeGradients.textGradient ? (
|
||||||
|
<>
|
||||||
|
{/* light mode */}
|
||||||
|
<span className={classNames("text-1xl font-bold dark:hidden")}>
|
||||||
|
{hourSet.format("H")}
|
||||||
|
</span>
|
||||||
|
{/* dark mode */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
backgroundImage: rangeGradients.darkTextGradient,
|
||||||
|
}}
|
||||||
|
className={classNames(
|
||||||
|
"text-1xl hidden font-bold dark:block",
|
||||||
|
rangeOverlap ? "bg-clip-text text-transparent" : ""
|
||||||
|
)}>
|
||||||
|
{hourSet.format("H")}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-1xl font-bold">{hourSet.format("H")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col text-center text-xs leading-3">
|
||||||
|
<span>{hourSet.format("MMM")}</span>
|
||||||
|
<span>{hourSet.format("DD")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export const DAY_CELL_WIDTH = 30;
|
|
@ -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<typeof createTimezoneBuddyStore>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<TimezoneBuddyProps>) => {
|
||||||
|
const DEFAULT_PROPS: TimezoneBuddyProps = {
|
||||||
|
timeMode: "24h",
|
||||||
|
browsingDate: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return createStore<TimezoneBuddyState>()((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<TimezoneBuddyStore | null>(null);
|
|
@ -1,11 +1,13 @@
|
||||||
import authedProcedure from "../../../procedures/authedProcedure";
|
import authedProcedure from "../../../procedures/authedProcedure";
|
||||||
import { router } from "../../../trpc";
|
import { router } from "../../../trpc";
|
||||||
import { scheduleRouter } from "./schedule/_router";
|
import { scheduleRouter } from "./schedule/_router";
|
||||||
|
import { ZListTeamAvailaiblityScheme } from "./team/listTeamAvailability.schema";
|
||||||
import { ZUserInputSchema } from "./user.schema";
|
import { ZUserInputSchema } from "./user.schema";
|
||||||
|
|
||||||
type AvailabilityRouterHandlerCache = {
|
type AvailabilityRouterHandlerCache = {
|
||||||
list?: typeof import("./list.handler").listHandler;
|
list?: typeof import("./list.handler").listHandler;
|
||||||
user?: typeof import("./user.handler").userHandler;
|
user?: typeof import("./user.handler").userHandler;
|
||||||
|
listTeamAvailability?: typeof import("./team/listTeamAvailability.handler").listTeamAvailabilityHandler;
|
||||||
};
|
};
|
||||||
|
|
||||||
const UNSTABLE_HANDLER_CACHE: AvailabilityRouterHandlerCache = {};
|
const UNSTABLE_HANDLER_CACHE: AvailabilityRouterHandlerCache = {};
|
||||||
|
@ -41,6 +43,23 @@ export const availabilityRouter = router({
|
||||||
input,
|
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,
|
schedule: scheduleRouter,
|
||||||
});
|
});
|
||||||
|
|
|
@ -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<TrpcSessionUser>;
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
|
@ -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<typeof ZListTeamAvailaiblityScheme>;
|
|
@ -34,6 +34,7 @@ export interface DataTableProps<TData, TValue> {
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onScroll?: (e: React.UIEvent<HTMLDivElement, UIEvent>) => void;
|
onScroll?: (e: React.UIEvent<HTMLDivElement, UIEvent>) => void;
|
||||||
CTA?: React.ReactNode;
|
CTA?: React.ReactNode;
|
||||||
|
tableOverlay?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTable<TData, TValue>({
|
export function DataTable<TData, TValue>({
|
||||||
|
@ -45,6 +46,7 @@ export function DataTable<TData, TValue>({
|
||||||
selectionOptions,
|
selectionOptions,
|
||||||
tableContainerRef,
|
tableContainerRef,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
tableOverlay,
|
||||||
onScroll,
|
onScroll,
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
const [rowSelection, setRowSelection] = useState({});
|
const [rowSelection, setRowSelection] = useState({});
|
||||||
|
@ -159,6 +161,7 @@ export function DataTable<TData, TValue>({
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
{tableOverlay && tableOverlay}
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
{/* <DataTablePagination table={table} /> */}
|
{/* <DataTablePagination table={table} /> */}
|
||||||
|
|
Loading…
Reference in New Issue