feat: insights orgs [CAL-1888] (#9551)

* initial commit

* fixes on data returned

* mobile style fixes

* Update on styles and dates not being shown on graphics

* feat: update insights filters UI (#9756)

* feat: update insights filters UI

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* Fixes for filters with new UI

* fix initialConfig lost while clearing filters

* Add single user conditional for first render

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Alan <alannnc@gmail.com>

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Efraín Rochín <roae.85@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
alannnc
2023-06-29 19:03:44 +02:00
committed by GitHub
co-authored by Efraín Rochín Udit Takkar Peer Richelsen
parent 44e5002369
commit d180e14013
22 changed files with 993 additions and 405 deletions
+6 -3
View File
@@ -27,7 +27,7 @@ const Heading = () => {
<h3 className="font-cal max-w-28 sm:max-w-72 md:max-w-80 text-emphasis truncate text-xl font-semibold tracking-wide xl:max-w-full">
{t("insights")}
</h3>
<p className="text-default hidden text-sm md:block">{t("subtitle_analytics")}</p>
<p className="text-default mt-2 hidden text-sm md:block">{t("insights_subtitle")}</p>
</div>
);
};
@@ -35,6 +35,7 @@ const Heading = () => {
export default function InsightsPage() {
const { t } = useLocale();
const { data: user } = trpc.viewer.me.useQuery();
const features = [
{
icon: <Users className="h-5 w-5" />,
@@ -77,10 +78,12 @@ export default function InsightsPage() {
<></>
) : (
<FiltersProvider>
<div className="mb-8 ml-auto mt-0 flex w-full flex-wrap justify-between md:-mt-8">
<div className="ml-auto mt-0">
<Heading />
<Filters />
</div>
<Filters />
<div className="mb-4 space-y-6">
<BookingKPICards />
@@ -27,6 +27,7 @@
"event_request_cancelled": "Your scheduled event was canceled",
"organizer": "Organizer",
"need_to_reschedule_or_cancel": "Need to reschedule or cancel?",
"no_options_available": "No options available",
"cancellation_reason": "Reason for cancellation (optional)",
"cancellation_reason_placeholder": "Why are you cancelling?",
"rejection_reason": "Reason for rejecting",
@@ -786,6 +787,7 @@
"add_new_event_type": "Add a new event type",
"new_event_type_to_book_description": "Create a new event type for people to book times with.",
"length": "Length",
"people":"People",
"minimum_booking_notice": "Minimum Notice",
"offset_toggle": "Offset start times",
"offset_toggle_description": "Offset timeslots shown to bookers by a specified number of minutes",
@@ -1791,10 +1793,10 @@
"events_rescheduled": "Events Rescheduled",
"from_last_period": "from last period",
"from_to_date_period": "From: {{startDate}} To: {{endDate}}",
"subtitle_analytics": "Learn more about your team's activity",
"redirect_url_warning": "Adding a redirect will disable the success page. Make sure to mention \"Booking Confirmed\" on your custom success page.",
"event_trends": "Event Trends",
"clear_filters": "Clear Filters",
"clear": "Clear",
"hold": "Hold",
"on_booking_option": "Collect payment on booking",
"hold_option": "Charge no-show fee",
@@ -1917,5 +1919,9 @@
"404_the_team": "The team",
"404_claim_entity_org": "Claim your subdomain for your organization",
"404_claim_entity_team": "Claim this team and start managing schedules collectively",
"insights_all_org_filter": "All",
"insights_team_filter": "Team: {{teamName}}",
"insights_user_filter": "User: {{userName}}",
"insights_subtitle": "View booking insights across your events",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -12,7 +12,7 @@ import { LoadingInsight } from "./LoadingInsights";
export const AverageEventDurationChart = () => {
const { t } = useLocale();
const { filter } = useFilterContext();
const { dateRange, selectedMemberUserId } = filter;
const { dateRange, selectedMemberUserId, isAll } = filter;
const [startDate, endDate] = dateRange;
const { selectedTeamId: teamId, selectedUserId } = filter;
@@ -23,6 +23,7 @@ export const AverageEventDurationChart = () => {
teamId: teamId ?? undefined,
memberUserId: selectedMemberUserId ?? undefined,
userId: selectedUserId ?? undefined,
isAll,
},
{
staleTime: 30000,
@@ -11,7 +11,7 @@ import { KPICard } from "./KPICard";
export const BookingKPICards = () => {
const { t } = useLocale();
const { filter } = useFilterContext();
const { dateRange, selectedEventTypeId, selectedUserId, selectedMemberUserId } = filter;
const { dateRange, selectedEventTypeId, selectedUserId, selectedMemberUserId, isAll } = filter;
const [startDate, endDate] = dateRange;
const { selectedTeamId: teamId } = filter;
@@ -24,6 +24,7 @@ export const BookingKPICards = () => {
eventTypeId: selectedEventTypeId ?? undefined,
memberUserId: selectedMemberUserId ?? undefined,
userId: selectedUserId ?? undefined,
isAll,
},
{
staleTime: 30000,
@@ -18,6 +18,7 @@ export const BookingStatusLineChart = () => {
selectedTimeView = "week",
dateRange,
selectedEventTypeId,
isAll,
} = filter;
const [startDate, endDate] = dateRange;
@@ -35,6 +36,7 @@ export const BookingStatusLineChart = () => {
teamId: selectedTeamId ?? undefined,
eventTypeId: selectedEventTypeId ?? undefined,
userId: selectedUserId ?? undefined,
isAll,
},
{
staleTime: 30000,
@@ -11,7 +11,7 @@ import { TotalBookingUsersTable } from "./TotalBookingUsersTable";
export const LeastBookedTeamMembersTable = () => {
const { t } = useLocale();
const { filter } = useFilterContext();
const { dateRange, selectedEventTypeId, selectedTeamId: teamId } = filter;
const { dateRange, selectedEventTypeId, selectedTeamId: teamId, isAll } = filter;
const [startDate, endDate] = dateRange;
const { data, isSuccess, isLoading } = trpc.viewer.insights.membersWithLeastBookings.useQuery(
@@ -20,6 +20,7 @@ export const LeastBookedTeamMembersTable = () => {
endDate: endDate.toISOString(),
teamId,
eventTypeId: selectedEventTypeId ?? undefined,
isAll,
},
{
staleTime: 30000,
@@ -11,7 +11,7 @@ import { TotalBookingUsersTable } from "./TotalBookingUsersTable";
export const MostBookedTeamMembersTable = () => {
const { t } = useLocale();
const { filter } = useFilterContext();
const { dateRange, selectedEventTypeId } = filter;
const { dateRange, selectedEventTypeId, isAll } = filter;
const [startDate, endDate] = dateRange;
const { selectedTeamId: teamId } = filter;
@@ -21,6 +21,7 @@ export const MostBookedTeamMembersTable = () => {
endDate: endDate.toISOString(),
teamId,
eventTypeId: selectedEventTypeId ?? undefined,
isAll,
},
{
staleTime: 30000,
@@ -10,7 +10,7 @@ import { LoadingInsight } from "./LoadingInsights";
export const PopularEventsTable = () => {
const { t } = useLocale();
const { filter } = useFilterContext();
const { dateRange, selectedMemberUserId, selectedUserId } = filter;
const { dateRange, selectedMemberUserId, selectedUserId, isAll } = filter;
const [startDate, endDate] = dateRange;
const { selectedTeamId: teamId } = filter;
@@ -21,6 +21,7 @@ export const PopularEventsTable = () => {
teamId: teamId ?? undefined,
userId: selectedUserId ?? undefined,
memberUserId: selectedMemberUserId ?? undefined,
isAll,
},
{
staleTime: 30000,
@@ -51,32 +51,39 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
memberUserIdParsed = safe.data.memberUserId;
}
// TODO: Sync insight filters with URL parameters
const [selectedTimeView, setSelectedTimeView] =
useState<FilterContextType["filter"]["selectedTimeView"]>("week");
const [selectedUserId, setSelectedUserId] = useState<FilterContextType["filter"]["selectedUserId"]>(
userIdParsed || null
);
const [selectedMemberUserId, setSelectedMemberUserId] = useState<
FilterContextType["filter"]["selectedMemberUserId"]
>(memberUserIdParsed || null);
const [selectedTeamId, setSelectedTeamId] = useState<FilterContextType["filter"]["selectedTeamId"]>(
teamIdParsed || null
);
const [selectedEventTypeId, setSelectedEventTypeId] = useState<
FilterContextType["filter"]["selectedEventTypeId"]
>(eventTypeIdParsed || null);
const [selectedFilter, setSelectedFilter] = useState<FilterContextType["filter"]["selectedFilter"]>(
filterParsed ? [filterParsed] : null
);
const [selectedTeamName, setSelectedTeamName] =
useState<FilterContextType["filter"]["selectedTeamName"]>(null);
const [dateRange, setDateRange] = useState<FilterContextType["filter"]["dateRange"]>([
startTimeParsed ? dayjs(startTimeParsed) : dayjs().subtract(1, "month"),
endTimeParsed ? dayjs(endTimeParsed) : dayjs(),
"t",
]);
const [configFilters, setConfigFilters] = useState<FilterContextType["filter"]>({
dateRange: [
startTimeParsed ? dayjs(startTimeParsed) : dayjs().subtract(1, "month"),
endTimeParsed ? dayjs(endTimeParsed) : dayjs(),
"t",
],
selectedTimeView: "week",
selectedUserId: userIdParsed || null,
selectedMemberUserId: memberUserIdParsed || null,
selectedTeamId: teamIdParsed || null,
selectedTeamName: null,
selectedEventTypeId: eventTypeIdParsed || null,
selectedFilter: filterParsed ? [filterParsed] : null,
isAll: false,
initialConfig: {
userId: null,
teamId: null,
isAll: false,
},
});
const {
dateRange,
selectedTimeView,
selectedMemberUserId,
selectedTeamId,
selectedUserId,
selectedEventTypeId,
selectedFilter,
selectedTeamName,
isAll,
initialConfig,
} = configFilters;
return (
<FilterProvider
value={{
@@ -89,100 +96,67 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
selectedTeamName,
selectedEventTypeId,
selectedFilter,
isAll,
initialConfig,
},
setSelectedFilter: (filter) => {
setSelectedFilter(filter);
const userId =
filter?.[0] === "user" ? selectedMemberUserId : selectedUserId ? selectedUserId : undefined;
const eventTypeId = filter?.[0] === "event-type" ? selectedEventTypeId : undefined;
router.push({
query: {
...router.query,
filter: filter?.[0],
userId,
eventTypeId,
},
setConfigFilters: (newConfigFilters) => {
setConfigFilters({
...configFilters,
...newConfigFilters,
});
},
setDateRange: (dateRange) => {
setDateRange(dateRange);
const {
selectedMemberUserId,
selectedTeamId,
selectedUserId,
selectedEventTypeId,
selectedFilter,
isAll,
dateRange,
} = newConfigFilters;
const [startTime, endTime] = dateRange || [null, null];
const mergedQueryParams = {
...(router.query || {}),
...(selectedMemberUserId !== undefined && { memberUserId: selectedMemberUserId }),
...(selectedTeamId !== undefined && { teamId: selectedTeamId }),
...(selectedUserId !== undefined && { userId: selectedUserId }),
...(selectedEventTypeId !== undefined && { eventTypeId: selectedEventTypeId }),
...(selectedFilter !== undefined &&
selectedFilter &&
selectedFilter?.length > 0 && { filter: selectedFilter[0] }),
...(isAll !== undefined && { isAll }),
...(startTime !== undefined && startTime && { startTime: startTime.toISOString() }),
...(endTime !== undefined && endTime && { endTime: endTime.toISOString() }),
};
router.push({
query: {
...router.query,
startTime: dateRange[0].toISOString(),
endTime: dateRange[1].toISOString(),
},
});
},
setSelectedTimeView: (selectedTimeView) => setSelectedTimeView(selectedTimeView),
setSelectedMemberUserId: (selectedMemberUserId) => {
setSelectedMemberUserId(selectedMemberUserId);
const { userId, eventTypeId, ...rest } = router.query;
router.push({
query: {
...rest,
memberUserId: selectedMemberUserId,
},
});
},
setSelectedTeamId: (selectedTeamId) => {
setSelectedTeamId(selectedTeamId);
setSelectedUserId(null);
setSelectedMemberUserId(null);
setSelectedEventTypeId(null);
const { teamId, eventTypeId, memberUserId, ...rest } = router.query;
router.push({
query: {
...rest,
teamId: selectedTeamId,
},
});
},
setSelectedUserId: (selectedUserId) => {
setSelectedUserId(selectedUserId);
setSelectedTeamId(null);
setSelectedTeamName(null);
setSelectedEventTypeId(null);
const { teamId, eventTypeId, memberUserId, ...rest } = router.query;
router.push({
query: {
...rest,
userId: selectedUserId,
},
});
},
setSelectedTeamName: (selectedTeamName) => setSelectedTeamName(selectedTeamName),
setSelectedEventTypeId: (selectedEventTypeId) => {
setSelectedEventTypeId(selectedEventTypeId);
router.push({
query: {
...router.query,
eventTypeId: selectedEventTypeId,
},
query: mergedQueryParams,
});
},
clearFilters: () => {
setSelectedTeamName(null);
setSelectedEventTypeId(null);
setSelectedMemberUserId(null);
setSelectedFilter(null);
const { teamId, userId, ...rest } = router.query;
const query: { teamId?: number; userId?: number } = {};
const parsedTeamId = Number(Array.isArray(teamId) ? teamId[0] : teamId);
const parsedUserId = Number(Array.isArray(userId) ? userId[0] : userId);
const { initialConfig } = configFilters;
if ((teamId && !userId) || (userId && teamId)) {
query.teamId = parsedTeamId;
setSelectedTeamId(parsedTeamId);
setSelectedUserId(null);
} else if (userId && !teamId) {
query.userId = parsedUserId;
setSelectedUserId(parsedUserId);
setSelectedTeamId(null);
}
const teamId = initialConfig?.teamId ? initialConfig.teamId : undefined;
const userId = initialConfig?.userId ? initialConfig.userId : undefined;
setConfigFilters({
selectedEventTypeId: null,
selectedFilter: null,
selectedMemberUserId: null,
selectedTeamId: teamId,
selectedTeamName: null,
selectedTimeView: "week",
selectedUserId: userId,
isAll: !!initialConfig?.isAll,
dateRange: [dayjs().subtract(1, "month"), dayjs(), "t"],
initialConfig,
});
router.push({
query,
query: {
...(teamId && { teamId: teamId }),
...(userId && { userId: userId }),
},
});
},
}}>
+19 -18
View File
@@ -2,26 +2,27 @@ import * as React from "react";
import type { Dayjs } from "@calcom/dayjs";
export type FilterContextType = {
filter: {
dateRange: [Dayjs, Dayjs, null | string];
selectedTimeView: "year" | "week" | "month";
selectedFilter: Array<"user" | "event-type"> | null;
selectedTeamId: number | null;
selectedTeamName: string | null;
selectedUserId: number | null;
selectedMemberUserId: number | null;
selectedEventTypeId: number | null;
interface IFilter {
dateRange: [Dayjs, Dayjs, null | string];
selectedTimeView?: "year" | "week" | "month";
selectedFilter?: Array<"user" | "event-type"> | null;
selectedTeamId?: number | null;
selectedTeamName?: string | null;
selectedUserId?: number | null;
selectedMemberUserId?: number | null;
selectedEventTypeId?: number | null;
isAll?: boolean;
initialConfig?: {
teamId?: number | null;
userId?: number | null;
isAll?: boolean;
};
setDateRange: ([start, end, range]: [Dayjs, Dayjs, null | string]) => void;
setSelectedFilter: (filter: Array<"user" | "event-type"> | null) => void;
setSelectedTeamId: (teamId: number | null) => void;
setSelectedTeamName: (teamName: string | null) => void;
setSelectedUserId: (userId: number | null) => void;
setSelectedMemberUserId: (userId: number | null) => void;
setSelectedEventTypeId: (eventTypeId: number | null) => void;
setSelectedTimeView: (timeView: "year" | "week" | "month") => void;
}
export type FilterContextType = {
filter: IFilter;
clearFilters: () => void;
setConfigFilters: (config: Partial<IFilter>) => void;
};
export const FilterContext = React.createContext<FilterContextType | null>(null);
@@ -1,8 +1,16 @@
.custom-date > .tremor-DateRangePicker-root > .tremor-DateRangePicker-button {
box-shadow: none;
max-width: 288px;
width: 100%;
}
/* Media query for screens larger than 768px */
@media (max-width: 639) {
.custom-date > .tremor-DateRangePicker-root > .tremor-DateRangePicker-button {
max-width: 400px;
}
}
.tremor-DateRangePicker-calendarButton, .tremor-DateRangePicker-dropdownButton {
@apply border-subtle bg-default text-sm leading-4 placeholder:text-sm placeholder:font-normal focus-within:ring-0 focus-within:ring-emphasis hover:border-subtle dark:focus-within:ring-emphasis hover:bg-subtle
}
@@ -10,13 +10,13 @@ type RangeType = "tdy" | "w" | "t" | "m" | "y" | undefined | null;
export const DateSelect = () => {
const { t } = useLocale();
const { filter, setDateRange } = useFilterContext();
const { filter, setConfigFilters } = useFilterContext();
const currentDate = dayjs();
const [startDate, endDate, range] = filter.dateRange;
const [startDate, endDate, range] = filter?.dateRange || [null, null, null];
const startValue = startDate?.toDate() || null;
const endValue = endDate?.toDate() || null;
return (
<div className="custom-date">
<div className="custom-date w-full sm:w-auto">
<DateRangePicker
value={[startValue, endValue, range]}
defaultValue={[startValue, endValue, range]}
@@ -29,22 +29,28 @@ export const DateSelect = () => {
range &&
(range === "tdy" || range === "w" || range === "t" || range === "m" || range === "y")
) {
setDateRange([dayjs(start).startOf("d"), dayjs(end).endOf("d"), range]);
setConfigFilters({
dateRange: [dayjs(start).startOf("d"), dayjs(end).endOf("d"), range],
});
return;
} else if (start && !end) {
// If only start time has value that means selected date should push to dateRange with last value null
const currentDates = filter.dateRange;
// remove last position of array
currentDates.pop();
// push new value to array
currentDates.push(dayjs(selected));
// if lenght > 2 then remove first value
if (currentDates.length > 2) {
currentDates.shift();
if (currentDates && currentDates.length > 0) {
// remove last position of array
currentDates.pop();
// push new value to array
currentDates.push(dayjs(selected));
// if lenght > 2 then remove first value
if (currentDates.length > 2) {
currentDates.shift();
}
setConfigFilters({
dateRange: [currentDates[0], currentDates[1], null],
});
}
setDateRange([currentDates[0], currentDates[1], null]);
return;
}
@@ -57,7 +63,7 @@ export const DateSelect = () => {
minDate={currentDate.subtract(2, "year").toDate()}
maxDate={currentDate.toDate()}
color="gray"
className="h-[42px] max-w-sm"
className="h-[42px]"
/>
</div>
);
@@ -1,9 +1,8 @@
import { isArray } from "lodash";
import { FilterCheckboxFieldsContainer } from "@calcom/features/filters/components/TeamsFilter";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import { Select } from "@calcom/ui";
import { AnimatedPopover, Checkbox } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
@@ -11,49 +10,66 @@ type EventType = RouterOutputs["viewer"]["insights"]["eventTypeList"][number];
type Option = { value: string; label: string };
const mapEventTypeToOption = (eventType: EventType): Option => ({
value: eventType.slug,
label: eventType.title,
value: eventType.id.toString(),
label: eventType.teamId ? `${eventType.title} (${eventType.team?.name})` : eventType.title,
});
export const EventTypeList = () => {
const { t } = useLocale();
const { filter, setSelectedEventTypeId } = useFilterContext();
const { selectedTeamId, selectedEventTypeId, selectedUserId } = filter;
const { filter, setConfigFilters } = useFilterContext();
const { selectedTeamId, selectedEventTypeId, selectedUserId, isAll } = filter;
const { selectedFilter } = filter;
const { data, isSuccess } = trpc.viewer.insights.eventTypeList.useQuery({
teamId: selectedTeamId ?? undefined,
userId: selectedUserId ?? undefined,
isAll,
});
if (!selectedFilter?.includes("event-type")) return null;
if (!selectedTeamId && !selectedUserId) return null;
const filterOptions =
data?.map(mapEventTypeToOption) ?? ([{ label: "No event types found", value: "" }] as Option[]);
const filterOptions = data?.map(mapEventTypeToOption);
const selectedEventType = data?.find((item) => item.id === selectedEventTypeId);
const eventTypeValue = selectedEventType ? mapEventTypeToOption(selectedEventType) : null;
if (!isSuccess || !data || !isArray(data)) return null;
if (!isSuccess || !data || !Array.isArray(data)) return null;
const getPopoverText = () => {
if (eventTypeValue?.label) {
return `${t("event_type")}: ${eventTypeValue?.label}`;
}
return t("event_type");
};
return (
<Select<Option>
isSearchable={false}
isMulti={false}
options={filterOptions}
onChange={(input) => {
if (input) {
const selectedEventTypeId = data.find((item) => item.slug === input.value)?.id;
!!selectedEventTypeId && setSelectedEventTypeId(selectedEventTypeId);
}
}}
defaultValue={eventTypeValue}
value={eventTypeValue}
className="w-52 min-w-[180px]"
placeholder={
<div className="flex flex-row">
<p>{t("select_event_type")}</p>
</div>
}
/>
<AnimatedPopover text={getPopoverText()}>
<FilterCheckboxFieldsContainer>
{filterOptions?.map((eventType) => (
<div key={eventType.value} className="item-center hover:bg-muted flex cursor-pointer px-4 py-2">
<Checkbox
checked={eventTypeValue?.value === eventType.value}
onChange={(e) => {
if (e.target.checked) {
const selectedEventTypeId = data.find((item) => item.id.toString() === eventType.value)?.id;
!!selectedEventTypeId &&
setConfigFilters({
selectedEventTypeId,
});
} else if (!e.target.checked) {
setConfigFilters({
selectedEventTypeId: null,
});
}
}}
description={eventType.label}
/>
</div>
))}
{filterOptions?.length === 0 && (
<h2 className="text-default px-4 py-2 text-sm font-medium">{t("no_options_available")}</h2>
)}
</FilterCheckboxFieldsContainer>
</AnimatedPopover>
);
};
@@ -1,24 +1,34 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Select } from "@calcom/ui";
import { Filter } from "@calcom/ui/components/icon";
import type { SVGComponent } from "@calcom/types/SVGComponent";
import {
Dropdown,
DropdownMenuContent,
DropdownMenuItem,
DropdownItem,
DropdownMenuTrigger,
Tooltip,
} from "@calcom/ui";
import { Plus, Link, User, Check } from "@calcom/ui/components/icon";
import { useFilterContext } from "../context/provider";
type Option = { value: "event-type" | "user"; label: string };
type Option = { value: "event-type" | "user"; label: string; StartIcon?: SVGComponent };
export const FilterType = () => {
const { t } = useLocale();
const { setSelectedFilter, filter } = useFilterContext();
const { filter, setConfigFilters } = useFilterContext();
const { selectedFilter, selectedUserId } = filter;
let filterOptions: Option[] = [
{
label: t("event_type"),
value: "event-type",
StartIcon: Link,
},
{
label: t("user"),
value: "user",
StartIcon: User,
},
];
@@ -32,23 +42,36 @@ export const FilterType = () => {
: null;
return (
<Select<Option>
options={filterOptions}
value={filterValue}
defaultValue={filterValue}
onChange={(newValue) => {
if (newValue) {
// This can multiple values, but for now we only want to have one filter active at a time
setSelectedFilter([newValue.value]);
}
}}
className="w-32 min-w-[130px]"
placeholder={
<div className="text-emphasis flex flex-row">
<Filter className="m-auto h-4 w-4" />
{t("add_filter")}
<Dropdown>
<DropdownMenuTrigger asChild>
<div className="hover:border-emphasis border-default text-default hover:text-emphasis mb-4 flex h-9 max-h-72 items-center justify-between whitespace-nowrap rounded-md border px-3 py-2 text-sm hover:cursor-pointer focus:border-neutral-300 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-1">
<Plus className="mr-2 h-4 w-4" />
<Tooltip content={t("add_filter")}>
<div>{t("add_filter")}</div>
</Tooltip>
</div>
}
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-44">
{filterOptions?.map((option) => (
<DropdownMenuItem key={option.label} className="w-44">
<DropdownItem
type="button"
StartIcon={option.StartIcon}
onClick={() => {
// This can multiple values, but for now we only want to have one filter active at a time
setConfigFilters({
selectedFilter: [option.value],
});
}}
childrenClassName="w-full">
<div className="flex w-full items-center justify-between">
{t(option.label)}
{filterValue?.value === option.value && <Check className="h-4 w-4" />}
</div>
</DropdownItem>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</Dropdown>
);
};
@@ -1,67 +1,164 @@
import { useSession } from "next-auth/react";
import { useEffect } from "react";
import type { RouterOutputs } from "@calcom/trpc";
import {
FilterCheckboxField,
FilterCheckboxFieldsContainer,
} from "@calcom/features/filters/components/TeamsFilter";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Select } from "@calcom/ui";
import { AnimatedPopover, Avatar, Divider } from "@calcom/ui";
import { Layers, User } from "@calcom/ui/components/icon";
import { useFilterContext } from "../context/provider";
type Team = RouterOutputs["viewer"]["insights"]["teamListForUser"][number];
type Option = { value: number; label: string | null; userId?: number };
const mapTeamToOption = (team: Team): Option => ({
value: team.id,
label: team.name ?? "",
userId: team.userId,
});
export const TeamAndSelfList = () => {
const { filter, setSelectedTeamId, setSelectedTeamName, setSelectedUserId } = useFilterContext();
const { selectedTeamId, selectedUserId } = filter;
const { data, isSuccess } = trpc.viewer.insights.teamListForUser.useQuery();
const { t } = useLocale();
const session = useSession();
const { filter, setConfigFilters } = useFilterContext();
const { selectedTeamId, selectedUserId, isAll } = filter;
const { data, isSuccess } = trpc.viewer.insights.teamListForUser.useQuery(undefined, {
// Teams don't change that frequently
refetchOnWindowFocus: false,
});
useEffect(() => {
if (data && data?.length > 0) {
if (isSuccess && session.data?.user.id) {
// We have a team?
if (data[0].id) {
setSelectedTeamId(data[0].id);
setSelectedTeamName(data[0].name);
} else if (data[0].userId) {
if (data[0]?.id && data && data?.length > 0) {
const isAllSelected = !!data[0]?.isOrg;
setConfigFilters({
selectedTeamId: data[0].id,
selectedUserId: null,
isAll: isAllSelected,
initialConfig: {
teamId: data[0].id,
userId: null,
isAll: isAllSelected,
},
});
} else if (session.data?.user.id) {
// default to user
setSelectedUserId(data[0].userId);
setConfigFilters({
initialConfig: {
teamId: null,
userId: session.data?.user.id,
isAll: false,
},
});
}
} else if (session.data?.user.id) {
setConfigFilters({
selectedUserId: session.data?.user.id,
selectedTeamId: null,
isAll: false,
initialConfig: {
teamId: null,
userId: session.data?.user.id,
isAll: false,
},
});
}
}, [data]);
}, [data, session.data?.user.id]);
const UserListOptions = data?.map(mapTeamToOption) || ([{ label: "Empty", value: -1 }] as Option[]);
const selectedTeam = data?.find((item) => {
if (!!selectedUserId && !selectedTeamId) {
return item.userId === selectedUserId;
const getTextPopover = () => {
if (isAll) {
return `${t("all")}`;
} else if (selectedUserId) {
return `${t("yours")}`;
} else if (selectedTeamId) {
const selectedTeam = data?.find((item) => {
return item.id === selectedTeamId;
});
return `${t("team")}: ${selectedTeam?.name}`;
}
return item.id === selectedTeamId;
});
const teamValue = selectedTeam ? mapTeamToOption(selectedTeam) : null;
if (!isSuccess || data?.length === 0) return null;
return t("select");
};
const text = getTextPopover();
return (
<>
<Select<Option>
isSearchable={false}
isMulti={false}
value={teamValue}
defaultValue={selectedTeamId ? { value: data[0].id, label: data[0].name } : null}
className="h-[38px] w-[90vw] min-w-[160px] max-w-[100px]"
options={UserListOptions}
onChange={(input) => {
if (!!input?.userId) {
setSelectedUserId(input.userId);
} else if (input && input.value) {
setSelectedTeamId(input.value);
setSelectedTeamName(input.label);
}
}}
/>
</>
<AnimatedPopover text={text}>
<FilterCheckboxFieldsContainer>
{isSuccess && data?.length > 0 && data[0].isOrg && (
<FilterCheckboxField
id="all"
icon={<Layers className="h-4 w-4" />}
checked={isAll}
onChange={(e) => {
setConfigFilters({
selectedTeamId: data[0].isOrg ? data[0].id : null,
selectedUserId: null,
selectedTeamName: null,
isAll: true,
});
}}
label={t("insights_all_org_filter")}
/>
)}
<Divider />
{data?.map((team) => (
<FilterCheckboxField
key={team.id}
id={team.name || ""}
label={team.name || ""}
checked={selectedTeamId === team.id && !isAll}
onChange={(e) => {
if (e.target.checked) {
setConfigFilters({
selectedTeamId: team.id,
selectedUserId: null,
selectedTeamName: team.name,
isAll: false,
// Setting these to null to reset the filters
selectedEventTypeId: null,
selectedMemberUserId: null,
selectedFilter: null,
});
} else if (!e.target.checked) {
setConfigFilters({
selectedTeamId: null,
selectedTeamName: null,
isAll: false,
});
}
}}
icon={
<Avatar
alt={team?.name || ""}
imageSrc={getPlaceholderAvatar(team.logo, team?.name as string)}
size="xs"
/>
}
/>
))}
<Divider />
<FilterCheckboxField
id="yours"
icon={<User className="h-4 w-4" />}
checked={selectedUserId === session.data?.user.id}
onChange={(e) => {
if (e.target.checked) {
setConfigFilters({
selectedUserId: session.data?.user.id,
selectedTeamId: null,
isAll: false,
});
} else if (!e.target.checked) {
setConfigFilters({
selectedUserId: null,
isAll: false,
});
}
}}
label={t("yours")}
/>
</FilterCheckboxFieldsContainer>
</AnimatedPopover>
);
};
@@ -1,52 +1,82 @@
import {
FilterCheckboxField,
FilterCheckboxFieldsContainer,
} from "@calcom/features/filters/components/TeamsFilter";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import { Select } from "@calcom/ui";
import { AnimatedPopover, Avatar } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
type User = RouterOutputs["viewer"]["insights"]["userList"][number];
type Option = { value: number; label: string };
type Option = { value: number; label: string; username: string | null };
const mapUserToOption = (user: User): Option => ({
value: user.id,
label: user.name ?? "",
label: user.name ?? user.email, // every user should have at least email
username: user.username,
});
export const UserListInTeam = () => {
const { t } = useLocale();
const { filter, setSelectedMemberUserId } = useFilterContext();
const { selectedFilter, selectedTeamId, selectedMemberUserId } = filter;
const { filter, setConfigFilters } = useFilterContext();
const { selectedFilter, selectedTeamId, selectedMemberUserId, isAll } = filter;
const { data, isSuccess } = trpc.viewer.insights.userList.useQuery({
teamId: selectedTeamId,
teamId: selectedTeamId ?? -1,
isAll: !!isAll,
});
if (!selectedFilter?.includes("user")) return null;
if (!selectedTeamId) return null;
const userListOptions = data?.map(mapUserToOption) ?? ([] as { value: number; label: string }[]);
const userListOptions = data?.map(mapUserToOption);
const selectedTeamUser = data?.find((item) => item.id === selectedMemberUserId);
const userValue = selectedTeamUser ? mapUserToOption(selectedTeamUser) : null;
if (!isSuccess || data?.length === 0) return null;
const getTextForPopover = () => {
if (userValue?.label) {
return `${t("people")}: ${userValue.label}`;
}
return t("people");
};
return (
<Select<Option>
isSearchable={true}
className="mb-0 h-[38px] w-40 min-w-[140px] capitalize md:min-w-[150px] md:max-w-[200px]"
defaultValue={userValue}
value={userValue}
options={userListOptions}
onChange={(input) => {
if (input) {
setSelectedMemberUserId(input.value);
}
}}
placeholder={
<div className="flex flex-row">
<p>{t("select_user")}</p>
</div>
}
/>
<AnimatedPopover text={getTextForPopover()}>
<FilterCheckboxFieldsContainer>
{userListOptions?.map((member) => (
<FilterCheckboxField
key={member.value}
id={member?.value?.toString()}
label={member?.label ?? member.username ?? "No Name"}
checked={userValue?.value === member?.value}
onChange={(e) => {
if (e.target.checked) {
setConfigFilters({
selectedMemberUserId: member.value,
});
} else if (!e.target.checked) {
setConfigFilters({
selectedMemberUserId: undefined,
});
}
}}
icon={
<Avatar
alt={`${member?.value} avatar`}
imageSrc={member.username ? `${WEBAPP_URL}/${member.username}/avatar.png` : undefined}
size="xs"
/>
}
/>
))}
{userListOptions?.length === 0 && (
<h2 className="text-default px-4 py-2 text-sm font-medium">{t("no_options_available")}</h2>
)}
</FilterCheckboxFieldsContainer>
</AnimatedPopover>
);
};
+13 -10
View File
@@ -1,6 +1,7 @@
import { useFilterContext } from "@calcom/features/insights/context/provider";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Tooltip } from "@calcom/ui";
import { X } from "@calcom/ui/components/icon";
import { DateSelect } from "./DateSelect";
import { EventTypeList } from "./EventTypeList";
@@ -22,11 +23,12 @@ const ClearFilters = () => {
color="secondary"
target="_blank"
rel="noreferrer"
className="h-[38px]"
className="min-w-24 h-[38px] border-0"
onClick={() => {
clearFilters();
}}>
{t("clear_filters")}
<X className="mr-1 h-4 w-4" />
{t("clear")}
</Button>
</Tooltip>
);
@@ -34,18 +36,18 @@ const ClearFilters = () => {
export const Filters = () => {
return (
<div className="mt-2 flex flex-col flex-wrap gap-2 md:flex-row lg:flex-nowrap">
<TeamAndSelfList />
<div className="mb-4 ml-auto mt-6 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-between">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-nowrap sm:justify-start">
<TeamAndSelfList />
<FilterType />
<UserListInTeam />
<UserListInTeam />
<EventTypeList />
<EventTypeList />
<FilterType />
<DateSelect />
<ClearFilters />
<ClearFilters />
</div>
{/* @NOTE: To be released in next iteration */}
{/* <ButtonGroup combined containerProps={{ className: "hidden lg:flex mr-2" }}>
@@ -70,6 +72,7 @@ export const Filters = () => {
/>
</Tooltip>
</ButtonGroup> */}
<DateSelect />
</div>
);
};
+25 -2
View File
@@ -113,6 +113,9 @@ class EventsInsights {
if (timeView) {
switch (timeView) {
case "day":
resultTimeLine = this.getDailyTimeline(startDate, endDate);
break;
case "week":
resultTimeLine = this.getWeekTimeline(startDate, endDate);
break;
@@ -143,16 +146,36 @@ class EventsInsights {
return resultTimeView;
};
static getDailyTimeline(startDate: Dayjs, endDate: Dayjs): string[] {
const now = dayjs();
const endOfDay = now.endOf("day");
let pivotDate = dayjs(startDate);
const dates: string[] = [];
while ((pivotDate.isBefore(endDate) || pivotDate.isSame(endDate)) && pivotDate.isBefore(endOfDay)) {
dates.push(pivotDate.format("YYYY-MM-DD"));
pivotDate = pivotDate.add(1, "day");
}
return dates;
}
static getWeekTimeline(startDate: Dayjs, endDate: Dayjs): string[] {
const now = dayjs();
const endOfDay = now.endOf("day");
let pivotDate = dayjs(startDate);
const dates: string[] = [];
while (pivotDate.isBefore(endDate) && pivotDate.isBefore(endOfDay)) {
const weekEndDate = pivotDate.add(7, "day").isBefore(endOfDay) ? pivotDate.add(7, "day") : endOfDay;
while (pivotDate.isBefore(endDate) || pivotDate.isSame(endDate)) {
const pivotAdded = pivotDate.add(6, "day");
const weekEndDate = pivotAdded.isBefore(endOfDay) ? pivotAdded : endOfDay;
dates.push(pivotDate.format("YYYY-MM-DD"));
if (pivotDate.isSame(endDate)) {
break;
}
pivotDate = weekEndDate.add(1, "day");
}
return dates;
}
+502 -115
View File
@@ -12,10 +12,9 @@ import { EventsInsights } from "./events";
const UserBelongsToTeamInput = z.object({
teamId: z.coerce.number().optional().nullable(),
isAll: z.boolean().optional(),
});
//const userBelongsToTeamMiddleware = isAuthed.unstable_pipe();
const userBelongsToTeamProcedure = authedProcedure.use(async ({ ctx, next, rawInput }) => {
const parse = UserBelongsToTeamInput.safeParse(rawInput);
if (!parse.success) {
@@ -27,6 +26,7 @@ const userBelongsToTeamProcedure = authedProcedure.use(async ({ ctx, next, rawIn
const membershipWhereConditional: Prisma.MembershipWhereInput = {
userId: ctx.user.id,
accepted: true,
};
if (parse.data.teamId) {
@@ -37,8 +37,33 @@ const userBelongsToTeamProcedure = authedProcedure.use(async ({ ctx, next, rawIn
where: membershipWhereConditional,
});
if (!membership) {
throw new TRPCError({ code: "UNAUTHORIZED" });
// Probably we couldn't find a membership because the user is not a direct member of the team
// So that would mean ctx.user.organization is present
if ((parse.data.isAll && ctx.user.organizationId) || (!membership && ctx.user.organizationId)) {
//Look for membership type in organizationId
const membershipOrg = await ctx.prisma.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: ctx.user.organizationId,
accepted: true,
role: {
in: ["OWNER", "ADMIN"],
},
},
});
if (!membershipOrg) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
...ctx,
user: {
...ctx.user,
isOwnerAdminOfParentTeam: true,
},
},
});
}
return next();
@@ -49,6 +74,7 @@ const UserSelect = {
name: true,
email: true,
avatar: true,
username: true,
};
const emptyResponseEventsByStatus = {
@@ -75,6 +101,15 @@ const emptyResponseEventsByStatus = {
},
};
interface IResultTeamList {
id: number;
slug: string | null;
name: string | null;
logo: string | null;
userId?: number;
isOrg?: boolean;
}
export const insightsRouter = router({
eventsByStatus: userBelongsToTeamProcedure
.input(
@@ -85,16 +120,17 @@ export const insightsRouter = router({
eventTypeId: z.coerce.number().optional(),
memberUserId: z.coerce.number().optional(),
userId: z.coerce.number().optional(),
isAll: z.boolean().optional(),
})
)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, eventTypeId, memberUserId, userId } = input;
const { teamId, startDate, endDate, eventTypeId, memberUserId, userId, isAll } = input;
if (userId && userId !== ctx.user.id) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
let whereConditional: Prisma.BookingTimeStatusWhereInput = {};
let teamConditional: Prisma.TeamWhereInput = {};
if (eventTypeId) {
whereConditional["eventTypeId"] = eventTypeId;
@@ -107,10 +143,56 @@ export const insightsRouter = router({
whereConditional["userId"] = userId;
}
if (teamId) {
if (isAll && ctx.user.isOwnerAdminOfParentTeam && ctx.user.organizationId) {
const teamsFromOrg = await ctx.prisma.team.findMany({
where: {
parentId: ctx.user.organizationId,
},
select: {
id: true,
},
});
if (teamsFromOrg.length === 0) {
return emptyResponseEventsByStatus;
}
teamConditional = {
id: {
in: [ctx.user.organizationId, ...teamsFromOrg.map((t) => t.id)],
},
};
const usersFromOrg = await ctx.prisma.membership.findMany({
where: {
team: teamConditional,
accepted: true,
},
select: {
userId: true,
},
});
const userIdsFromOrg = usersFromOrg.map((u) => u.userId);
whereConditional = {
...whereConditional,
OR: [
{
userId: {
in: userIdsFromOrg,
},
teamId: null,
},
{
teamId: {
in: [ctx.user.organizationId, ...teamsFromOrg.map((t) => t.id)],
},
},
],
};
}
if (teamId && !isAll) {
const usersFromTeam = await ctx.prisma.membership.findMany({
where: {
teamId: teamId,
accepted: true,
},
select: {
userId: true,
@@ -133,7 +215,6 @@ export const insightsRouter = router({
};
}
// Migrate to use prisma views
const baseBookings = await EventsInsights.getBaseBookingForEventStatus({
...whereConditional,
createdAt: {
@@ -213,17 +294,19 @@ export const insightsRouter = router({
endDate: z.string(),
eventTypeId: z.coerce.number().optional(),
memberUserId: z.coerce.number().optional(),
timeView: z.enum(["week", "month", "year"]),
timeView: z.enum(["week", "month", "year", "day"]),
userId: z.coerce.number().optional(),
isAll: z.boolean().optional(),
})
)
.query(async ({ ctx, input }) => {
const {
teamId,
startDate: startDateString,
endDate: endDateString,
eventTypeId,
memberUserId,
isAll,
startDate: startDateString,
endDate: endDateString,
timeView: inputTimeView,
userId: selfUserId,
} = input;
@@ -240,14 +323,62 @@ export const insightsRouter = router({
return [];
}
const timeView = inputTimeView;
let timeView = inputTimeView;
if (timeView === "week") {
// Difference between start and end date is less than 14 days use day view
if (endDate.diff(startDate, "day") < 14) {
timeView = "day";
}
}
let whereConditional: Prisma.BookingTimeStatusWhereInput = {};
if (teamId) {
if (isAll && ctx.user.isOwnerAdminOfParentTeam && ctx.user.organizationId) {
const teamsFromOrg = await ctx.prisma.team.findMany({
where: {
parentId: user.organizationId,
},
select: {
id: true,
},
});
const usersFromOrg = await ctx.prisma.membership.findMany({
where: {
teamId: {
in: [ctx.user.organizationId, ...teamsFromOrg.map((t) => t.id)],
},
accepted: true,
},
select: {
userId: true,
},
});
const userIdsFromOrg = usersFromOrg.map((u) => u.userId);
whereConditional = {
OR: [
{
userId: {
in: userIdsFromOrg,
},
teamId: null,
},
{
teamId: {
in: [ctx.user.organizationId, ...teamsFromOrg.map((t) => t.id)],
},
},
],
};
}
if (teamId && !isAll) {
const usersFromTeam = await ctx.prisma.membership.findMany({
where: {
teamId,
accepted: true,
},
select: {
userId: true,
@@ -308,11 +439,13 @@ export const insightsRouter = router({
Rescheduled: 0,
Cancelled: 0,
};
const startOfEndOf = timeView === "year" ? "year" : timeView === "month" ? "month" : "week";
const startDate = dayjs(date).startOf(startOfEndOf);
const endDate = dayjs(date).endOf(startOfEndOf);
const startOfEndOf = timeView;
let startDate = dayjs(date).startOf(startOfEndOf);
let endDate = dayjs(date).endOf(startOfEndOf);
if (timeView === "week") {
startDate = dayjs(date).startOf("day");
endDate = dayjs(date).add(6, "day").endOf("day");
}
const promisesResult = await Promise.all([
EventsInsights.getCreatedEventsInTimeRange(
{
@@ -360,10 +493,11 @@ export const insightsRouter = router({
startDate: z.string(),
endDate: z.string(),
userId: z.coerce.number().optional(),
isAll: z.boolean().optional(),
})
)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, memberUserId, userId } = input;
const { teamId, startDate, endDate, memberUserId, userId, isAll } = input;
const user = ctx.user;
@@ -381,10 +515,53 @@ export const insightsRouter = router({
lte: dayjs(endDate).endOf("day").toDate(),
},
};
if (teamId) {
if (isAll && ctx.user.isOwnerAdminOfParentTeam && ctx.user.organizationId) {
const teamsFromOrg = await ctx.prisma.team.findMany({
where: {
parentId: user.organizationId,
},
select: {
id: true,
},
});
const usersFromOrg = await ctx.prisma.membership.findMany({
where: {
teamId: {
in: [ctx.user.organizationId, ...teamsFromOrg.map((t) => t.id)],
},
accepted: true,
},
select: {
userId: true,
},
});
const userIdsFromOrg = usersFromOrg.map((u) => u.userId);
bookingWhere = {
...bookingWhere,
OR: [
{
userId: {
in: userIdsFromOrg,
},
teamId: null,
},
{
teamId: {
in: [ctx.user.organizationId, ...teamsFromOrg.map((t) => t.id)],
},
},
],
};
}
if (teamId && !isAll) {
const usersFromTeam = await ctx.prisma.membership.findMany({
where: {
teamId,
accepted: true,
},
select: {
userId: true,
@@ -519,10 +696,18 @@ export const insightsRouter = router({
startDate: z.string(),
endDate: z.string(),
userId: z.coerce.number().optional(),
isAll: z.boolean().optional(),
})
)
.query(async ({ ctx, input }) => {
const { teamId, startDate: startDateString, endDate: endDateString, memberUserId, userId } = input;
const {
teamId,
startDate: startDateString,
endDate: endDateString,
memberUserId,
userId,
isAll,
} = input;
if (userId && ctx.user?.id !== userId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
@@ -546,10 +731,36 @@ export const insightsRouter = router({
whereConditional["userId"] = userId;
}
if (teamId) {
if (isAll && ctx.user.isOwnerAdminOfParentTeam && ctx.user.organizationId) {
const teamsFromOrg = await ctx.prisma.team.findMany({
where: {
parentId: ctx.user?.organizationId,
},
select: {
id: true,
},
});
whereConditional = {
...whereConditional,
OR: [
{
teamId: {
in: [ctx.user?.organizationId, ...teamsFromOrg.map((t) => t.id)],
},
},
{
userId: ctx.user?.id,
teamId: null,
},
],
};
}
if (teamId && !isAll) {
const usersFromTeam = await ctx.prisma.membership.findMany({
where: {
teamId,
accepted: true,
},
select: {
userId: true,
@@ -629,14 +840,16 @@ export const insightsRouter = router({
membersWithMostBookings: userBelongsToTeamProcedure
.input(
z.object({
teamId: z.coerce.number().nullable(),
teamId: z.coerce.number().nullable().optional(),
startDate: z.string(),
endDate: z.string(),
eventTypeId: z.coerce.number().optional(),
isAll: z.boolean().optional(),
})
)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, eventTypeId } = input;
const { teamId, startDate, endDate, eventTypeId, isAll } = input;
if (!teamId) {
return [];
}
@@ -654,10 +867,47 @@ export const insightsRouter = router({
bookingWhere.eventTypeId = eventTypeId;
}
if (teamId) {
if (isAll && user.isOwnerAdminOfParentTeam && user.organizationId) {
delete bookingWhere.teamId;
const teamsFromOrg = await ctx.prisma.team.findMany({
where: {
parentId: user?.organizationId,
},
select: {
id: true,
},
});
const usersFromTeam = await ctx.prisma.membership.findMany({
where: {
teamId: {
in: [user?.organizationId, ...teamsFromOrg.map((t) => t.id)],
},
accepted: true,
},
select: {
userId: true,
},
});
bookingWhere["OR"] = [
{
teamId: {
in: [user?.organizationId, ...teamsFromOrg.map((t) => t.id)],
},
},
{
userId: {
in: usersFromTeam.map((u) => u.userId),
},
teamId: null,
},
];
}
if (teamId && !isAll) {
const usersFromTeam = await ctx.prisma.membership.findMany({
where: {
teamId,
accepted: true,
},
select: {
userId: true,
@@ -699,12 +949,14 @@ export const insightsRouter = router({
if (userIds.length === 0) {
return [];
}
const usersFromTeam = await ctx.prisma.user.findMany({
where: {
id: {
in: userIds as number[],
},
},
select: UserSelect,
});
const userHashMap = new Map();
@@ -726,20 +978,22 @@ export const insightsRouter = router({
membersWithLeastBookings: userBelongsToTeamProcedure
.input(
z.object({
teamId: z.coerce.number().nullable(),
teamId: z.coerce.number().nullable().optional(),
startDate: z.string(),
endDate: z.string(),
eventTypeId: z.coerce.number().optional(),
isAll: z.boolean().optional(),
})
)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, eventTypeId } = input;
const { teamId, startDate, endDate, eventTypeId, isAll } = input;
if (!teamId) {
return [];
}
const user = ctx.user;
const bookingWhere: Prisma.BookingTimeStatusWhereInput = {
teamId,
eventTypeId,
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
@@ -747,10 +1001,48 @@ export const insightsRouter = router({
},
};
if (teamId) {
if (isAll && user.isOwnerAdminOfParentTeam) {
delete bookingWhere.teamId;
const teamsFromOrg = await ctx.prisma.team.findMany({
where: {
parentId: user?.organizationId,
},
select: {
id: true,
},
});
const usersFromTeam = await ctx.prisma.membership.findMany({
where: {
teamId: {
in: teamsFromOrg.map((t) => t.id),
},
accepted: true,
},
select: {
userId: true,
},
});
bookingWhere["OR"] = [
{
teamId: {
in: teamsFromOrg.map((t) => t.id),
},
},
{
userId: {
in: usersFromTeam.map((u) => u.userId),
},
teamId: null,
},
];
}
if (teamId && !isAll) {
const usersFromTeam = await ctx.prisma.membership.findMany({
where: {
teamId,
accepted: true,
},
select: {
userId: true,
@@ -796,6 +1088,7 @@ export const insightsRouter = router({
in: userIds as number[],
},
},
select: UserSelect,
});
const userHashMap = new Map();
@@ -822,29 +1115,80 @@ export const insightsRouter = router({
where: {
id: user.id,
},
select: {
id: true,
name: true,
avatar: true,
},
select: UserSelect,
});
// Look if user it's admin in multiple teams
const belongsToTeams = await ctx.prisma.membership.findMany({
where: {
userId: user.id,
team: {
slug: { not: null },
},
OR: [
{
role: "ADMIN",
},
{
role: "OWNER",
},
],
if (!userData) {
return [];
}
const membershipConditional: Prisma.MembershipWhereInput = {
team: {
slug: { not: null },
},
accepted: true,
userId: user.id,
OR: [
{
role: "ADMIN",
},
{
role: "OWNER",
},
],
};
// Validate if user belongs to org as admin/owner
if (user.organizationId) {
const teamsFromOrg = await ctx.prisma.team.findMany({
where: {
parentId: user.organizationId,
},
select: {
id: true,
slug: true,
name: true,
logo: true,
},
});
const orgTeam = await ctx.prisma.team.findUnique({
where: {
id: user.organizationId,
},
select: {
id: true,
slug: true,
name: true,
logo: true,
},
});
if (!orgTeam) {
return [];
}
const result: IResultTeamList[] = [
{
id: orgTeam.id,
slug: orgTeam.slug,
name: orgTeam.name,
logo: orgTeam.logo,
isOrg: true,
},
...teamsFromOrg.map(
(team: Prisma.TeamGetPayload<{ select: { id: true; slug: true; name: true; logo: true } }>) => {
return {
...team,
};
}
),
];
return result;
}
// Look if user it's admin/owner in multiple teams
const belongsToTeams = await ctx.prisma.membership.findMany({
where: membershipConditional,
include: {
team: {
select: {
@@ -852,48 +1196,78 @@ export const insightsRouter = router({
name: true,
logo: true,
slug: true,
metadata: true,
},
},
},
});
const result: {
id: number;
slug: string | null;
name: string | null;
logo: string | null;
userId?: number;
}[] = belongsToTeams.map((membership) => {
if (belongsToTeams.length === 0) {
return [];
}
const result: IResultTeamList[] = belongsToTeams.map((membership) => {
return { ...membership.team };
});
if (userData && userData.id) {
result.push({
id: 0,
slug: "",
userId: userData.id,
name: userData.name,
logo: userData.avatar,
});
}
return result;
}),
userList: userBelongsToTeamProcedure
.input(
z.object({
teamId: z.coerce.number().nullable(),
isAll: z.boolean().nullable(),
})
)
.query(async ({ ctx, input }) => {
const user = ctx.user;
const { teamId, isAll } = input;
if (!input.teamId) {
if (!teamId) {
return [];
}
if (isAll && user.organizationId && user.isOwnerAdminOfParentTeam) {
const usersInTeam = await ctx.prisma.membership.findMany({
where: {
team: {
parentId: user.organizationId,
},
},
include: {
user: {
select: UserSelect,
},
},
});
return usersInTeam.map((membership) => membership.user);
}
const membership = await ctx.prisma.membership.findFirst({
where: {
userId: user.id,
teamId: input.teamId,
teamId,
accepted: true,
},
include: {
user: {
select: UserSelect,
},
},
});
if (!membership) {
return [];
}
const isMember = membership && membership.role === "MEMBER";
// If user is not admin, return himself only
if (isMember) {
return [membership.user];
}
const usersInTeam = await ctx.prisma.membership.findMany({
where: {
teamId,
accepted: true,
},
include: {
user: {
@@ -902,20 +1276,6 @@ export const insightsRouter = router({
},
});
// If user is not admin, return himself only
if (membership && membership.role === "MEMBER") {
return [membership.user];
}
const usersInTeam = await ctx.prisma.membership.findMany({
where: {
teamId: input.teamId,
},
include: {
user: {
select: UserSelect,
},
},
});
return usersInTeam.map((membership) => membership.user);
}),
eventTypeList: userBelongsToTeamProcedure
@@ -923,18 +1283,39 @@ export const insightsRouter = router({
z.object({
teamId: z.coerce.number().optional().nullable(),
userId: z.coerce.number().optional().nullable(),
isAll: z.boolean().optional(),
})
)
.query(async ({ ctx, input }) => {
const { prisma, user } = ctx;
const { teamId, userId } = input;
const { teamId, userId, isAll } = input;
if (!teamId && !userId) {
return [];
}
const membershipWhereConditional: Prisma.MembershipWhereInput = {};
if (teamId) {
let childrenTeamIds: number[] = [];
if (isAll && teamId && user.organizationId && user.isOwnerAdminOfParentTeam) {
const childTeams = await prisma.team.findMany({
where: {
parentId: user.organizationId,
},
select: {
id: true,
},
});
if (childTeams.length > 0) {
childrenTeamIds = childTeams.map((team) => team.id);
}
membershipWhereConditional["teamId"] = {
in: [user.organizationId, ...childrenTeamIds],
};
}
if (teamId && !isAll) {
membershipWhereConditional["teamId"] = teamId;
membershipWhereConditional["userId"] = user.id;
}
@@ -948,12 +1329,17 @@ export const insightsRouter = router({
where: membershipWhereConditional,
});
if (!membership) {
throw new Error("User is not part of a team");
if (!membership && !user.isOwnerAdminOfParentTeam) {
throw new Error("User is not part of a team/org");
}
const eventTypeWhereConditional: Prisma.EventTypeWhereInput = {};
if (teamId) {
if (isAll && childrenTeamIds.length > 0 && user.organizationId && user.isOwnerAdminOfParentTeam) {
eventTypeWhereConditional["teamId"] = {
in: [user.organizationId, ...childrenTeamIds],
};
}
if (teamId && !isAll) {
eventTypeWhereConditional["teamId"] = teamId;
}
if (userId) {
@@ -965,40 +1351,41 @@ export const insightsRouter = router({
slug: true;
teamId: true;
title: true;
team: {
select: {
name: true;
};
};
};
}>[] = [];
switch (membership?.role) {
case "MEMBER":
eventTypeWhereConditional["OR"] = {
userId: user.id,
users: { some: { id: user.id } },
// @TODO this is not working as expected
// hosts: { some: { id: user.id } },
};
eventTypeResult = await prisma.eventType.findMany({
select: {
id: true,
slug: true,
teamId: true,
title: true,
},
where: eventTypeWhereConditional,
});
break;
default:
eventTypeResult = await prisma.eventType.findMany({
select: {
id: true,
slug: true,
teamId: true,
title: true,
},
where: eventTypeWhereConditional,
});
break;
let isMember = membership?.role === "MEMBER";
if (user.isOwnerAdminOfParentTeam) {
isMember = false;
}
if (isMember) {
eventTypeWhereConditional["OR"] = [
{ userId: user.id },
{ users: { some: { id: user.id } } },
// @TODO this is not working as expected
// hosts: { some: { id: user.id } },
];
}
eventTypeResult = await prisma.eventType.findMany({
select: {
id: true,
slug: true,
title: true,
teamId: true,
team: {
select: {
name: true,
},
},
},
where: eventTypeWhereConditional,
});
return eventTypeResult;
}),
});
@@ -15,7 +15,6 @@ import {
DropdownMenuTrigger,
showToast,
} from "@calcom/ui";
import { Globe, MoreHorizontal, Trash, Star, Copy } from "@calcom/ui/components/icon";
export function ScheduleListItem({
@@ -37,16 +37,20 @@ export const listMembersHandler = async ({ ctx, input }: ListMembersOptions) =>
username: true,
},
},
accepted: true,
},
},
},
});
type UserMap = Record<number, (typeof teams)[number]["members"][number]["user"]>;
type UserMap = Record<number, (typeof teams)[number]["members"][number]["user"] & { accepted: boolean }>;
// flatten users to be unique by id
const users = teams
.flatMap((t) => t.members)
.reduce((acc, m) => (m.user.id in acc ? acc : { ...acc, [m.user.id]: m.user }), {} as UserMap);
.reduce(
(acc, m) => (m.user.id in acc ? acc : { ...acc, [m.user.id]: { ...m.user, accepted: m.accepted } }),
{} as UserMap
);
return Object.values(users);
};
@@ -108,6 +108,7 @@ type DropdownItemProps = {
EndIcon?: SVGComponent;
href?: string;
disabled?: boolean;
childrenClassName?: string;
} & ButtonOrLinkProps;
type ButtonOrLinkProps = ComponentProps<"button"> & ComponentProps<"a">;
@@ -129,7 +130,7 @@ export function ButtonOrLink({ href, ...props }: ButtonOrLinkProps) {
}
export const DropdownItem = (props: DropdownItemProps) => {
const { StartIcon, EndIcon, children, color, ...rest } = props;
const { StartIcon, EndIcon, children, color, childrenClassName, ...rest } = props;
return (
<ButtonOrLink
@@ -140,7 +141,7 @@ export const DropdownItem = (props: DropdownItemProps) => {
)}>
<>
{StartIcon && <StartIcon className="h-4 w-4" />}
<div className="text-sm font-medium leading-5">{children}</div>
<div className={classNames("text-sm font-medium leading-5", childrenClassName)}>{children}</div>
{EndIcon && <EndIcon className="h-4 w-4" />}
</>
</ButtonOrLink>