164 lines
5.7 KiB
TypeScript
164 lines
5.7 KiB
TypeScript
|
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>
|
||
|
);
|
||
|
}
|