fix: replace filter implementations on /insights (#19033)
* fix: replace filter implementations on /insights * add authorization for teamIds * replace all the implementations * provide eventTypeId * remove log * fix teamId for isAll * fix Download button * remove legacy implementations * clean up * nullish check * remove unusable filters * revert style * fix type error * fix e2e test * fix type error * fix type error * fix e2e * extract util method * fix type error * fix type error * fix type error --------- Co-authored-by: Benny Joo <sldisek783@gmail.com>
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
DataTableProvider,
|
||||
DataTableFilters,
|
||||
DateRangeFilter,
|
||||
ColumnFilterType,
|
||||
type FilterableColumn,
|
||||
} from "@calcom/features/data-table";
|
||||
import {
|
||||
AverageEventDurationChart,
|
||||
BookingKPICards,
|
||||
@@ -12,25 +19,56 @@ import {
|
||||
HighestRatedMembersTable,
|
||||
LowestRatedMembersTable,
|
||||
} from "@calcom/features/insights/components";
|
||||
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
|
||||
import { Filters } from "@calcom/features/insights/filters";
|
||||
import "@calcom/features/insights/components/tremor.css";
|
||||
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
|
||||
import { Download } from "@calcom/features/insights/filters/Download";
|
||||
import { OrgTeamsFilter } from "@calcom/features/insights/filters/OrgTeamsFilter";
|
||||
import { useInsightsBookings } from "@calcom/features/insights/hooks/useInsightsBookings";
|
||||
import { useInsightsOrgTeams } from "@calcom/features/insights/hooks/useInsightsOrgTeams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
export default function InsightsPage() {
|
||||
return (
|
||||
<DataTableProvider>
|
||||
<InsightsOrgTeamsProvider>
|
||||
<InsightsPageContent />
|
||||
</InsightsOrgTeamsProvider>
|
||||
</DataTableProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const createdAtColumn: Extract<FilterableColumn, { type: ColumnFilterType.DATE_RANGE }> = {
|
||||
id: "createdAt",
|
||||
title: "createdAt",
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
};
|
||||
|
||||
function InsightsPageContent() {
|
||||
const { t } = useLocale();
|
||||
const { table } = useInsightsBookings();
|
||||
const { isAll, teamId, userId } = useInsightsOrgTeams();
|
||||
|
||||
return (
|
||||
<FiltersProvider>
|
||||
<Filters />
|
||||
<>
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
data-testid={`insights-filters-${isAll}-${teamId}-${userId}`}>
|
||||
<OrgTeamsFilter />
|
||||
<DataTableFilters.AddFilterButton table={table} />
|
||||
<DataTableFilters.ActiveFilters table={table} />
|
||||
<DataTableFilters.ClearFiltersButton exclude={["createdAt"]} />
|
||||
<div className="grow" />
|
||||
<Download />
|
||||
<DateRangeFilter column={createdAtColumn} />
|
||||
</div>
|
||||
|
||||
<div className="mb-4 space-y-4">
|
||||
<div className="my-4 space-y-4">
|
||||
<BookingKPICards />
|
||||
|
||||
<BookingStatusLineChart />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<PopularEventsTable />
|
||||
|
||||
<AverageEventDurationChart />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
@@ -53,6 +91,6 @@ export default function InsightsPage() {
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
</FiltersProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,10 +75,8 @@ test.describe("Insights", async () => {
|
||||
|
||||
// go to insights page
|
||||
await page.goto("/insights");
|
||||
await page.waitForURL(/.*\/insights\?teamId=[^&]*&isAll=false.*/);
|
||||
|
||||
// expect url to have isAll and TeamId in query params
|
||||
await expect(page).toHaveURL(/.*\/insights\?teamId=[^&]*&isAll=false.*/);
|
||||
await page.locator('[data-testid^="insights-filters-false-"]').waitFor();
|
||||
});
|
||||
|
||||
test("should be able to go to insights as members", async ({ page, users }) => {
|
||||
@@ -91,10 +89,7 @@ test.describe("Insights", async () => {
|
||||
// go to insights page
|
||||
await page.goto("/insights");
|
||||
|
||||
await page.waitForURL(/.*\/insights\?userId=[^&]*&isAll=false.*/);
|
||||
|
||||
// expect url to have isAll and TeamId in query params
|
||||
await expect(page).toHaveURL(/.*\/insights\?userId=[^&]*&isAll=false.*/);
|
||||
await page.locator('[data-testid^="insights-filters-false-"]').waitFor();
|
||||
});
|
||||
|
||||
test("team select filter should have 2 teams and your account option only as member", async ({
|
||||
@@ -107,19 +102,22 @@ test.describe("Insights", async () => {
|
||||
await user.apiLogin();
|
||||
|
||||
await createTeamsAndMembership(user.id, userTwo.id);
|
||||
|
||||
// go to insights page
|
||||
await page.goto("/insights");
|
||||
await page.locator(`[data-testid=insights-filters-false-undefined-${user.id}]`).waitFor();
|
||||
|
||||
// get div from team select filter with this class flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1
|
||||
await page.getByTestId("dashboard-shell").getByText("Team: test-insights").click();
|
||||
await page
|
||||
.locator('div[class="flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1"]')
|
||||
.click();
|
||||
const teamSelectFilter = await page.locator(
|
||||
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
|
||||
await page.getByTestId("dashboard-shell").getByText("Your account").click();
|
||||
|
||||
await expect(await page.locator('[data-testid="org-teams-filter-item"]')).toHaveCount(3);
|
||||
|
||||
await expect(await page.locator('[data-testid="org-teams-filter-item"]', { hasText: "All" })).toHaveCount(
|
||||
0
|
||||
);
|
||||
|
||||
await expect(teamSelectFilter).toHaveCount(3);
|
||||
await expect(
|
||||
await page.locator('[data-testid="org-teams-filter-item"]', { hasText: "Your account" })
|
||||
).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("Insights Organization should have isAll option true", async ({ users, page }) => {
|
||||
@@ -132,17 +130,20 @@ test.describe("Insights", async () => {
|
||||
await owner.apiLogin();
|
||||
|
||||
await page.goto("/insights");
|
||||
await page.locator('[data-testid^="insights-filters-true-"]').waitFor();
|
||||
|
||||
await page.getByTestId("dashboard-shell").getByText("All").nth(1).click();
|
||||
await page.locator('[data-testid^="insights-filters-true-"]').getByText("All").click();
|
||||
|
||||
const teamSelectFilter = await page.locator(
|
||||
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
|
||||
);
|
||||
await expect(await page.locator('[data-testid="org-teams-filter-item"]')).toHaveCount(3);
|
||||
|
||||
await expect(teamSelectFilter).toHaveCount(4);
|
||||
await expect(
|
||||
await page
|
||||
.locator('[data-testid="org-teams-filter-item"]', { hasText: "All" })
|
||||
.locator('input[type="checkbox"]')
|
||||
).toBeChecked();
|
||||
});
|
||||
|
||||
test("should have all option in team-and-self filter as admin", async ({ page, users }) => {
|
||||
test("should not have all option as admin in non-org", async ({ page, users }) => {
|
||||
const owner = await users.create();
|
||||
const member = await users.create();
|
||||
|
||||
@@ -152,16 +153,13 @@ test.describe("Insights", async () => {
|
||||
|
||||
await page.goto("/insights");
|
||||
|
||||
// get div from team select filter with this class flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1
|
||||
await page.getByTestId("dashboard-shell").getByText("Team: test-insights").click();
|
||||
await page
|
||||
.locator('div[class="flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1"]')
|
||||
.click();
|
||||
const teamSelectFilter = await page.locator(
|
||||
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
|
||||
);
|
||||
await page.getByTestId("dashboard-shell").getByText("Your account").click();
|
||||
|
||||
await expect(teamSelectFilter).toHaveCount(3);
|
||||
await expect(await page.locator('[data-testid="org-teams-filter-item"]')).toHaveCount(3);
|
||||
|
||||
await expect(await page.locator('[data-testid="org-teams-filter-item"]', { hasText: "All" })).toHaveCount(
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
test("should be able to switch between teams and self profile for insights", async ({ page, users }) => {
|
||||
@@ -174,25 +172,18 @@ test.describe("Insights", async () => {
|
||||
|
||||
await page.goto("/insights");
|
||||
|
||||
// get div from team select filter with this class flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1
|
||||
await page.getByTestId("dashboard-shell").getByText("Team: test-insights").click();
|
||||
await page
|
||||
.locator('div[class="flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1"]')
|
||||
.click();
|
||||
const teamSelectFilter = await page.locator(
|
||||
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
|
||||
);
|
||||
await page.getByTestId("dashboard-shell").getByText("Your account").click();
|
||||
|
||||
await expect(teamSelectFilter).toHaveCount(3);
|
||||
await expect(await page.locator('[data-testid="org-teams-filter-item"]')).toHaveCount(3);
|
||||
|
||||
// switch to self profile
|
||||
await page.getByTestId("dashboard-shell").getByText("Your Account").click();
|
||||
await page.locator('[data-testid="org-teams-filter-item"]', { hasText: "Your account" }).click();
|
||||
|
||||
// switch to team 1
|
||||
await page.getByTestId("dashboard-shell").getByText("test-insights").nth(0).click();
|
||||
await page.locator('[data-testid="org-teams-filter-item"]', { hasText: "test-insights" }).first().click();
|
||||
|
||||
// switch to team 2
|
||||
await page.getByTestId("dashboard-shell").getByText("test-insights-2").click();
|
||||
await page.locator('[data-testid="org-teams-filter-item"]', { hasText: "test-insights-2" }).click();
|
||||
});
|
||||
|
||||
test("should be able to switch between memberUsers", async ({ page, users }) => {
|
||||
@@ -205,36 +196,39 @@ test.describe("Insights", async () => {
|
||||
|
||||
await page.goto("/insights");
|
||||
|
||||
await page.getByText("Add filter").click();
|
||||
// Choose a team
|
||||
await page.getByTestId("dashboard-shell").getByText("Your account").click();
|
||||
await page.locator('[data-testid="org-teams-filter-item"]').nth(1).click();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByRole("button", { name: "User" }).click();
|
||||
// <div class="flex select-none truncate font-medium" data-state="closed">People</div>
|
||||
// await page.locator('div[class="flex select-none truncate font-medium"]').getByText("People").click();
|
||||
await page.waitForSelector('[data-testid="people-filter-button"]');
|
||||
await page.click('[data-testid="people-filter-button"]');
|
||||
// Choose User filter item from dropdown
|
||||
await page.getByTestId("add-filter-button").click();
|
||||
await page.getByTestId("add-filter-item-bookingUserId").click();
|
||||
|
||||
// Wait for the URL to include bookingUserId
|
||||
await page.waitForURL((url) => url.toString().includes("bookingUserId"));
|
||||
|
||||
// Click User filter to see a user list
|
||||
await page.getByTestId("filter-popover-trigger-bookingUserId").click();
|
||||
|
||||
await page
|
||||
// .locator(
|
||||
// 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
|
||||
// )
|
||||
.locator('[data-testid="people-filter-group-option-0"]')
|
||||
// .nth(0)
|
||||
.locator('[data-testid="single-select-options-bookingUserId"]')
|
||||
.getByRole("option")
|
||||
.nth(0)
|
||||
.click();
|
||||
|
||||
await page
|
||||
// .locator(
|
||||
// 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
|
||||
// )
|
||||
.locator('[data-testid="people-filter-group-option-1"]')
|
||||
// .nth(1)
|
||||
.locator('[data-testid="single-select-options-bookingUserId"]')
|
||||
.getByRole("option")
|
||||
.nth(1)
|
||||
.click();
|
||||
|
||||
// press escape button to close the filter
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByRole("button", { name: "Clear" }).click();
|
||||
await page.getByTestId("clear-filters-button").click();
|
||||
|
||||
// expect for "Team: test-insight" text in page
|
||||
await expect(page.locator("text=Team: test-insights")).toBeVisible();
|
||||
await expect(page.url()).not.toContain("bookingUserId");
|
||||
});
|
||||
|
||||
test("should test download button", async ({ page, users }) => {
|
||||
|
||||
@@ -63,7 +63,8 @@ function AddFilterButtonComponent<TData>(
|
||||
<CommandItem
|
||||
key={column.id}
|
||||
onSelect={() => handleAddFilter(column.id)}
|
||||
className="px-4 py-2">
|
||||
className="px-4 py-2"
|
||||
data-testid={`add-filter-item-${column.id}`}>
|
||||
{startCase(column.title)}
|
||||
</CommandItem>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ export const ClearFiltersButton = ({ exclude }: { exclude?: string[] }) => {
|
||||
return (
|
||||
<Tooltip content={t("clear_filters")}>
|
||||
<Button
|
||||
data-testid="clear-filters-button"
|
||||
variant="icon"
|
||||
color="secondary"
|
||||
target="_blank"
|
||||
|
||||
@@ -18,37 +18,22 @@ import {
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { useDataTable, useFilterValue } from "../../hooks";
|
||||
import {
|
||||
CUSTOM_PRESET,
|
||||
CUSTOM_PRESET_VALUE,
|
||||
DEFAULT_PRESET,
|
||||
PRESET_OPTIONS,
|
||||
getDefaultStartDate,
|
||||
getDefaultEndDate,
|
||||
type PresetOption,
|
||||
} from "../../lib/dateRange";
|
||||
import type { FilterableColumn } from "../../lib/types";
|
||||
import { ZDateRangeFilterValue, ColumnFilterType } from "../../lib/types";
|
||||
|
||||
type PresetOption = {
|
||||
labelKey: string;
|
||||
i18nOptions?: Record<string, string | number>;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type DateRangeFilterProps = {
|
||||
column: Extract<FilterableColumn, { type: ColumnFilterType.DATE_RANGE }>;
|
||||
};
|
||||
|
||||
const CUSTOM_PRESET_VALUE = "c" as const;
|
||||
|
||||
const DEFAULT_PRESET: PresetOption = {
|
||||
labelKey: "last_number_of_days",
|
||||
i18nOptions: { count: 7 },
|
||||
value: "w",
|
||||
};
|
||||
const CUSTOM_PRESET: PresetOption = { labelKey: "custom_range", value: CUSTOM_PRESET_VALUE };
|
||||
|
||||
const PRESET_OPTIONS: PresetOption[] = [
|
||||
{ labelKey: "today", value: "tdy" },
|
||||
DEFAULT_PRESET,
|
||||
{ labelKey: "last_number_of_days", i18nOptions: { count: 30 }, value: "t" },
|
||||
{ labelKey: "month_to_date", value: "m" },
|
||||
{ labelKey: "year_to_date", value: "y" },
|
||||
CUSTOM_PRESET,
|
||||
];
|
||||
|
||||
const getDateRangeFromPreset = (val: string | null) => {
|
||||
let startDate;
|
||||
let endDate;
|
||||
@@ -87,10 +72,6 @@ const getDateRangeFromPreset = (val: string | null) => {
|
||||
return { startDate, endDate, preset };
|
||||
};
|
||||
|
||||
const getDefaultStartDate = () => dayjs().subtract(1, "week").startOf("day");
|
||||
|
||||
const getDefaultEndDate = () => dayjs().endOf("day");
|
||||
|
||||
export const DateRangeFilter = ({ column }: DateRangeFilterProps) => {
|
||||
const filterValue = useFilterValue(column.id, ZDateRangeFilterValue);
|
||||
const { updateFilter } = useDataTable();
|
||||
|
||||
@@ -134,7 +134,10 @@ export function FilterPopover({ column }: FilterPopoverProps) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button color="secondary" className="items-center">
|
||||
<Button
|
||||
color="secondary"
|
||||
className="items-center"
|
||||
data-testid={`filter-popover-trigger-${column.id}`}>
|
||||
<Icon name={icon} className="mr-2 h-4 w-4" />
|
||||
<span>{startCase(column.title)}</span>
|
||||
<AppliedFilterValue column={column} filterValue={filterValue} />
|
||||
|
||||
@@ -28,7 +28,7 @@ export function MultiSelectFilterOptions({ column }: MultiSelectFilterOptionsPro
|
||||
const { updateFilter, removeFilter } = useDataTable();
|
||||
|
||||
return (
|
||||
<Command>
|
||||
<Command data-testid={`multi-select-options-${column.id}`}>
|
||||
<CommandInput placeholder={t("search")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("no_options_found")}</CommandEmpty>
|
||||
|
||||
@@ -28,7 +28,7 @@ export function SingleSelectFilterOptions({ column }: SingleSelectFilterOptionsP
|
||||
const { updateFilter, removeFilter } = useDataTable();
|
||||
|
||||
return (
|
||||
<Command>
|
||||
<Command data-testid={`single-select-options-${column.id}`}>
|
||||
<CommandInput placeholder={t("search")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("no_options_found")}</CommandEmpty>
|
||||
|
||||
@@ -4,12 +4,14 @@ import { type Table } from "@tanstack/react-table";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { FilterableColumn } from "../lib/types";
|
||||
import type { FilterableColumn, FacetedValue } from "../lib/types";
|
||||
import { ColumnFilterType } from "../lib/types";
|
||||
import { convertMapToFacetedValues } from "../lib/utils";
|
||||
|
||||
export function useFilterableColumns<TData>(table: Table<TData>) {
|
||||
const columns = useMemo(
|
||||
() => table.getAllColumns().filter((column) => column.getCanFilter()),
|
||||
|
||||
[table.getAllColumns()]
|
||||
);
|
||||
|
||||
@@ -25,20 +27,18 @@ export function useFilterableColumns<TData>(table: Table<TData>) {
|
||||
type,
|
||||
};
|
||||
if (type === ColumnFilterType.MULTI_SELECT || type === ColumnFilterType.SINGLE_SELECT) {
|
||||
const values = column.getFacetedUniqueValues();
|
||||
const options = Array.from(values.keys()).map((option) => {
|
||||
if (typeof option === "string") {
|
||||
return {
|
||||
label: option,
|
||||
value: option,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
label: option.label as string,
|
||||
value: option.value as string | number,
|
||||
};
|
||||
}
|
||||
});
|
||||
// `column.getFacetedUniqueValues` gets out of sync
|
||||
// when we pass a new `getFacetedUniqueValues` to
|
||||
// `useReactTable({ ... })`.
|
||||
//
|
||||
// So we use `table.options.getFacetedUniqueValues` instead.
|
||||
let values: Map<FacetedValue, number> | (() => Map<FacetedValue, number>) | undefined =
|
||||
table.options?.getFacetedUniqueValues?.(table, column.id);
|
||||
if (typeof values === "function") {
|
||||
values = values();
|
||||
}
|
||||
|
||||
const options = convertMapToFacetedValues(values);
|
||||
return {
|
||||
...base,
|
||||
options,
|
||||
@@ -50,7 +50,10 @@ export function useFilterableColumns<TData>(table: Table<TData>) {
|
||||
}
|
||||
})
|
||||
.filter((column): column is FilterableColumn => Boolean(column)),
|
||||
[columns]
|
||||
|
||||
// re-calculate this when the `getFacetedUniqueValues` changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[columns, table.options.getFacetedUniqueValues]
|
||||
);
|
||||
|
||||
return filterableColumns;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import dayjs from "@calcom/dayjs";
|
||||
|
||||
export type PresetOptionValue = "c" | "w" | "m" | "y" | "t" | "tdy";
|
||||
|
||||
export type PresetOption = {
|
||||
labelKey: string;
|
||||
i18nOptions?: Record<string, string | number>;
|
||||
value: PresetOptionValue;
|
||||
};
|
||||
|
||||
export const CUSTOM_PRESET_VALUE = "c" as const;
|
||||
|
||||
export const DEFAULT_PRESET: PresetOption = {
|
||||
labelKey: "last_number_of_days",
|
||||
i18nOptions: { count: 7 },
|
||||
value: "w",
|
||||
};
|
||||
export const CUSTOM_PRESET: PresetOption = { labelKey: "custom_range", value: CUSTOM_PRESET_VALUE };
|
||||
|
||||
export const PRESET_OPTIONS: PresetOption[] = [
|
||||
{ labelKey: "today", value: "tdy" },
|
||||
DEFAULT_PRESET,
|
||||
{ labelKey: "last_number_of_days", i18nOptions: { count: 30 }, value: "t" },
|
||||
{ labelKey: "month_to_date", value: "m" },
|
||||
{ labelKey: "year_to_date", value: "y" },
|
||||
CUSTOM_PRESET,
|
||||
];
|
||||
|
||||
export const getDefaultStartDate = () => dayjs().subtract(1, "week").startOf("day");
|
||||
|
||||
export const getDefaultEndDate = () => dayjs().endOf("day");
|
||||
@@ -148,3 +148,8 @@ export const ZSorting = z.object({
|
||||
export type SortingState = Array<z.infer<typeof ZSorting>>;
|
||||
|
||||
export const ZColumnVisibility = z.record(z.string(), z.boolean());
|
||||
|
||||
export type FacetedValue = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
FilterValue,
|
||||
NumberFilterValue,
|
||||
DateRangeFilterValue,
|
||||
FacetedValue,
|
||||
} from "./types";
|
||||
import {
|
||||
ZNumberFilterValue,
|
||||
@@ -147,3 +148,21 @@ export const dataTableFilter = (cellValue: unknown, filterValue: FilterValue) =>
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const convertFacetedValuesToMap = (array: FacetedValue[]) => {
|
||||
return new Map<FacetedValue, number>(
|
||||
array.map((option) => [{ label: option.label, value: option.value }, 1])
|
||||
);
|
||||
};
|
||||
|
||||
export const convertMapToFacetedValues = (map: Map<FacetedValue, number> | undefined) => {
|
||||
if (!map || !(map instanceof Map)) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(map.keys()).map((option) => {
|
||||
if (typeof option === "string") {
|
||||
return { label: option, value: option };
|
||||
}
|
||||
return { label: option.label as string, value: option.value as string | number };
|
||||
});
|
||||
};
|
||||
|
||||
@@ -318,7 +318,7 @@ function MemberListContent(props: Props) {
|
||||
id: "member",
|
||||
accessorFn: (data) => data.email,
|
||||
enableHiding: false,
|
||||
header: `Member (${totalDBRowCount})`,
|
||||
header: "Member",
|
||||
size: 250,
|
||||
cell: ({ row }) => {
|
||||
const { username, email, avatarUrl, accepted, name } = row.original;
|
||||
@@ -621,7 +621,6 @@ function MemberListContent(props: Props) {
|
||||
}, [props.isOrgAdminOrOwner, dispatch, totalDBRowCount, session?.user.id]);
|
||||
//we must flatten the array of arrays from the useInfiniteQuery hook
|
||||
const flatData = useMemo(() => data?.pages?.flatMap((page) => page.members) ?? [], [data]) as User[];
|
||||
const totalFetched = flatData.length;
|
||||
|
||||
const table = useReactTable({
|
||||
data: flatData,
|
||||
|
||||
@@ -154,38 +154,43 @@ export const FilterCheckboxFieldsContainer = ({
|
||||
|
||||
type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||
label: string;
|
||||
testId?: string;
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
export const FilterCheckboxField = forwardRef<HTMLInputElement, Props>(({ label, icon, ...rest }, ref) => {
|
||||
return (
|
||||
<div className="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer">
|
||||
<label className="flex w-full max-w-full items-center justify-between hover:cursor-pointer">
|
||||
<div className="flex items-center truncate">
|
||||
{icon && (
|
||||
<div className="text-default flex h-4 w-4 items-center justify-center ltr:mr-2 rtl:ml-2">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<Tooltip content={label}>
|
||||
<label
|
||||
htmlFor={rest.id}
|
||||
className="text-default me-1 cursor-pointer truncate text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
{...rest}
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
className="text-emphasis dark:text-muted focus:ring-emphasis border-default bg-default h-4 w-4 rounded transition hover:cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
export const FilterCheckboxField = forwardRef<HTMLInputElement, Props>(
|
||||
({ label, icon, testId, ...rest }, ref) => {
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer">
|
||||
<label className="flex w-full max-w-full items-center justify-between hover:cursor-pointer">
|
||||
<div className="flex items-center truncate">
|
||||
{icon && (
|
||||
<div className="text-default flex h-4 w-4 items-center justify-center ltr:mr-2 rtl:ml-2">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<Tooltip content={label}>
|
||||
<label
|
||||
htmlFor={rest.id}
|
||||
className="text-default me-1 cursor-pointer truncate text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
{...rest}
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
className="text-emphasis dark:text-muted focus:ring-emphasis border-default bg-default h-4 w-4 rounded transition hover:cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FilterCheckboxField.displayName = "FilterCheckboxField";
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Title } from "@tremor/react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import { useInsightsParameters } from "../hooks/useInsightsParameters";
|
||||
import { valueFormatter } from "../lib/valueFormatter";
|
||||
import { CardInsights } from "./Card";
|
||||
import { LineChart } from "./LineChart";
|
||||
@@ -12,19 +11,16 @@ import { LoadingInsight } from "./LoadingInsights";
|
||||
|
||||
export const AverageEventDurationChart = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter } = useFilterContext();
|
||||
const { dateRange, selectedMemberUserId, isAll, initialConfig } = filter;
|
||||
const [startDate, endDate] = dateRange;
|
||||
const { selectedTeamId: teamId, selectedUserId, selectedEventTypeId } = filter;
|
||||
const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
|
||||
const { isAll, teamId, userId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.averageEventDuration.useQuery(
|
||||
{
|
||||
startDate: dayjs.utc(startDate).toISOString(),
|
||||
endDate: dayjs.utc(endDate).toISOString(),
|
||||
startDate,
|
||||
endDate,
|
||||
teamId,
|
||||
eventTypeId: selectedEventTypeId ?? undefined,
|
||||
memberUserId: selectedMemberUserId ?? undefined,
|
||||
userId: selectedUserId ?? undefined,
|
||||
eventTypeId,
|
||||
memberUserId,
|
||||
userId,
|
||||
isAll,
|
||||
},
|
||||
{
|
||||
@@ -32,15 +28,13 @@ export const AverageEventDurationChart = () => {
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
// At least one of the following initial configs should have a value
|
||||
enabled: initialConfigIsReady,
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !startDate || !endDate || (!teamId && !selectedUserId)) return null;
|
||||
const isNoData = (data && data.length === 0) || data.every((item) => item["Average"] === 0);
|
||||
if (!isSuccess || !data) return null;
|
||||
const isNoData = data.every((item) => item["Average"] === 0);
|
||||
return (
|
||||
<CardInsights>
|
||||
<Title className="text-emphasis">{t("average_event_duration")}</Title>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useId } from "react";
|
||||
|
||||
import { Badge } from "@calcom/ui";
|
||||
|
||||
import type { RoutingFormTableRow } from "../lib/types";
|
||||
import { CellWithOverflowX } from "./CellWithOverflowX";
|
||||
|
||||
export function BookedByCell({
|
||||
attendees,
|
||||
rowId,
|
||||
}: {
|
||||
attendees: RoutingFormTableRow["bookingAttendees"] | undefined;
|
||||
rowId: number;
|
||||
}) {
|
||||
const cellId = useId();
|
||||
if (!attendees || attendees.length === 0) return <div className="min-w-[200px]" />;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[200px] flex-wrap gap-1">
|
||||
{attendees.map((attendee) => (
|
||||
<CellWithOverflowX key={`${cellId}-${attendee.email}-${rowId}`} className="w-[200px]">
|
||||
<Badge variant="gray" className="whitespace-nowrap" title={attendee.email}>
|
||||
{attendee.name}
|
||||
</Badge>
|
||||
</CellWithOverflowX>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import Link from "next/link";
|
||||
import { useId } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import {
|
||||
Badge,
|
||||
Avatar,
|
||||
Icon,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
HoverCardPortal,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import type { RoutingFormTableRow } from "../lib/types";
|
||||
import { BookingStatusBadge } from "./BookingStatusBadge";
|
||||
|
||||
export function BookingAtCell({ row, rowId }: { row: RoutingFormTableRow; rowId: number }) {
|
||||
const cellId = useId();
|
||||
const { copyToClipboard } = useCopy();
|
||||
|
||||
if (!row.bookingUserId || !row.bookingCreatedAt) {
|
||||
return <div className="w-[250px]" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="flex items-center gap-2" key={`${cellId}-booking-${rowId}`}>
|
||||
<Avatar size="xs" imageSrc={row.bookingUserAvatarUrl ?? ""} alt={row.bookingUserName ?? ""} />
|
||||
<Link href={`/booking/${row.bookingUid}`}>
|
||||
<Badge variant="gray">{dayjs(row.bookingCreatedAt).format("MMM D, YYYY HH:mm")}</Badge>
|
||||
</Link>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar size="sm" imageSrc={row.bookingUserAvatarUrl ?? ""} alt={row.bookingUserName ?? ""} />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{row.bookingUserName}</p>
|
||||
<p className="group/booking_status_email text-subtle flex items-center text-xs">
|
||||
<span className="truncate">{row.bookingUserEmail}</span>
|
||||
<button
|
||||
className="invisible ml-2 group-hover/booking_status_email:visible"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
copyToClipboard(row.bookingUserEmail ?? "");
|
||||
}}>
|
||||
<Icon name="copy" />
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-emphasis mt-4 flex items-center gap-2 text-xs">
|
||||
<span>Status:</span>
|
||||
<BookingStatusBadge bookingStatus={row.bookingStatus} />
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
@@ -4,29 +4,22 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import { useInsightsParameters } from "../hooks/useInsightsParameters";
|
||||
import { CardInsights } from "./Card";
|
||||
import { KPICard } from "./KPICard";
|
||||
|
||||
export const BookingKPICards = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter } = useFilterContext();
|
||||
|
||||
const { dateRange, selectedEventTypeId, selectedUserId, selectedMemberUserId, isAll, initialConfig } =
|
||||
filter;
|
||||
const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
|
||||
const [startDate, endDate] = dateRange;
|
||||
|
||||
const { selectedTeamId: teamId } = filter;
|
||||
const { startDate, endDate, teamId, userId, isAll, memberUserId, eventTypeId } = useInsightsParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.eventsByStatus.useQuery(
|
||||
{
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
startDate,
|
||||
endDate,
|
||||
teamId,
|
||||
eventTypeId: selectedEventTypeId ?? undefined,
|
||||
memberUserId: selectedMemberUserId ?? undefined,
|
||||
userId: selectedUserId ?? undefined,
|
||||
eventTypeId,
|
||||
memberUserId,
|
||||
userId,
|
||||
isAll,
|
||||
},
|
||||
{
|
||||
@@ -34,7 +27,6 @@ export const BookingKPICards = () => {
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
enabled: initialConfigIsReady,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -88,7 +80,7 @@ export const BookingKPICards = () => {
|
||||
return <LoadingKPICards categories={categories} />;
|
||||
}
|
||||
|
||||
if (!isSuccess || !startDate || !endDate || (!teamId && !selectedUserId)) return null;
|
||||
if (!isSuccess || !data) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { Badge, type BadgeProps } from "@calcom/ui";
|
||||
|
||||
import { bookingStatusToText } from "../lib/bookingStatusToText";
|
||||
|
||||
export function BookingStatusBadge({ bookingStatus }: { bookingStatus: BookingStatus | null }) {
|
||||
let badgeVariant: BadgeProps["variant"] = "success";
|
||||
|
||||
if (!bookingStatus) return null;
|
||||
|
||||
switch (bookingStatus) {
|
||||
case BookingStatus.REJECTED:
|
||||
case BookingStatus.AWAITING_HOST:
|
||||
case BookingStatus.PENDING:
|
||||
case BookingStatus.CANCELLED:
|
||||
badgeVariant = "warning";
|
||||
break;
|
||||
}
|
||||
|
||||
return <Badge variant={badgeVariant}>{bookingStatusToText(bookingStatus)}</Badge>;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Title } from "@tremor/react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import { useInsightsParameters } from "../hooks/useInsightsParameters";
|
||||
import { valueFormatter } from "../lib/valueFormatter";
|
||||
import { CardInsights } from "./Card";
|
||||
import { LineChart } from "./LineChart";
|
||||
@@ -12,20 +12,15 @@ import { LoadingInsight } from "./LoadingInsights";
|
||||
|
||||
export const BookingStatusLineChart = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter } = useFilterContext();
|
||||
const {
|
||||
selectedTeamId,
|
||||
selectedUserId,
|
||||
selectedTimeView = "week",
|
||||
dateRange,
|
||||
selectedEventTypeId,
|
||||
isAll,
|
||||
initialConfig,
|
||||
} = filter;
|
||||
const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
|
||||
const [startDate, endDate] = dateRange;
|
||||
const { isAll, teamId, userId, startDate, endDate, dateRangePreset, eventTypeId } = useInsightsParameters();
|
||||
|
||||
if (!startDate || !endDate) return null;
|
||||
const selectedTimeView = useMemo(() => {
|
||||
if (dateRangePreset === "tdy") return "day";
|
||||
else if (dateRangePreset === "w") return "week";
|
||||
else if (dateRangePreset === "m") return "month";
|
||||
else if (dateRangePreset === "y") return "year";
|
||||
else return "week";
|
||||
}, [dateRangePreset]);
|
||||
|
||||
const {
|
||||
data: eventsTimeLine,
|
||||
@@ -34,11 +29,11 @@ export const BookingStatusLineChart = () => {
|
||||
} = trpc.viewer.insights.eventsTimeline.useQuery(
|
||||
{
|
||||
timeView: selectedTimeView,
|
||||
startDate: dayjs.utc(startDate).toISOString(),
|
||||
endDate: dayjs.utc(endDate).toISOString(),
|
||||
teamId: selectedTeamId ?? undefined,
|
||||
eventTypeId: selectedEventTypeId ?? undefined,
|
||||
userId: selectedUserId ?? undefined,
|
||||
startDate,
|
||||
endDate,
|
||||
teamId,
|
||||
eventTypeId,
|
||||
userId,
|
||||
isAll,
|
||||
},
|
||||
{
|
||||
@@ -46,7 +41,6 @@ export const BookingStatusLineChart = () => {
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
enabled: initialConfigIsReady,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
|
||||
export function CellWithOverflowX({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={classNames("group relative max-w-[200px]", className)}>
|
||||
<div
|
||||
className="no-scrollbar flex gap-1 overflow-x-auto whitespace-nowrap"
|
||||
ref={(el) => {
|
||||
// Only add shadow if the scroll width is greater than 200px
|
||||
if (!el) return;
|
||||
const nextElement = el.nextElementSibling;
|
||||
if (!nextElement) return;
|
||||
|
||||
if (el.scrollWidth > 200) {
|
||||
nextElement.classList.remove("hidden");
|
||||
} else {
|
||||
nextElement.classList.add("hidden");
|
||||
}
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
<div className="from-default absolute right-0 top-0 hidden h-full w-8 bg-gradient-to-l to-transparent " />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,24 +3,21 @@ import { Title } from "@tremor/react";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import { useInsightsParameters } from "../hooks/useInsightsParameters";
|
||||
import { CardInsights } from "./Card";
|
||||
import { LoadingInsight } from "./LoadingInsights";
|
||||
import { TotalUserFeedbackTable } from "./TotalUserFeedbackTable";
|
||||
|
||||
export const HighestNoShowHostTable = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter } = useFilterContext();
|
||||
const { dateRange, selectedEventTypeId, isAll, initialConfig } = filter;
|
||||
const [startDate, endDate] = dateRange;
|
||||
const { selectedTeamId: teamId } = filter;
|
||||
const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostNoShow.useQuery(
|
||||
{
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
startDate,
|
||||
endDate,
|
||||
teamId,
|
||||
eventTypeId: selectedEventTypeId ?? undefined,
|
||||
eventTypeId,
|
||||
isAll,
|
||||
},
|
||||
{
|
||||
@@ -28,13 +25,12 @@ export const HighestNoShowHostTable = () => {
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll),
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !startDate || !endDate || !teamId) return null;
|
||||
if (!isSuccess || !data) return null;
|
||||
|
||||
return data && data.length > 0 ? (
|
||||
<CardInsights className="shadow-none">
|
||||
|
||||
@@ -3,24 +3,21 @@ import { Title } from "@tremor/react";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import { useInsightsParameters } from "../hooks/useInsightsParameters";
|
||||
import { CardInsights } from "./Card";
|
||||
import { LoadingInsight } from "./LoadingInsights";
|
||||
import { TotalUserFeedbackTable } from "./TotalUserFeedbackTable";
|
||||
|
||||
export const HighestRatedMembersTable = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter } = useFilterContext();
|
||||
const { dateRange, selectedEventTypeId, isAll, initialConfig } = filter;
|
||||
const [startDate, endDate] = dateRange;
|
||||
const { selectedTeamId: teamId } = filter;
|
||||
const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithHighestRatings.useQuery(
|
||||
{
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
startDate,
|
||||
endDate,
|
||||
teamId,
|
||||
eventTypeId: selectedEventTypeId ?? undefined,
|
||||
eventTypeId,
|
||||
isAll,
|
||||
},
|
||||
{
|
||||
@@ -28,13 +25,12 @@ export const HighestRatedMembersTable = () => {
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll),
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !startDate || !endDate || !teamId) return null;
|
||||
if (!isSuccess || !data) return null;
|
||||
|
||||
return data && data.length > 0 ? (
|
||||
<CardInsights className="shadow-none">
|
||||
|
||||
@@ -3,23 +3,21 @@ import { Title } from "@tremor/react";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import { useInsightsParameters } from "../hooks/useInsightsParameters";
|
||||
import { CardInsights } from "./Card";
|
||||
import { LoadingInsight } from "./LoadingInsights";
|
||||
import { TotalBookingUsersTable } from "./TotalBookingUsersTable";
|
||||
|
||||
export const LeastBookedTeamMembersTable = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter } = useFilterContext();
|
||||
const { dateRange, selectedEventTypeId, selectedTeamId: teamId, isAll, initialConfig } = filter;
|
||||
const [startDate, endDate] = dateRange;
|
||||
const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithLeastBookings.useQuery(
|
||||
{
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
startDate,
|
||||
endDate,
|
||||
teamId,
|
||||
eventTypeId: selectedEventTypeId ?? undefined,
|
||||
eventTypeId,
|
||||
isAll,
|
||||
},
|
||||
{
|
||||
@@ -27,13 +25,12 @@ export const LeastBookedTeamMembersTable = () => {
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll),
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !startDate || !endDate || !teamId) return null;
|
||||
if (!isSuccess || !data) return null;
|
||||
|
||||
return (
|
||||
<CardInsights>
|
||||
|
||||
@@ -3,24 +3,21 @@ import { Title } from "@tremor/react";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import { useInsightsParameters } from "../hooks/useInsightsParameters";
|
||||
import { CardInsights } from "./Card";
|
||||
import { LoadingInsight } from "./LoadingInsights";
|
||||
import { TotalUserFeedbackTable } from "./TotalUserFeedbackTable";
|
||||
|
||||
export const LowestRatedMembersTable = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter } = useFilterContext();
|
||||
const { dateRange, selectedEventTypeId, isAll, initialConfig } = filter;
|
||||
const [startDate, endDate] = dateRange;
|
||||
const { selectedTeamId: teamId } = filter;
|
||||
const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithLowestRatings.useQuery(
|
||||
{
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
startDate,
|
||||
endDate,
|
||||
teamId,
|
||||
eventTypeId: selectedEventTypeId ?? undefined,
|
||||
eventTypeId,
|
||||
isAll,
|
||||
},
|
||||
{
|
||||
@@ -28,13 +25,12 @@ export const LowestRatedMembersTable = () => {
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll),
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !startDate || !endDate || !teamId) return null;
|
||||
if (!isSuccess || !data) return null;
|
||||
|
||||
return data && data.length > 0 ? (
|
||||
<CardInsights className="shadow-none">
|
||||
|
||||
@@ -3,24 +3,21 @@ import { Title } from "@tremor/react";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import { useInsightsParameters } from "../hooks/useInsightsParameters";
|
||||
import { CardInsights } from "./Card";
|
||||
import { LoadingInsight } from "./LoadingInsights";
|
||||
import { TotalBookingUsersTable } from "./TotalBookingUsersTable";
|
||||
|
||||
export const MostBookedTeamMembersTable = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter } = useFilterContext();
|
||||
const { dateRange, selectedEventTypeId, isAll, initialConfig } = filter;
|
||||
const [startDate, endDate] = dateRange;
|
||||
const { selectedTeamId: teamId } = filter;
|
||||
const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostBookings.useQuery(
|
||||
{
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
startDate,
|
||||
endDate,
|
||||
teamId,
|
||||
eventTypeId: selectedEventTypeId ?? undefined,
|
||||
eventTypeId,
|
||||
isAll,
|
||||
},
|
||||
{
|
||||
@@ -28,13 +25,12 @@ export const MostBookedTeamMembersTable = () => {
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll),
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !startDate || !endDate || !teamId) return null;
|
||||
if (!isSuccess || !data) return null;
|
||||
|
||||
return (
|
||||
<CardInsights className="shadow-none">
|
||||
|
||||
@@ -3,26 +3,22 @@ import { Table, TableBody, TableCell, TableRow, Text, Title } from "@tremor/reac
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import { useInsightsParameters } from "../hooks/useInsightsParameters";
|
||||
import { CardInsights } from "./Card";
|
||||
import { LoadingInsight } from "./LoadingInsights";
|
||||
|
||||
export const PopularEventsTable = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter } = useFilterContext();
|
||||
const { dateRange, selectedMemberUserId, selectedUserId, isAll, initialConfig, selectedEventTypeId } =
|
||||
filter;
|
||||
const [startDate, endDate] = dateRange;
|
||||
const { selectedTeamId: teamId } = filter;
|
||||
const { isAll, teamId, userId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.popularEventTypes.useQuery(
|
||||
{
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
teamId: teamId ?? undefined,
|
||||
userId: selectedUserId ?? undefined,
|
||||
eventTypeId: selectedEventTypeId ?? undefined,
|
||||
memberUserId: selectedMemberUserId ?? undefined,
|
||||
startDate,
|
||||
endDate,
|
||||
teamId,
|
||||
userId,
|
||||
eventTypeId,
|
||||
memberUserId,
|
||||
isAll,
|
||||
},
|
||||
{
|
||||
@@ -30,13 +26,12 @@ export const PopularEventsTable = () => {
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll),
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !startDate || !endDate || (!teamId && !selectedUserId)) return null;
|
||||
if (!isSuccess || !data) return null;
|
||||
|
||||
return (
|
||||
<CardInsights>
|
||||
|
||||
@@ -3,23 +3,21 @@ import { Title } from "@tremor/react";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import { useInsightsParameters } from "../hooks/useInsightsParameters";
|
||||
import { CardInsights } from "./Card";
|
||||
import { FeedbackTable } from "./FeedbackTable";
|
||||
import { LoadingInsight } from "./LoadingInsights";
|
||||
|
||||
export const RecentFeedbackTable = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter } = useFilterContext();
|
||||
const { dateRange, selectedEventTypeId, selectedTeamId: teamId, isAll, initialConfig } = filter;
|
||||
const [startDate, endDate] = dateRange;
|
||||
const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.recentRatings.useQuery(
|
||||
{
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
startDate,
|
||||
endDate,
|
||||
teamId,
|
||||
eventTypeId: selectedEventTypeId ?? undefined,
|
||||
eventTypeId,
|
||||
isAll,
|
||||
},
|
||||
{
|
||||
@@ -27,13 +25,12 @@ export const RecentFeedbackTable = () => {
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll),
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !startDate || !endDate || !teamId) return null;
|
||||
if (!isSuccess || !data) return null;
|
||||
|
||||
return (
|
||||
<CardInsights>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useId } from "react";
|
||||
|
||||
import { Badge, HoverCard, HoverCardContent, HoverCardTrigger, HoverCardPortal } from "@calcom/ui";
|
||||
|
||||
import { CellWithOverflowX } from "./CellWithOverflowX";
|
||||
|
||||
export function ResponseValueCell({
|
||||
optionMap,
|
||||
values,
|
||||
rowId,
|
||||
}: {
|
||||
optionMap: Record<string, string>;
|
||||
values: string[];
|
||||
rowId: number;
|
||||
}) {
|
||||
const cellId = useId();
|
||||
if (values.length === 0) return <div className="h-6 w-[200px]" />;
|
||||
|
||||
return (
|
||||
<CellWithOverflowX className="flex w-[200px] gap-1">
|
||||
{values.length > 2 ? (
|
||||
<>
|
||||
{values.slice(0, 2).map((id: string, i: number) => (
|
||||
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
|
||||
{optionMap[id] ?? id}
|
||||
</Badge>
|
||||
))}
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<Badge variant="gray">+{values.length - 2}</Badge>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent side="bottom" align="start" className="w-fit">
|
||||
<div className="flex flex-col gap-1">
|
||||
{values.slice(2).map((id: string, i: number) => (
|
||||
<span key={`${cellId}-overflow-${i}-${rowId}`} className="text-default text-sm">
|
||||
{optionMap[id] ?? id}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</HoverCard>
|
||||
</>
|
||||
) : (
|
||||
values.map((id: string, i: number) => (
|
||||
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
|
||||
{optionMap[id] ?? id}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</CellWithOverflowX>
|
||||
);
|
||||
}
|
||||
@@ -6,243 +6,29 @@ import {
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
createColumnHelper,
|
||||
} from "@tanstack/react-table";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import startCase from "lodash/startCase";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useId } from "react";
|
||||
import { z } from "zod";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
DataTableWrapper,
|
||||
DataTableFilters,
|
||||
DataTableSkeleton,
|
||||
dataTableFilter,
|
||||
useDataTable,
|
||||
DateRangeFilter,
|
||||
ColumnFilterType,
|
||||
type FilterableColumn,
|
||||
} from "@calcom/features/data-table";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { RoutingFormFieldType } from "@calcom/routing-forms/lib/FieldTypes";
|
||||
import { trpc, type RouterOutputs } from "@calcom/trpc";
|
||||
import {
|
||||
Badge,
|
||||
Avatar,
|
||||
Icon,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
type BadgeProps,
|
||||
HoverCardPortal,
|
||||
} from "@calcom/ui";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { RoutingFormResponsesDownload } from "../filters/Download";
|
||||
import { OrgTeamsFilter } from "../filters/OrgTeamsFilter";
|
||||
import { useInsightsColumns } from "../hooks/useInsightsColumns";
|
||||
import { useInsightsFacetedUniqueValues } from "../hooks/useInsightsFacetedUniqueValues";
|
||||
import { useInsightsParameters } from "../hooks/useInsightsParameters";
|
||||
import {
|
||||
ZResponseMultipleValues,
|
||||
ZResponseSingleValue,
|
||||
ZResponseTextValue,
|
||||
ZResponseNumericValue,
|
||||
} from "../lib/types";
|
||||
import type { RoutingFormTableRow } from "../lib/types";
|
||||
import { RoutingKPICards } from "./RoutingKPICards";
|
||||
|
||||
type RoutingFormTableRow = RouterOutputs["viewer"]["insights"]["routingFormResponses"]["data"][number];
|
||||
|
||||
const statusOrder: Record<BookingStatus, number> = {
|
||||
[BookingStatus.ACCEPTED]: 1,
|
||||
[BookingStatus.PENDING]: 2,
|
||||
[BookingStatus.AWAITING_HOST]: 3,
|
||||
[BookingStatus.CANCELLED]: 4,
|
||||
[BookingStatus.REJECTED]: 5,
|
||||
};
|
||||
|
||||
function CellWithOverflowX({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={classNames("group relative max-w-[200px]", className)}>
|
||||
<div
|
||||
className="no-scrollbar flex gap-1 overflow-x-auto whitespace-nowrap"
|
||||
ref={(el) => {
|
||||
// Only add shadow if the scroll width is greater than 200px
|
||||
if (!el) return;
|
||||
const nextElement = el.nextElementSibling;
|
||||
if (!nextElement) return;
|
||||
|
||||
if (el.scrollWidth > 200) {
|
||||
nextElement.classList.remove("hidden");
|
||||
} else {
|
||||
nextElement.classList.add("hidden");
|
||||
}
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
<div className="from-default absolute right-0 top-0 hidden h-full w-8 bg-gradient-to-l to-transparent " />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Upper case the first letter of each word and replace underscores with spaces
|
||||
function bookingStatusToText(status: BookingStatus) {
|
||||
return status
|
||||
.toLowerCase()
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function BookedByCell({
|
||||
attendees,
|
||||
rowId,
|
||||
}: {
|
||||
attendees: RoutingFormTableRow["bookingAttendees"] | undefined;
|
||||
rowId: number;
|
||||
}) {
|
||||
const cellId = useId();
|
||||
if (!attendees || attendees.length === 0) return <div className="min-w-[200px]" />;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[200px] flex-wrap gap-1">
|
||||
{attendees.map((attendee) => (
|
||||
<CellWithOverflowX key={`${cellId}-${attendee.email}-${rowId}`} className="w-[200px]">
|
||||
<Badge variant="gray" className="whitespace-nowrap" title={attendee.email}>
|
||||
{attendee.name}
|
||||
</Badge>
|
||||
</CellWithOverflowX>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResponseValueCell({
|
||||
optionMap,
|
||||
values,
|
||||
rowId,
|
||||
}: {
|
||||
optionMap: Record<string, string>;
|
||||
values: string[];
|
||||
rowId: number;
|
||||
}) {
|
||||
const cellId = useId();
|
||||
if (values.length === 0) return <div className="h-6 w-[200px]" />;
|
||||
|
||||
return (
|
||||
<CellWithOverflowX className="flex w-[200px] gap-1">
|
||||
{values.length > 2 ? (
|
||||
<>
|
||||
{values.slice(0, 2).map((id: string, i: number) => (
|
||||
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
|
||||
{optionMap[id] ?? id}
|
||||
</Badge>
|
||||
))}
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<Badge variant="gray">+{values.length - 2}</Badge>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent side="bottom" align="start" className="w-fit">
|
||||
<div className="flex flex-col gap-1">
|
||||
{values.slice(2).map((id: string, i: number) => (
|
||||
<span key={`${cellId}-overflow-${i}-${rowId}`} className="text-default text-sm">
|
||||
{optionMap[id] ?? id}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</HoverCard>
|
||||
</>
|
||||
) : (
|
||||
values.map((id: string, i: number) => (
|
||||
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
|
||||
{optionMap[id] ?? id}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</CellWithOverflowX>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingStatusBadge({ bookingStatus }: { bookingStatus: BookingStatus | null }) {
|
||||
let badgeVariant: BadgeProps["variant"] = "success";
|
||||
|
||||
if (!bookingStatus) return null;
|
||||
|
||||
switch (bookingStatus) {
|
||||
case BookingStatus.REJECTED:
|
||||
case BookingStatus.AWAITING_HOST:
|
||||
case BookingStatus.PENDING:
|
||||
case BookingStatus.CANCELLED:
|
||||
badgeVariant = "warning";
|
||||
break;
|
||||
}
|
||||
|
||||
return <Badge variant={badgeVariant}>{bookingStatusToText(bookingStatus)}</Badge>;
|
||||
}
|
||||
|
||||
function BookingAtCell({
|
||||
row,
|
||||
rowId,
|
||||
copyToClipboard,
|
||||
t,
|
||||
}: {
|
||||
row: RoutingFormTableRow;
|
||||
rowId: number;
|
||||
copyToClipboard: (text: string) => void;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const cellId = useId();
|
||||
|
||||
if (!row.bookingUserId || !row.bookingCreatedAt) {
|
||||
return <div className="w-[250px]" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="flex items-center gap-2" key={`${cellId}-booking-${rowId}`}>
|
||||
<Avatar size="xs" imageSrc={row.bookingUserAvatarUrl ?? ""} alt={row.bookingUserName ?? ""} />
|
||||
<Link href={`/booking/${row.bookingUid}`}>
|
||||
<Badge variant="gray">{dayjs(row.bookingCreatedAt).format("MMM D, YYYY HH:mm")}</Badge>
|
||||
</Link>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar size="sm" imageSrc={row.bookingUserAvatarUrl ?? ""} alt={row.bookingUserName ?? ""} />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{row.bookingUserName}</p>
|
||||
<p className="group/booking_status_email text-subtle flex items-center text-xs">
|
||||
<span className="truncate">{row.bookingUserEmail}</span>
|
||||
<button
|
||||
className="invisible ml-2 group-hover/booking_status_email:visible"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
copyToClipboard(row.bookingUserEmail ?? "");
|
||||
}}>
|
||||
<Icon name="copy" />
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-emphasis mt-4 flex items-center gap-2 text-xs">
|
||||
<span>Status:</span>
|
||||
<BookingStatusBadge bookingStatus={row.bookingStatus} />
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
export type RoutingFormTableType = ReturnType<typeof useReactTable<RoutingFormTableRow>>;
|
||||
|
||||
const createdAtColumn: Extract<FilterableColumn, { type: ColumnFilterType.DATE_RANGE }> = {
|
||||
@@ -252,8 +38,6 @@ const createdAtColumn: Extract<FilterableColumn, { type: ColumnFilterType.DATE_R
|
||||
};
|
||||
|
||||
export function RoutingFormResponsesTable() {
|
||||
const { t } = useLocale();
|
||||
const { copyToClipboard } = useCopy();
|
||||
const { isAll, teamId, userId, memberUserIds, routingFormId, startDate, endDate, columnFilters } =
|
||||
useInsightsParameters();
|
||||
|
||||
@@ -268,16 +52,7 @@ export function RoutingFormResponsesTable() {
|
||||
routingFormId,
|
||||
});
|
||||
|
||||
const { data: forms } = trpc.viewer.insights.getRoutingFormsForFilters.useQuery({
|
||||
userId,
|
||||
teamId,
|
||||
isAll,
|
||||
});
|
||||
|
||||
const { data: users } = trpc.viewer.insights.userList.useQuery({
|
||||
teamId: teamId ?? -1,
|
||||
isAll,
|
||||
});
|
||||
const getInsightsFacetedUniqueValues = useInsightsFacetedUniqueValues({ headers, userId, teamId, isAll });
|
||||
|
||||
const { sorting } = useDataTable();
|
||||
|
||||
@@ -309,202 +84,7 @@ export function RoutingFormResponsesTable() {
|
||||
return (data?.pages?.flatMap((page) => page.data) ?? []) as RoutingFormTableRow[];
|
||||
}, [data, isHeadersSuccess]);
|
||||
|
||||
const columnHelper = createColumnHelper<RoutingFormTableRow>();
|
||||
|
||||
const columns = useMemo(() => {
|
||||
if (!isHeadersSuccess) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
columnHelper.accessor("formId", {
|
||||
id: "formId",
|
||||
header: t("routing_forms"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
filter: { type: ColumnFilterType.SINGLE_SELECT },
|
||||
},
|
||||
cell: () => null,
|
||||
filterFn: (row, id, filterValue) => {
|
||||
const cellValue = row.original.formId;
|
||||
return dataTableFilter(cellValue, filterValue);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("bookingUserId", {
|
||||
id: "bookingUserId",
|
||||
header: t("people"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
filter: { type: ColumnFilterType.MULTI_SELECT },
|
||||
},
|
||||
cell: () => null,
|
||||
filterFn: (row, id, filterValue) => {
|
||||
const cellValue = row.original.bookingUserId;
|
||||
return dataTableFilter(cellValue, filterValue);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("bookingAttendees", {
|
||||
id: "bookingAttendees",
|
||||
header: t("routing_form_insights_booked_by"),
|
||||
size: 200,
|
||||
enableColumnFilter: false,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
return <BookedByCell attendees={info.getValue()} rowId={info.row.original.id} />;
|
||||
},
|
||||
}),
|
||||
|
||||
...((headers || []).map((fieldHeader) => {
|
||||
const isText = [
|
||||
RoutingFormFieldType.TEXT,
|
||||
RoutingFormFieldType.EMAIL,
|
||||
RoutingFormFieldType.PHONE,
|
||||
RoutingFormFieldType.TEXTAREA,
|
||||
].includes(fieldHeader.type as RoutingFormFieldType);
|
||||
|
||||
const isNumber = fieldHeader.type === RoutingFormFieldType.NUMBER;
|
||||
|
||||
const isSingleSelect = fieldHeader.type === RoutingFormFieldType.SINGLE_SELECT;
|
||||
const isMultiSelect = fieldHeader.type === RoutingFormFieldType.MULTI_SELECT;
|
||||
|
||||
const filterType = isSingleSelect
|
||||
? ColumnFilterType.SINGLE_SELECT
|
||||
: isNumber
|
||||
? ColumnFilterType.NUMBER
|
||||
: isText
|
||||
? ColumnFilterType.TEXT
|
||||
: ColumnFilterType.MULTI_SELECT;
|
||||
|
||||
const optionMap =
|
||||
fieldHeader.options?.reduce((acc, option) => {
|
||||
if (option.id) {
|
||||
acc[option.id] = option.label;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>) ?? {};
|
||||
|
||||
return columnHelper.accessor(`response.${fieldHeader.id}`, {
|
||||
id: fieldHeader.id,
|
||||
header: startCase(fieldHeader.label),
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const values = info.getValue();
|
||||
if (isMultiSelect || isSingleSelect) {
|
||||
const result = z.union([ZResponseMultipleValues, ZResponseSingleValue]).safeParse(values);
|
||||
return (
|
||||
result.success && (
|
||||
<ResponseValueCell
|
||||
optionMap={optionMap}
|
||||
values={Array.isArray(result.data.value) ? result.data.value : [result.data.value]}
|
||||
rowId={info.row.original.id}
|
||||
/>
|
||||
)
|
||||
);
|
||||
} else if (isText || isNumber) {
|
||||
const result = z.union([ZResponseTextValue, ZResponseNumericValue]).safeParse(values);
|
||||
return (
|
||||
result.success && (
|
||||
<div className="truncate">
|
||||
<span title={String(result.data.value)}>{result.data.value}</span>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
meta: {
|
||||
filter: { type: filterType },
|
||||
},
|
||||
filterFn: (row, id, filterValue) => {
|
||||
const cellValue = row.original.response[id]?.value;
|
||||
return dataTableFilter(cellValue, filterValue);
|
||||
},
|
||||
});
|
||||
}) ?? []),
|
||||
columnHelper.accessor("bookingStatusOrder", {
|
||||
id: "bookingStatusOrder",
|
||||
header: t("routing_form_insights_booking_status"),
|
||||
sortDescFirst: false,
|
||||
cell: (info) => (
|
||||
<div className="max-w-[250px]">
|
||||
<BookingStatusBadge bookingStatus={info.row.original.bookingStatus} />
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
filter: { type: ColumnFilterType.MULTI_SELECT, icon: "circle" },
|
||||
},
|
||||
filterFn: (row, id, filterValue) => {
|
||||
const cellValue = row.original.bookingStatusOrder;
|
||||
return dataTableFilter(cellValue, filterValue);
|
||||
},
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const statusA = rowA.original.bookingStatusOrder ?? 6; // put it at the end if bookingStatusOrder is null
|
||||
const statusB = rowB.original.bookingStatusOrder ?? 6;
|
||||
return statusA - statusB;
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("bookingCreatedAt", {
|
||||
id: "bookingCreatedAt",
|
||||
header: t("routing_form_insights_booking_at"),
|
||||
enableColumnFilter: false,
|
||||
cell: (info) => (
|
||||
<div className="max-w-[250px]">
|
||||
<BookingAtCell
|
||||
row={info.row.original}
|
||||
rowId={info.row.original.id}
|
||||
copyToClipboard={copyToClipboard}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const dateA = rowA.original.bookingCreatedAt;
|
||||
const dateB = rowB.original.bookingCreatedAt;
|
||||
if (!dateA && !dateB) return 0;
|
||||
if (!dateA) return -1;
|
||||
if (!dateB) return 1;
|
||||
if (!(dateA instanceof Date) || !(dateB instanceof Date)) return 0;
|
||||
|
||||
return dateA.getTime() - dateB.getTime();
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("bookingAssignmentReason", {
|
||||
id: "bookingAssignmentReason",
|
||||
header: t("routing_form_insights_assignment_reason"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
filter: { type: ColumnFilterType.TEXT },
|
||||
},
|
||||
cell: (info) => {
|
||||
const assignmentReason = info.getValue();
|
||||
return <div className="max-w-[250px]">{assignmentReason}</div>;
|
||||
},
|
||||
filterFn: (row, id, filterValue) => {
|
||||
const reason = row.original.bookingAssignmentReason;
|
||||
return dataTableFilter(reason, filterValue);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("createdAt", {
|
||||
id: "createdAt",
|
||||
header: t("routing_form_insights_submitted_at"),
|
||||
// exclude from "Filters" component
|
||||
// because we already have a DateRangeFilter component
|
||||
enableColumnFilter: false,
|
||||
cell: (info) => (
|
||||
<div className="whitespace-nowrap">
|
||||
<Badge variant="gray">{dayjs(info.getValue()).format("MMM D, YYYY HH:mm")}</Badge>
|
||||
</div>
|
||||
),
|
||||
filterFn: (row, id, filterValue) => {
|
||||
const createdAt = row.original.createdAt;
|
||||
return dataTableFilter(createdAt, filterValue);
|
||||
},
|
||||
}),
|
||||
];
|
||||
}, [isHeadersSuccess, headers, t, copyToClipboard]);
|
||||
const columns = useInsightsColumns({ headers, isHeadersSuccess });
|
||||
|
||||
const table = useReactTable<RoutingFormTableRow>({
|
||||
data: processedData,
|
||||
@@ -521,49 +101,7 @@ export function RoutingFormResponsesTable() {
|
||||
bookingUserId: false,
|
||||
},
|
||||
},
|
||||
getFacetedUniqueValues: (_, columnId) => () => {
|
||||
if (!headers) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const fromArrayToMap = (array: { label: string; value: string | number }[]) => {
|
||||
return new Map(array.map((option) => [{ label: option.label, value: option.value }, 1]));
|
||||
};
|
||||
|
||||
const fieldHeader = headers.find((h) => h.id === columnId);
|
||||
if (fieldHeader?.options) {
|
||||
return fromArrayToMap(
|
||||
fieldHeader.options
|
||||
.filter((option): option is { id: string; label: string } => option.id !== null)
|
||||
.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.id,
|
||||
}))
|
||||
);
|
||||
} else if (columnId === "bookingStatusOrder") {
|
||||
return fromArrayToMap(
|
||||
Object.keys(statusOrder).map((status) => ({
|
||||
value: statusOrder[status as BookingStatus],
|
||||
label: bookingStatusToText(status as BookingStatus),
|
||||
}))
|
||||
);
|
||||
} else if (columnId === "formId") {
|
||||
return fromArrayToMap(
|
||||
forms?.map((form) => ({
|
||||
label: form.name,
|
||||
value: form.id,
|
||||
})) ?? []
|
||||
);
|
||||
} else if (columnId === "bookingUserId") {
|
||||
return fromArrayToMap(
|
||||
users?.map((user) => ({
|
||||
label: user.name ?? user.email,
|
||||
value: user.id,
|
||||
})) ?? []
|
||||
);
|
||||
}
|
||||
return new Map();
|
||||
},
|
||||
getFacetedUniqueValues: getInsightsFacetedUniqueValues,
|
||||
});
|
||||
|
||||
if ((isHeadersLoading && !headers) || ((isFetching || isLoading) && !data)) {
|
||||
|
||||
+1
-1
@@ -104,4 +104,4 @@
|
||||
|
||||
.tremor-TableBody-root {
|
||||
@apply divide-subtle;
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useDefaultRoutingForm } from "../hooks/useDefaultRoutingForm";
|
||||
import type { FilterContextType } from "./provider";
|
||||
import { FilterProvider } from "./provider";
|
||||
|
||||
const querySchema = z.object({
|
||||
startTime: z.string().nullable(),
|
||||
endTime: z.string().nullable(),
|
||||
teamId: z.coerce.number().nullable(),
|
||||
userId: z.coerce.number().nullable(),
|
||||
memberUserId: z.coerce.number().nullable(),
|
||||
eventTypeId: z.coerce.number().nullable(),
|
||||
filter: z
|
||||
.union([z.enum(["event-type", "user", "routing_forms", "booking_status"]), z.string().regex(/^rf_.*$/)])
|
||||
.nullable(),
|
||||
routingFormId: z.string().nullable(),
|
||||
bookingStatus: z.enum(["NO_BOOKING", ...Object.values(BookingStatus)]).nullable(),
|
||||
});
|
||||
|
||||
// TODO(SEAN): We can have a big refactor here and move this all out of context and into query state. This will make it easier to create shareable links
|
||||
export function FiltersProvider({ children }: { children: React.ReactNode }) {
|
||||
// searchParams to get initial values from query params
|
||||
const utils = trpc.useUtils();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
let startTimeParsed,
|
||||
endTimeParsed,
|
||||
teamIdParsed: number | undefined | null,
|
||||
userIdParsed,
|
||||
eventTypeIdParsed,
|
||||
filterParsed,
|
||||
routingFormIdParsed,
|
||||
bookingStatusParsed,
|
||||
memberUserIdParsed;
|
||||
|
||||
const safe = querySchema.safeParse({
|
||||
startTime: searchParams?.get("startTime") ?? null,
|
||||
endTime: searchParams?.get("endTime") ?? null,
|
||||
teamId: searchParams?.get("teamId") ?? null,
|
||||
userId: searchParams?.get("userId") ?? null,
|
||||
eventTypeId: searchParams?.get("eventTypeId") ?? null,
|
||||
filter: searchParams?.get("filter") ?? null,
|
||||
memberUserId: searchParams?.get("memberUserId") ?? null,
|
||||
routingFormId: searchParams?.get("routingFormId") ?? null,
|
||||
bookingStatus: searchParams?.get("bookingStatus") ?? null,
|
||||
});
|
||||
|
||||
if (!safe.success) {
|
||||
console.error("Failed to parse query params");
|
||||
} else {
|
||||
startTimeParsed = safe.data.startTime;
|
||||
endTimeParsed = safe.data.endTime;
|
||||
teamIdParsed = safe.data.teamId;
|
||||
userIdParsed = safe.data.userId;
|
||||
eventTypeIdParsed = safe.data.eventTypeId;
|
||||
filterParsed = safe.data.filter;
|
||||
routingFormIdParsed = safe.data.routingFormId;
|
||||
memberUserIdParsed = safe.data.memberUserId;
|
||||
bookingStatusParsed = safe.data.bookingStatus;
|
||||
}
|
||||
|
||||
const [configFilters, setConfigFilters] = useState<FilterContextType["filter"]>({
|
||||
dateRange: [
|
||||
startTimeParsed ? dayjs(startTimeParsed) : dayjs().subtract(1, "week"),
|
||||
endTimeParsed ? dayjs(endTimeParsed) : dayjs(),
|
||||
!startTimeParsed && !endTimeParsed ? "w" : null,
|
||||
],
|
||||
selectedTimeView: "week",
|
||||
selectedUserId: userIdParsed || null,
|
||||
selectedMemberUserId: memberUserIdParsed || null,
|
||||
selectedTeamId: teamIdParsed || null,
|
||||
selectedTeamName: null,
|
||||
selectedEventTypeId: eventTypeIdParsed || null,
|
||||
selectedFilter: filterParsed
|
||||
? [filterParsed as "event-type" | "user" | "routing_forms" | `rf_${string}`]
|
||||
: null,
|
||||
selectedRoutingFormId: routingFormIdParsed || null,
|
||||
selectedBookingStatus: bookingStatusParsed || null,
|
||||
isAll: false,
|
||||
initialConfig: {
|
||||
userId: null,
|
||||
teamId: null,
|
||||
isAll: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Use the custom hook
|
||||
const { mostPopularForm } = useDefaultRoutingForm({
|
||||
userId: userIdParsed,
|
||||
teamId: teamIdParsed,
|
||||
isAll: safe.success ? !!safe.data.teamId : false,
|
||||
routingFormId: routingFormIdParsed,
|
||||
onRoutingFormChange: (formId) => {
|
||||
setConfigFilters((prev) => ({
|
||||
...prev,
|
||||
selectedFilter: prev.selectedFilter ? [...prev.selectedFilter, "routing_forms"] : ["routing_forms"],
|
||||
selectedRoutingFormId: formId,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
dateRange,
|
||||
selectedTimeView,
|
||||
selectedMemberUserId,
|
||||
selectedTeamId,
|
||||
selectedUserId,
|
||||
selectedEventTypeId,
|
||||
selectedFilter,
|
||||
selectedTeamName,
|
||||
selectedRoutingFormId,
|
||||
selectedBookingStatus,
|
||||
selectedRoutingFormFilter,
|
||||
isAll,
|
||||
initialConfig,
|
||||
} = configFilters;
|
||||
return (
|
||||
<FilterProvider
|
||||
value={{
|
||||
filter: {
|
||||
dateRange,
|
||||
selectedTimeView,
|
||||
selectedMemberUserId,
|
||||
selectedTeamId,
|
||||
selectedUserId,
|
||||
selectedTeamName,
|
||||
selectedEventTypeId,
|
||||
selectedFilter,
|
||||
isAll,
|
||||
initialConfig,
|
||||
selectedRoutingFormId,
|
||||
selectedBookingStatus,
|
||||
selectedRoutingFormFilter,
|
||||
},
|
||||
setConfigFilters: (newConfigFilters) => {
|
||||
setConfigFilters({
|
||||
...configFilters,
|
||||
...newConfigFilters,
|
||||
});
|
||||
utils.viewer.insights.rawData.invalidate();
|
||||
const {
|
||||
selectedMemberUserId,
|
||||
selectedTeamId,
|
||||
selectedUserId,
|
||||
selectedEventTypeId,
|
||||
selectedFilter,
|
||||
isAll,
|
||||
dateRange,
|
||||
initialConfig,
|
||||
selectedRoutingFormId,
|
||||
selectedBookingStatus,
|
||||
} = newConfigFilters;
|
||||
const [startTime, endTime] = dateRange || [null, null];
|
||||
const newSearchParams = new URLSearchParams(searchParams?.toString() ?? undefined);
|
||||
function setParamsIfDefined(key: string, value: string | number | boolean | null | undefined) {
|
||||
if (value !== undefined && value !== null) newSearchParams.set(key, value.toString());
|
||||
}
|
||||
|
||||
setParamsIfDefined("memberUserId", selectedMemberUserId);
|
||||
setParamsIfDefined("teamId", selectedTeamId || initialConfig?.teamId);
|
||||
setParamsIfDefined("userId", selectedUserId || initialConfig?.userId);
|
||||
setParamsIfDefined("eventTypeId", selectedEventTypeId);
|
||||
setParamsIfDefined("isAll", isAll || initialConfig?.isAll);
|
||||
setParamsIfDefined("startTime", startTime?.format("YYYY-MM-DD"));
|
||||
setParamsIfDefined("endTime", endTime?.format("YYYY-MM-DD"));
|
||||
setParamsIfDefined("filter", selectedFilter?.[0]);
|
||||
setParamsIfDefined("routingFormId", selectedRoutingFormId);
|
||||
setParamsIfDefined("bookingStatus", selectedBookingStatus);
|
||||
router.push(`${pathname}?${newSearchParams.toString()}`);
|
||||
},
|
||||
clearFilters: () => {
|
||||
const { initialConfig } = configFilters;
|
||||
|
||||
const teamId = initialConfig?.teamId ? initialConfig.teamId : undefined;
|
||||
const userId = initialConfig?.userId ? initialConfig.userId : undefined;
|
||||
setConfigFilters({
|
||||
selectedEventTypeId: null,
|
||||
selectedFilter: null,
|
||||
selectedMemberUserId: null,
|
||||
selectedTeamId: teamId,
|
||||
selectedTeamName: null,
|
||||
selectedTimeView: "week",
|
||||
selectedUserId: userId,
|
||||
isAll: !!initialConfig?.isAll,
|
||||
dateRange: [dayjs().subtract(1, "week"), dayjs(), "w"],
|
||||
initialConfig,
|
||||
selectedRoutingFormId: mostPopularForm?.id ?? null, // Set the most popular form as default when clearing filters
|
||||
selectedBookingStatus: null,
|
||||
selectedRoutingFormFilter: null,
|
||||
});
|
||||
|
||||
const newSearchParams = new URLSearchParams();
|
||||
if (teamId) newSearchParams.set("teamId", teamId.toString());
|
||||
if (userId) newSearchParams.set("userId", userId.toString());
|
||||
|
||||
router.push(`${pathname}?${newSearchParams.toString()}`);
|
||||
},
|
||||
}}>
|
||||
{children}
|
||||
</FilterProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { BookingStatus } from "@prisma/client";
|
||||
import * as React from "react";
|
||||
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
|
||||
interface IFilter {
|
||||
dateRange: [Dayjs, Dayjs, null | string];
|
||||
selectedTimeView?: "year" | "week" | "month";
|
||||
selectedFilter?: Array<"user" | "event-type" | "routing_forms" | "booking_status" | `rf_${string}`> | null;
|
||||
selectedTeamId?: number | null;
|
||||
selectedTeamName?: string | null;
|
||||
selectedUserId?: number | null;
|
||||
selectedMemberUserId?: number | null;
|
||||
selectedEventTypeId?: number | null;
|
||||
selectedRoutingFormId?: string | null;
|
||||
selectedBookingStatus?: BookingStatus | "NO_BOOKING" | null;
|
||||
selectedRoutingFormFilter?: {
|
||||
fieldId: string;
|
||||
optionId: string;
|
||||
} | null;
|
||||
isAll?: boolean;
|
||||
initialConfig?: {
|
||||
teamId?: number | null;
|
||||
userId?: number | null;
|
||||
isAll?: boolean | null;
|
||||
};
|
||||
}
|
||||
|
||||
export type FilterContextType = {
|
||||
filter: IFilter;
|
||||
clearFilters: () => void;
|
||||
setConfigFilters: (config: Partial<IFilter>) => void;
|
||||
};
|
||||
|
||||
export const FilterContext = React.createContext<FilterContextType | null>(null);
|
||||
|
||||
export function useFilterContext() {
|
||||
const context = React.useContext(FilterContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useFilterContext must be used within a FilterProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export function FilterProvider<F extends FilterContextType>(props: { value: F; children: React.ReactNode }) {
|
||||
return React.createElement(FilterContext.Provider, { value: props.value }, props.children);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { Icon, FilterSelect } from "@calcom/ui";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
|
||||
type BookingStatusOption = {
|
||||
value: BookingStatus | "NO_BOOKING";
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const BookingStatusFilter = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter, setConfigFilters } = useFilterContext();
|
||||
const { selectedBookingStatus, selectedFilter } = filter;
|
||||
|
||||
const bookingStatusOptions = useMemo(() => {
|
||||
const options: BookingStatusOption[] = Object.values(BookingStatus).map((status) => ({
|
||||
value: status,
|
||||
label: t(`${status.toLowerCase()}`).charAt(0).toUpperCase() + t(`${status.toLowerCase()}`).slice(1),
|
||||
}));
|
||||
|
||||
options.push({
|
||||
value: "NO_BOOKING",
|
||||
label: t("no_booking"),
|
||||
});
|
||||
|
||||
return options;
|
||||
}, [t]);
|
||||
|
||||
if (!selectedFilter?.includes("booking_status")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterSelect
|
||||
title={t("routing_form_insights_booking_status")}
|
||||
options={bookingStatusOptions}
|
||||
selectedValue={selectedBookingStatus}
|
||||
onChange={(value) => {
|
||||
setConfigFilters({ selectedBookingStatus: value as BookingStatus | "NO_BOOKING" });
|
||||
}}
|
||||
buttonIcon={<Icon name="circle" className="mr-2 h-4 w-4" />}
|
||||
placeholder={t("search")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useDataTable } from "@calcom/features/data-table";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon, Button, Tooltip } from "@calcom/ui";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
|
||||
// This component clears filters from both:
|
||||
// - the filter from FilterContext
|
||||
// - the data table filters state
|
||||
export const ClearFilters = () => {
|
||||
const { t } = useLocale();
|
||||
const { clearFilters, filter } = useFilterContext();
|
||||
const { activeFilters, clearAll } = useDataTable();
|
||||
|
||||
const clear = () => {
|
||||
// clear filters from the filter context
|
||||
clearFilters();
|
||||
|
||||
// clear filters from the data table state
|
||||
clearAll();
|
||||
};
|
||||
|
||||
const { initialConfig, selectedTeamId, selectedMemberUserId } = filter;
|
||||
|
||||
const isFilterSelected =
|
||||
initialConfig?.teamId !== selectedTeamId ||
|
||||
initialConfig?.userId !== selectedMemberUserId ||
|
||||
activeFilters?.length > 0;
|
||||
|
||||
if (!isFilterSelected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content={t("clear_filters")}>
|
||||
<Button
|
||||
variant="icon"
|
||||
color="secondary"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="min-w-24 h-[38px] border-0"
|
||||
onClick={clear}>
|
||||
<Icon name="x" className="mr-1 h-4 w-4" />
|
||||
{t("clear")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -1,109 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { DateRangePicker } from "@calcom/ui";
|
||||
import { Select } from "@calcom/ui";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import "./DateSelect.css";
|
||||
|
||||
export const DateSelect = ({ className }: { className?: string }) => {
|
||||
const { t } = useLocale();
|
||||
const presetOptions = [
|
||||
{ label: t("today"), value: "tdy" },
|
||||
{ label: t("last_number_of_days", { count: 7 }), value: "w" },
|
||||
{ label: t("last_number_of_days", { count: 30 }), value: "t" },
|
||||
{ label: t("month_to_date"), value: "m" },
|
||||
{ label: t("year_to_date"), value: "y" },
|
||||
{ label: t("custom_range"), value: null },
|
||||
];
|
||||
|
||||
const { filter, setConfigFilters } = useFilterContext();
|
||||
const currentDate = dayjs();
|
||||
const [initialStartDate, initialEndDate, range] = filter?.dateRange || [null, null, null];
|
||||
const [startDate, setStartDate] = useState<Dayjs>(initialStartDate);
|
||||
const [endDate, setEndDate] = useState<Dayjs | undefined>(initialEndDate);
|
||||
const startValue = startDate.toDate();
|
||||
const endValue = endDate?.toDate();
|
||||
const [selectedPreset, setSelectedPreset] = useState(presetOptions.find((c) => c.value === range));
|
||||
|
||||
const updateDateRange = (val: string | null) => {
|
||||
setSelectedPreset(presetOptions.find((c) => c.value === val));
|
||||
let startDate = dayjs();
|
||||
let endDate = dayjs();
|
||||
|
||||
switch (val) {
|
||||
case "tdy": // Today
|
||||
startDate = dayjs().startOf("day");
|
||||
endDate = dayjs().endOf("day");
|
||||
break;
|
||||
case "w": // Last 7 days
|
||||
startDate = dayjs().subtract(1, "week").startOf("day");
|
||||
endDate = dayjs().endOf("day");
|
||||
break;
|
||||
case "t": // Last 30 days
|
||||
startDate = dayjs().subtract(30, "day").startOf("day");
|
||||
endDate = dayjs().endOf("day");
|
||||
break;
|
||||
case "m": // Month to Date
|
||||
startDate = dayjs().startOf("month");
|
||||
endDate = dayjs().endOf("day");
|
||||
break;
|
||||
case "y": // Year to Date
|
||||
startDate = dayjs().startOf("year");
|
||||
endDate = dayjs().endOf("day");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// Update the datepicker date selection
|
||||
setStartDate(startDate);
|
||||
setEndDate(endDate);
|
||||
// Update the configuration filter
|
||||
setConfigFilters({
|
||||
dateRange: [dayjs(startDate), dayjs(endDate), val],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames("ml inline-flex space-x-2 rtl:space-x-reverse", className)}>
|
||||
<DateRangePicker
|
||||
dates={{
|
||||
startDate: startValue,
|
||||
endDate: endValue,
|
||||
}}
|
||||
minDate={currentDate.subtract(2, "year").toDate()}
|
||||
maxDate={currentDate.toDate()}
|
||||
disabled={false}
|
||||
onDatesChange={({ startDate, endDate }) => {
|
||||
if (startDate && endDate) {
|
||||
setConfigFilters({
|
||||
dateRange: [dayjs(startDate), dayjs(endDate), null],
|
||||
});
|
||||
}
|
||||
setStartDate(dayjs(startDate));
|
||||
setEndDate(endDate ? dayjs(endDate) : endDate);
|
||||
setSelectedPreset(presetOptions.find((c) => c.value === null));
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
variant="default"
|
||||
data-testid="insights-preset"
|
||||
options={presetOptions}
|
||||
value={selectedPreset}
|
||||
className="w-40 capitalize text-black"
|
||||
defaultValue={selectedPreset}
|
||||
styles={{ menuPortal: (base) => ({ ...base, zIndex: 9999 }) }}
|
||||
menuPortalTarget={document.body}
|
||||
onChange={(e) => {
|
||||
if (e) {
|
||||
updateDateRange(e.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,22 +1,23 @@
|
||||
import { useFilterContext } from "@calcom/features/insights/context/provider";
|
||||
import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Button, Dropdown, DropdownItem, DropdownMenuContent, DropdownMenuTrigger } from "@calcom/ui";
|
||||
|
||||
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
|
||||
|
||||
const Download = () => {
|
||||
const { filter } = useFilterContext();
|
||||
const { t } = useLocale();
|
||||
const { startDate, endDate, teamId, userId, eventTypeId, memberUserId } = useInsightsParameters();
|
||||
|
||||
const { data, isPending } = trpc.viewer.insights.rawData.useQuery(
|
||||
{
|
||||
startDate: filter.dateRange[0].toISOString(),
|
||||
endDate: filter.dateRange[1].toISOString(),
|
||||
teamId: filter.selectedTeamId,
|
||||
userId: filter.selectedUserId,
|
||||
eventTypeId: filter.selectedEventTypeId,
|
||||
memberUserId: filter.selectedMemberUserId,
|
||||
startDate,
|
||||
endDate,
|
||||
teamId,
|
||||
userId,
|
||||
eventTypeId,
|
||||
memberUserId,
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { memo } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Icon, FilterSelect } from "@calcom/ui";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
|
||||
type EventType = RouterOutputs["viewer"]["insights"]["eventTypeList"][number];
|
||||
|
||||
const mapEventTypeToOption = (eventType: EventType) => ({
|
||||
value: eventType.id,
|
||||
label: eventType.teamId ? `${eventType.title} (${eventType.team?.name})` : eventType.title,
|
||||
});
|
||||
|
||||
export const EventTypeList = memo(() => {
|
||||
const { t } = useLocale();
|
||||
const { filter, setConfigFilters } = useFilterContext();
|
||||
const { selectedTeamId, selectedEventTypeId, selectedUserId, isAll } = filter;
|
||||
const { selectedFilter } = filter;
|
||||
|
||||
const { data, isSuccess } = trpc.viewer.insights.eventTypeList.useQuery({
|
||||
teamId: selectedTeamId ?? undefined,
|
||||
userId: selectedUserId ?? undefined,
|
||||
isAll,
|
||||
});
|
||||
|
||||
if (!selectedFilter?.includes("event-type")) return null;
|
||||
if (!selectedTeamId && !selectedUserId) return null;
|
||||
if (!isSuccess || !data || !Array.isArray(data)) return null;
|
||||
|
||||
const filterOptions = data.map(mapEventTypeToOption);
|
||||
|
||||
return (
|
||||
<FilterSelect
|
||||
title={t("event_type")}
|
||||
options={filterOptions}
|
||||
selectedValue={selectedEventTypeId}
|
||||
onChange={(value) => setConfigFilters({ selectedEventTypeId: value ? Number(value) : null })}
|
||||
buttonIcon={<Icon name="calendar" className="mr-2 h-4 w-4" />}
|
||||
emptyText={t("no_options_available")}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
EventTypeList.displayName = "EventTypeList";
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { IconName } from "@calcom/ui";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Icon,
|
||||
Tooltip,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
|
||||
type FilterType = "event-type" | "user" | "routing_forms" | `rf_${string}` | "booking_status";
|
||||
|
||||
type Option = {
|
||||
value: FilterType;
|
||||
label: string;
|
||||
StartIcon: IconName;
|
||||
};
|
||||
|
||||
export const FilterType = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter, setConfigFilters } = useFilterContext();
|
||||
const { selectedFilter, selectedUserId } = filter;
|
||||
|
||||
const filterOptions = useMemo(() => {
|
||||
let options: Option[] = [
|
||||
{
|
||||
label: t("user"),
|
||||
value: "user",
|
||||
StartIcon: "users" as IconName,
|
||||
},
|
||||
];
|
||||
|
||||
options.push({
|
||||
label: t("event_type"),
|
||||
value: "event-type",
|
||||
StartIcon: "link",
|
||||
});
|
||||
|
||||
if (selectedUserId) {
|
||||
// remove user option from filterOptions
|
||||
options = options.filter((option) => option.value !== "user");
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [t, selectedUserId]);
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="hover:border-emphasis border-default text-default hover:text-emphasis focus:border-subtle mb-4 flex h-9 max-h-72 items-center justify-between whitespace-nowrap rounded-md border px-3 py-2 text-sm hover:cursor-pointer focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-1">
|
||||
<Icon name="plus" className="mr-2 h-4 w-4" />
|
||||
<Tooltip content={t("add_filter")}>
|
||||
<div>{selectedFilter?.length ? `${selectedFilter.length} ${t("filters")}` : t("add_filter")}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="z-50">
|
||||
{filterOptions?.map((option) => (
|
||||
<DropdownMenuItem key={option.label} className="w-full">
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon={option.StartIcon}
|
||||
onClick={() => {
|
||||
setConfigFilters({
|
||||
selectedFilter: selectedFilter
|
||||
? selectedFilter.includes(option.value)
|
||||
? selectedFilter.filter((f) => f !== option.value)
|
||||
: [...selectedFilter, option.value]
|
||||
: [option.value],
|
||||
});
|
||||
}}
|
||||
childrenClassName="w-full">
|
||||
<div className="flex w-full items-center justify-between whitespace-normal">
|
||||
<span className="mr-2">{t(option.label)}</span>
|
||||
{selectedFilter?.includes(option.value) && (
|
||||
<Icon name="check" className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
@@ -14,19 +14,16 @@ import { useInsightsOrgTeams } from "../hooks/useInsightsOrgTeams";
|
||||
|
||||
export type OrgTeamsType = "org" | "team" | "yours";
|
||||
|
||||
// This is a clone of TeamAndSelfList, but without useFilterContext().
|
||||
// It's meant to be used in the RoutingFormResponsesTable.
|
||||
export const OrgTeamsFilter = () => {
|
||||
const { orgTeamsType, selectedTeamId, setOrgTeamsType, setSelectedTeamId } = useInsightsOrgTeams();
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const currentOrgId = session.data?.user.org?.id;
|
||||
const currentUserId = session.data?.user.id;
|
||||
const currentUserName = session.data?.user.name;
|
||||
|
||||
const [query, setQuery] = useState<string>("");
|
||||
|
||||
const { data, isSuccess } = trpc.viewer.insights.teamListForUser.useQuery(undefined, {
|
||||
const { data } = trpc.viewer.insights.teamListForUser.useQuery(undefined, {
|
||||
// Teams don't change that frequently
|
||||
refetchOnWindowFocus: false,
|
||||
trpc: {
|
||||
@@ -60,8 +57,16 @@ export const OrgTeamsFilter = () => {
|
||||
return { text: t("select"), imageUrl: undefined };
|
||||
};
|
||||
|
||||
const resetSelection = () => {
|
||||
if (isOrgDataAvailable) {
|
||||
onSelected({ type: "org", teamId: currentOrgId });
|
||||
} else {
|
||||
onSelected({ type: "yours", teamId: undefined });
|
||||
}
|
||||
};
|
||||
|
||||
const { text, placeholder, imageUrl } = getPopoverProps();
|
||||
const isOrgDataAvailable = !!data && data.length > 0 && !!data[0].isOrg;
|
||||
const isOrgDataAvailable = !!data && data.length > 0 && !!data[0].isOrg && data[0].id === currentOrgId;
|
||||
|
||||
const PrefixComponent =
|
||||
orgTeamsType !== undefined && (imageUrl || placeholder) ? (
|
||||
@@ -86,10 +91,15 @@ export const OrgTeamsFilter = () => {
|
||||
{isOrgDataAvailable && (
|
||||
<FilterCheckboxField
|
||||
id="all"
|
||||
testId="org-teams-filter-item"
|
||||
icon={<Icon name="layers" className="h-4 w-4" />}
|
||||
checked={orgTeamsType === "org"}
|
||||
onChange={(e) => {
|
||||
onSelected({ type: "org", teamId: undefined });
|
||||
if (e.target.checked) {
|
||||
onSelected({ type: "org", teamId: currentOrgId });
|
||||
} else {
|
||||
resetSelection();
|
||||
}
|
||||
}}
|
||||
label={t("all")}
|
||||
/>
|
||||
@@ -97,6 +107,7 @@ export const OrgTeamsFilter = () => {
|
||||
|
||||
<FilterCheckboxField
|
||||
id="yours"
|
||||
testId="org-teams-filter-item"
|
||||
icon={
|
||||
<Avatar
|
||||
alt={`${currentUserName} avatar`}
|
||||
@@ -109,19 +120,20 @@ export const OrgTeamsFilter = () => {
|
||||
if (e.target.checked) {
|
||||
onSelected({ type: "yours", teamId: undefined });
|
||||
} else if (!e.target.checked) {
|
||||
onSelected({ type: currentOrgId ? "org" : "yours", teamId: undefined });
|
||||
resetSelection();
|
||||
}
|
||||
}}
|
||||
label={t("yours")}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
{data
|
||||
?.filter((team) => !team.isOrg)
|
||||
{(data || [])
|
||||
.filter((team) => !team.isOrg)
|
||||
.filter((team) => team.name?.toLowerCase().includes(query.toLowerCase()))
|
||||
.map((team) => {
|
||||
return (
|
||||
<FilterCheckboxField
|
||||
testId="org-teams-filter-item"
|
||||
key={team.id}
|
||||
id={team.name || ""}
|
||||
label={team.name || ""}
|
||||
@@ -130,7 +142,7 @@ export const OrgTeamsFilter = () => {
|
||||
if (e.target.checked) {
|
||||
onSelected({ type: "team", teamId: team.id });
|
||||
} else if (!e.target.checked) {
|
||||
onSelected({ type: currentOrgId ? "org" : "yours", teamId: undefined });
|
||||
resetSelection();
|
||||
}
|
||||
}}
|
||||
icon={
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Icon, FilterSelect } from "@calcom/ui";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
|
||||
export const RoutingFormFieldFilter = ({ fieldId }: { fieldId: string }) => {
|
||||
const { t } = useLocale();
|
||||
const { filter, setConfigFilters } = useFilterContext();
|
||||
const { selectedTeamId, selectedUserId, isAll, selectedRoutingFormId, selectedRoutingFormFilter } = filter;
|
||||
|
||||
const { data: fieldOptions } = trpc.viewer.insights.getRoutingFormFieldOptions.useQuery(
|
||||
{
|
||||
userId: selectedUserId ?? undefined,
|
||||
teamId: selectedTeamId ?? undefined,
|
||||
isAll: !!isAll,
|
||||
routingFormId: selectedRoutingFormId ?? undefined,
|
||||
},
|
||||
{
|
||||
enabled: !!selectedTeamId || !!isAll,
|
||||
}
|
||||
);
|
||||
|
||||
const currentField = useMemo(() => {
|
||||
return fieldOptions?.find((option) => `${option.id}` === fieldId);
|
||||
}, [fieldOptions]);
|
||||
|
||||
if (!currentField) return null;
|
||||
|
||||
const options =
|
||||
currentField.options?.map((option) => ({
|
||||
value: option.id ?? "",
|
||||
label: option.label,
|
||||
})) ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterSelect
|
||||
title={currentField.label}
|
||||
options={options}
|
||||
selectedValue={selectedRoutingFormFilter?.optionId}
|
||||
onChange={(value) => {
|
||||
console.log("value", value);
|
||||
if (!value) {
|
||||
setConfigFilters({
|
||||
selectedRoutingFormFilter: null,
|
||||
});
|
||||
} else {
|
||||
setConfigFilters({
|
||||
selectedRoutingFormFilter: {
|
||||
fieldId,
|
||||
optionId: value as string,
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
buttonIcon={<Icon name="layers" className="mr-2 h-4 w-4" />}
|
||||
placeholder={t("search")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
import { memo } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Icon, FilterSelect } from "@calcom/ui";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
|
||||
type Form = RouterOutputs["viewer"]["insights"]["getRoutingFormsForFilters"][number];
|
||||
|
||||
function buildFilterOptions(forms: Form[]) {
|
||||
return forms.map((form) => ({
|
||||
value: form.id,
|
||||
label: form.name,
|
||||
}));
|
||||
}
|
||||
|
||||
export const RoutingFormFilterList = memo(() => {
|
||||
const { t } = useLocale();
|
||||
const { filter, setConfigFilters } = useFilterContext();
|
||||
const { selectedTeamId, selectedUserId, selectedRoutingFormId, isAll } = filter;
|
||||
const { selectedFilter } = filter;
|
||||
|
||||
const { data: allForms } = trpc.viewer.insights.getRoutingFormsForFilters.useQuery(
|
||||
{
|
||||
userId: selectedUserId ?? undefined,
|
||||
teamId: selectedTeamId ?? undefined,
|
||||
isAll: isAll ?? false,
|
||||
},
|
||||
{
|
||||
enabled: selectedFilter?.includes("routing_forms"),
|
||||
}
|
||||
);
|
||||
|
||||
if (!selectedFilter?.includes("routing_forms")) return null;
|
||||
|
||||
const filterOptions = buildFilterOptions(allForms || []);
|
||||
|
||||
return (
|
||||
<FilterSelect
|
||||
title={t("routing_forms")}
|
||||
options={filterOptions}
|
||||
selectedValue={selectedRoutingFormId}
|
||||
onChange={(value) => setConfigFilters({ selectedRoutingFormId: value as string })}
|
||||
buttonIcon={<Icon name="filter" className="mr-2 h-4 w-4" />}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
RoutingFormFilterList.displayName = "RoutingFormFilterList";
|
||||
@@ -1,182 +0,0 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import {
|
||||
FilterCheckboxField,
|
||||
FilterCheckboxFieldsContainer,
|
||||
} from "@calcom/features/filters/components/TeamsFilter";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { AnimatedPopover, Avatar, Divider, Icon } from "@calcom/ui";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
|
||||
export const TeamAndSelfList = ({
|
||||
omitOrg = false,
|
||||
className = "",
|
||||
}: {
|
||||
omitOrg?: boolean;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const currentOrgId = session.data?.user.org?.id;
|
||||
const { filter, setConfigFilters } = useFilterContext();
|
||||
const { selectedTeamId, selectedUserId, isAll } = filter;
|
||||
const { data, isSuccess } = trpc.viewer.insights.teamListForUser.useQuery(undefined, {
|
||||
// Teams don't change that frequently
|
||||
refetchOnWindowFocus: false,
|
||||
trpc: {
|
||||
context: {
|
||||
skipBatch: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const isInitialSetupAlready = !!(
|
||||
filter.initialConfig?.teamId ||
|
||||
filter.initialConfig?.userId ||
|
||||
filter.initialConfig?.isAll
|
||||
);
|
||||
if (isInitialSetupAlready) return;
|
||||
if (isSuccess && session.data?.user.id) {
|
||||
// We have a team?
|
||||
if (data[0]?.id && data && data?.length > 0) {
|
||||
const isAllSelected = !!data[0]?.isOrg;
|
||||
setConfigFilters({
|
||||
selectedTeamId: data[0].id,
|
||||
selectedUserId: null,
|
||||
isAll: isAllSelected,
|
||||
initialConfig: {
|
||||
teamId: data[0].id,
|
||||
userId: null,
|
||||
isAll: isAllSelected,
|
||||
},
|
||||
});
|
||||
} else if (session.data?.user.id) {
|
||||
// default to user
|
||||
setConfigFilters({
|
||||
selectedUserId: session.data?.user.id,
|
||||
initialConfig: {
|
||||
teamId: null,
|
||||
userId: session.data?.user.id,
|
||||
isAll: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [data, session.data?.user.id, filter.initialConfig, isSuccess, setConfigFilters]);
|
||||
|
||||
const getTextPopover = () => {
|
||||
if (isAll) {
|
||||
return `${t("all")}`;
|
||||
} else if (selectedUserId) {
|
||||
return `${t("yours")}`;
|
||||
} else if (selectedTeamId) {
|
||||
const selectedTeam = data?.find((item) => {
|
||||
return item.id === selectedTeamId;
|
||||
});
|
||||
return `${t("team")}: ${selectedTeam?.name}`;
|
||||
}
|
||||
|
||||
return t("select");
|
||||
};
|
||||
|
||||
const text = getTextPopover();
|
||||
const isOrgDataAvailable = !!data && data.length > 0 && !!data[0].isOrg;
|
||||
|
||||
return (
|
||||
<AnimatedPopover text={text} popoverTriggerClassNames={className}>
|
||||
<FilterCheckboxFieldsContainer>
|
||||
{isOrgDataAvailable && (
|
||||
<FilterCheckboxField
|
||||
id="all"
|
||||
icon={<Icon name="layers" className="h-4 w-4" />}
|
||||
checked={isAll}
|
||||
onChange={(e) => {
|
||||
setConfigFilters({
|
||||
selectedTeamId: data[0].id,
|
||||
selectedUserId: null,
|
||||
selectedTeamName: null,
|
||||
selectedRoutingFormId: null,
|
||||
isAll: true,
|
||||
});
|
||||
}}
|
||||
label={t("all")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
{data?.map((team) => {
|
||||
if (omitOrg && team.id === currentOrgId) return null;
|
||||
return (
|
||||
<FilterCheckboxField
|
||||
key={team.id}
|
||||
id={team.name || ""}
|
||||
label={team.name || ""}
|
||||
checked={selectedTeamId === team.id && !isAll}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setConfigFilters({
|
||||
selectedTeamId: team.id,
|
||||
selectedUserId: null,
|
||||
selectedTeamName: team.name,
|
||||
isAll: false,
|
||||
// Setting these to null to reset the filters
|
||||
selectedEventTypeId: null,
|
||||
selectedMemberUserId: null,
|
||||
selectedFilter: null,
|
||||
selectedRoutingFormId: null,
|
||||
});
|
||||
} else if (!e.target.checked) {
|
||||
setConfigFilters({
|
||||
selectedTeamId: isOrgDataAvailable ? data[0].id : null,
|
||||
selectedTeamName: null,
|
||||
selectedRoutingFormId: null,
|
||||
isAll: isOrgDataAvailable,
|
||||
});
|
||||
}
|
||||
}}
|
||||
icon={
|
||||
<Avatar
|
||||
alt={team.name || ""}
|
||||
imageSrc={getPlaceholderAvatar(team.logoUrl, team.name)}
|
||||
size="xs"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Divider />
|
||||
|
||||
<FilterCheckboxField
|
||||
id="yours"
|
||||
icon={<Icon name="user" className="h-4 w-4" />}
|
||||
checked={selectedUserId === session.data?.user.id}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setConfigFilters({
|
||||
selectedRoutingFormId: null,
|
||||
selectedUserId: session.data?.user.id,
|
||||
selectedMemberUserId: null,
|
||||
selectedTeamId: null,
|
||||
isAll: false,
|
||||
});
|
||||
} else if (!e.target.checked) {
|
||||
setConfigFilters({
|
||||
selectedTeamId: isOrgDataAvailable ? data[0].id : null,
|
||||
selectedUserId: null,
|
||||
selectedTeamName: null,
|
||||
selectedRoutingFormId: null,
|
||||
isAll: isOrgDataAvailable,
|
||||
});
|
||||
}
|
||||
}}
|
||||
label={t("yours")}
|
||||
/>
|
||||
</FilterCheckboxFieldsContainer>
|
||||
</AnimatedPopover>
|
||||
);
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Icon, Avatar, FilterSelect } from "@calcom/ui";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
|
||||
type User = RouterOutputs["viewer"]["insights"]["userList"][number];
|
||||
|
||||
const mapUserToOption = (user: User) => ({
|
||||
value: user.id,
|
||||
label: user.name ?? user.email,
|
||||
icon: <Avatar alt={`${user.name} avatar`} imageSrc={user.avatarUrl} size="sm" className="mr-2" />,
|
||||
});
|
||||
|
||||
export const UserListInTeam = ({ onClear }: { onClear?: () => void }) => {
|
||||
const { t } = useLocale();
|
||||
const { filter, setConfigFilters } = useFilterContext();
|
||||
const { selectedFilter, selectedTeamId, selectedMemberUserId, isAll } = filter;
|
||||
|
||||
const { data, isSuccess } = trpc.viewer.insights.userList.useQuery({
|
||||
teamId: selectedTeamId ?? -1,
|
||||
isAll: !!isAll,
|
||||
});
|
||||
|
||||
if (!selectedFilter?.includes("user")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!selectedTeamId || !isSuccess || data?.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userListOptions = data.map(mapUserToOption);
|
||||
|
||||
return (
|
||||
<FilterSelect
|
||||
title={t("people")}
|
||||
options={userListOptions}
|
||||
selectedValue={selectedMemberUserId}
|
||||
onChange={(value) => {
|
||||
setConfigFilters({ selectedMemberUserId: value === null ? null : Number(value) });
|
||||
if (value === null) {
|
||||
onClear?.();
|
||||
}
|
||||
}}
|
||||
buttonIcon={<Icon name="users" className="mr-2 h-4 w-4" />}
|
||||
placeholder={t("search")}
|
||||
testId="people-filter"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useFilterContext } from "@calcom/features/insights/context/provider";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Icon, Tooltip } from "@calcom/ui";
|
||||
|
||||
import { DateSelect } from "./DateSelect";
|
||||
import { Download } from "./Download";
|
||||
import { EventTypeList } from "./EventTypeList";
|
||||
import { FilterType } from "./FilterType";
|
||||
import { TeamAndSelfList } from "./TeamAndSelfList";
|
||||
import { UserListInTeam } from "./UserListInTeam";
|
||||
|
||||
const ClearFilters = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter, clearFilters } = useFilterContext();
|
||||
const { selectedFilter } = filter;
|
||||
|
||||
if (!selectedFilter || selectedFilter?.length < 1) return null;
|
||||
|
||||
return (
|
||||
<Tooltip content={t("clear_filters")}>
|
||||
<Button
|
||||
variant="icon"
|
||||
color="secondary"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="min-w-24 h-[38px] border-0"
|
||||
onClick={() => {
|
||||
clearFilters();
|
||||
}}>
|
||||
<Icon name="x" className="mr-1 h-4 w-4" />
|
||||
{t("clear")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const Filters = () => {
|
||||
const { filter } = useFilterContext();
|
||||
const { selectedFilter } = filter;
|
||||
|
||||
// Get all filters that relate to the routing form
|
||||
const routingFormFieldIds = selectedFilter
|
||||
? selectedFilter.map((filter) => {
|
||||
if (filter.startsWith("rf_")) return filter.substring(3);
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="ml-auto mt-6 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-between">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-nowrap sm:justify-start">
|
||||
<TeamAndSelfList omitOrg={false} />
|
||||
|
||||
<UserListInTeam />
|
||||
|
||||
<EventTypeList />
|
||||
|
||||
<FilterType />
|
||||
|
||||
<ClearFilters />
|
||||
</div>
|
||||
|
||||
{/* @NOTE: To be released in next iteration */}
|
||||
{/* <ButtonGroup combined containerProps={{ className: "hidden lg:flex mr-2" }}>
|
||||
<Tooltip content={t("settings")}>
|
||||
<Button
|
||||
variant="icon"
|
||||
color="secondary"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
StartIcon="settings"
|
||||
className="h-[38px]"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("download_csv")>
|
||||
<Button
|
||||
variant="icon"
|
||||
color="secondary"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
StartIcon="download"
|
||||
className="h-[38px]"
|
||||
/>
|
||||
</Tooltip>
|
||||
</ButtonGroup> */}
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:flex-nowrap sm:justify-between">
|
||||
<Download />
|
||||
<DateSelect className="me-2 ms-2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
createColumnHelper,
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
} from "@tanstack/react-table";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { ColumnFilterType } from "@calcom/features/data-table";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import type { HeaderRow, RoutingFormTableRow } from "../lib/types";
|
||||
import { useInsightsFacetedUniqueValues } from "./useInsightsFacetedUniqueValues";
|
||||
import { useInsightsParameters } from "./useInsightsParameters";
|
||||
|
||||
type DummyTableRow = {
|
||||
bookingUserId: RoutingFormTableRow["bookingUserId"];
|
||||
eventTypeId: number | null;
|
||||
};
|
||||
|
||||
const emptyData: DummyTableRow[] = [];
|
||||
const dummyHeaders: HeaderRow[] = [];
|
||||
|
||||
export const useInsightsBookings = () => {
|
||||
const { t } = useLocale();
|
||||
const { isAll, teamId, userId } = useInsightsParameters();
|
||||
|
||||
const getInsightsFacetedUniqueValues = useInsightsFacetedUniqueValues({
|
||||
headers: dummyHeaders,
|
||||
userId,
|
||||
teamId,
|
||||
isAll,
|
||||
});
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const columnHelper = createColumnHelper<DummyTableRow>();
|
||||
return [
|
||||
columnHelper.accessor("bookingUserId", {
|
||||
id: "bookingUserId",
|
||||
header: t("user"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
},
|
||||
},
|
||||
cell: () => null,
|
||||
}),
|
||||
columnHelper.accessor("eventTypeId", {
|
||||
id: "eventTypeId",
|
||||
header: t("event_type"),
|
||||
size: 200,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
},
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
}),
|
||||
];
|
||||
}, [t]);
|
||||
|
||||
const table = useReactTable<DummyTableRow>({
|
||||
data: emptyData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedUniqueValues: getInsightsFacetedUniqueValues,
|
||||
});
|
||||
|
||||
return { table };
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import startCase from "lodash/startCase";
|
||||
import { useMemo } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { dataTableFilter, ColumnFilterType } from "@calcom/features/data-table";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RoutingFormFieldType } from "@calcom/routing-forms/lib/FieldTypes";
|
||||
import { Badge } from "@calcom/ui";
|
||||
|
||||
import { BookedByCell } from "../components/BookedByCell";
|
||||
import { BookingAtCell } from "../components/BookingAtCell";
|
||||
import { BookingStatusBadge } from "../components/BookingStatusBadge";
|
||||
import { ResponseValueCell } from "../components/ResponseValueCell";
|
||||
import type { HeaderRow, RoutingFormTableRow } from "../lib/types";
|
||||
import {
|
||||
ZResponseMultipleValues,
|
||||
ZResponseSingleValue,
|
||||
ZResponseTextValue,
|
||||
ZResponseNumericValue,
|
||||
} from "../lib/types";
|
||||
|
||||
export const useInsightsColumns = ({
|
||||
headers,
|
||||
isHeadersSuccess,
|
||||
}: {
|
||||
headers: HeaderRow[] | undefined;
|
||||
isHeadersSuccess: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return useMemo(() => {
|
||||
if (!isHeadersSuccess) {
|
||||
return [];
|
||||
}
|
||||
const columnHelper = createColumnHelper<RoutingFormTableRow>();
|
||||
|
||||
return [
|
||||
columnHelper.accessor("formId", {
|
||||
id: "formId",
|
||||
header: t("routing_forms"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
filter: { type: ColumnFilterType.SINGLE_SELECT },
|
||||
},
|
||||
cell: () => null,
|
||||
filterFn: (row, id, filterValue) => {
|
||||
const cellValue = row.original.formId;
|
||||
return dataTableFilter(cellValue, filterValue);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("bookingUserId", {
|
||||
id: "bookingUserId",
|
||||
header: t("user"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
cell: () => null,
|
||||
filterFn: (row, id, filterValue) => {
|
||||
const cellValue = row.original.bookingUserId;
|
||||
return dataTableFilter(cellValue, filterValue);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("bookingAttendees", {
|
||||
id: "bookingAttendees",
|
||||
header: t("routing_form_insights_booked_by"),
|
||||
size: 200,
|
||||
enableColumnFilter: false,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
return <BookedByCell attendees={info.getValue()} rowId={info.row.original.id} />;
|
||||
},
|
||||
}),
|
||||
|
||||
...((headers || []).map((fieldHeader) => {
|
||||
const isText = [
|
||||
RoutingFormFieldType.TEXT,
|
||||
RoutingFormFieldType.EMAIL,
|
||||
RoutingFormFieldType.PHONE,
|
||||
RoutingFormFieldType.TEXTAREA,
|
||||
].includes(fieldHeader.type as RoutingFormFieldType);
|
||||
|
||||
const isNumber = fieldHeader.type === RoutingFormFieldType.NUMBER;
|
||||
|
||||
const isSingleSelect = fieldHeader.type === RoutingFormFieldType.SINGLE_SELECT;
|
||||
const isMultiSelect = fieldHeader.type === RoutingFormFieldType.MULTI_SELECT;
|
||||
|
||||
const filterType = isSingleSelect
|
||||
? ColumnFilterType.SINGLE_SELECT
|
||||
: isNumber
|
||||
? ColumnFilterType.NUMBER
|
||||
: isText
|
||||
? ColumnFilterType.TEXT
|
||||
: ColumnFilterType.MULTI_SELECT;
|
||||
|
||||
const optionMap =
|
||||
fieldHeader.options?.reduce((acc, option) => {
|
||||
if (option.id) {
|
||||
acc[option.id] = option.label;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>) ?? {};
|
||||
|
||||
return columnHelper.accessor(`response.${fieldHeader.id}`, {
|
||||
id: fieldHeader.id,
|
||||
header: startCase(fieldHeader.label),
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const values = info.getValue();
|
||||
if (isMultiSelect || isSingleSelect) {
|
||||
const result = z.union([ZResponseMultipleValues, ZResponseSingleValue]).safeParse(values);
|
||||
return (
|
||||
result.success && (
|
||||
<ResponseValueCell
|
||||
optionMap={optionMap}
|
||||
values={Array.isArray(result.data.value) ? result.data.value : [result.data.value]}
|
||||
rowId={info.row.original.id}
|
||||
/>
|
||||
)
|
||||
);
|
||||
} else if (isText || isNumber) {
|
||||
const result = z.union([ZResponseTextValue, ZResponseNumericValue]).safeParse(values);
|
||||
return (
|
||||
result.success && (
|
||||
<div className="truncate">
|
||||
<span title={String(result.data.value)}>{result.data.value}</span>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
meta: {
|
||||
filter: { type: filterType },
|
||||
},
|
||||
filterFn: (row, id, filterValue) => {
|
||||
const cellValue = row.original.response[id]?.value;
|
||||
return dataTableFilter(cellValue, filterValue);
|
||||
},
|
||||
});
|
||||
}) ?? []),
|
||||
columnHelper.accessor("bookingStatusOrder", {
|
||||
id: "bookingStatusOrder",
|
||||
header: t("routing_form_insights_booking_status"),
|
||||
sortDescFirst: false,
|
||||
cell: (info) => (
|
||||
<div className="max-w-[250px]">
|
||||
<BookingStatusBadge bookingStatus={info.row.original.bookingStatus} />
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
filter: { type: ColumnFilterType.MULTI_SELECT, icon: "circle" },
|
||||
},
|
||||
filterFn: (row, id, filterValue) => {
|
||||
const cellValue = row.original.bookingStatusOrder;
|
||||
return dataTableFilter(cellValue, filterValue);
|
||||
},
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const statusA = rowA.original.bookingStatusOrder ?? 6; // put it at the end if bookingStatusOrder is null
|
||||
const statusB = rowB.original.bookingStatusOrder ?? 6;
|
||||
return statusA - statusB;
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("bookingCreatedAt", {
|
||||
id: "bookingCreatedAt",
|
||||
header: t("routing_form_insights_booking_at"),
|
||||
enableColumnFilter: false,
|
||||
cell: (info) => (
|
||||
<div className="max-w-[250px]">
|
||||
<BookingAtCell row={info.row.original} rowId={info.row.original.id} />
|
||||
</div>
|
||||
),
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const dateA = rowA.original.bookingCreatedAt;
|
||||
const dateB = rowB.original.bookingCreatedAt;
|
||||
if (!dateA && !dateB) return 0;
|
||||
if (!dateA) return -1;
|
||||
if (!dateB) return 1;
|
||||
if (!(dateA instanceof Date) || !(dateB instanceof Date)) return 0;
|
||||
|
||||
return dateA.getTime() - dateB.getTime();
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("bookingAssignmentReason", {
|
||||
id: "bookingAssignmentReason",
|
||||
header: t("routing_form_insights_assignment_reason"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
filter: { type: ColumnFilterType.TEXT },
|
||||
},
|
||||
cell: (info) => {
|
||||
const assignmentReason = info.getValue();
|
||||
return <div className="max-w-[250px]">{assignmentReason}</div>;
|
||||
},
|
||||
filterFn: (row, id, filterValue) => {
|
||||
const reason = row.original.bookingAssignmentReason;
|
||||
return dataTableFilter(reason, filterValue);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("createdAt", {
|
||||
id: "createdAt",
|
||||
header: t("routing_form_insights_submitted_at"),
|
||||
// exclude from "Filters" component
|
||||
// because we already have a DateRangeFilter component
|
||||
enableColumnFilter: false,
|
||||
cell: (info) => (
|
||||
<div className="whitespace-nowrap">
|
||||
<Badge variant="gray">{dayjs(info.getValue()).format("MMM D, YYYY HH:mm")}</Badge>
|
||||
</div>
|
||||
),
|
||||
filterFn: (row, id, filterValue) => {
|
||||
const createdAt = row.original.createdAt;
|
||||
return dataTableFilter(createdAt, filterValue);
|
||||
},
|
||||
}),
|
||||
];
|
||||
}, [isHeadersSuccess, headers]);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Table } from "@tanstack/react-table";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { convertFacetedValuesToMap, type FacetedValue } from "@calcom/features/data-table";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { bookingStatusToText } from "../lib/bookingStatusToText";
|
||||
import type { HeaderRow } from "../lib/types";
|
||||
|
||||
const statusOrder: Record<BookingStatus, number> = {
|
||||
[BookingStatus.ACCEPTED]: 1,
|
||||
[BookingStatus.PENDING]: 2,
|
||||
[BookingStatus.AWAITING_HOST]: 3,
|
||||
[BookingStatus.CANCELLED]: 4,
|
||||
[BookingStatus.REJECTED]: 5,
|
||||
};
|
||||
|
||||
export const useInsightsFacetedUniqueValues = ({
|
||||
headers,
|
||||
userId,
|
||||
teamId,
|
||||
isAll,
|
||||
}: {
|
||||
headers: HeaderRow[] | undefined;
|
||||
userId: number | undefined;
|
||||
teamId: number | undefined;
|
||||
isAll: boolean;
|
||||
}) => {
|
||||
const { data: forms } = trpc.viewer.insights.getRoutingFormsForFilters.useQuery({
|
||||
userId,
|
||||
teamId,
|
||||
isAll,
|
||||
});
|
||||
|
||||
const { data: users } = trpc.viewer.insights.userList.useQuery({
|
||||
teamId,
|
||||
isAll,
|
||||
});
|
||||
|
||||
const { data: eventTypes } = trpc.viewer.insights.eventTypeList.useQuery({
|
||||
teamId,
|
||||
userId,
|
||||
isAll,
|
||||
});
|
||||
|
||||
return useCallback(
|
||||
(_: Table<any>, columnId: string) => (): Map<FacetedValue, number> => {
|
||||
if (!headers) {
|
||||
return new Map<FacetedValue, number>();
|
||||
}
|
||||
|
||||
const fieldHeader = headers.find((h) => h.id === columnId);
|
||||
if (fieldHeader?.options) {
|
||||
return convertFacetedValuesToMap(
|
||||
fieldHeader.options
|
||||
.filter((option): option is { id: string; label: string } => option.id !== null)
|
||||
.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.id,
|
||||
}))
|
||||
);
|
||||
} else if (columnId === "bookingStatusOrder") {
|
||||
return convertFacetedValuesToMap(
|
||||
Object.keys(statusOrder).map((status) => ({
|
||||
value: statusOrder[status as BookingStatus],
|
||||
label: bookingStatusToText(status as BookingStatus),
|
||||
}))
|
||||
);
|
||||
} else if (columnId === "formId") {
|
||||
return convertFacetedValuesToMap(
|
||||
forms?.map((form) => ({
|
||||
label: form.name,
|
||||
value: form.id,
|
||||
})) ?? []
|
||||
);
|
||||
} else if (columnId === "bookingUserId") {
|
||||
return convertFacetedValuesToMap(
|
||||
users?.map((user) => ({
|
||||
label: user.name ?? user.email,
|
||||
value: user.id,
|
||||
})) ?? []
|
||||
);
|
||||
} else if (columnId === "eventTypeId") {
|
||||
return convertFacetedValuesToMap(
|
||||
eventTypes?.map((eventType) => ({
|
||||
value: eventType.id,
|
||||
label: eventType.teamId ? `${eventType.title} (${eventType.team?.name})` : eventType.title,
|
||||
})) ?? []
|
||||
);
|
||||
}
|
||||
return new Map<FacetedValue, number>();
|
||||
},
|
||||
[headers, forms, users, eventTypes]
|
||||
);
|
||||
};
|
||||
@@ -8,10 +8,11 @@ export function useInsightsOrgTeams() {
|
||||
if (!context) {
|
||||
throw new Error("useInsightsOrgTeams must be used within a InsightsOrgTeamsProvider");
|
||||
}
|
||||
const { orgTeamsType, selectedTeamId, setOrgTeamsType, setSelectedTeamId } = context;
|
||||
const session = useSession();
|
||||
const currentOrgId = session.data?.user.org?.id;
|
||||
const { orgTeamsType, selectedTeamId, setOrgTeamsType, setSelectedTeamId } = context;
|
||||
const isAll = orgTeamsType === "org";
|
||||
const teamId = orgTeamsType === "team" ? selectedTeamId : undefined;
|
||||
const teamId = orgTeamsType === "org" ? currentOrgId : orgTeamsType === "team" ? selectedTeamId : undefined;
|
||||
const userId = orgTeamsType === "yours" ? session.data?.user.id : undefined;
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
useFilterValue,
|
||||
useColumnFilters,
|
||||
@@ -8,6 +7,12 @@ import {
|
||||
ZSingleSelectFilterValue,
|
||||
ZDateRangeFilterValue,
|
||||
} from "@calcom/features/data-table";
|
||||
import {
|
||||
getDefaultStartDate,
|
||||
getDefaultEndDate,
|
||||
CUSTOM_PRESET_VALUE,
|
||||
type PresetOptionValue,
|
||||
} from "@calcom/features/data-table/lib/dateRange";
|
||||
|
||||
import { useInsightsOrgTeams } from "./useInsightsOrgTeams";
|
||||
|
||||
@@ -17,26 +22,37 @@ export function useInsightsParameters() {
|
||||
const memberUserIds = useFilterValue("bookingUserId", ZMultiSelectFilterValue)?.data as
|
||||
| number[]
|
||||
| undefined;
|
||||
const memberUserId = useFilterValue("bookingUserId", ZSingleSelectFilterValue)?.data as number | undefined;
|
||||
const eventTypeId = useFilterValue("eventTypeId", ZSingleSelectFilterValue)?.data as number | undefined;
|
||||
const routingFormId = useFilterValue("formId", ZSingleSelectFilterValue)?.data as string | undefined;
|
||||
const createdAtRange = useFilterValue("createdAt", ZDateRangeFilterValue)?.data;
|
||||
const startDate = useMemo(
|
||||
() => createdAtRange?.startDate ?? dayjs().subtract(1, "week").startOf("day").toISOString(),
|
||||
() => createdAtRange?.startDate ?? getDefaultStartDate().toISOString(),
|
||||
[createdAtRange?.startDate]
|
||||
);
|
||||
const endDate = useMemo(
|
||||
() => createdAtRange?.endDate ?? dayjs().endOf("day").toISOString(),
|
||||
() => createdAtRange?.endDate ?? getDefaultEndDate().toISOString(),
|
||||
[createdAtRange?.endDate]
|
||||
);
|
||||
const columnFilters = useColumnFilters({ exclude: ["bookingUserId", "formId", "createdAt"] });
|
||||
const dateRangePreset = useMemo<PresetOptionValue>(() => {
|
||||
return (createdAtRange?.preset as PresetOptionValue) ?? CUSTOM_PRESET_VALUE;
|
||||
}, [createdAtRange?.preset]);
|
||||
|
||||
const columnFilters = useColumnFilters({
|
||||
exclude: ["bookingUserId", "formId", "createdAt", "eventTypeId"],
|
||||
});
|
||||
|
||||
return {
|
||||
isAll,
|
||||
teamId,
|
||||
userId,
|
||||
memberUserIds,
|
||||
memberUserId,
|
||||
eventTypeId,
|
||||
routingFormId,
|
||||
startDate,
|
||||
endDate,
|
||||
dateRangePreset,
|
||||
columnFilters,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
// Upper case the first letter of each word and replace underscores with spaces
|
||||
export function bookingStatusToText(status: BookingStatus) {
|
||||
return status
|
||||
.toLowerCase()
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { RouterOutputs } from "@calcom/trpc";
|
||||
|
||||
export type RoutingFormTableRow = RouterOutputs["viewer"]["insights"]["routingFormResponses"]["data"][number];
|
||||
|
||||
export type HeaderRow = RouterOutputs["viewer"]["insights"]["routingFormResponsesHeaders"][number];
|
||||
|
||||
export const ZResponseMultipleValues = z.object({
|
||||
label: z.string(),
|
||||
value: z.array(z.string()),
|
||||
|
||||
@@ -70,6 +70,23 @@ class RoutingEventsInsights {
|
||||
teamIds = [teamId];
|
||||
}
|
||||
|
||||
// Filter teamIds to only include teams the user has access to
|
||||
if (teamIds.length > 0) {
|
||||
const accessibleTeams = await prisma.membership.findMany({
|
||||
where: {
|
||||
userId: userId ?? -1,
|
||||
teamId: {
|
||||
in: teamIds,
|
||||
},
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
teamId: true,
|
||||
},
|
||||
});
|
||||
teamIds = accessibleTeams.map((membership) => membership.teamId);
|
||||
}
|
||||
|
||||
// Base where condition for forms
|
||||
const formsWhereCondition: WhereForTeamOrAllTeams = {
|
||||
...(teamIds.length > 0
|
||||
@@ -145,7 +162,7 @@ class RoutingEventsInsights {
|
||||
isAll,
|
||||
organizationId,
|
||||
}: {
|
||||
userId?: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
isAll: boolean;
|
||||
organizationId?: number | undefined;
|
||||
|
||||
@@ -987,7 +987,7 @@ export const insightsRouter = router({
|
||||
userList: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
teamId: z.coerce.number().nullable(),
|
||||
teamId: z.number().optional(),
|
||||
isAll: z.boolean().nullable(),
|
||||
})
|
||||
)
|
||||
@@ -995,10 +995,6 @@ export const insightsRouter = router({
|
||||
const user = ctx.user;
|
||||
const { teamId, isAll } = input;
|
||||
|
||||
if (!teamId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isAll && user.organizationId && user.organization.isOrgAdmin) {
|
||||
const usersInTeam = await ctx.insightsDb.membership.findMany({
|
||||
where: {
|
||||
@@ -1016,6 +1012,10 @@ export const insightsRouter = router({
|
||||
return usersInTeam.map((membership) => membership.user);
|
||||
}
|
||||
|
||||
if (!teamId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const membership = await ctx.insightsDb.membership.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -1552,7 +1552,7 @@ export const insightsRouter = router({
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { userId, teamId, isAll } = input;
|
||||
return await RoutingEventsInsights.getRoutingFormsForFilters({
|
||||
userId,
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
isAll,
|
||||
organizationId: ctx.user.organizationId ?? undefined,
|
||||
@@ -1569,7 +1569,7 @@ export const insightsRouter = router({
|
||||
organizationId: ctx.user.organizationId ?? null,
|
||||
routingFormId: input.routingFormId,
|
||||
cursor: input.cursor,
|
||||
userId: input.userId,
|
||||
userId: ctx.user.id,
|
||||
memberUserIds: input.memberUserIds,
|
||||
limit: input.limit,
|
||||
columnFilters: input.columnFilters,
|
||||
@@ -1587,7 +1587,7 @@ export const insightsRouter = router({
|
||||
organizationId: ctx.user.organizationId ?? null,
|
||||
routingFormId: input.routingFormId,
|
||||
cursor: input.cursor,
|
||||
userId: input.userId,
|
||||
userId: ctx.user.id,
|
||||
memberUserIds: input.memberUserIds,
|
||||
limit: input.limit,
|
||||
columnFilters: input.columnFilters,
|
||||
@@ -1605,7 +1605,7 @@ export const insightsRouter = router({
|
||||
organizationId: ctx.user.organizationId ?? null,
|
||||
routingFormId: input.routingFormId,
|
||||
cursor: input.cursor,
|
||||
userId: input.userId,
|
||||
userId: ctx.user.id,
|
||||
memberUserIds: input.memberUserIds,
|
||||
limit: input.limit ?? BATCH_SIZE,
|
||||
columnFilters: input.columnFilters,
|
||||
@@ -1624,6 +1624,7 @@ export const insightsRouter = router({
|
||||
.query(async ({ input, ctx }) => {
|
||||
const options = await RoutingEventsInsights.getRoutingFormFieldOptions({
|
||||
...input,
|
||||
userId: ctx.user.id,
|
||||
organizationId: ctx.user.organizationId ?? null,
|
||||
});
|
||||
return options;
|
||||
@@ -1640,6 +1641,7 @@ export const insightsRouter = router({
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await RoutingEventsInsights.getFailedBookingsByRoutingFormGroup({
|
||||
...input,
|
||||
userId: ctx.user.id,
|
||||
organizationId: ctx.user.organizationId ?? null,
|
||||
});
|
||||
}),
|
||||
@@ -1654,7 +1656,7 @@ export const insightsRouter = router({
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const headers = await RoutingEventsInsights.getRoutingFormHeaders({
|
||||
userId: input.userId ?? null,
|
||||
userId: ctx.user.id,
|
||||
teamId: input.teamId ?? null,
|
||||
isAll: input.isAll,
|
||||
organizationId: ctx.user.organizationId ?? null,
|
||||
@@ -1683,7 +1685,7 @@ export const insightsRouter = router({
|
||||
input;
|
||||
|
||||
return await RoutingEventsInsights.routedToPerPeriod({
|
||||
userId: userId ?? null,
|
||||
userId: ctx.user.id,
|
||||
teamId: teamId ?? null,
|
||||
startDate,
|
||||
endDate,
|
||||
@@ -1709,7 +1711,7 @@ export const insightsRouter = router({
|
||||
const { startDate, endDate } = input;
|
||||
try {
|
||||
const csvData = await RoutingEventsInsights.routedToPerPeriodCsv({
|
||||
userId: input.userId ?? null,
|
||||
userId: ctx.user.id,
|
||||
teamId: input.teamId ?? null,
|
||||
startDate,
|
||||
endDate,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
singleSelectFilter,
|
||||
multiSelectFilter,
|
||||
ColumnFilterType,
|
||||
convertFacetedValuesToMap,
|
||||
} from "@calcom/features/data-table";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
@@ -458,13 +459,28 @@ function UserListTableContent() {
|
||||
if (facetedTeamValues) {
|
||||
switch (columnId) {
|
||||
case "role":
|
||||
return new Map(facetedTeamValues.roles.map((role) => [role, 1]));
|
||||
return convertFacetedValuesToMap(
|
||||
facetedTeamValues.roles.map((role) => ({
|
||||
label: role,
|
||||
value: role,
|
||||
}))
|
||||
);
|
||||
case "teams":
|
||||
return new Map(facetedTeamValues.teams.map((team) => [team.name, 1]));
|
||||
return convertFacetedValuesToMap(
|
||||
facetedTeamValues.teams.map((team) => ({
|
||||
label: team.name,
|
||||
value: team.name,
|
||||
}))
|
||||
);
|
||||
default:
|
||||
const attribute = facetedTeamValues.attributes.find((attr) => attr.id === columnId);
|
||||
if (attribute) {
|
||||
return new Map(attribute?.options.map(({ value }) => [value, 1]) ?? []);
|
||||
return convertFacetedValuesToMap(
|
||||
attribute?.options.map(({ value }) => ({
|
||||
label: value,
|
||||
value,
|
||||
})) ?? []
|
||||
);
|
||||
}
|
||||
return new Map();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user