feat: add drop off funnel chart for /insights/routing (#22106)
* feat: add Drop-off funnel chart for /insights/routing * convert insights services to use kysely * WIP * implement drop-off data WIP * updates * revert services back to Prisma * do not expose findMany * WIP * revert usage * fix type errors * updates * clean up * update playground * update styles * support columnFilters * update tooltip * add skeleton * remove dynamic loading * avoid using Prisma.raw * update condition for user scope * Update packages/features/insights/components/RoutingFunnelContent.tsx Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * playground fix * fix type error * update playground * update styles * implement subtitle to ChartCard * revert insights booking service * update border * update ChartCard style * style updates * convert the playground to a client component * clean up * apply feedback * fix tests * fix unit test * hide scrollbar --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
cubic-dev-ai[bot]
parent
c2fc8774df
commit
10fca86ddc
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export default function PlaygroundLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isPlaygroundRoot = pathname === "/settings/admin/playground";
|
||||
|
||||
return isPlaygroundRoot ? (
|
||||
children
|
||||
) : (
|
||||
<div>
|
||||
<Link href="/settings/admin/playground" className="text-sm underline">
|
||||
← Playground
|
||||
</Link>
|
||||
<div className="h-8" />
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Link from "next/link";
|
||||
|
||||
const LINKS = [
|
||||
{
|
||||
title: "Routing Funnel",
|
||||
href: "/settings/admin/playground/routing-funnel",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Playground</h1>
|
||||
|
||||
<ul className="mt-8">
|
||||
{LINKS.map((link) => (
|
||||
<li key={link.title}>
|
||||
<Link href={link.href} className="list-item list-disc font-medium underline">
|
||||
{link.title} →
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { ChartCard } from "@calcom/features/insights/components/ChartCard";
|
||||
import { RoutingFunnelContent, legend } from "@calcom/features/insights/components/RoutingFunnelContent";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
// Random sample data for playground testing
|
||||
const sampleRoutingFunnelData = [
|
||||
{
|
||||
name: "Week 1",
|
||||
formattedDateFull: "Week 1",
|
||||
totalSubmissions: 150,
|
||||
successfulRoutings: 120,
|
||||
acceptedBookings: 95,
|
||||
},
|
||||
{
|
||||
name: "Week 2",
|
||||
formattedDateFull: "Week 2",
|
||||
totalSubmissions: 180,
|
||||
successfulRoutings: 145,
|
||||
acceptedBookings: 110,
|
||||
},
|
||||
{
|
||||
name: "Week 3",
|
||||
formattedDateFull: "Week 3",
|
||||
totalSubmissions: 200,
|
||||
successfulRoutings: 160,
|
||||
acceptedBookings: 125,
|
||||
},
|
||||
{
|
||||
name: "Week 4",
|
||||
formattedDateFull: "Week 4",
|
||||
totalSubmissions: 170,
|
||||
successfulRoutings: 135,
|
||||
acceptedBookings: 105,
|
||||
},
|
||||
{
|
||||
name: "Week 5",
|
||||
formattedDateFull: "Week 5",
|
||||
totalSubmissions: 220,
|
||||
successfulRoutings: 175,
|
||||
acceptedBookings: 140,
|
||||
},
|
||||
{
|
||||
name: "Week 6",
|
||||
formattedDateFull: "Week 6",
|
||||
totalSubmissions: 190,
|
||||
successfulRoutings: 155,
|
||||
acceptedBookings: 120,
|
||||
},
|
||||
];
|
||||
|
||||
export default function RoutingFunnelPlayground() {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold">Routing Funnel Playground</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
This page demonstrates the RoutingFunnelContent component with sample data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl">
|
||||
<ChartCard
|
||||
title={t("routing_funnel")}
|
||||
subtitle="Hello world!"
|
||||
legend={legend}
|
||||
legendSize="sm"
|
||||
cta={{
|
||||
label: "Show all",
|
||||
onClick: () => {
|
||||
alert("hello!");
|
||||
},
|
||||
}}>
|
||||
<RoutingFunnelContent data={sampleRoutingFunnelData} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 rounded-lg bg-gray-50 p-4">
|
||||
<h2 className="mb-2 text-lg font-semibold">Sample Data Used:</h2>
|
||||
<pre className="overflow-auto text-sm text-gray-700">
|
||||
{JSON.stringify(sampleRoutingFunnelData, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1
@@ -147,6 +147,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
{ name: "lockedSMS", href: "/settings/admin/lockedSMS" },
|
||||
{ name: "oAuth", href: "/settings/admin/oAuth" },
|
||||
{ name: "Workspace Platforms", href: "/settings/admin/workspace-platforms" },
|
||||
{ name: "Playground", href: "/settings/admin/playground" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
RoutingFormResponsesTable,
|
||||
FailedBookingsByField,
|
||||
RoutedToPerPeriod,
|
||||
RoutingFunnel,
|
||||
} from "@calcom/features/insights/components";
|
||||
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -19,9 +20,12 @@ export default function InsightsRoutingFormResponsesPage() {
|
||||
<div className="mb-4 space-y-4">
|
||||
<RoutingFormResponsesTable />
|
||||
|
||||
<RoutedToPerPeriod />
|
||||
<RoutingFunnel />
|
||||
|
||||
<FailedBookingsByField />
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<RoutedToPerPeriod />
|
||||
<FailedBookingsByField />
|
||||
</div>
|
||||
|
||||
<small className="text-default block text-center">
|
||||
{t("looking_for_more_insights")}{" "}
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
"you_can_view_booking_details_with_this_url": "You can view the booking details from this url {{url}} and add the event to your calendar",
|
||||
"no_options_available": "No options available",
|
||||
"cancellation_reason": "Reason for cancellation (optional)",
|
||||
"cancelled_by":"Cancelled By",
|
||||
"cancelled_by": "Cancelled By",
|
||||
"cancellation_reason_host": "Reason for cancellation",
|
||||
"cancellation_reason_placeholder": "Why are you cancelling?",
|
||||
"notify_attendee_cancellation_reason_warning": "Cancellation reason will be shared with guests",
|
||||
@@ -285,7 +285,7 @@
|
||||
"guests": "Guests",
|
||||
"guest": "Guest",
|
||||
"web_conferencing_details_to_follow": "Web conferencing details to follow in the confirmation email.",
|
||||
"confirmation":"Confirmation",
|
||||
"confirmation": "Confirmation",
|
||||
"what_booker_should_provide": "What your booker should provide to receive confirmations",
|
||||
"404_the_user": "The username",
|
||||
"username": "Username",
|
||||
@@ -3349,6 +3349,10 @@
|
||||
"booking_not_allowed_by_restriction_schedule_error": "Booking outside restriction schedule availability.",
|
||||
"restriction_schedule_not_found_error": "Restriction schedule not found",
|
||||
"converted_image_size_limit_exceed": "Image size limit exceeded, please use a smaller image preferably in JPEG format",
|
||||
"routing_funnel": "Routing Funnel",
|
||||
"routing_funnel_total_submissions": "Total Submissions",
|
||||
"routing_funnel_successful_routings": "Successful Routings",
|
||||
"routing_funnel_accepted_bookings": "Accepted Bookings",
|
||||
"license_key": "License key",
|
||||
"license_selection_title": "Choose your license",
|
||||
"license_selection_description": "Select how you want to license your Cal.com deployment",
|
||||
@@ -3367,6 +3371,7 @@
|
||||
"license_validation_failed": "Failed to validate license key",
|
||||
"license_key_saved": "License key saved successfully",
|
||||
"timezone_mismatch_tooltip": "You are viewing the report based on your profile timezone ({{userTimezone}}), while your browser is set to timezone ({{browserTimezone}})",
|
||||
"failed_bookings_by_field": "Failed Bookings By Field",
|
||||
"event_type_no_hosts": "No hosts are assigned to event type",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { makeSqlCondition } from "../server";
|
||||
import { ColumnFilterType } from "../types";
|
||||
import type {
|
||||
MultiSelectFilterValue,
|
||||
SingleSelectFilterValue,
|
||||
TextFilterValue,
|
||||
NumberFilterValue,
|
||||
} from "../types";
|
||||
|
||||
describe("makeSqlCondition", () => {
|
||||
describe("Multi-select filter values", () => {
|
||||
it("should create ANY condition for multi-select values", () => {
|
||||
const filterValue: MultiSelectFilterValue = {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
data: ["option1", "option2", "option3"],
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`= ANY(${["option1", "option2", "option3"]})`);
|
||||
});
|
||||
|
||||
it("should work with table alias", () => {
|
||||
const filterValue: MultiSelectFilterValue = {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
data: [1, 2, 3],
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`= ANY(${[1, 2, 3]})`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Single-select filter values", () => {
|
||||
it("should create equals condition for single-select values", () => {
|
||||
const filterValue: SingleSelectFilterValue = {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
data: "option1",
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`= ${"option1"}`);
|
||||
});
|
||||
|
||||
it("should work with numeric values", () => {
|
||||
const filterValue: SingleSelectFilterValue = {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
data: 123,
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`= ${123}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Text filter values", () => {
|
||||
it("should create equals condition", () => {
|
||||
const filterValue: TextFilterValue = {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "equals", operand: "test" },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`= ${"test"}`);
|
||||
});
|
||||
|
||||
it("should create not equals condition", () => {
|
||||
const filterValue: TextFilterValue = {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "notEquals", operand: "test" },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`!= ${"test"}`);
|
||||
});
|
||||
|
||||
it("should create contains condition", () => {
|
||||
const filterValue: TextFilterValue = {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "contains", operand: "test" },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`ILIKE ${`%test%`}`);
|
||||
});
|
||||
|
||||
it("should create not contains condition", () => {
|
||||
const filterValue: TextFilterValue = {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "notContains", operand: "test" },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`NOT ILIKE ${`%test%`}`);
|
||||
});
|
||||
|
||||
it("should create starts with condition", () => {
|
||||
const filterValue: TextFilterValue = {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "startsWith", operand: "test" },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`ILIKE ${`test%`}`);
|
||||
});
|
||||
|
||||
it("should create ends with condition", () => {
|
||||
const filterValue: TextFilterValue = {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "endsWith", operand: "test" },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`ILIKE ${`%test`}`);
|
||||
});
|
||||
|
||||
it("should create is empty condition", () => {
|
||||
const filterValue: TextFilterValue = {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "isEmpty", operand: "" },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`= ''`);
|
||||
});
|
||||
|
||||
it("should create is not empty condition", () => {
|
||||
const filterValue: TextFilterValue = {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "isNotEmpty", operand: "" },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`!= ''`);
|
||||
});
|
||||
|
||||
it("should return null for unknown text operator", () => {
|
||||
const filterValue: TextFilterValue = {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "unknown" as any, operand: "test" },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Number filter values", () => {
|
||||
it("should create equals condition", () => {
|
||||
const filterValue: NumberFilterValue = {
|
||||
type: ColumnFilterType.NUMBER,
|
||||
data: { operator: "eq", operand: 42 },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`= ${42}`);
|
||||
});
|
||||
|
||||
it("should create not equals condition", () => {
|
||||
const filterValue: NumberFilterValue = {
|
||||
type: ColumnFilterType.NUMBER,
|
||||
data: { operator: "neq", operand: 42 },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`!= ${42}`);
|
||||
});
|
||||
|
||||
it("should create greater than condition", () => {
|
||||
const filterValue: NumberFilterValue = {
|
||||
type: ColumnFilterType.NUMBER,
|
||||
data: { operator: "gt", operand: 42 },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`> ${42}`);
|
||||
});
|
||||
|
||||
it("should create greater than or equal condition", () => {
|
||||
const filterValue: NumberFilterValue = {
|
||||
type: ColumnFilterType.NUMBER,
|
||||
data: { operator: "gte", operand: 42 },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`>= ${42}`);
|
||||
});
|
||||
|
||||
it("should create less than condition", () => {
|
||||
const filterValue: NumberFilterValue = {
|
||||
type: ColumnFilterType.NUMBER,
|
||||
data: { operator: "lt", operand: 42 },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`< ${42}`);
|
||||
});
|
||||
|
||||
it("should create less than or equal condition", () => {
|
||||
const filterValue: NumberFilterValue = {
|
||||
type: ColumnFilterType.NUMBER,
|
||||
data: { operator: "lte", operand: 42 },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`<= ${42}`);
|
||||
});
|
||||
|
||||
it("should return null for unknown number operator", () => {
|
||||
const filterValue: NumberFilterValue = {
|
||||
type: ColumnFilterType.NUMBER,
|
||||
data: { operator: "unknown" as any, operand: 42 },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should return null for unknown filter type", () => {
|
||||
const filterValue = {
|
||||
type: "unknown" as any,
|
||||
data: "test",
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle empty multi-select array", () => {
|
||||
const filterValue: MultiSelectFilterValue = {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
data: [],
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`= ANY(${[]})`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import type { FilterValue, SortingState } from "./types";
|
||||
import {
|
||||
isSingleSelectFilterValue,
|
||||
@@ -21,6 +23,9 @@ export function makeOrderBy(sorting: SortingState) {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause for use with Prisma queries
|
||||
*/
|
||||
export function makeWhereClause(props: MakeWhereClauseProps) {
|
||||
const { columnName, filterValue } = props;
|
||||
const isJson = props.json === true || (typeof props.json === "object" && props.json.path?.length > 0);
|
||||
@@ -171,3 +176,56 @@ export function makeWhereClause(props: MakeWhereClauseProps) {
|
||||
}
|
||||
throw new Error(`Invalid filter type: ${JSON.stringify({ columnName, filterValue })}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a SQL where clause for use with raw SQL queries
|
||||
*/
|
||||
export function makeSqlCondition(filterValue: FilterValue): Prisma.Sql | null {
|
||||
if (isMultiSelectFilterValue(filterValue)) {
|
||||
return Prisma.sql`= ANY(${filterValue.data})`;
|
||||
} else if (isSingleSelectFilterValue(filterValue)) {
|
||||
return Prisma.sql`= ${filterValue.data}`;
|
||||
} else if (isTextFilterValue(filterValue)) {
|
||||
const { operator, operand } = filterValue.data;
|
||||
switch (operator) {
|
||||
case "equals":
|
||||
return Prisma.sql`= ${operand}`;
|
||||
case "notEquals":
|
||||
return Prisma.sql`!= ${operand}`;
|
||||
case "contains":
|
||||
return Prisma.sql`ILIKE ${`%${operand}%`}`;
|
||||
case "notContains":
|
||||
return Prisma.sql`NOT ILIKE ${`%${operand}%`}`;
|
||||
case "startsWith":
|
||||
return Prisma.sql`ILIKE ${`${operand}%`}`;
|
||||
case "endsWith":
|
||||
return Prisma.sql`ILIKE ${`%${operand}`}`;
|
||||
case "isEmpty":
|
||||
return Prisma.sql`= ''`;
|
||||
case "isNotEmpty":
|
||||
return Prisma.sql`!= ''`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} else if (isNumberFilterValue(filterValue)) {
|
||||
const { operator, operand } = filterValue.data;
|
||||
switch (operator) {
|
||||
case "eq":
|
||||
return Prisma.sql`= ${operand}`;
|
||||
case "neq":
|
||||
return Prisma.sql`!= ${operand}`;
|
||||
case "gt":
|
||||
return Prisma.sql`> ${operand}`;
|
||||
case "gte":
|
||||
return Prisma.sql`>= ${operand}`;
|
||||
case "lt":
|
||||
return Prisma.sql`< ${operand}`;
|
||||
case "lte":
|
||||
return Prisma.sql`<= ${operand}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Fragment } from "react";
|
||||
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
type LegendItem = {
|
||||
label: string;
|
||||
color: string; // hex format
|
||||
};
|
||||
|
||||
export type LegendSize = "sm" | "default";
|
||||
|
||||
export function ChartCard({
|
||||
title,
|
||||
subtitle,
|
||||
cta,
|
||||
legend,
|
||||
legendSize,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
cta?: { label: string; onClick: () => void };
|
||||
legend?: Array<LegendItem>;
|
||||
legendSize?: LegendSize;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-muted group relative flex w-full flex-col items-center rounded-2xl px-1 pb-1">
|
||||
<div className="flex h-11 w-full shrink-0 items-center justify-between gap-2 px-4">
|
||||
<h2 className="text-emphasis mr-4 shrink-0 text-sm font-semibold">{title}</h2>
|
||||
<div className="no-scrollbar flex items-center gap-2 overflow-x-auto">
|
||||
{legend && legend.length > 0 && <Legend items={legend} size={legendSize} />}
|
||||
{cta && (
|
||||
<Button className="shrink-0" color="secondary" onClick={cta.onClick}>
|
||||
{cta.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-default border-muted w-full grow gap-3 rounded-xl border">
|
||||
{subtitle && (
|
||||
<h3 className="text-subtle border-muted border-b p-3 text-sm font-medium leading-none">
|
||||
{subtitle}
|
||||
</h3>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ items, size = "default" }: { items: LegendItem[]; size?: LegendSize }) {
|
||||
return (
|
||||
<div className="bg-default flex items-center gap-2 rounded-lg px-1.5 py-1">
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={item.label}>
|
||||
<div
|
||||
className="relative flex items-center gap-2 rounded-md px-1.5 py-1"
|
||||
style={{ backgroundColor: `${item.color}33` }}>
|
||||
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
|
||||
<Tooltip content={item.label}>
|
||||
<p
|
||||
className={classNames(
|
||||
"text-default truncate text-sm font-medium leading-none",
|
||||
size === "sm" ? "w-16" : ""
|
||||
)}>
|
||||
{item.label}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{index < items.length - 1 && <div className="bg-muted h-5 w-[1px]" />}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { ToggleGroup } from "@calcom/ui/components/form";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
|
||||
import { useInsightsParameters } from "../hooks/useInsightsParameters";
|
||||
import { ChartCard } from "./ChartCard";
|
||||
import { BarList } from "./tremor/BarList";
|
||||
|
||||
interface FormCardProps {
|
||||
@@ -27,34 +28,30 @@ function FormCard({ formName, fields }: FormCardProps) {
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="border-subtle w-full rounded-md border">
|
||||
<div className="flex flex-col">
|
||||
<div className="bg-subtle border-subtle flex h-12 items-center border-b">
|
||||
<h3 className="text-default px-2 text-left align-middle font-medium">{formName}</h3>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<ToggleGroup
|
||||
options={toggleOptions}
|
||||
value={selectedField}
|
||||
className="w-fit"
|
||||
onValueChange={(value) => value && setSelectedField(value)}
|
||||
/>
|
||||
{selectedFieldData && (
|
||||
<div className="scrollbar-thin mt-4 h-[400px] overflow-y-auto">
|
||||
<BarList
|
||||
data={selectedFieldData}
|
||||
valueFormatter={(value: number) => value.toString()}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="p-4">
|
||||
<ToggleGroup
|
||||
options={toggleOptions}
|
||||
value={selectedField}
|
||||
className="w-fit"
|
||||
onValueChange={(value) => value && setSelectedField(value)}
|
||||
/>
|
||||
{selectedFieldData && (
|
||||
<div className="scrollbar-thin mt-4 h-[400px] overflow-y-auto">
|
||||
<BarList
|
||||
data={selectedFieldData}
|
||||
valueFormatter={(value: number) => value.toString()}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FailedBookingsByField() {
|
||||
const { t } = useLocale();
|
||||
const { userId, teamId, startDate, endDate, isAll, routingFormId } = useInsightsParameters();
|
||||
const { data } = trpc.viewer.insights.failedBookingsByField.useQuery({
|
||||
userId,
|
||||
@@ -63,20 +60,13 @@ export function FailedBookingsByField() {
|
||||
routingFormId,
|
||||
});
|
||||
|
||||
if (!data) return null;
|
||||
if (!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];
|
||||
return (
|
||||
<div className="w-full text-sm">
|
||||
<div className="flex h-12 items-center">
|
||||
<h2 className="text-emphasis text-md font-semibold">Failed Bookings By Field</h2>
|
||||
</div>
|
||||
<div className="">
|
||||
<div className={classNames("grid grid-cols-1 gap-6 lg:grid-cols-2")}>
|
||||
{Object.entries(data).map(([formName, fields]) => (
|
||||
<FormCard key={formName} formName={formName} fields={fields} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChartCard title={t("failed_bookings_by_field")} subtitle={formName}>
|
||||
<FormCard key={formName} formName={formName} fields={fields} />
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
import { useInsightsParameters } from "../hooks/useInsightsParameters";
|
||||
import { ChartCard } from "./ChartCard";
|
||||
|
||||
interface DownloadButtonProps {
|
||||
teamId?: number;
|
||||
@@ -365,98 +366,96 @@ export function RoutedToPerPeriod() {
|
||||
};
|
||||
|
||||
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>
|
||||
<ChartCard title={t("routed_to_per_period")}>
|
||||
<div className="w-full text-sm">
|
||||
<FormCard
|
||||
selectedPeriod={selectedPeriod}
|
||||
onPeriodChange={setSelectedPeriod}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
userId={userId}
|
||||
teamId={teamId}
|
||||
isAll={isAll}
|
||||
routingFormId={routingFormId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}>
|
||||
<div className="mt-6">
|
||||
<div
|
||||
className="scrollbar-thin border-subtle relative overflow-auto rounded-md border"
|
||||
ref={tableContainerRef}>
|
||||
<TableNew className="border-0">
|
||||
<TableHeader className="bg-subtle sticky top-0 z-10">
|
||||
<TableRow>
|
||||
<TableHead className="bg-subtle sticky left-0 z-30 w-[200px]">{t("user")}</TableHead>
|
||||
{uniquePeriods.map((period, index) => {
|
||||
const date = period;
|
||||
const today = new Date();
|
||||
|
||||
<FormCard
|
||||
selectedPeriod={selectedPeriod}
|
||||
onPeriodChange={setSelectedPeriod}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
userId={userId}
|
||||
teamId={teamId}
|
||||
isAll={isAll}
|
||||
routingFormId={routingFormId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}>
|
||||
<div className="mt-6">
|
||||
<div
|
||||
className="scrollbar-thin border-subtle relaitve relative h-[80dvh] overflow-auto rounded-md border"
|
||||
ref={tableContainerRef}>
|
||||
<TableNew className="border-0">
|
||||
<TableHeader className="bg-subtle sticky top-0 z-10">
|
||||
<TableRow>
|
||||
<TableHead className="bg-subtle sticky left-0 z-30 w-[200px]">{t("user")}</TableHead>
|
||||
{uniquePeriods.map((period, index) => {
|
||||
const date = period;
|
||||
const today = new Date();
|
||||
|
||||
const isCurrent = isCurrentPeriod(date, today, selectedPeriod);
|
||||
const isCurrent = isCurrentPeriod(date, today, selectedPeriod);
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
key={period.toISOString()}
|
||||
className="text-center"
|
||||
data-is-current={isCurrent}>
|
||||
<span className={classNames(isCurrent && "font-bold")}>
|
||||
{date.toLocaleDateString()}
|
||||
</span>
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="relative">
|
||||
{processedData.map((row, index) => {
|
||||
return (
|
||||
<TableHead
|
||||
key={period.toISOString()}
|
||||
className="text-center"
|
||||
data-is-current={isCurrent}>
|
||||
<span className={classNames(isCurrent && "font-bold")}>
|
||||
{date.toLocaleDateString()}
|
||||
</span>
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="relative">
|
||||
{processedData.map((row, index) => {
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
ref={index === processedData.length - 1 ? loadMoreRef : undefined}
|
||||
className="divide-muted divide-x">
|
||||
<TableCell className="bg-default w-[200px]">
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center gap-2">
|
||||
<Avatar size="sm" imageSrc={row.avatarUrl} alt={row.name} />
|
||||
<div className="flex flex-col gap-1 truncate">
|
||||
<span>{row.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="p-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-default font-medium">{row.name}</span>
|
||||
</div>
|
||||
<div className="text-subtle flex flex-col text-sm">
|
||||
<div>
|
||||
{t("total_bookings_per_period")}: <Badge>{row.totalBookings}</Badge>
|
||||
{t("status")}: {getPerformanceBadge(row.performance, t)}
|
||||
<TableRow
|
||||
key={row.id}
|
||||
ref={index === processedData.length - 1 ? loadMoreRef : undefined}
|
||||
className="divide-muted divide-x">
|
||||
<TableCell className="bg-default w-[200px]">
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center gap-2">
|
||||
<Avatar size="sm" imageSrc={row.avatarUrl} alt={row.name} />
|
||||
<div className="flex flex-col gap-1 truncate">
|
||||
<span>{row.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</TableCell>
|
||||
{uniquePeriods.map((period) => {
|
||||
return (
|
||||
<TableCell key={period.toISOString()} className="text-center">
|
||||
{row.stats[period.toISOString()] === 0 ? null : (
|
||||
<>{row.stats[period.toISOString()]}</>
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</TableNew>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="p-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-default font-medium">{row.name}</span>
|
||||
</div>
|
||||
<div className="text-subtle flex flex-col text-sm">
|
||||
<div>
|
||||
{t("total_bookings_per_period")}: <Badge>{row.totalBookings}</Badge>
|
||||
{t("status")}: {getPerformanceBadge(row.performance, t)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</TableCell>
|
||||
{uniquePeriods.map((period) => {
|
||||
return (
|
||||
<TableCell key={period.toISOString()} className="text-center">
|
||||
{row.stats[period.toISOString()] === 0 ? null : (
|
||||
<>{row.stats[period.toISOString()]}</>
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</TableNew>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormCard>
|
||||
</div>
|
||||
</FormCard>
|
||||
</div>
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useColumnFilters } from "@calcom/features/data-table";
|
||||
import { useInsightsParameters } from "@calcom/features/insights/hooks/useInsightsParameters";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { ChartCard } from "./ChartCard";
|
||||
import { RoutingFunnelContent, legend } from "./RoutingFunnelContent";
|
||||
import { RoutingFunnelSkeleton } from "./RoutingFunnelSkeleton";
|
||||
|
||||
export function RoutingFunnel() {
|
||||
const { t } = useLocale();
|
||||
const { scope, selectedTeamId, startDate, endDate } = useInsightsParameters();
|
||||
const columnFilters = useColumnFilters({
|
||||
exclude: ["createdAt"],
|
||||
});
|
||||
const { data, isSuccess, isLoading } = trpc.viewer.insights.getRoutingFunnelData.useQuery(
|
||||
{
|
||||
scope,
|
||||
selectedTeamId,
|
||||
startDate,
|
||||
endDate,
|
||||
columnFilters,
|
||||
},
|
||||
{
|
||||
staleTime: 30000,
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (isLoading || !isSuccess || !data) {
|
||||
return (
|
||||
<ChartCard title={t("routing_funnel")} legend={legend}>
|
||||
<RoutingFunnelSkeleton />
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartCard title={t("routing_funnel")} legend={legend}>
|
||||
<RoutingFunnelContent data={data} />
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
interface RoutingFunnelData {
|
||||
name: string;
|
||||
formattedDateFull: string;
|
||||
totalSubmissions: number;
|
||||
successfulRoutings: number;
|
||||
acceptedBookings: number;
|
||||
}
|
||||
|
||||
interface RoutingFunnelContentProps {
|
||||
data: RoutingFunnelData[];
|
||||
}
|
||||
|
||||
const COLOR = {
|
||||
TOTAL: "#9AA2F7",
|
||||
SUCCESFUL: "#89CFB5",
|
||||
ACCEPTED: "#F7A1A1",
|
||||
};
|
||||
|
||||
export const legend = [
|
||||
{ label: "Total Submissions", color: COLOR.TOTAL },
|
||||
{ label: "Successful Routings", color: COLOR.SUCCESFUL },
|
||||
{ label: "Accepted Bookings", color: COLOR.ACCEPTED },
|
||||
];
|
||||
|
||||
export function RoutingFunnelContent({ data }: RoutingFunnelContentProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="name" className="text-xs" axisLine={false} tickLine={false} />
|
||||
<YAxis allowDecimals={false} className="text-xs opacity-50" axisLine={false} tickLine={false} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Area
|
||||
type="linear"
|
||||
name={t("routing_funnel_total_submissions")}
|
||||
dataKey="totalSubmissions"
|
||||
stroke={COLOR.TOTAL}
|
||||
fill={COLOR.TOTAL}
|
||||
fillOpacity={1}
|
||||
/>
|
||||
<Area
|
||||
type="linear"
|
||||
name={t("routing_funnel_successful_routings")}
|
||||
dataKey="successfulRoutings"
|
||||
stroke={COLOR.SUCCESFUL}
|
||||
fill={COLOR.SUCCESFUL}
|
||||
fillOpacity={1}
|
||||
/>
|
||||
<Area
|
||||
type="linear"
|
||||
name={t("routing_funnel_accepted_bookings")}
|
||||
dataKey="acceptedBookings"
|
||||
stroke={COLOR.ACCEPTED}
|
||||
fill={COLOR.ACCEPTED}
|
||||
fillOpacity={1}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom Tooltip component
|
||||
const CustomTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
value: number;
|
||||
dataKey: string;
|
||||
name: string;
|
||||
color: string;
|
||||
payload: RoutingFunnelData;
|
||||
}>;
|
||||
label?: string;
|
||||
}) => {
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalSubmissions = payload.find((p) => p.dataKey === "totalSubmissions")?.value || 0;
|
||||
|
||||
return (
|
||||
<div className="bg-default text-inverted border-subtle rounded-lg border p-3 shadow-lg">
|
||||
<p className="text-default font-medium">{payload[0].payload.formattedDateFull}</p>
|
||||
{payload.map((entry, index: number) => {
|
||||
const value = entry.value;
|
||||
let displayValue = value.toString();
|
||||
|
||||
if (entry.dataKey === "successfulRoutings" || entry.dataKey === "acceptedBookings") {
|
||||
const percentage = totalSubmissions > 0 ? ((value / totalSubmissions) * 100).toFixed(1) : "0";
|
||||
displayValue = `${value} (${percentage}%)`;
|
||||
}
|
||||
|
||||
return (
|
||||
<p key={index} style={{ color: entry.color }}>
|
||||
{entry.name}: {displayValue}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
|
||||
export function RoutingFunnelSkeleton() {
|
||||
return (
|
||||
<div className="relative h-[300px] w-full">
|
||||
{/* Chart container */}
|
||||
<div className="h-full w-full p-4">
|
||||
{/* Chart area */}
|
||||
<div className="relative h-[220px]">
|
||||
{/* Y-axis */}
|
||||
<div className="text-muted-foreground absolute bottom-0 left-0 top-0 flex w-8 flex-col justify-between text-xs">
|
||||
<SkeletonText className="h-3 w-6" />
|
||||
<SkeletonText className="h-3 w-6" />
|
||||
<SkeletonText className="h-3 w-6" />
|
||||
<SkeletonText className="h-3 w-6" />
|
||||
<SkeletonText className="h-3 w-6" />
|
||||
</div>
|
||||
|
||||
{/* Chart content */}
|
||||
<div className="ml-8 h-full">
|
||||
{/* Grid lines */}
|
||||
<div className="flex h-full flex-col justify-between">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="border-muted border-b" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stacked area chart skeleton */}
|
||||
<div className="absolute inset-0 bottom-0 top-0 ml-0 flex items-end justify-between px-4">
|
||||
{Array.from({ length: 7 }).map((_, i) => (
|
||||
<div key={i} className="flex w-8 flex-col items-center">
|
||||
{/* Stacked areas - simulating the three data series */}
|
||||
<div
|
||||
className="w-full rounded-t bg-[#8884d8] opacity-20"
|
||||
style={{ height: `${Math.random() * 40 + 30}%` }}
|
||||
/>
|
||||
<div
|
||||
className="w-full bg-[#83a6ed] opacity-20"
|
||||
style={{ height: `${Math.random() * 30 + 20}%` }}
|
||||
/>
|
||||
<div
|
||||
className="w-full rounded-b bg-[#82ca9d] opacity-20"
|
||||
style={{ height: `${Math.random() * 20 + 10}%` }}
|
||||
/>
|
||||
|
||||
{/* X-axis label */}
|
||||
<SkeletonText className="mt-2 h-3 w-8" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend area */}
|
||||
<div className="mt-4 flex justify-center">
|
||||
<div className="flex gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-sm bg-[#8884d8]" />
|
||||
<SkeletonText className="h-3 w-24" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-sm bg-[#83a6ed]" />
|
||||
<SkeletonText className="h-3 w-28" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-sm bg-[#82ca9d]" />
|
||||
<SkeletonText className="w-26 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export { MostCancelledBookingsTables } from "./MostCancelledBookingsTables";
|
||||
export { PopularEventsTable } from "./PopularEventsTable";
|
||||
export { RecentFeedbackTable } from "./RecentFeedbackTable";
|
||||
export { RoutedToPerPeriod } from "./RoutedToPerPeriod";
|
||||
export { RoutingFunnel } from "./RoutingFunnel";
|
||||
export { RoutingFormResponsesTable, type RoutingFormTableType } from "./RoutingFormResponsesTable";
|
||||
export { RoutingKPICards } from "./RoutingKPICards";
|
||||
export { TimezoneBadge } from "./TimezoneBadge";
|
||||
|
||||
@@ -15,6 +15,10 @@ export function useInsightsOrgTeams() {
|
||||
const teamId = orgTeamsType === "org" ? currentOrgId : orgTeamsType === "team" ? selectedTeamId : undefined;
|
||||
const userId = orgTeamsType === "yours" ? session.data?.user.id : undefined;
|
||||
|
||||
// Adding `scope` for InsightsRoutingService
|
||||
// (renaming 'yours' to 'user')
|
||||
const scope: "org" | "team" | "user" = orgTeamsType === "yours" ? "user" : orgTeamsType;
|
||||
|
||||
return {
|
||||
orgTeamsType,
|
||||
setOrgTeamsType,
|
||||
@@ -23,5 +27,6 @@ export function useInsightsOrgTeams() {
|
||||
isAll,
|
||||
teamId,
|
||||
userId,
|
||||
scope,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { useInsightsOrgTeams } from "./useInsightsOrgTeams";
|
||||
|
||||
export function useInsightsParameters() {
|
||||
const { isAll, teamId, userId } = useInsightsOrgTeams();
|
||||
const { isAll, teamId, userId, scope, selectedTeamId } = useInsightsOrgTeams();
|
||||
|
||||
const memberUserIds = useFilterValue("bookingUserId", ZMultiSelectFilterValue)?.data as
|
||||
| number[]
|
||||
@@ -58,6 +58,8 @@ export function useInsightsParameters() {
|
||||
isAll,
|
||||
teamId,
|
||||
userId,
|
||||
scope,
|
||||
selectedTeamId,
|
||||
memberUserIds,
|
||||
memberUserId,
|
||||
eventTypeId,
|
||||
|
||||
@@ -73,7 +73,7 @@ export interface GetDateRangesParams {
|
||||
endDate: string;
|
||||
timeZone: string;
|
||||
timeView: "day" | "week" | "month" | "year";
|
||||
weekStart: "Sunday" | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday";
|
||||
weekStart: "Sunday" | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | string;
|
||||
}
|
||||
|
||||
class EventsInsights {
|
||||
|
||||
@@ -61,3 +61,11 @@ export const routingFormResponsesInputSchema = z.object({
|
||||
offset: z.number().optional(),
|
||||
limit: z.number().max(100).optional(),
|
||||
}) satisfies z.ZodType<RoutingFormResponsesInput>;
|
||||
|
||||
export const routingRepositoryBaseInputSchema = z.object({
|
||||
scope: z.union([z.literal("user"), z.literal("team"), z.literal("org")]),
|
||||
selectedTeamId: z.number().optional(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
columnFilters: z.array(ZColumnFilter).optional(),
|
||||
});
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
rawDataInputSchema,
|
||||
routingFormResponsesInputSchema,
|
||||
routingFormStatsInputSchema,
|
||||
routingRepositoryBaseInputSchema,
|
||||
} from "@calcom/features/insights/server/raw-data.schema";
|
||||
import { InsightsRoutingService } from "@calcom/lib/server/service/insightsRouting";
|
||||
import type { readonlyPrisma } from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
|
||||
@@ -1727,6 +1729,37 @@ export const insightsRouter = router({
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||
}
|
||||
}),
|
||||
getRoutingFunnelData: userBelongsToTeamProcedure
|
||||
.input(routingRepositoryBaseInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const timeView = EventsInsights.getTimeView(input.startDate, input.endDate);
|
||||
const dateRanges = EventsInsights.getDateRanges({
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
timeZone: ctx.user.timeZone,
|
||||
timeView,
|
||||
weekStart: ctx.user.weekStart,
|
||||
});
|
||||
const insightsRoutingService = new InsightsRoutingService({
|
||||
prisma: ctx.insightsDb,
|
||||
options: {
|
||||
scope: input.scope,
|
||||
teamId: input.selectedTeamId,
|
||||
userId: ctx.user.id,
|
||||
orgId: ctx.user.organizationId,
|
||||
},
|
||||
filters: {
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
columnFilters: input.columnFilters,
|
||||
},
|
||||
});
|
||||
try {
|
||||
return await insightsRoutingService.getRoutingFunnelData(dateRanges);
|
||||
} catch (e) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
async function getEventTypeList({
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"lexical": "^0.9.0",
|
||||
"react-select": "^5.7.0",
|
||||
"react-sticky-box": "^2.0.4",
|
||||
"recharts": "^3.0.2",
|
||||
"stripe-event-types": "^3.1.0",
|
||||
"web-push": "^3.6.7",
|
||||
"zustand": "^4.3.2"
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import type { Team, User, Membership } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { randomUUID } from "crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ColumnFilterType } from "@calcom/features/data-table/lib/types";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { InsightsRoutingService } from "../../service/insightsRouting";
|
||||
|
||||
// SQL condition constants for testing
|
||||
const NOTHING_CONDITION = Prisma.sql`1=0`;
|
||||
|
||||
// Helper function to create default filters for testing
|
||||
const createDefaultFilters = () => ({
|
||||
startDate: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
endDate: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Helper function to create unique test data
|
||||
async function createTestData({
|
||||
teamRole = MembershipRole.MEMBER,
|
||||
@@ -240,10 +251,11 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: null as any,
|
||||
filters: createDefaultFilters(),
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({ id: -1 });
|
||||
expect(conditions).toEqual(NOTHING_CONDITION);
|
||||
});
|
||||
|
||||
it("should return NOTHING for non-owner/admin user", async () => {
|
||||
@@ -274,11 +286,13 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
scope: "org",
|
||||
userId: regularUser.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: createDefaultFilters(),
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({ id: -1 });
|
||||
expect(conditions).toEqual(NOTHING_CONDITION);
|
||||
|
||||
// Clean up
|
||||
await prisma.membership.delete({
|
||||
@@ -302,17 +316,23 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: createDefaultFilters(),
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
formUserId: testData.user.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(conditions).toMatchInlineSnapshot(`
|
||||
e {
|
||||
"strings": [
|
||||
""formUserId" = ",
|
||||
" AND "formTeamId" IS NULL",
|
||||
],
|
||||
"values": [
|
||||
${testData.user.id},
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
@@ -331,16 +351,11 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
orgId: testData.org.id,
|
||||
teamId: testData.team.id,
|
||||
},
|
||||
filters: createDefaultFilters(),
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
formTeamId: testData.team.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(conditions).toEqual(Prisma.sql`"formTeamId" = ${testData.team.id}`);
|
||||
|
||||
// Clean up
|
||||
await testData.cleanup();
|
||||
@@ -366,20 +381,31 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
scope: "org",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: createDefaultFilters(),
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
formTeamId: {
|
||||
in: [testData.org.id, testData.team.id, team2.id, team3.id],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(conditions).toMatchInlineSnapshot(`
|
||||
e {
|
||||
"strings": [
|
||||
"("formTeamId" = ANY(",
|
||||
")) OR ("formUserId" = ",
|
||||
" AND "formTeamId" IS NULL)",
|
||||
],
|
||||
"values": [
|
||||
[
|
||||
${testData.org.id},
|
||||
${testData.team.id},
|
||||
${team2.id},
|
||||
${team3.id},
|
||||
],
|
||||
${testData.user.id},
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
@@ -408,10 +434,11 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
orgId: testData.org.id,
|
||||
teamId: unrelatedTeam.id,
|
||||
},
|
||||
filters: createDefaultFilters(),
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({ AND: [{ id: -1 }] });
|
||||
expect(conditions).toEqual(NOTHING_CONDITION);
|
||||
|
||||
// Clean up
|
||||
await prisma.team.delete({
|
||||
@@ -422,7 +449,7 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
});
|
||||
|
||||
describe("Filter Conditions", () => {
|
||||
it("should return null when no filters are provided", async () => {
|
||||
it("should build filter conditions when dates are provided", async () => {
|
||||
const testData = await createTestData();
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
@@ -431,11 +458,18 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: {
|
||||
startDate: "2024-01-01",
|
||||
endDate: "2024-12-31",
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await service.getFilterConditions();
|
||||
expect(conditions).toBeNull();
|
||||
expect(conditions).toEqual(
|
||||
Prisma.sql`"createdAt" >= ${"2024-01-01"}::timestamp AND "createdAt" <= ${"2024-12-31"}::timestamp`
|
||||
);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
@@ -454,18 +488,14 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: createDefaultFilters(),
|
||||
});
|
||||
|
||||
// First call should build conditions
|
||||
const conditions1 = await service.getAuthorizationConditions();
|
||||
expect(conditions1).toEqual({
|
||||
AND: [
|
||||
{
|
||||
formUserId: testData.user.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(conditions1).toEqual(Prisma.sql`"formUserId" = ${testData.user.id} AND "formTeamId" IS NULL`);
|
||||
|
||||
// Second call should use cached conditions
|
||||
const conditions2 = await service.getAuthorizationConditions();
|
||||
@@ -484,12 +514,14 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: createDefaultFilters(),
|
||||
});
|
||||
|
||||
// First call should build conditions
|
||||
const conditions1 = await service.getFilterConditions();
|
||||
expect(conditions1).toBeNull();
|
||||
expect(conditions1).toBeDefined();
|
||||
|
||||
// Second call should use cached conditions
|
||||
const conditions2 = await service.getFilterConditions();
|
||||
@@ -499,42 +531,136 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("findMany", () => {
|
||||
describe("getBaseConditions", () => {
|
||||
it("should combine authorization and filter conditions", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const filters = createDefaultFilters();
|
||||
const dateCondition = Prisma.sql`"createdAt" >= ${filters.startDate}::timestamp AND "createdAt" <= ${filters.endDate}::timestamp`;
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters,
|
||||
});
|
||||
|
||||
const results = await service.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
formName: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Should return the user form response since it matches the authorization conditions
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.id).toBe(testData.formResponse.id);
|
||||
const results = await service.getBaseConditions();
|
||||
expect(results).toEqual(
|
||||
Prisma.sql`("formUserId" = ${testData.user.id} AND "formTeamId" IS NULL) AND (${dateCondition})`
|
||||
);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should filter out unauthorized responses", async () => {
|
||||
it("should include personal form in org scope", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const personalForm = await prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name: "My Personal Form",
|
||||
userId: testData.user.id,
|
||||
teamId: null,
|
||||
fields: [
|
||||
{
|
||||
id: "text-field-id",
|
||||
type: "text",
|
||||
label: "Name",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const personalFormResponse = await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: `personal-form-filler-${randomUUID()}`,
|
||||
formId: personalForm.id,
|
||||
response: {
|
||||
"text-field-id": {
|
||||
label: "Name",
|
||||
value: "Personal Form Response",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "org",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: createDefaultFilters(),
|
||||
});
|
||||
|
||||
const baseConditions = await service.getBaseConditions();
|
||||
const results = await prisma.$queryRaw<Array<{ id: number }>>`
|
||||
SELECT id FROM "RoutingFormResponseDenormalized" WHERE ${baseConditions}
|
||||
`;
|
||||
|
||||
// Should only return the authorized user's form response
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]?.id).toBe(testData.formResponse.id);
|
||||
expect(results[1]?.id).toBe(personalFormResponse.id);
|
||||
|
||||
// Clean up
|
||||
await prisma.app_RoutingForms_FormResponse.delete({
|
||||
where: { id: personalFormResponse.id },
|
||||
});
|
||||
await prisma.app_RoutingForms_Form.delete({
|
||||
where: { id: personalForm.id },
|
||||
});
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should get personal form response", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const personalForm = await prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name: "My Personal Form",
|
||||
userId: testData.user.id,
|
||||
teamId: null,
|
||||
fields: [
|
||||
{
|
||||
id: "text-field-id",
|
||||
type: "text",
|
||||
label: "Name",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const personalFormResponse = await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: `personal-form-filler-${randomUUID()}`,
|
||||
formId: personalForm.id,
|
||||
response: {
|
||||
"text-field-id": {
|
||||
label: "Name",
|
||||
value: "Personal Form Response",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create another user and form
|
||||
const otherUser = await prisma.user.create({
|
||||
data: {
|
||||
@@ -579,27 +705,27 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: createDefaultFilters(),
|
||||
});
|
||||
|
||||
const results = await service.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
formName: true,
|
||||
},
|
||||
});
|
||||
const baseConditions = await service.getBaseConditions();
|
||||
const results = await prisma.$queryRaw<Array<{ id: number }>>`
|
||||
SELECT id FROM "RoutingFormResponseDenormalized" WHERE ${baseConditions}
|
||||
`;
|
||||
|
||||
// Should only return the authorized user's form response
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.id).toBe(testData.formResponse.id);
|
||||
expect(results[0]?.id).toBe(personalFormResponse.id);
|
||||
expect(results[0]?.id).not.toBe(otherFormResponse.id);
|
||||
|
||||
// Clean up
|
||||
await prisma.app_RoutingForms_FormResponse.delete({
|
||||
where: { id: otherFormResponse.id },
|
||||
await prisma.app_RoutingForms_FormResponse.deleteMany({
|
||||
where: { id: { in: [personalFormResponse.id, otherFormResponse.id] } },
|
||||
});
|
||||
await prisma.app_RoutingForms_Form.delete({
|
||||
where: { id: otherForm.id },
|
||||
await prisma.app_RoutingForms_Form.deleteMany({
|
||||
where: { id: { in: [personalForm.id, otherForm.id] } },
|
||||
});
|
||||
await prisma.user.delete({
|
||||
where: { id: otherUser.id },
|
||||
@@ -669,15 +795,15 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
scope: "org",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: createDefaultFilters(),
|
||||
});
|
||||
|
||||
const results = await service.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
formName: true,
|
||||
},
|
||||
});
|
||||
const baseConditions = await service.getBaseConditions();
|
||||
const results = await prisma.$queryRaw<Array<{ id: number }>>`
|
||||
SELECT id FROM "RoutingFormResponseDenormalized" WHERE ${baseConditions}
|
||||
`;
|
||||
|
||||
// Should return both form responses (original user's and team member's)
|
||||
expect(results).toHaveLength(2);
|
||||
@@ -702,4 +828,574 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
await testData.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("columnFilters", () => {
|
||||
it("should handle empty columnFilters", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const defaultFilters = createDefaultFilters();
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: {
|
||||
...defaultFilters,
|
||||
columnFilters: [],
|
||||
},
|
||||
});
|
||||
|
||||
const filterConditions = await service.getFilterConditions();
|
||||
expect(filterConditions).toEqual(
|
||||
Prisma.sql`"createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${defaultFilters.endDate}::timestamp`
|
||||
);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should handle undefined columnFilters", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const defaultFilters = createDefaultFilters();
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: {
|
||||
...defaultFilters,
|
||||
columnFilters: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const filterConditions = await service.getFilterConditions();
|
||||
expect(filterConditions).toEqual(
|
||||
Prisma.sql`"createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${defaultFilters.endDate}::timestamp`
|
||||
);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should filter by booking status order (multi-select)", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const defaultFilters = createDefaultFilters();
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: {
|
||||
...defaultFilters,
|
||||
columnFilters: [
|
||||
{
|
||||
id: "bookingStatusOrder",
|
||||
value: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
data: ["pending", "accepted"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const filterConditions = await service.getFilterConditions();
|
||||
expect(filterConditions).toEqual(
|
||||
Prisma.sql`("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
|
||||
defaultFilters.endDate
|
||||
}::timestamp) AND ("bookingStatusOrder" = ANY(${["pending", "accepted"]}))`
|
||||
);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should filter by booking assignment reason (text)", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const defaultFilters = createDefaultFilters();
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: {
|
||||
...defaultFilters,
|
||||
columnFilters: [
|
||||
{
|
||||
id: "bookingAssignmentReason",
|
||||
value: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "contains", operand: "manual" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const filterConditions = await service.getFilterConditions();
|
||||
expect(filterConditions).toEqual(
|
||||
Prisma.sql`("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
|
||||
defaultFilters.endDate
|
||||
}::timestamp) AND ("bookingAssignmentReason" ILIKE ${`%manual%`})`
|
||||
);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should filter by booking UID (text)", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const defaultFilters = createDefaultFilters();
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: {
|
||||
...defaultFilters,
|
||||
columnFilters: [
|
||||
{
|
||||
id: "bookingUid",
|
||||
value: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "equals", operand: "test-booking-123" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const filterConditions = await service.getFilterConditions();
|
||||
expect(filterConditions).toEqual(
|
||||
Prisma.sql`("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
|
||||
defaultFilters.endDate
|
||||
}::timestamp) AND ("bookingUid" = ${"test-booking-123"})`
|
||||
);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should filter by member user IDs (multi-select)", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const defaultFilters = createDefaultFilters();
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: {
|
||||
...defaultFilters,
|
||||
columnFilters: [
|
||||
{
|
||||
id: "bookingUserId",
|
||||
value: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
data: [testData.user.id, 999],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const filterConditions = await service.getFilterConditions();
|
||||
expect(filterConditions).toEqual(
|
||||
Prisma.sql`("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
|
||||
defaultFilters.endDate
|
||||
}::timestamp) AND ("bookingUserId" = ANY(${[testData.user.id, 999]}))`
|
||||
);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should filter by booking attendees (text)", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const defaultFilters = createDefaultFilters();
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: {
|
||||
...defaultFilters,
|
||||
columnFilters: [
|
||||
{
|
||||
id: "bookingAttendees",
|
||||
value: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "contains", operand: "john@example.com" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const filterConditions = await service.getFilterConditions();
|
||||
expect(filterConditions).toEqual(
|
||||
Prisma.sql`("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
|
||||
defaultFilters.endDate
|
||||
}::timestamp) AND (EXISTS (
|
||||
SELECT 1 FROM "Booking" b
|
||||
INNER JOIN "Attendee" a ON a."bookingId" = b."id"
|
||||
WHERE b."uid" = "RoutingFormResponseDenormalized"."bookingUid"
|
||||
AND (${Prisma.sql`(a.name ILIKE ${`%john@example.com%`}) OR (a.email ILIKE ${`%john@example.com%`})`})
|
||||
))`
|
||||
);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should filter by custom form fields (text)", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const defaultFilters = createDefaultFilters();
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: {
|
||||
...defaultFilters,
|
||||
columnFilters: [
|
||||
{
|
||||
id: "custom-field-id",
|
||||
value: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "equals", operand: "test value" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const filterConditions = await service.getFilterConditions();
|
||||
expect(filterConditions).toEqual(
|
||||
Prisma.sql`("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
|
||||
defaultFilters.endDate
|
||||
}::timestamp) AND (EXISTS (
|
||||
SELECT 1 FROM "RoutingFormResponseField" rrf
|
||||
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
|
||||
AND rrf."fieldId" = ${"custom-field-id"}
|
||||
AND rrf."valueString" = ${"test value"}
|
||||
))`
|
||||
);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should filter by custom form fields (multi-select)", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const defaultFilters = createDefaultFilters();
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: {
|
||||
...defaultFilters,
|
||||
columnFilters: [
|
||||
{
|
||||
id: "custom-multi-field-id",
|
||||
value: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
data: ["option1", "option2"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const filterConditions = await service.getFilterConditions();
|
||||
expect(filterConditions).toEqual(
|
||||
Prisma.sql`("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
|
||||
defaultFilters.endDate
|
||||
}::timestamp) AND (EXISTS (
|
||||
SELECT 1 FROM "RoutingFormResponseField" rrf
|
||||
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
|
||||
AND rrf."fieldId" = ${"custom-multi-field-id"}
|
||||
AND rrf."valueStringArray" @> ${["option1", "option2"]}
|
||||
))`
|
||||
);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should combine multiple filters with AND", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const defaultFilters = createDefaultFilters();
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: {
|
||||
...defaultFilters,
|
||||
columnFilters: [
|
||||
{
|
||||
id: "bookingStatusOrder",
|
||||
value: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
data: ["pending"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bookingAssignmentReason",
|
||||
value: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "contains", operand: "manual" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "custom-field-id",
|
||||
value: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "equals", operand: "test" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const filterConditions = await service.getFilterConditions();
|
||||
expect(filterConditions).toEqual(
|
||||
Prisma.sql`((("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
|
||||
defaultFilters.endDate
|
||||
}::timestamp) AND ("bookingStatusOrder" = ANY(${[
|
||||
"pending",
|
||||
]}))) AND ("bookingAssignmentReason" ILIKE ${`%manual%`})) AND (EXISTS (
|
||||
SELECT 1 FROM "RoutingFormResponseField" rrf
|
||||
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
|
||||
AND rrf."fieldId" = ${"custom-field-id"}
|
||||
AND rrf."valueString" = ${"test"}
|
||||
))`
|
||||
);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should filter by form ID (single-select)", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const defaultFilters = createDefaultFilters();
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: {
|
||||
...defaultFilters,
|
||||
columnFilters: [
|
||||
{
|
||||
id: "formId",
|
||||
value: {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
data: "form-123",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const filterConditions = await service.getFilterConditions();
|
||||
expect(filterConditions).toEqual(
|
||||
Prisma.sql`("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
|
||||
defaultFilters.endDate
|
||||
}::timestamp) AND ("formId" = ${"form-123"})`
|
||||
);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should filter by form ID with other filters", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const defaultFilters = createDefaultFilters();
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: {
|
||||
...defaultFilters,
|
||||
columnFilters: [
|
||||
{
|
||||
id: "formId",
|
||||
value: {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
data: "form-456",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bookingStatusOrder",
|
||||
value: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
data: ["pending", "accepted"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const filterConditions = await service.getFilterConditions();
|
||||
expect(filterConditions).toEqual(
|
||||
Prisma.sql`(("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
|
||||
defaultFilters.endDate
|
||||
}::timestamp) AND ("bookingStatusOrder" = ANY(${[
|
||||
"pending",
|
||||
"accepted",
|
||||
]}))) AND ("formId" = ${"form-456"})`
|
||||
);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should exclude system filters from form field processing", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const defaultFilters = createDefaultFilters();
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: {
|
||||
...defaultFilters,
|
||||
columnFilters: [
|
||||
{
|
||||
id: "bookingStatusOrder", // System filter
|
||||
value: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
data: ["pending"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "custom-field-id", // Custom field filter
|
||||
value: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "equals", operand: "test" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const filterConditions = await service.getFilterConditions();
|
||||
expect(filterConditions).toEqual(
|
||||
Prisma.sql`(("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
|
||||
defaultFilters.endDate
|
||||
}::timestamp) AND ("bookingStatusOrder" = ANY(${["pending"]}))) AND (EXISTS (
|
||||
SELECT 1 FROM "RoutingFormResponseField" rrf
|
||||
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
|
||||
AND rrf."fieldId" = ${"custom-field-id"}
|
||||
AND rrf."valueString" = ${"test"}
|
||||
))`
|
||||
);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should return null when no filters are applied", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: {},
|
||||
});
|
||||
|
||||
const filterConditions = await service.getFilterConditions();
|
||||
expect(filterConditions).toBeNull();
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import { makeSqlCondition } from "@calcom/features/data-table/lib/server";
|
||||
import type { FilterValue, TextFilterValue, TypedColumnFilter } from "@calcom/features/data-table/lib/types";
|
||||
import type { ColumnFilterType } from "@calcom/features/data-table/lib/types";
|
||||
import {
|
||||
isMultiSelectFilterValue,
|
||||
isTextFilterValue,
|
||||
isNumberFilterValue,
|
||||
isSingleSelectFilterValue,
|
||||
} from "@calcom/features/data-table/lib/utils";
|
||||
import type { DateRange } from "@calcom/features/insights/server/events";
|
||||
import type { readonlyPrisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -26,22 +36,29 @@ export const insightsRoutingServiceOptionsSchema = z.discriminatedUnion("scope",
|
||||
}),
|
||||
]);
|
||||
|
||||
export type InsightsRoutingServicePublicOptions = {
|
||||
scope: "user" | "org" | "team";
|
||||
userId: number;
|
||||
orgId: number | null;
|
||||
teamId: number | undefined;
|
||||
};
|
||||
|
||||
export type InsightsRoutingServiceOptions = z.infer<typeof insightsRoutingServiceOptionsSchema>;
|
||||
|
||||
export type InsightsRoutingServiceFilterOptions = {
|
||||
// Empty for now
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
columnFilters?: TypedColumnFilter<ColumnFilterType>[];
|
||||
};
|
||||
|
||||
const NOTHING = {
|
||||
id: -1,
|
||||
} as const;
|
||||
const NOTHING_CONDITION = Prisma.sql`1=0`;
|
||||
|
||||
export class InsightsRoutingService {
|
||||
private prisma: typeof readonlyPrisma;
|
||||
private options: InsightsRoutingServiceOptions | null;
|
||||
private filters?: InsightsRoutingServiceFilterOptions;
|
||||
private cachedAuthConditions?: Prisma.RoutingFormResponseDenormalizedWhereInput;
|
||||
private cachedFilterConditions?: Prisma.RoutingFormResponseDenormalizedWhereInput | null;
|
||||
private filters: InsightsRoutingServiceFilterOptions;
|
||||
private cachedAuthConditions?: Prisma.Sql;
|
||||
private cachedFilterConditions?: Prisma.Sql | null;
|
||||
|
||||
constructor({
|
||||
prisma,
|
||||
@@ -49,8 +66,8 @@ export class InsightsRoutingService {
|
||||
filters,
|
||||
}: {
|
||||
prisma: typeof readonlyPrisma;
|
||||
options: InsightsRoutingServiceOptions;
|
||||
filters?: InsightsRoutingServiceFilterOptions;
|
||||
options: InsightsRoutingServicePublicOptions;
|
||||
filters: InsightsRoutingServiceFilterOptions;
|
||||
}) {
|
||||
this.prisma = prisma;
|
||||
|
||||
@@ -60,71 +77,254 @@ export class InsightsRoutingService {
|
||||
this.filters = filters;
|
||||
}
|
||||
|
||||
async findMany(findManyArgs: Prisma.RoutingFormResponseDenormalizedFindManyArgs) {
|
||||
const authConditions = await this.getAuthorizationConditions();
|
||||
const filterConditions = await this.getFilterConditions();
|
||||
/**
|
||||
* Returns routing funnel data split by the given date ranges.
|
||||
* @param dateRanges Array of { startDate, endDate, formattedDate }
|
||||
*/
|
||||
async getRoutingFunnelData(dateRanges: DateRange[]) {
|
||||
if (!dateRanges.length) return [];
|
||||
|
||||
return this.prisma.routingFormResponseDenormalized.findMany({
|
||||
...findManyArgs,
|
||||
where: {
|
||||
...findManyArgs.where,
|
||||
AND: [authConditions, filterConditions].filter(
|
||||
(c): c is Prisma.RoutingFormResponseDenormalizedWhereInput => c !== null && c !== undefined
|
||||
),
|
||||
},
|
||||
// Validate date formats
|
||||
for (const range of dateRanges) {
|
||||
if (isNaN(Date.parse(range.startDate)) || isNaN(Date.parse(range.endDate))) {
|
||||
throw new Error(`Invalid date format in range: ${range.startDate} - ${range.endDate}`);
|
||||
}
|
||||
}
|
||||
|
||||
const baseConditions = await this.getBaseConditions();
|
||||
|
||||
// Build the CASE statements for each date range with proper date casting
|
||||
const caseStatements = dateRanges
|
||||
.map(
|
||||
(dateRange) => Prisma.sql`
|
||||
WHEN "createdAt" >= ${dateRange.startDate}::timestamp AND "createdAt" <= ${dateRange.endDate}::timestamp THEN ${dateRange.formattedDate}
|
||||
`
|
||||
)
|
||||
.reduce((acc, curr) => Prisma.sql`${acc} ${curr}`);
|
||||
|
||||
// Single query to get all data grouped by date ranges using CTE
|
||||
const results = await this.prisma.$queryRaw<
|
||||
Array<{
|
||||
dateRange: string | null;
|
||||
totalSubmissions: bigint;
|
||||
successfulRoutings: bigint;
|
||||
acceptedBookings: bigint;
|
||||
}>
|
||||
>`
|
||||
WITH date_ranged_data AS (
|
||||
SELECT
|
||||
CASE ${caseStatements}
|
||||
ELSE NULL
|
||||
END as "dateRange",
|
||||
"bookingUid",
|
||||
"bookingStatus"
|
||||
FROM "RoutingFormResponseDenormalized"
|
||||
WHERE ${baseConditions}
|
||||
)
|
||||
SELECT
|
||||
"dateRange",
|
||||
COUNT(*) as "totalSubmissions",
|
||||
COUNT(CASE WHEN "bookingUid" IS NOT NULL THEN 1 END) as "successfulRoutings",
|
||||
COUNT(CASE WHEN "bookingStatus" NOT IN ('cancelled', 'rejected') THEN 1 END) as "acceptedBookings"
|
||||
FROM date_ranged_data
|
||||
WHERE "dateRange" IS NOT NULL
|
||||
GROUP BY "dateRange"
|
||||
ORDER BY "dateRange"
|
||||
`;
|
||||
|
||||
// Create a map of results by dateRange for easy lookup
|
||||
const resultsMap = new Map(
|
||||
results
|
||||
.filter((row): row is typeof row & { dateRange: string } => row.dateRange !== null)
|
||||
.map((row) => [
|
||||
row.dateRange,
|
||||
{
|
||||
name: row.dateRange,
|
||||
totalSubmissions: Number(row.totalSubmissions),
|
||||
successfulRoutings: Number(row.successfulRoutings),
|
||||
acceptedBookings: Number(row.acceptedBookings),
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
// Return all date ranges, filling with 0 values for missing data
|
||||
return dateRanges.map((dateRange) => {
|
||||
const existingData = resultsMap.get(dateRange.formattedDate);
|
||||
const data = existingData || {
|
||||
name: dateRange.formattedDate,
|
||||
totalSubmissions: 0,
|
||||
successfulRoutings: 0,
|
||||
acceptedBookings: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
...data,
|
||||
formattedDateFull: dateRange.formattedDateFull,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getAuthorizationConditions(): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput> {
|
||||
async getBaseConditions(): Promise<Prisma.Sql> {
|
||||
const authConditions = await this.getAuthorizationConditions();
|
||||
const filterConditions = await this.getFilterConditions();
|
||||
|
||||
if (authConditions && filterConditions) {
|
||||
return Prisma.sql`(${authConditions}) AND (${filterConditions})`;
|
||||
} else if (authConditions) {
|
||||
return authConditions;
|
||||
} else if (filterConditions) {
|
||||
return filterConditions;
|
||||
} else {
|
||||
return NOTHING_CONDITION;
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthorizationConditions(): Promise<Prisma.Sql> {
|
||||
if (this.cachedAuthConditions === undefined) {
|
||||
this.cachedAuthConditions = await this.buildAuthorizationConditions();
|
||||
}
|
||||
return this.cachedAuthConditions;
|
||||
}
|
||||
|
||||
async getFilterConditions(): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput | null> {
|
||||
async getFilterConditions(): Promise<Prisma.Sql | null> {
|
||||
if (this.cachedFilterConditions === undefined) {
|
||||
this.cachedFilterConditions = await this.buildFilterConditions();
|
||||
}
|
||||
return this.cachedFilterConditions;
|
||||
}
|
||||
|
||||
async buildFilterConditions(): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput | null> {
|
||||
// Empty for now
|
||||
return null;
|
||||
async buildFilterConditions(): Promise<Prisma.Sql | null> {
|
||||
const conditions: Prisma.Sql[] = [];
|
||||
|
||||
// Date range filtering
|
||||
if (this.filters.startDate && this.filters.endDate) {
|
||||
conditions.push(
|
||||
Prisma.sql`"createdAt" >= ${this.filters.startDate}::timestamp AND "createdAt" <= ${this.filters.endDate}::timestamp`
|
||||
);
|
||||
}
|
||||
|
||||
// Extract specific filters from columnFilters
|
||||
// Convert columnFilters array to object for easier access
|
||||
const filtersMap =
|
||||
this.filters.columnFilters?.reduce((acc, filter) => {
|
||||
acc[filter.id] = filter;
|
||||
return acc;
|
||||
}, {} as Record<string, TypedColumnFilter<ColumnFilterType>>) || {};
|
||||
|
||||
// Extract booking status order filter
|
||||
const bookingStatusOrder = filtersMap["bookingStatusOrder"];
|
||||
if (bookingStatusOrder && isMultiSelectFilterValue(bookingStatusOrder.value)) {
|
||||
const statusCondition = makeSqlCondition(bookingStatusOrder.value);
|
||||
if (statusCondition) {
|
||||
conditions.push(Prisma.sql`"bookingStatusOrder" ${statusCondition}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract booking assignment reason filter
|
||||
const bookingAssignmentReason = filtersMap["bookingAssignmentReason"];
|
||||
if (bookingAssignmentReason && isTextFilterValue(bookingAssignmentReason.value)) {
|
||||
const reasonCondition = makeSqlCondition(bookingAssignmentReason.value);
|
||||
if (reasonCondition) {
|
||||
conditions.push(Prisma.sql`"bookingAssignmentReason" ${reasonCondition}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract booking UID filter
|
||||
const bookingUid = filtersMap["bookingUid"];
|
||||
if (bookingUid && isTextFilterValue(bookingUid.value)) {
|
||||
const uidCondition = makeSqlCondition(bookingUid.value);
|
||||
if (uidCondition) {
|
||||
conditions.push(Prisma.sql`"bookingUid" ${uidCondition}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract booking attendees filter
|
||||
const bookingAttendees = filtersMap["bookingAttendees"];
|
||||
if (bookingAttendees && isTextFilterValue(bookingAttendees.value)) {
|
||||
const attendeesCondition = this.buildAttendeeSqlCondition(bookingAttendees.value);
|
||||
if (attendeesCondition) {
|
||||
conditions.push(attendeesCondition);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract member user IDs filter (multi-select)
|
||||
const memberUserIds = filtersMap["bookingUserId"];
|
||||
if (memberUserIds && isMultiSelectFilterValue(memberUserIds.value)) {
|
||||
conditions.push(Prisma.sql`"bookingUserId" = ANY(${memberUserIds.value.data})`);
|
||||
}
|
||||
|
||||
// Extract form ID filter (single-select)
|
||||
const formId = filtersMap["formId"];
|
||||
if (formId && isSingleSelectFilterValue(formId.value)) {
|
||||
const formIdCondition = makeSqlCondition(formId.value);
|
||||
if (formIdCondition) {
|
||||
conditions.push(Prisma.sql`"formId" ${formIdCondition}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract form field filters (exclude the system filters we already processed)
|
||||
const alreadyHandledFilters = [
|
||||
"bookingStatusOrder",
|
||||
"bookingAssignmentReason",
|
||||
"bookingUid",
|
||||
"bookingAttendees",
|
||||
"bookingUserId",
|
||||
"formId",
|
||||
];
|
||||
|
||||
const fieldFilters = (this.filters.columnFilters || []).filter(
|
||||
(filter) => !alreadyHandledFilters.includes(filter.id)
|
||||
);
|
||||
|
||||
if (fieldFilters.length > 0) {
|
||||
const fieldConditions = fieldFilters
|
||||
.map((fieldFilter) => this.buildFormFieldSqlCondition(fieldFilter.id, fieldFilter.value))
|
||||
.filter((condition): condition is Prisma.Sql => condition !== null);
|
||||
|
||||
if (fieldConditions.length > 0) {
|
||||
// Join multiple field conditions with AND
|
||||
const joinedConditions = fieldConditions.reduce((acc, condition, index) => {
|
||||
if (index === 0) return condition;
|
||||
return Prisma.sql`(${acc}) AND (${condition})`;
|
||||
});
|
||||
conditions.push(joinedConditions);
|
||||
}
|
||||
}
|
||||
|
||||
if (conditions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Join all conditions with AND
|
||||
return conditions.reduce((acc, condition, index) => {
|
||||
if (index === 0) return condition;
|
||||
return Prisma.sql`(${acc}) AND (${condition})`;
|
||||
});
|
||||
}
|
||||
|
||||
async buildAuthorizationConditions(): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput> {
|
||||
async buildAuthorizationConditions(): Promise<Prisma.Sql> {
|
||||
if (!this.options) {
|
||||
return NOTHING;
|
||||
return NOTHING_CONDITION;
|
||||
}
|
||||
const isOwnerOrAdmin = await this.isOrgOwnerOrAdmin(this.options.userId, this.options.orgId);
|
||||
if (!isOwnerOrAdmin) {
|
||||
return NOTHING;
|
||||
return NOTHING_CONDITION;
|
||||
}
|
||||
|
||||
const conditions: Prisma.RoutingFormResponseDenormalizedWhereInput[] = [];
|
||||
|
||||
if (this.options.scope === "user") {
|
||||
conditions.push({
|
||||
formUserId: this.options.userId,
|
||||
});
|
||||
return Prisma.sql`"formUserId" = ${this.options.userId} AND "formTeamId" IS NULL`;
|
||||
} else if (this.options.scope === "org") {
|
||||
conditions.push(await this.buildOrgAuthorizationCondition(this.options));
|
||||
return await this.buildOrgAuthorizationCondition(this.options);
|
||||
} else if (this.options.scope === "team") {
|
||||
conditions.push(await this.buildTeamAuthorizationCondition(this.options));
|
||||
return await this.buildTeamAuthorizationCondition(this.options);
|
||||
} else {
|
||||
return NOTHING;
|
||||
return NOTHING_CONDITION;
|
||||
}
|
||||
|
||||
return {
|
||||
AND: conditions,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildOrgAuthorizationCondition(
|
||||
options: Extract<InsightsRoutingServiceOptions, { scope: "org" }>
|
||||
): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput> {
|
||||
): Promise<Prisma.Sql> {
|
||||
// Get all teams from the organization
|
||||
const teamRepo = new TeamRepository(this.prisma);
|
||||
const teamsFromOrg = await teamRepo.findAllByParentId({
|
||||
@@ -132,16 +332,14 @@ export class InsightsRoutingService {
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return {
|
||||
formTeamId: {
|
||||
in: [options.orgId, ...teamsFromOrg.map((t) => t.id)],
|
||||
},
|
||||
};
|
||||
const teamIds = [options.orgId, ...teamsFromOrg.map((t) => t.id)];
|
||||
|
||||
return Prisma.sql`("formTeamId" = ANY(${teamIds})) OR ("formUserId" = ${options.userId} AND "formTeamId" IS NULL)`;
|
||||
}
|
||||
|
||||
private async buildTeamAuthorizationCondition(
|
||||
options: Extract<InsightsRoutingServiceOptions, { scope: "team" }>
|
||||
): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput> {
|
||||
): Promise<Prisma.Sql> {
|
||||
const teamRepo = new TeamRepository(this.prisma);
|
||||
const childTeamOfOrg = await teamRepo.findByIdAndParentId({
|
||||
id: options.teamId,
|
||||
@@ -149,12 +347,10 @@ export class InsightsRoutingService {
|
||||
select: { id: true },
|
||||
});
|
||||
if (!childTeamOfOrg) {
|
||||
return NOTHING;
|
||||
return NOTHING_CONDITION;
|
||||
}
|
||||
|
||||
return {
|
||||
formTeamId: options.teamId,
|
||||
};
|
||||
return Prisma.sql`"formTeamId" = ${options.teamId}`;
|
||||
}
|
||||
|
||||
private async isOrgOwnerOrAdmin(userId: number, orgId: number): Promise<boolean> {
|
||||
@@ -167,4 +363,71 @@ export class InsightsRoutingService {
|
||||
(membership.role === MembershipRole.OWNER || membership.role === MembershipRole.ADMIN)
|
||||
);
|
||||
}
|
||||
|
||||
private buildFormFieldSqlCondition(fieldId: string, filterValue: FilterValue): Prisma.Sql | null {
|
||||
if (isMultiSelectFilterValue(filterValue)) {
|
||||
// For multi-select fields, check if the field contains any of the selected values
|
||||
return Prisma.sql`EXISTS (
|
||||
SELECT 1 FROM "RoutingFormResponseField" rrf
|
||||
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
|
||||
AND rrf."fieldId" = ${fieldId}
|
||||
AND rrf."valueStringArray" @> ${filterValue.data.map(String)}
|
||||
)`;
|
||||
} else if (isSingleSelectFilterValue(filterValue)) {
|
||||
return Prisma.sql`EXISTS (
|
||||
SELECT 1 FROM "RoutingFormResponseField" rrf
|
||||
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
|
||||
AND rrf."fieldId" = ${fieldId}
|
||||
AND rrf."valueString" = ${filterValue.data}
|
||||
)`;
|
||||
} else if (isTextFilterValue(filterValue)) {
|
||||
const condition = makeSqlCondition(filterValue);
|
||||
if (!condition) {
|
||||
return null;
|
||||
}
|
||||
return Prisma.sql`EXISTS (
|
||||
SELECT 1 FROM "RoutingFormResponseField" rrf
|
||||
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
|
||||
AND rrf."fieldId" = ${fieldId}
|
||||
AND rrf."valueString" ${condition}
|
||||
)`;
|
||||
} else if (isNumberFilterValue(filterValue)) {
|
||||
const condition = makeSqlCondition(filterValue);
|
||||
if (!condition) {
|
||||
return null;
|
||||
}
|
||||
return Prisma.sql`EXISTS (
|
||||
SELECT 1 FROM "RoutingFormResponseField" rrf
|
||||
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
|
||||
AND rrf."fieldId" = ${fieldId}
|
||||
AND rrf."valueNumber" ${condition}
|
||||
)`;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private buildAttendeeSqlCondition(filterValue: TextFilterValue): Prisma.Sql | null {
|
||||
if (!isTextFilterValue(filterValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textCondition = makeSqlCondition(filterValue);
|
||||
|
||||
if (!textCondition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nameCondition = Prisma.sql`a.name ${textCondition}`;
|
||||
const emailCondition = Prisma.sql`a.email ${textCondition}`;
|
||||
|
||||
const combinedCondition = Prisma.sql`(${nameCondition}) OR (${emailCondition})`;
|
||||
|
||||
return Prisma.sql`EXISTS (
|
||||
SELECT 1 FROM "Booking" b
|
||||
INNER JOIN "Attendee" a ON a."bookingId" = b."id"
|
||||
WHERE b."uid" = "RoutingFormResponseDenormalized"."bookingUid"
|
||||
AND (${combinedCondition})
|
||||
)`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3177,6 +3177,7 @@ __metadata:
|
||||
lexical: ^0.9.0
|
||||
react-select: ^5.7.0
|
||||
react-sticky-box: ^2.0.4
|
||||
recharts: ^3.0.2
|
||||
stripe-event-types: ^3.1.0
|
||||
web-push: ^3.6.7
|
||||
zustand: ^4.3.2
|
||||
@@ -14074,6 +14075,28 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@reduxjs/toolkit@npm:1.x.x || 2.x.x":
|
||||
version: 2.8.2
|
||||
resolution: "@reduxjs/toolkit@npm:2.8.2"
|
||||
dependencies:
|
||||
"@standard-schema/spec": ^1.0.0
|
||||
"@standard-schema/utils": ^0.3.0
|
||||
immer: ^10.0.3
|
||||
redux: ^5.0.1
|
||||
redux-thunk: ^3.1.0
|
||||
reselect: ^5.1.0
|
||||
peerDependencies:
|
||||
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
|
||||
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-redux:
|
||||
optional: true
|
||||
checksum: 94bb4714ff734ea2c0e632a29b0b165507321be097258e6bd66dddca81760610defcc9eff3a46d35e1de010402d24078a7e74648cbc94d5eacfc84dcb01c7161
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@repeaterjs/repeater@npm:^3.0.4, @repeaterjs/repeater@npm:^3.0.6":
|
||||
version: 3.0.6
|
||||
resolution: "@repeaterjs/repeater@npm:3.0.6"
|
||||
@@ -16151,6 +16174,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@standard-schema/utils@npm:^0.3.0":
|
||||
version: 0.3.0
|
||||
resolution: "@standard-schema/utils@npm:0.3.0"
|
||||
checksum: 7084f875d322792f2e0a5904009434c8374b9345b09ba89828b68fd56fa3c2b366d35bf340d9e8c72736ef01793c2f70d350c372ed79845dc3566c58d34b4b51
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/blocks@npm:^7.6.3":
|
||||
version: 7.6.3
|
||||
resolution: "@storybook/blocks@npm:7.6.3"
|
||||
@@ -18331,6 +18361,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/use-sync-external-store@npm:^0.0.6":
|
||||
version: 0.0.6
|
||||
resolution: "@types/use-sync-external-store@npm:0.0.6"
|
||||
checksum: a95ce330668501ad9b1c5b7f2b14872ad201e552a0e567787b8f1588b22c7040c7c3d80f142cbb9f92d13c4ea41c46af57a20f2af4edf27f224d352abcfe4049
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/uuid@npm:8.3.1":
|
||||
version: 8.3.1
|
||||
resolution: "@types/uuid@npm:8.3.1"
|
||||
@@ -23997,7 +24034,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"decimal.js-light@npm:^2.4.1":
|
||||
"decimal.js-light@npm:^2.4.1, decimal.js-light@npm:^2.5.1":
|
||||
version: 2.5.1
|
||||
resolution: "decimal.js-light@npm:2.5.1"
|
||||
checksum: f5a2c7eac1c4541c8ab8a5c8abea64fc1761cefc7794bd5f8afd57a8a78d1b51785e0c4e4f85f4895a043eaa90ddca1edc3981d1263eb6ddce60f32bf5fe66c9
|
||||
@@ -25468,6 +25505,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"es-toolkit@npm:^1.39.3":
|
||||
version: 1.39.5
|
||||
resolution: "es-toolkit@npm:1.39.5"
|
||||
dependenciesMeta:
|
||||
"@trivago/prettier-plugin-sort-imports@4.3.0":
|
||||
unplugged: true
|
||||
prettier-plugin-sort-re-exports@0.0.1:
|
||||
unplugged: true
|
||||
checksum: 92295637b10fb717c8b2cfd2423caa839a10636ed49d7b79bb2acfb364fe43769aa3c19fa9cc54e2638b6fe8acdc5035611096e4a53bdda65de1f8e62b02a2ad
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"es6-promise@npm:^4.2.4":
|
||||
version: 4.2.8
|
||||
resolution: "es6-promise@npm:4.2.8"
|
||||
@@ -26794,6 +26843,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"eventemitter3@npm:^5.0.1":
|
||||
version: 5.0.1
|
||||
resolution: "eventemitter3@npm:5.0.1"
|
||||
checksum: 543d6c858ab699303c3c32e0f0f47fc64d360bf73c3daf0ac0b5079710e340d6fe9f15487f94e66c629f5f82cd1a8678d692f3dbb6f6fcd1190e1b97fcad36f8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"events@npm:^3.0.0, events@npm:^3.1.0, events@npm:^3.2.0, events@npm:^3.3.0":
|
||||
version: 3.3.0
|
||||
resolution: "events@npm:3.3.0"
|
||||
@@ -29919,6 +29975,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"immer@npm:^10.0.3, immer@npm:^10.1.1":
|
||||
version: 10.1.1
|
||||
resolution: "immer@npm:10.1.1"
|
||||
checksum: 07c67970b7d22aded73607193d84861bf786f07d47f7d7c98bb10016c7a88f6654ad78ae1e220b3c623695b133aabbf24f5eb8d9e8060cff11e89ccd81c9c10b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"immutable@npm:^3.8.2":
|
||||
version: 3.8.2
|
||||
resolution: "immutable@npm:3.8.2"
|
||||
@@ -39697,6 +39760,25 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-redux@npm:8.x.x || 9.x.x":
|
||||
version: 9.2.0
|
||||
resolution: "react-redux@npm:9.2.0"
|
||||
dependencies:
|
||||
"@types/use-sync-external-store": ^0.0.6
|
||||
use-sync-external-store: ^1.4.0
|
||||
peerDependencies:
|
||||
"@types/react": ^18.2.25 || ^19
|
||||
react: ^18.0 || ^19
|
||||
redux: ^5.0.0
|
||||
peerDependenciesMeta:
|
||||
"@types/react":
|
||||
optional: true
|
||||
redux:
|
||||
optional: true
|
||||
checksum: 96dfe2929561d7c98d4443722738e4595f08758bde27b7bc20cd98ba9b0dfe9b81b9fa17b6888be94a0c1d2d1305397ae493a8219698536d011a941589eb82bd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-redux@npm:^7.2.2":
|
||||
version: 7.2.8
|
||||
resolution: "react-redux@npm:7.2.8"
|
||||
@@ -40298,6 +40380,29 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"recharts@npm:^3.0.2":
|
||||
version: 3.0.2
|
||||
resolution: "recharts@npm:3.0.2"
|
||||
dependencies:
|
||||
"@reduxjs/toolkit": 1.x.x || 2.x.x
|
||||
clsx: ^2.1.1
|
||||
decimal.js-light: ^2.5.1
|
||||
es-toolkit: ^1.39.3
|
||||
eventemitter3: ^5.0.1
|
||||
immer: ^10.1.1
|
||||
react-redux: 8.x.x || 9.x.x
|
||||
reselect: 5.1.1
|
||||
tiny-invariant: ^1.3.3
|
||||
use-sync-external-store: ^1.2.2
|
||||
victory-vendor: ^37.0.2
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
checksum: 7fe805a8a4aedf89ee551be53fbdf20a5690118db8d0d55b5794665c83c5568d30802645a177b5d38e602fa8bc3a04bdaecb123df7c2c378e46773526538094e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rechoir@npm:^0.6.2":
|
||||
version: 0.6.2
|
||||
resolution: "rechoir@npm:0.6.2"
|
||||
@@ -40420,6 +40525,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"redux-thunk@npm:^3.1.0":
|
||||
version: 3.1.0
|
||||
resolution: "redux-thunk@npm:3.1.0"
|
||||
peerDependencies:
|
||||
redux: ^5.0.0
|
||||
checksum: bea96f8233975aad4c9f24ca1ffd08ac7ec91eaefc26e7ba9935544dc55d7f09ba2aa726676dab53dc79d0c91e8071f9729cddfea927f4c41839757d2ade0f50
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"redux@npm:^4.0.0":
|
||||
version: 4.1.2
|
||||
resolution: "redux@npm:4.1.2"
|
||||
@@ -40438,6 +40552,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"redux@npm:^5.0.1":
|
||||
version: 5.0.1
|
||||
resolution: "redux@npm:5.0.1"
|
||||
checksum: e74affa9009dd5d994878b9a1ce30d6569d986117175056edb003de2651c05b10fe7819d6fa94aea1a94de9a82f252f986547f007a2fbeb35c317a2e5f5ecf2c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"reflect-metadata@npm:0.2.2":
|
||||
version: 0.2.2
|
||||
resolution: "reflect-metadata@npm:0.2.2"
|
||||
@@ -40823,6 +40944,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"reselect@npm:5.1.1, reselect@npm:^5.1.0":
|
||||
version: 5.1.1
|
||||
resolution: "reselect@npm:5.1.1"
|
||||
checksum: 5d32d48be29071ddda21a775945c2210cf4ca3fccde1c4a0e1582ac3bf99c431c6c2330ef7ca34eae4c06feea617e7cb2c275c4b33ccf9a930836dfc98b49b13
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"resize-observer-polyfill@npm:^1.5.1":
|
||||
version: 1.5.1
|
||||
resolution: "resize-observer-polyfill@npm:1.5.1"
|
||||
@@ -44332,6 +44460,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tiny-invariant@npm:^1.3.3":
|
||||
version: 1.3.3
|
||||
resolution: "tiny-invariant@npm:1.3.3"
|
||||
checksum: 5e185c8cc2266967984ce3b352a4e57cb89dad5a8abb0dea21468a6ecaa67cd5bb47a3b7a85d08041008644af4f667fb8b6575ba38ba5fb00b3b5068306e59fe
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tiny-warning@npm:^1.0.0, tiny-warning@npm:^1.0.3":
|
||||
version: 1.0.3
|
||||
resolution: "tiny-warning@npm:1.0.3"
|
||||
@@ -46181,6 +46316,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"use-sync-external-store@npm:^1.2.2, use-sync-external-store@npm:^1.4.0":
|
||||
version: 1.5.0
|
||||
resolution: "use-sync-external-store@npm:1.5.0"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
checksum: 5e639c9273200adb6985b512c96a3a02c458bc8ca1a72e91da9cdc6426144fc6538dca434b0f99b28fb1baabc82e1c383ba7900b25ccdcb43758fb058dc66c34
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"utif@npm:^2.0.1":
|
||||
version: 2.0.1
|
||||
resolution: "utif@npm:2.0.1"
|
||||
@@ -46357,6 +46501,28 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"victory-vendor@npm:^37.0.2":
|
||||
version: 37.3.6
|
||||
resolution: "victory-vendor@npm:37.3.6"
|
||||
dependencies:
|
||||
"@types/d3-array": ^3.0.3
|
||||
"@types/d3-ease": ^3.0.0
|
||||
"@types/d3-interpolate": ^3.0.1
|
||||
"@types/d3-scale": ^4.0.2
|
||||
"@types/d3-shape": ^3.1.0
|
||||
"@types/d3-time": ^3.0.0
|
||||
"@types/d3-timer": ^3.0.0
|
||||
d3-array: ^3.1.6
|
||||
d3-ease: ^3.0.1
|
||||
d3-interpolate: ^3.0.1
|
||||
d3-scale: ^4.0.2
|
||||
d3-shape: ^3.1.0
|
||||
d3-time: ^3.0.0
|
||||
d3-timer: ^3.0.1
|
||||
checksum: fc49f195823f59466ac66d10266e5af783777a1da2f84aad7d3023ce2db149fd1522f1ecbd6d3223849ec1f4c33ba2e8fd9a5eb0443f076698c5832ed574731a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"vite-node@npm:2.1.1":
|
||||
version: 2.1.1
|
||||
resolution: "vite-node@npm:2.1.1"
|
||||
|
||||
Reference in New Issue
Block a user