fix: integer to text comparison in routing insights query (#25019)
* fix: integer to text comparison in routing insights query Add explicit integer array cast to prevent PostgreSQL type mismatch error when comparing bookingUserId (integer) with user_id array values in getRoutedToPerPeriodData query * add e2e tests * refactor: remove LoadingInsight component and handle loading in ChartCard - Enhanced ChartCard to render default loading UI when isPending is true - Replaced all LoadingInsight usages with ChartCard that accepts isPending/isError props - Removed LoadingInsight component and updated exports - Updated 16 chart components to use the new pattern - ChartCard now shows spinner and skeleton title during loading state Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: make children prop optional in ChartCard when isPending is true Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: remove unused loadingState prop from ChartCard - Remove loadingState prop and ChartLoadingState type export - Simplify computedLoadingState to derive state only from isPending/isError - No functional changes - loadingState was not being used by any components - data-loading-state attribute behavior remains unchanged for E2E tests Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: remove duplicate isPending early returns from chart components - Update all 16 chart components to use single ChartCard return pattern - Gate children rendering with !isPending && isSuccess && data checks - Prevents data processing code from executing during loading state - Improves code consistency and maintainability across all charts Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: remove redundant !isPending check from chart conditionals - Simplify conditional rendering to use just 'isSuccess && data' or 'isSuccess' - In TanStack Query, isSuccess and isPending are mutually exclusive - The !isPending check was redundant since isSuccess already implies !isPending - Applied to all 16 chart components for consistency - Components with safe defaults (data ?? []) use just 'isSuccess' - Components requiring data check use 'isSuccess && data' Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * revert the mistake * apply feedback * apply feedback * fix e2e --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
3c46c35b69
commit
6479adf14e
30 files changed
+794
-404
No files matched your search
@@ -0,0 +1,288 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import { expectAllChartsToLoad, getAllChartIds } from "./lib/chart-helpers";
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test.describe("Insights > Charts Loading", () => {
|
||||
test.describe("Routing Insights", () => {
|
||||
test("all charts should load successfully with data", async ({ page, users, routingForms, prisma }) => {
|
||||
// Setup: Create routing form with complete data
|
||||
const owner = await users.create(
|
||||
{ name: "Chart Test Owner" },
|
||||
{
|
||||
hasTeam: true,
|
||||
isUnpublished: true,
|
||||
isOrg: true,
|
||||
hasSubteam: true,
|
||||
}
|
||||
);
|
||||
await owner.apiLogin();
|
||||
|
||||
const membership = await owner.getFirstTeamMembership();
|
||||
|
||||
// Create a routing form with fields
|
||||
const formName = "Chart Loading Test Form";
|
||||
const form = await routingForms.create({
|
||||
name: formName,
|
||||
userId: owner.id,
|
||||
teamId: membership.teamId,
|
||||
fields: [
|
||||
{
|
||||
type: "text",
|
||||
label: "Name",
|
||||
identifier: "name",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
type: "email",
|
||||
label: "Email",
|
||||
identifier: "email",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
label: "Department",
|
||||
identifier: "department",
|
||||
required: true,
|
||||
options: [
|
||||
{
|
||||
id: uuidv4(),
|
||||
label: "Sales",
|
||||
},
|
||||
{
|
||||
id: uuidv4(),
|
||||
label: "Support",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const fieldIds = (form.fields as Array<{ id: string }>).map((f) => f.id);
|
||||
|
||||
// Create users for routing
|
||||
const user1 = await users.create({ name: "Sales User" });
|
||||
const user2 = await users.create({ name: "Support User" });
|
||||
|
||||
// Add users to the team
|
||||
await prisma.membership.createMany({
|
||||
data: [
|
||||
{
|
||||
teamId: membership.teamId,
|
||||
userId: user1.id,
|
||||
role: "MEMBER",
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
teamId: membership.teamId,
|
||||
userId: user2.id,
|
||||
role: "MEMBER",
|
||||
accepted: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Create bookings for both users
|
||||
const booking1 = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `booking-${uuidv4()}`,
|
||||
title: "Sales Booking",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(Date.now() + 60 * 60 * 1000),
|
||||
userId: user1.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
|
||||
const booking2 = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `booking-${uuidv4()}`,
|
||||
title: "Support Booking",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(Date.now() + 60 * 60 * 1000),
|
||||
userId: user2.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
|
||||
// Create form responses with bookings
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "filler-1",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[fieldIds[0]]: { label: "Name", value: "John Doe" },
|
||||
[fieldIds[1]]: { label: "Email", value: "john@example.com" },
|
||||
[fieldIds[2]]: { label: "Department", value: "Sales" },
|
||||
},
|
||||
routedToBookingUid: booking1.uid,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "filler-2",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[fieldIds[0]]: { label: "Name", value: "Jane Smith" },
|
||||
[fieldIds[1]]: { label: "Email", value: "jane@example.com" },
|
||||
[fieldIds[2]]: { label: "Department", value: "Support" },
|
||||
},
|
||||
routedToBookingUid: booking2.uid,
|
||||
},
|
||||
});
|
||||
|
||||
// Create a response without booking (for "Failed Bookings" chart)
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "filler-3",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[fieldIds[0]]: { label: "Name", value: "Bob Wilson" },
|
||||
[fieldIds[1]]: { label: "Email", value: "bob@example.com" },
|
||||
[fieldIds[2]]: { label: "Department", value: "Sales" },
|
||||
},
|
||||
routedToBookingUid: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Navigate to insights routing page
|
||||
await page.goto("/insights/routing");
|
||||
await expect(page).toHaveURL(/\/insights\/routing/);
|
||||
|
||||
// Wait for the page to be fully loaded
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Get all chart IDs before checking
|
||||
const chartIds = await getAllChartIds(page);
|
||||
console.log(`[Routing] Found ${chartIds.length} charts: ${chartIds.join(", ")}`);
|
||||
|
||||
// Assert all charts load successfully
|
||||
await expectAllChartsToLoad(page, 20000); // 20 second timeout for all charts
|
||||
|
||||
// Verify specific expected charts are present and loaded
|
||||
const expectedCharts = ["stats", "routing-funnel", "routed-to-per-period", "failed-bookings-by-field"];
|
||||
|
||||
for (const chartId of expectedCharts) {
|
||||
const chart = page.locator(`[data-testid="chart-card"][data-chart-id="${chartId}"]`);
|
||||
await expect(chart).toBeVisible();
|
||||
await expect(chart).toHaveAttribute("data-loading-state", "loaded");
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
await prisma.booking.delete({ where: { id: booking1.id } });
|
||||
await prisma.booking.delete({ where: { id: booking2.id } });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Bookings Insights", () => {
|
||||
test("all charts should load successfully with booking data", async ({
|
||||
page,
|
||||
users,
|
||||
bookings,
|
||||
prisma,
|
||||
}) => {
|
||||
// Setup: Create users with team membership
|
||||
const owner = await users.create({ name: "Chart Test Owner" });
|
||||
const member = await users.create({ name: "Chart Test Member" });
|
||||
|
||||
// Create team and add both users
|
||||
const team = await prisma.team.create({
|
||||
data: {
|
||||
name: "test-insights-bookings",
|
||||
slug: `test-insights-bookings-${Date.now()}`,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.membership.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: owner.id,
|
||||
teamId: team.id,
|
||||
accepted: true,
|
||||
role: "ADMIN",
|
||||
},
|
||||
{
|
||||
userId: member.id,
|
||||
teamId: team.id,
|
||||
accepted: true,
|
||||
role: "MEMBER",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await owner.apiLogin();
|
||||
|
||||
// Create some bookings for the user
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
const dayAfterTomorrow = new Date(tomorrow);
|
||||
dayAfterTomorrow.setDate(dayAfterTomorrow.getDate() + 1);
|
||||
|
||||
const threeDaysFromNow = new Date(dayAfterTomorrow);
|
||||
threeDaysFromNow.setDate(threeDaysFromNow.getDate() + 1);
|
||||
|
||||
// Create bookings at different times to ensure unique idempotency keys
|
||||
await bookings.create(owner.id, owner.username, owner.eventTypes[0].id, {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: now,
|
||||
endTime: tomorrow,
|
||||
});
|
||||
|
||||
await bookings.create(owner.id, owner.username, owner.eventTypes[0].id, {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: tomorrow,
|
||||
endTime: dayAfterTomorrow,
|
||||
});
|
||||
|
||||
await bookings.create(owner.id, owner.username, owner.eventTypes[0].id, {
|
||||
status: BookingStatus.CANCELLED,
|
||||
startTime: dayAfterTomorrow,
|
||||
endTime: threeDaysFromNow,
|
||||
});
|
||||
|
||||
// Navigate to insights bookings page
|
||||
await page.goto("/insights");
|
||||
await expect(page).toHaveURL(/\/insights/);
|
||||
|
||||
// Wait for the page to be fully loaded
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Get all chart IDs before checking
|
||||
const chartIds = await getAllChartIds(page);
|
||||
console.log(`[Bookings] Found ${chartIds.length} charts: ${chartIds.join(", ")}`);
|
||||
|
||||
// Assert all charts load successfully
|
||||
await expectAllChartsToLoad(page, 20000); // 20 second timeout for all charts
|
||||
|
||||
// Verify specific expected charts are present and loaded
|
||||
const expectedCharts = [
|
||||
"events",
|
||||
"performance",
|
||||
"event-trends",
|
||||
"no-show-hosts-over-time",
|
||||
"csat-over-time",
|
||||
"bookings-by-hour",
|
||||
"average-event-duration",
|
||||
];
|
||||
|
||||
for (const chartId of expectedCharts) {
|
||||
const chart = page.locator(`[data-testid="chart-card"][data-chart-id="${chartId}"]`);
|
||||
// Chart should exist and be loaded
|
||||
await expect(chart).toHaveCount(1);
|
||||
await expect(chart).toHaveAttribute("data-loading-state", "loaded");
|
||||
}
|
||||
|
||||
// Verify no charts are in error state
|
||||
const errorCharts = page.locator('[data-testid="chart-card"][data-loading-state="error"]');
|
||||
await expect(errorCharts).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -272,7 +272,7 @@ test.describe("Insights", async () => {
|
||||
|
||||
for (const title of expectedChartTitles) {
|
||||
const chartCard = page
|
||||
.locator("[data-testid='panel-card'] h2")
|
||||
.locator("[data-testid='chart-card'] h2")
|
||||
.filter({ hasText: new RegExp(`^${title}$`) });
|
||||
await expect(chartCard).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export type ChartLoadingState = "initial" | "loading" | "loaded" | "error";
|
||||
|
||||
export interface ChartLoadingResult {
|
||||
chartId: string | null;
|
||||
loadingState: ChartLoadingState;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all chart cards on the page to reach "loaded" state
|
||||
* @param page Playwright page object
|
||||
* @param timeout Maximum time to wait for all charts (default: 15000ms)
|
||||
* @returns Array of chart loading results
|
||||
*/
|
||||
export async function waitForAllChartsToLoad(page: Page, timeout = 15000): Promise<ChartLoadingResult[]> {
|
||||
const startTime = Date.now();
|
||||
const results: ChartLoadingResult[] = [];
|
||||
|
||||
// Wait for at least one chart card to be visible
|
||||
await page.locator('[data-testid="chart-card"]').first().waitFor({ state: "visible", timeout });
|
||||
|
||||
// Get all chart cards
|
||||
const chartCards = await page.locator('[data-testid="chart-card"]').all();
|
||||
|
||||
if (chartCards.length === 0) {
|
||||
throw new Error("No chart cards found on the page");
|
||||
}
|
||||
|
||||
// Wait for each chart to reach loaded state
|
||||
for (let i = 0; i < chartCards.length; i++) {
|
||||
const chart = chartCards[i];
|
||||
const remainingTime = timeout - (Date.now() - startTime);
|
||||
|
||||
if (remainingTime <= 0) {
|
||||
throw new Error(`Timeout waiting for charts to load after ${timeout}ms`);
|
||||
}
|
||||
|
||||
const chartId = await chart.getAttribute("data-chart-id");
|
||||
const result: ChartLoadingResult = {
|
||||
chartId,
|
||||
loadingState: "initial",
|
||||
success: false,
|
||||
};
|
||||
|
||||
try {
|
||||
// Wait for this specific chart to reach loaded state
|
||||
await expect(chart).toHaveAttribute("data-loading-state", "loaded", {
|
||||
timeout: remainingTime,
|
||||
});
|
||||
|
||||
result.loadingState = "loaded";
|
||||
result.success = true;
|
||||
} catch {
|
||||
// Get the actual state if it failed
|
||||
const actualState = await chart.getAttribute("data-loading-state");
|
||||
result.loadingState = (actualState as ChartLoadingState) || "initial";
|
||||
result.error = `Chart "${chartId}" failed to load. Current state: ${actualState}`;
|
||||
result.success = false;
|
||||
}
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that all charts on the page have loaded successfully
|
||||
* @param page Playwright page object
|
||||
* @param timeout Maximum time to wait for all charts (default: 15000ms)
|
||||
*/
|
||||
export async function expectAllChartsToLoad(page: Page, timeout = 15000): Promise<void> {
|
||||
const results = await waitForAllChartsToLoad(page, timeout);
|
||||
|
||||
const failedCharts = results.filter((r) => !r.success);
|
||||
|
||||
if (failedCharts.length > 0) {
|
||||
const errorMessages = failedCharts.map((r) => r.error).join("\n");
|
||||
throw new Error(`${failedCharts.length} chart(s) failed to load:\n${errorMessages}`);
|
||||
}
|
||||
|
||||
// Additional check: ensure no charts are in error state
|
||||
const errorCharts = page.locator('[data-testid="chart-card"][data-loading-state="error"]');
|
||||
const errorCount = await errorCharts.count();
|
||||
|
||||
if (errorCount > 0) {
|
||||
const errorChartIds = await Promise.all(
|
||||
(await errorCharts.all()).map((chart) => chart.getAttribute("data-chart-id"))
|
||||
);
|
||||
throw new Error(`${errorCount} chart(s) are in error state: ${errorChartIds.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the loading state of a specific chart by its ID
|
||||
* @param page Playwright page object
|
||||
* @param chartId The chart ID (derived from title)
|
||||
* @returns The loading state of the chart
|
||||
*/
|
||||
export async function getChartLoadingState(page: Page, chartId: string): Promise<ChartLoadingState | null> {
|
||||
const chart = page.locator(`[data-testid="chart-card"][data-chart-id="${chartId}"]`);
|
||||
const state = await chart.getAttribute("data-loading-state");
|
||||
return state as ChartLoadingState | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a specific chart to reach loaded state
|
||||
* @param page Playwright page object
|
||||
* @param chartId The chart ID (derived from title)
|
||||
* @param timeout Maximum time to wait (default: 10000ms)
|
||||
*/
|
||||
export async function waitForChartToLoad(page: Page, chartId: string, timeout = 10000): Promise<void> {
|
||||
const chart = page.locator(`[data-testid="chart-card"][data-chart-id="${chartId}"]`);
|
||||
await expect(chart).toHaveAttribute("data-loading-state", "loaded", { timeout });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all chart IDs on the page
|
||||
* @param page Playwright page object
|
||||
* @returns Array of chart IDs
|
||||
*/
|
||||
export async function getAllChartIds(page: Page): Promise<string[]> {
|
||||
const chartCards = await page.locator('[data-testid="chart-card"]').all();
|
||||
const chartIds = await Promise.all(
|
||||
chartCards.map(async (chart) => {
|
||||
const id = await chart.getAttribute("data-chart-id");
|
||||
return id || "unknown";
|
||||
})
|
||||
);
|
||||
return chartIds;
|
||||
}
|
||||
@@ -29,16 +29,19 @@ export const MyNewChart = () => {
|
||||
const { t } = useLocale();
|
||||
const insightsBookingParams = useInsightsBookingParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.myNewChartData.useQuery(insightsBookingParams, {
|
||||
staleTime: 180000, // 3 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
trpc: { context: { skipBatch: true } },
|
||||
});
|
||||
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.myNewChartData.useQuery(
|
||||
insightsBookingParams,
|
||||
{
|
||||
staleTime: 180000, // 3 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
trpc: { context: { skipBatch: true } },
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
return (
|
||||
<ChartCard title={t("my_new_chart_title")}>
|
||||
<ChartCard title={t("my_new_chart_title")} isPending={isPending} isError={isError}>
|
||||
{isSuccess && data?.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={data}>
|
||||
@@ -177,7 +180,28 @@ export class InsightsBookingBaseService {
|
||||
2. **Raw SQL for Performance**: Use `$queryRaw` for complex aggregations and better performance
|
||||
3. **Base Conditions**: Always use `await this.getBaseConditions()` for proper filtering and permissions
|
||||
4. **Error Handling**: Wrap service calls in try-catch blocks with `TRPCError`
|
||||
5. **Loading States**: Always show loading indicators with `LoadingInsight`
|
||||
6. **Consistent Styling**: Use `recharts` for new charts
|
||||
7. **Date Handling**: Use `getDateRanges()` and `getTimeView()` for time-based charts
|
||||
8. **Prisma v6 Compatibility**: Use `Prisma.sql` for the entire query instead of mixing template literals with SQL fragments
|
||||
5. **Loading States**: Always destructure `isPending` and `isError` from the query and pass them to `ChartCard`
|
||||
6. **ChartCard Props**: Pass `isPending` and `isError` to `ChartCard` - it will automatically calculate the loading state
|
||||
7. **Consistent Styling**: Use `recharts` for new charts
|
||||
8. **Date Handling**: Use `getDateRanges()` and `getTimeView()` for time-based charts
|
||||
9. **Prisma v6 Compatibility**: Use `Prisma.sql` for the entire query instead of mixing template literals with SQL fragments
|
||||
|
||||
## Loading State Management
|
||||
|
||||
`ChartCard` automatically handles loading states. Simply pass `isPending` and `isError`:
|
||||
|
||||
```typescript
|
||||
const { data, isPending, isError } = trpc.viewer.insights.myData.useQuery(...);
|
||||
|
||||
// ChartCard will automatically show:
|
||||
// - "loading" state when isPending is true
|
||||
// - "error" state when isError is true
|
||||
// - "loaded" state when both are false
|
||||
return (
|
||||
<ChartCard title={t("my_chart")} isPending={isPending} isError={isError}>
|
||||
{/* Your chart content */}
|
||||
</ChartCard>
|
||||
);
|
||||
```
|
||||
|
||||
This enables E2E tests to verify that all charts load successfully by checking the `data-loading-state` attribute.
|
||||
@@ -4,6 +4,8 @@ import { Fragment, useMemo, type ReactNode } from "react";
|
||||
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { PanelCard } from "@calcom/ui/components/card";
|
||||
import { Spinner } from "@calcom/ui/components/icon";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
type PanelCardProps = React.ComponentProps<typeof PanelCard>;
|
||||
@@ -20,21 +22,49 @@ export function ChartCard({
|
||||
legendSize,
|
||||
enabledLegend,
|
||||
onSeriesToggle,
|
||||
isPending,
|
||||
isError,
|
||||
...panelCardProps
|
||||
}: PanelCardProps & {
|
||||
}: Omit<PanelCardProps, 'children'> & {
|
||||
legend?: Array<LegendItem>;
|
||||
legendSize?: LegendSize;
|
||||
enabledLegend?: Array<LegendItem>;
|
||||
onSeriesToggle?: (label: string) => void;
|
||||
isPending?: boolean;
|
||||
isError?: boolean;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const legendComponent =
|
||||
legend && legend.length > 0 ? (
|
||||
<Legend items={legend} size={legendSize} enabledItems={enabledLegend} onItemToggle={onSeriesToggle} />
|
||||
) : null;
|
||||
|
||||
// Generate a chart ID from the title for testing purposes
|
||||
const chartId = useMemo(() => {
|
||||
if (typeof panelCardProps.title === "string") {
|
||||
return panelCardProps.title.toLowerCase().replace(/\s+/g, "-");
|
||||
}
|
||||
return undefined;
|
||||
}, [panelCardProps.title]);
|
||||
|
||||
// Calculate loading state from isPending/isError
|
||||
const computedLoadingState = useMemo(() => {
|
||||
if (isPending) return "loading";
|
||||
if (isError) return "error";
|
||||
return "loaded";
|
||||
}, [isPending, isError]);
|
||||
|
||||
const shouldShowDefaultLoading = isPending && !panelCardProps.children;
|
||||
|
||||
const displayTitle = shouldShowDefaultLoading ? <SkeletonText className="w-32" /> : panelCardProps.title;
|
||||
|
||||
return (
|
||||
<PanelCard
|
||||
{...panelCardProps}
|
||||
title={displayTitle}
|
||||
data-testid="chart-card"
|
||||
data-chart-id={chartId}
|
||||
data-loading-state={computedLoadingState}
|
||||
headerContent={
|
||||
panelCardProps.headerContent ? (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -45,7 +75,13 @@ export function ChartCard({
|
||||
legendComponent
|
||||
)
|
||||
}>
|
||||
{panelCardProps.children}
|
||||
{shouldShowDefaultLoading ? (
|
||||
<div className="m-auto flex h-80 flex-col items-center justify-center">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
) : (
|
||||
panelCardProps.children
|
||||
)}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
|
||||
import { ChartCard } from "./ChartCard";
|
||||
|
||||
export const LoadingInsight = () => {
|
||||
return (
|
||||
<ChartCard title={<SkeletonText className="w-32" />}>
|
||||
<div className="m-auto flex h-80 flex-col items-center justify-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
className="animate-spin">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
</div>
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
@@ -8,7 +8,6 @@ import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
|
||||
const COLOR = {
|
||||
AVERAGE: "#3b82f6",
|
||||
@@ -69,7 +68,7 @@ export const AverageEventDurationChart = () => {
|
||||
const { t } = useLocale();
|
||||
const insightsBookingParams = useInsightsBookingParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.averageEventDuration.useQuery(
|
||||
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.averageEventDuration.useQuery(
|
||||
insightsBookingParams,
|
||||
{
|
||||
staleTime: 180000,
|
||||
@@ -80,45 +79,42 @@ export const AverageEventDurationChart = () => {
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !data) return null;
|
||||
const isNoData = data.every((item) => item["Average"] === 0);
|
||||
return (
|
||||
<ChartCard title={t("average_event_duration")}>
|
||||
{isNoData && (
|
||||
<div className="text-default flex h-60 text-center">
|
||||
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
|
||||
</div>
|
||||
)}
|
||||
{data && data.length > 0 && !isNoData && (
|
||||
<div className="mt-4 h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="Date" className="text-xs" axisLine={false} tickLine={false} />
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
className="text-xs opacity-50"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={formatDuration}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
type="linear"
|
||||
dataKey="Average"
|
||||
name={t("average_event_duration")}
|
||||
stroke={COLOR.AVERAGE}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
animationDuration={1000}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
<ChartCard title={t("average_event_duration")} isPending={isPending} isError={isError}>
|
||||
{isSuccess && data ? (
|
||||
data.every((item) => item["Average"] === 0) ? (
|
||||
<div className="text-default flex h-60 text-center">
|
||||
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="Date" className="text-xs" axisLine={false} tickLine={false} />
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
className="text-xs opacity-50"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={formatDuration}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
type="linear"
|
||||
dataKey="Average"
|
||||
name={t("average_event_duration")}
|
||||
stroke={COLOR.AVERAGE}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
animationDuration={1000}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
@@ -13,7 +13,7 @@ export const BookingKPICards = () => {
|
||||
const { t } = useLocale();
|
||||
const insightsBookingParams = useInsightsBookingParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.bookingKPIStats.useQuery(
|
||||
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.bookingKPIStats.useQuery(
|
||||
insightsBookingParams,
|
||||
{
|
||||
staleTime: 180000,
|
||||
@@ -70,7 +70,12 @@ export const BookingKPICards = () => {
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<LoadingKPICards eventCategories={eventCategories} performanceCategories={performanceCategories} />
|
||||
<LoadingKPICards
|
||||
eventCategories={eventCategories}
|
||||
performanceCategories={performanceCategories}
|
||||
isPending={isPending}
|
||||
isError={isError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,7 +83,7 @@ export const BookingKPICards = () => {
|
||||
|
||||
return (
|
||||
<div className="stack-y-4">
|
||||
<ChartCard title={t("events")}>
|
||||
<ChartCard title={t("events")} isPending={isPending} isError={isError}>
|
||||
<StatContainer>
|
||||
{eventCategories.map((item, index) => (
|
||||
<StatItem key={item.title} index={index}>
|
||||
@@ -92,7 +97,7 @@ export const BookingKPICards = () => {
|
||||
</StatContainer>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title={t("performance")}>
|
||||
<ChartCard title={t("performance")} isPending={isPending} isError={isError}>
|
||||
<StatContainer>
|
||||
{performanceCategories.map((item, index) => (
|
||||
<StatItem key={item.title} index={index}>
|
||||
@@ -112,13 +117,15 @@ export const BookingKPICards = () => {
|
||||
const LoadingKPICards = (props: {
|
||||
eventCategories: { title: string; index: string }[];
|
||||
performanceCategories: { title: string; index: string }[];
|
||||
isPending: boolean;
|
||||
isError: boolean;
|
||||
}) => {
|
||||
const { eventCategories, performanceCategories } = props;
|
||||
const { eventCategories, performanceCategories, isPending, isError } = props;
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div className="stack-y-4">
|
||||
<ChartCard title={t("events")}>
|
||||
<ChartCard title={t("events")} isPending={isPending} isError={isError}>
|
||||
<StatContainer>
|
||||
{eventCategories.map((item, index) => (
|
||||
<StatItem key={item.title} index={index}>
|
||||
@@ -136,7 +143,7 @@ const LoadingKPICards = (props: {
|
||||
</StatContainer>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title={t("performance")}>
|
||||
<ChartCard title={t("performance")} isPending={isPending} isError={isError}>
|
||||
<StatContainer>
|
||||
{performanceCategories.map((item, index) => (
|
||||
<StatItem key={item.title} index={index}>
|
||||
|
||||
@@ -17,7 +17,6 @@ import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
|
||||
type BookingsByHourData = RouterOutputs["viewer"]["insights"]["bookingsByHourStats"][number];
|
||||
|
||||
@@ -97,7 +96,7 @@ export const BookingsByHourChart = () => {
|
||||
const { t } = useLocale();
|
||||
const insightsBookingParams = useInsightsBookingParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.bookingsByHourStats.useQuery(
|
||||
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.bookingsByHourStats.useQuery(
|
||||
insightsBookingParams,
|
||||
{
|
||||
staleTime: 180000,
|
||||
@@ -108,13 +107,9 @@ export const BookingsByHourChart = () => {
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !data) return null;
|
||||
|
||||
return (
|
||||
<ChartCard title={t("bookings_by_hour")}>
|
||||
<BookingsByHourChartContent data={data} />
|
||||
<ChartCard title={t("bookings_by_hour")} isPending={isPending} isError={isError}>
|
||||
{isSuccess && data ? <BookingsByHourChartContent data={data} /> : null}
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
@@ -8,7 +8,6 @@ import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
|
||||
const COLOR = {
|
||||
CSAT: "#22c55e",
|
||||
@@ -57,6 +56,7 @@ export const CSATOverTimeChart = () => {
|
||||
data: csatData,
|
||||
isSuccess,
|
||||
isPending,
|
||||
isError,
|
||||
} = trpc.viewer.insights.csatOverTime.useQuery(insightsBookingParams, {
|
||||
staleTime: 180000,
|
||||
refetchOnWindowFocus: false,
|
||||
@@ -65,39 +65,38 @@ export const CSATOverTimeChart = () => {
|
||||
},
|
||||
});
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess) return null;
|
||||
|
||||
return (
|
||||
<ChartCard title={t("csat_over_time")} className="h-full">
|
||||
<div className="linechart ml-4 mt-4 h-80 sm:ml-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={csatData ?? []} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="Month" className="text-xs" axisLine={false} tickLine={false} />
|
||||
<YAxis
|
||||
allowDecimals={true}
|
||||
className="text-xs opacity-50"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
domain={[0, 100]}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
type="linear"
|
||||
dataKey="CSAT"
|
||||
name={t("csat")}
|
||||
stroke={COLOR.CSAT}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
animationDuration={1000}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ChartCard title={t("csat_over_time")} className="h-full" isPending={isPending} isError={isError}>
|
||||
{isSuccess ? (
|
||||
<div className="linechart ml-4 mt-4 h-80 sm:ml-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={csatData ?? []} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="Month" className="text-xs" axisLine={false} tickLine={false} />
|
||||
<YAxis
|
||||
allowDecimals={true}
|
||||
className="text-xs opacity-50"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
domain={[0, 100]}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
type="linear"
|
||||
dataKey="CSAT"
|
||||
name={t("csat")}
|
||||
stroke={COLOR.CSAT}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
animationDuration={1000}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : null}
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
@@ -10,7 +10,6 @@ import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingPara
|
||||
import { useToggleableLegend } from "../../hooks/useToggleableLegend";
|
||||
import { valueFormatter } from "../../lib/valueFormatter";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
|
||||
const COLOR = {
|
||||
CREATED: "#a855f7",
|
||||
@@ -73,6 +72,7 @@ export const EventTrendsChart = () => {
|
||||
data: eventTrends,
|
||||
isSuccess,
|
||||
isPending,
|
||||
isError,
|
||||
} = trpc.viewer.insights.eventTrends.useQuery(insightsBookingParams, {
|
||||
staleTime: 180000,
|
||||
refetchOnWindowFocus: false,
|
||||
@@ -81,45 +81,45 @@ export const EventTrendsChart = () => {
|
||||
},
|
||||
});
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess) return null;
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title={t("event_trends")}
|
||||
legend={legend}
|
||||
enabledLegend={enabledLegend}
|
||||
onSeriesToggle={toggleSeries}>
|
||||
<div className="linechart ml-4 mt-4 h-80 sm:ml-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={eventTrends ?? []} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="Month" className="text-xs" axisLine={false} tickLine={false} />
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
className="text-xs opacity-50"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={valueFormatter}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
{enabledLegend.map((item) => (
|
||||
<Line
|
||||
key={item.label}
|
||||
type="linear"
|
||||
dataKey={item.label}
|
||||
name={item.label}
|
||||
stroke={item.color}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
animationDuration={1000}
|
||||
onSeriesToggle={toggleSeries}
|
||||
isPending={isPending}
|
||||
isError={isError}>
|
||||
{isSuccess ? (
|
||||
<div className="linechart ml-4 mt-4 h-80 sm:ml-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={eventTrends ?? []} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="Month" className="text-xs" axisLine={false} tickLine={false} />
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
className="text-xs opacity-50"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={valueFormatter}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
{enabledLegend.map((item) => (
|
||||
<Line
|
||||
key={item.label}
|
||||
type="linear"
|
||||
dataKey={item.label}
|
||||
name={item.label}
|
||||
stroke={item.color}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
animationDuration={1000}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : null}
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
@@ -5,14 +5,13 @@ import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
import { UserStatsTable } from "../UserStatsTable";
|
||||
|
||||
export const HighestNoShowHostTable = () => {
|
||||
const { t } = useLocale();
|
||||
const insightsBookingParams = useInsightsBookingParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostNoShow.useQuery(
|
||||
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.membersWithMostNoShow.useQuery(
|
||||
insightsBookingParams,
|
||||
{
|
||||
staleTime: 180000,
|
||||
@@ -23,13 +22,9 @@ export const HighestNoShowHostTable = () => {
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !data) return null;
|
||||
|
||||
return (
|
||||
<ChartCard title={t("most_no_show_host")}>
|
||||
<UserStatsTable data={data} />
|
||||
<ChartCard title={t("most_no_show_host")} isPending={isPending} isError={isError}>
|
||||
{isSuccess && data ? <UserStatsTable data={data} /> : null}
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
@@ -5,14 +5,13 @@ import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
import { UserStatsTable } from "../UserStatsTable";
|
||||
|
||||
export const HighestRatedMembersTable = () => {
|
||||
const { t } = useLocale();
|
||||
const insightsBookingParams = useInsightsBookingParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithHighestRatings.useQuery(
|
||||
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.membersWithHighestRatings.useQuery(
|
||||
insightsBookingParams,
|
||||
{
|
||||
staleTime: 180000,
|
||||
@@ -23,13 +22,10 @@ export const HighestRatedMembersTable = () => {
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !data) return null;
|
||||
|
||||
return (
|
||||
<ChartCard title={t("highest_rated")}>
|
||||
<UserStatsTable data={data} />
|
||||
<ChartCard title={t("highest_rated")} isPending={isPending} isError={isError}>
|
||||
{isSuccess && data ? <UserStatsTable data={data} /> : null}
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
@@ -5,14 +5,13 @@ import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
import { UserStatsTable } from "../UserStatsTable";
|
||||
|
||||
export const LeastBookedTeamMembersTable = () => {
|
||||
const { t } = useLocale();
|
||||
const insightsBookingParams = useInsightsBookingParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithLeastBookings.useQuery(
|
||||
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.membersWithLeastBookings.useQuery(
|
||||
insightsBookingParams,
|
||||
{
|
||||
staleTime: 180000,
|
||||
@@ -23,13 +22,9 @@ export const LeastBookedTeamMembersTable = () => {
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !data) return null;
|
||||
|
||||
return (
|
||||
<ChartCard title={t("least_bookings_scheduled")}>
|
||||
<UserStatsTable data={data} />
|
||||
<ChartCard title={t("least_bookings_scheduled")} isPending={isPending} isError={isError}>
|
||||
{isSuccess && data ? <UserStatsTable data={data} /> : null}
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
@@ -1,38 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
import { UserStatsTable } from "../UserStatsTable";
|
||||
|
||||
export const LeastCompletedTeamMembersTable = () => {
|
||||
const { t } = useLocale();
|
||||
let insightsBookingParams = useInsightsBookingParameters();
|
||||
const insightsBookingParams = useInsightsBookingParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithLeastCompletedBookings.useQuery(
|
||||
insightsBookingParams,
|
||||
{
|
||||
const { data, isSuccess, isPending, isError } =
|
||||
trpc.viewer.insights.membersWithLeastCompletedBookings.useQuery(insightsBookingParams, {
|
||||
staleTime: 180000,
|
||||
refetchOnWindowFocus: false,
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !data) return null;
|
||||
});
|
||||
|
||||
return (
|
||||
<ChartCard title={t("least_bookings_completed")}>
|
||||
<UserStatsTable data={data} />
|
||||
<ChartCard title={t("least_bookings_completed")} isPending={isPending} isError={isError}>
|
||||
{isSuccess && data ? <UserStatsTable data={data} /> : null}
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
@@ -5,14 +5,13 @@ import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
import { UserStatsTable } from "../UserStatsTable";
|
||||
|
||||
export const LowestRatedMembersTable = () => {
|
||||
const { t } = useLocale();
|
||||
const insightsBookingParams = useInsightsBookingParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithLowestRatings.useQuery(
|
||||
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.membersWithLowestRatings.useQuery(
|
||||
insightsBookingParams,
|
||||
{
|
||||
staleTime: 180000,
|
||||
@@ -23,13 +22,10 @@ export const LowestRatedMembersTable = () => {
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !data) return null;
|
||||
|
||||
return (
|
||||
<ChartCard title={t("lowest_rated")}>
|
||||
<UserStatsTable data={data} />
|
||||
<ChartCard title={t("lowest_rated")} isPending={isPending} isError={isError}>
|
||||
{isSuccess && data ? <UserStatsTable data={data} /> : null}
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
@@ -5,14 +5,13 @@ import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
import { UserStatsTable } from "../UserStatsTable";
|
||||
|
||||
export const MostBookedTeamMembersTable = () => {
|
||||
const { t } = useLocale();
|
||||
const insightsBookingParams = useInsightsBookingParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostBookings.useQuery(
|
||||
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.membersWithMostBookings.useQuery(
|
||||
insightsBookingParams,
|
||||
{
|
||||
staleTime: 180000,
|
||||
@@ -23,13 +22,10 @@ export const MostBookedTeamMembersTable = () => {
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !data) return null;
|
||||
|
||||
return (
|
||||
<ChartCard title={t("most_bookings_scheduled")}>
|
||||
<UserStatsTable data={data} />
|
||||
<ChartCard title={t("most_bookings_scheduled")} isPending={isPending} isError={isError}>
|
||||
{isSuccess && data ? <UserStatsTable data={data} /> : null}
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
@@ -5,14 +5,13 @@ import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
import { UserStatsTable } from "../UserStatsTable";
|
||||
|
||||
export const MostCancelledBookingsTables = () => {
|
||||
const { t } = useLocale();
|
||||
const insightsBookingParams = useInsightsBookingParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostCancelledBookings.useQuery(
|
||||
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.membersWithMostCancelledBookings.useQuery(
|
||||
insightsBookingParams,
|
||||
{
|
||||
staleTime: 180000,
|
||||
@@ -23,13 +22,9 @@ export const MostCancelledBookingsTables = () => {
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !data) return null;
|
||||
|
||||
return (
|
||||
<ChartCard title={t("most_cancelled_bookings")}>
|
||||
<UserStatsTable data={data} />
|
||||
<ChartCard title={t("most_cancelled_bookings")} isPending={isPending} isError={isError}>
|
||||
{isSuccess && data ? <UserStatsTable data={data} /> : null}
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
@@ -1,38 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
import { UserStatsTable } from "../UserStatsTable";
|
||||
|
||||
export const MostCompletedTeamMembersTable = () => {
|
||||
const { t } = useLocale();
|
||||
let insightsBookingParams = useInsightsBookingParameters();
|
||||
const insightsBookingParams = useInsightsBookingParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostCompletedBookings.useQuery(
|
||||
insightsBookingParams,
|
||||
{
|
||||
const { data, isSuccess, isPending, isError } =
|
||||
trpc.viewer.insights.membersWithMostCompletedBookings.useQuery(insightsBookingParams, {
|
||||
staleTime: 180000,
|
||||
refetchOnWindowFocus: false,
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !data) return null;
|
||||
});
|
||||
|
||||
return (
|
||||
<ChartCard title={t("most_bookings_completed")}>
|
||||
<UserStatsTable data={data} />
|
||||
<ChartCard title={t("most_bookings_completed")} isPending={isPending} isError={isError}>
|
||||
{isSuccess && data ? <UserStatsTable data={data} /> : null}
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
@@ -9,7 +9,6 @@ import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { valueFormatter } from "../../lib/valueFormatter";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
|
||||
const COLOR = {
|
||||
NO_SHOW_HOST: "#64748b",
|
||||
@@ -58,6 +57,7 @@ export const NoShowHostsOverTimeChart = () => {
|
||||
data: noShowHostsData,
|
||||
isSuccess,
|
||||
isPending,
|
||||
isError,
|
||||
} = trpc.viewer.insights.noShowHostsOverTime.useQuery(insightsBookingParams, {
|
||||
staleTime: 180000,
|
||||
refetchOnWindowFocus: false,
|
||||
@@ -66,38 +66,36 @@ export const NoShowHostsOverTimeChart = () => {
|
||||
},
|
||||
});
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess) return null;
|
||||
|
||||
return (
|
||||
<ChartCard title={t("no_show_hosts_over_time")} className="h-full">
|
||||
<div className="linechart ml-4 mt-4 h-80 sm:ml-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={noShowHostsData ?? []} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="Month" className="text-xs" axisLine={false} tickLine={false} />
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
className="text-xs opacity-50"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={valueFormatter}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
type="linear"
|
||||
dataKey="Count"
|
||||
name={t("no_show_hosts")}
|
||||
stroke={COLOR.NO_SHOW_HOST}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
animationDuration={1000}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ChartCard title={t("no_show_hosts_over_time")} className="h-full" isPending={isPending} isError={isError}>
|
||||
{isSuccess ? (
|
||||
<div className="linechart ml-4 mt-4 h-80 sm:ml-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={noShowHostsData ?? []} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="Month" className="text-xs" axisLine={false} tickLine={false} />
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
className="text-xs opacity-50"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={valueFormatter}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
type="linear"
|
||||
dataKey="Count"
|
||||
name={t("no_show_hosts")}
|
||||
stroke={COLOR.NO_SHOW_HOST}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
animationDuration={1000}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : null}
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
@@ -5,13 +5,12 @@ import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { ChartCard, ChartCardItem } from "../ChartCard";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
|
||||
export const PopularEventsTable = () => {
|
||||
const { t } = useLocale();
|
||||
const insightsBookingParams = useInsightsBookingParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.popularEvents.useQuery(insightsBookingParams, {
|
||||
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.popularEvents.useQuery(insightsBookingParams, {
|
||||
staleTime: 180000,
|
||||
refetchOnWindowFocus: false,
|
||||
trpc: {
|
||||
@@ -19,29 +18,29 @@ export const PopularEventsTable = () => {
|
||||
},
|
||||
});
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !data) return null;
|
||||
|
||||
return (
|
||||
<ChartCard title={t("popular_events")}>
|
||||
<div>
|
||||
{data
|
||||
.filter((item) => item.eventTypeId)
|
||||
.map((item) => (
|
||||
<ChartCardItem key={item.eventTypeId} count={item.count}>
|
||||
<div className="flex">
|
||||
<div className="bg-subtle mr-1.5 h-5 w-[2px] shrink-0 rounded-sm" />
|
||||
<p>{item.eventTypeName}</p>
|
||||
</div>
|
||||
</ChartCardItem>
|
||||
))}
|
||||
</div>
|
||||
{data.length === 0 && (
|
||||
<div className="flex h-60 text-center">
|
||||
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
|
||||
</div>
|
||||
)}
|
||||
<ChartCard title={t("popular_events")} isPending={isPending} isError={isError}>
|
||||
{isSuccess && data ? (
|
||||
<>
|
||||
<div>
|
||||
{data
|
||||
.filter((item) => item.eventTypeId)
|
||||
.map((item) => (
|
||||
<ChartCardItem key={item.eventTypeId} count={item.count}>
|
||||
<div className="flex">
|
||||
<div className="bg-subtle mr-1.5 h-5 w-[2px] shrink-0 rounded-sm" />
|
||||
<p>{item.eventTypeName}</p>
|
||||
</div>
|
||||
</ChartCardItem>
|
||||
))}
|
||||
</div>
|
||||
{data.length === 0 && (
|
||||
<div className="flex h-60 text-center">
|
||||
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
@@ -5,14 +5,13 @@ import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
import { RecentFeedbackTableContent } from "./RecentFeedbackTableContent";
|
||||
|
||||
export const RecentFeedbackTable = () => {
|
||||
const { t } = useLocale();
|
||||
const insightsBookingParams = useInsightsBookingParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.recentRatings.useQuery(insightsBookingParams, {
|
||||
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.recentRatings.useQuery(insightsBookingParams, {
|
||||
staleTime: 180000,
|
||||
refetchOnWindowFocus: false,
|
||||
trpc: {
|
||||
@@ -20,13 +19,10 @@ export const RecentFeedbackTable = () => {
|
||||
},
|
||||
});
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !data) return null;
|
||||
|
||||
return (
|
||||
<ChartCard title={t("recent_ratings")}>
|
||||
<RecentFeedbackTableContent data={data} />
|
||||
<ChartCard title={t("recent_ratings")} isPending={isPending} isError={isError}>
|
||||
{isSuccess && data ? <RecentFeedbackTableContent data={data} /> : null}
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
@@ -8,7 +8,6 @@ import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { ChartCard, ChartCardItem } from "../ChartCard";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
|
||||
export const RecentNoShowGuestsChart = () => {
|
||||
const { t } = useLocale();
|
||||
@@ -16,7 +15,7 @@ export const RecentNoShowGuestsChart = () => {
|
||||
const insightsBookingParams = useInsightsBookingParameters();
|
||||
const timeZone = insightsBookingParams.timeZone;
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.recentNoShowGuests.useQuery(
|
||||
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.recentNoShowGuests.useQuery(
|
||||
insightsBookingParams,
|
||||
{
|
||||
staleTime: 180000,
|
||||
@@ -27,10 +26,6 @@ export const RecentNoShowGuestsChart = () => {
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !data) return null;
|
||||
|
||||
const handleCopyEmail = (email: string) => {
|
||||
copyToClipboard(email);
|
||||
showToast(t("email_copied"), "success");
|
||||
@@ -40,45 +35,49 @@ export const RecentNoShowGuestsChart = () => {
|
||||
<ChartCard
|
||||
title={t("recent_no_show_guests")}
|
||||
titleTooltip={t("recent_no_show_guests_tooltip")}
|
||||
className="h-full">
|
||||
<div className="h-full">
|
||||
<div className="sm:max-h-[30.6rem] sm:overflow-y-auto">
|
||||
{data.map((item) => (
|
||||
<ChartCardItem key={item.bookingId}>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
<div className="bg-subtle h-16 w-[2px] shrink-0 rounded-sm" />
|
||||
<div className="flex flex-col stack-y-1">
|
||||
<p className="text-sm font-medium">{item.guestName}</p>
|
||||
<div className="text-subtle text-sm leading-tight">
|
||||
<p>{item.eventTypeName}</p>
|
||||
<p>
|
||||
{Intl.DateTimeFormat(undefined, {
|
||||
timeZone,
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(item.startTime))}
|
||||
</p>
|
||||
className="h-full"
|
||||
isPending={isPending}
|
||||
isError={isError}>
|
||||
{isSuccess && data ? (
|
||||
<div className="h-full">
|
||||
<div className="sm:max-h-[30.6rem] sm:overflow-y-auto">
|
||||
{data.map((item) => (
|
||||
<ChartCardItem key={item.bookingId}>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
<div className="bg-subtle h-16 w-[2px] shrink-0 rounded-sm" />
|
||||
<div className="flex flex-col stack-y-1">
|
||||
<p className="text-sm font-medium">{item.guestName}</p>
|
||||
<div className="text-subtle text-sm leading-tight">
|
||||
<p>{item.eventTypeName}</p>
|
||||
<p>
|
||||
{Intl.DateTimeFormat(undefined, {
|
||||
timeZone,
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(item.startTime))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
|
||||
onClick={() => handleCopyEmail(item.guestEmail)}>
|
||||
{!isCopied ? t("email") : t("copied")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
|
||||
onClick={() => handleCopyEmail(item.guestEmail)}>
|
||||
{!isCopied ? t("email") : t("copied")}
|
||||
</Button>
|
||||
</div>
|
||||
</ChartCardItem>
|
||||
))}
|
||||
</div>
|
||||
{data.length === 0 && (
|
||||
<div className="flex h-60 text-center">
|
||||
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
|
||||
</ChartCardItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{data.length === 0 && (
|
||||
<div className="flex h-60 text-center">
|
||||
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,5 @@ export { CellWithOverflowX } from "./CellWithOverflowX";
|
||||
export { ChartCard } from "./ChartCard";
|
||||
export { KPICard } from "./KPICard";
|
||||
export { LineChart } from "./LineChart";
|
||||
export { LoadingInsight } from "./LoadingInsights";
|
||||
export { ResponseValueCell } from "./ResponseValueCell";
|
||||
export { UserStatsTable } from "./UserStatsTable";
|
||||
@@ -57,7 +57,7 @@ interface FormCardProps {
|
||||
fields: Record<string, { optionId: string; count: number; optionLabel: string }[]>;
|
||||
}
|
||||
|
||||
function FormCard({ formName, fields }: FormCardProps) {
|
||||
function FormCard({ fields }: FormCardProps) {
|
||||
const { t } = useLocale();
|
||||
const fieldNames = Object.keys(fields);
|
||||
const [selectedField, setSelectedField] = useState(fieldNames[0]);
|
||||
@@ -132,16 +132,17 @@ function FormCard({ formName, fields }: FormCardProps) {
|
||||
export function FailedBookingsByField() {
|
||||
const { t } = useLocale();
|
||||
const insightsRoutingParams = useInsightsRoutingParameters();
|
||||
const { data } = trpc.viewer.insights.failedBookingsByField.useQuery(insightsRoutingParams);
|
||||
const { data, isLoading, isError } =
|
||||
trpc.viewer.insights.failedBookingsByField.useQuery(insightsRoutingParams);
|
||||
|
||||
if (!data || Object.entries(data).length === 0) return null;
|
||||
if (!isLoading && !isError && (!data || Object.entries(data).length === 0)) return null;
|
||||
|
||||
// routingFormId is always set, meaning data has only one entry.
|
||||
const [formName, fields] = Object.entries(data)[0];
|
||||
const [formName, fields] = data ? Object.entries(data)[0] : ["", {}];
|
||||
|
||||
return (
|
||||
<ChartCard title={t("failed_bookings_by_field")}>
|
||||
<FormCard key={formName} formName={formName} fields={fields} />
|
||||
<ChartCard title={t("failed_bookings_by_field")} isPending={isLoading} isError={isError}>
|
||||
{data && <FormCard key={formName} formName={formName} fields={fields} />}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { useQueryState } from "nuqs";
|
||||
import { type ReactNode, useMemo, useRef, useState } from "react";
|
||||
import posthog from "posthog-js";
|
||||
|
||||
import { DataTableSkeleton } from "@calcom/features/data-table";
|
||||
import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
||||
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -179,7 +178,7 @@ export function RoutedToPerPeriod() {
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 500);
|
||||
|
||||
const { data, isLoading } = trpc.viewer.insights.routedToPerPeriod.useQuery(
|
||||
const { data, isLoading, isError } = trpc.viewer.insights.routedToPerPeriod.useQuery(
|
||||
{
|
||||
...routingParams,
|
||||
period: selectedPeriod,
|
||||
@@ -240,26 +239,6 @@ export function RoutedToPerPeriod() {
|
||||
});
|
||||
}, [data?.periodStats.data, flattenedUsers, uniquePeriods]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full text-sm">
|
||||
<div className="flex h-12 items-center">
|
||||
<h2 className="text-emphasis text-md font-semibold">{t("routed_to_per_period")}</h2>
|
||||
</div>
|
||||
|
||||
<FormCard
|
||||
selectedPeriod={selectedPeriod}
|
||||
onPeriodChange={setSelectedPeriod}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}>
|
||||
<div className="mt-6">
|
||||
<DataTableSkeleton columns={5} columnWidths={[200, 120, 120, 120, 120]} />
|
||||
</div>
|
||||
</FormCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isCurrentPeriod = (date: Date, today: Date, selectedPeriod: string): boolean => {
|
||||
if (selectedPeriod === "perDay") {
|
||||
return (
|
||||
@@ -280,7 +259,7 @@ export function RoutedToPerPeriod() {
|
||||
};
|
||||
|
||||
return (
|
||||
<ChartCard title={t("routed_to_per_period")}>
|
||||
<ChartCard title={t("routed_to_per_period")} isPending={isLoading} isError={isError}>
|
||||
<div className="w-full text-sm">
|
||||
<FormCard
|
||||
selectedPeriod={selectedPeriod}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function RoutingFunnel() {
|
||||
const { t } = useLocale();
|
||||
const insightsRoutingParams = useInsightsRoutingParameters();
|
||||
const { enabledLegend, toggleSeries } = useToggleableLegend(legend);
|
||||
const { data, isSuccess, isLoading } = trpc.viewer.insights.getRoutingFunnelData.useQuery(
|
||||
const { data, isSuccess, isLoading, isError } = trpc.viewer.insights.getRoutingFunnelData.useQuery(
|
||||
insightsRoutingParams,
|
||||
{
|
||||
staleTime: 30000,
|
||||
@@ -29,7 +29,9 @@ export function RoutingFunnel() {
|
||||
title={t("routing_funnel")}
|
||||
legend={legend}
|
||||
enabledLegend={enabledLegend}
|
||||
onSeriesToggle={toggleSeries}>
|
||||
onSeriesToggle={toggleSeries}
|
||||
isPending={isLoading}
|
||||
isError={isError}>
|
||||
<RoutingFunnelSkeleton />
|
||||
</ChartCard>
|
||||
);
|
||||
@@ -40,7 +42,9 @@ export function RoutingFunnel() {
|
||||
title={t("routing_funnel")}
|
||||
legend={legend}
|
||||
enabledLegend={enabledLegend}
|
||||
onSeriesToggle={toggleSeries}>
|
||||
onSeriesToggle={toggleSeries}
|
||||
isPending={isLoading}
|
||||
isError={isError}>
|
||||
<RoutingFunnelContent data={data} enabledLegend={enabledLegend} />
|
||||
</ChartCard>
|
||||
);
|
||||
|
||||
@@ -13,12 +13,15 @@ export const RoutingKPICards = () => {
|
||||
const { t } = useLocale();
|
||||
const insightsRoutingParameters = useInsightsRoutingParameters();
|
||||
|
||||
const { data, isPending } = trpc.viewer.insights.routingFormsByStatus.useQuery(insightsRoutingParameters, {
|
||||
staleTime: 30000,
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
});
|
||||
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.routingFormsByStatus.useQuery(
|
||||
insightsRoutingParameters,
|
||||
{
|
||||
staleTime: 30000,
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const categories: {
|
||||
title: string;
|
||||
@@ -39,38 +42,41 @@ export const RoutingKPICards = () => {
|
||||
];
|
||||
|
||||
if (isPending) {
|
||||
return <LoadingKPICards categories={categories} />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
return <LoadingKPICards categories={categories} isPending={isPending} isError={isError} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartCard title={t("stats")}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{categories.map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className={classNames(
|
||||
"border-muted border-b p-4 last:border-b-0 sm:border-b-0 sm:border-r sm:last:border-r-0"
|
||||
)}>
|
||||
<div className="text-default text-sm">{item.title}</div>
|
||||
<div className="flex items-baseline justify-start space-x-3 truncate">
|
||||
<div className="text-emphasis text-2xl font-semibold">{valueFormatter(data[item.index])}</div>
|
||||
<ChartCard title={t("stats")} isPending={isPending} isError={isError}>
|
||||
{isSuccess && data ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{categories.map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className={classNames(
|
||||
"border-muted border-b p-4 last:border-b-0 sm:border-b-0 sm:border-r sm:last:border-r-0"
|
||||
)}>
|
||||
<div className="text-default text-sm">{item.title}</div>
|
||||
<div className="flex items-baseline justify-start space-x-3 truncate">
|
||||
<div className="text-emphasis text-2xl font-semibold">{valueFormatter(data[item.index])}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
|
||||
const LoadingKPICards = (props: { categories: { title: string; index: string }[] }) => {
|
||||
const LoadingKPICards = (props: {
|
||||
categories: { title: string; index: string }[];
|
||||
isPending: boolean;
|
||||
isError: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { categories } = props;
|
||||
const { categories, isPending, isError } = props;
|
||||
|
||||
return (
|
||||
<ChartCard title={t("stats")}>
|
||||
<ChartCard title={t("stats")} isPending={isPending} isError={isError}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{categories.map((item) => (
|
||||
<div
|
||||
|
||||
@@ -508,7 +508,7 @@ export class InsightsRoutingBaseService {
|
||||
END) <= date_trunc(${dayjsPeriod}, ${endDate}::timestamp)
|
||||
),
|
||||
all_users AS (
|
||||
SELECT unnest(ARRAY[${Prisma.join(users.map((u) => u.id))}]) as user_id
|
||||
SELECT unnest(ARRAY[${Prisma.join(users.map((u) => u.id))}]::integer[]) as user_id
|
||||
),
|
||||
paginated_periods AS (
|
||||
SELECT date as period_start
|
||||
|
||||
@@ -20,6 +20,7 @@ export function PanelCard({
|
||||
children,
|
||||
collapsible = false,
|
||||
defaultCollapsed = false,
|
||||
...dataAttributes
|
||||
}: {
|
||||
title: string | ReactNode;
|
||||
subtitle?: string;
|
||||
@@ -30,7 +31,7 @@ export function PanelCard({
|
||||
children: ReactNode;
|
||||
collapsible?: boolean;
|
||||
defaultCollapsed?: boolean;
|
||||
}) {
|
||||
} & Record<`data-${string}`, string | undefined>) {
|
||||
const { t } = useLocale();
|
||||
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
|
||||
const contentId = useId();
|
||||
@@ -51,7 +52,8 @@ export function PanelCard({
|
||||
"bg-cal-muted group relative flex w-full flex-col items-center rounded-2xl px-1",
|
||||
!isCollapsed && "pb-1",
|
||||
className
|
||||
)}>
|
||||
)}
|
||||
{...dataAttributes}>
|
||||
<div className="flex h-11 w-full shrink-0 items-center justify-between gap-2 px-4">
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{collapsible && (
|
||||
|
||||
Reference in new issue
Block a user