diff --git a/apps/web/pages/insights/index.tsx b/apps/web/pages/insights/index.tsx index 0d4a7b8016..49c6f44c72 100644 --- a/apps/web/pages/insights/index.tsx +++ b/apps/web/pages/insights/index.tsx @@ -27,7 +27,7 @@ const Heading = () => {

{t("insights")}

-

{t("subtitle_analytics")}

+

{t("insights_subtitle")}

); }; @@ -35,6 +35,7 @@ const Heading = () => { export default function InsightsPage() { const { t } = useLocale(); const { data: user } = trpc.viewer.me.useQuery(); + const features = [ { icon: , @@ -77,10 +78,12 @@ export default function InsightsPage() { <> ) : ( -
+
-
+ + +
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 00eb2b2c10..7005778695 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/insights/components/AverageEventDurationChart.tsx b/packages/features/insights/components/AverageEventDurationChart.tsx index f2320f7f41..7e397ca8f3 100644 --- a/packages/features/insights/components/AverageEventDurationChart.tsx +++ b/packages/features/insights/components/AverageEventDurationChart.tsx @@ -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, diff --git a/packages/features/insights/components/BookingKPICards.tsx b/packages/features/insights/components/BookingKPICards.tsx index 3487dbad8d..097446f70c 100644 --- a/packages/features/insights/components/BookingKPICards.tsx +++ b/packages/features/insights/components/BookingKPICards.tsx @@ -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, diff --git a/packages/features/insights/components/BookingStatusLineChart.tsx b/packages/features/insights/components/BookingStatusLineChart.tsx index f16cfb4e8b..4bb0f37956 100644 --- a/packages/features/insights/components/BookingStatusLineChart.tsx +++ b/packages/features/insights/components/BookingStatusLineChart.tsx @@ -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, diff --git a/packages/features/insights/components/LeastBookedTeamMembersTable.tsx b/packages/features/insights/components/LeastBookedTeamMembersTable.tsx index 5ba2766511..0fdf794dca 100644 --- a/packages/features/insights/components/LeastBookedTeamMembersTable.tsx +++ b/packages/features/insights/components/LeastBookedTeamMembersTable.tsx @@ -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, diff --git a/packages/features/insights/components/MostBookedTeamMembersTable.tsx b/packages/features/insights/components/MostBookedTeamMembersTable.tsx index 984c4701c5..a79899c44d 100644 --- a/packages/features/insights/components/MostBookedTeamMembersTable.tsx +++ b/packages/features/insights/components/MostBookedTeamMembersTable.tsx @@ -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, diff --git a/packages/features/insights/components/PopularEventsTable.tsx b/packages/features/insights/components/PopularEventsTable.tsx index 9af8458027..0efb744209 100644 --- a/packages/features/insights/components/PopularEventsTable.tsx +++ b/packages/features/insights/components/PopularEventsTable.tsx @@ -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, diff --git a/packages/features/insights/context/FiltersProvider.tsx b/packages/features/insights/context/FiltersProvider.tsx index 5fc726537a..2b75008747 100644 --- a/packages/features/insights/context/FiltersProvider.tsx +++ b/packages/features/insights/context/FiltersProvider.tsx @@ -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("week"); - const [selectedUserId, setSelectedUserId] = useState( - userIdParsed || null - ); - const [selectedMemberUserId, setSelectedMemberUserId] = useState< - FilterContextType["filter"]["selectedMemberUserId"] - >(memberUserIdParsed || null); - const [selectedTeamId, setSelectedTeamId] = useState( - teamIdParsed || null - ); - const [selectedEventTypeId, setSelectedEventTypeId] = useState< - FilterContextType["filter"]["selectedEventTypeId"] - >(eventTypeIdParsed || null); - const [selectedFilter, setSelectedFilter] = useState( - filterParsed ? [filterParsed] : null - ); - const [selectedTeamName, setSelectedTeamName] = - useState(null); - const [dateRange, setDateRange] = useState([ - startTimeParsed ? dayjs(startTimeParsed) : dayjs().subtract(1, "month"), - endTimeParsed ? dayjs(endTimeParsed) : dayjs(), - "t", - ]); + const [configFilters, setConfigFilters] = useState({ + 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 ( { - 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 }), + }, }); }, }}> diff --git a/packages/features/insights/context/provider.ts b/packages/features/insights/context/provider.ts index 2fd22a01d6..025d1b4135 100644 --- a/packages/features/insights/context/provider.ts +++ b/packages/features/insights/context/provider.ts @@ -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) => void; }; export const FilterContext = React.createContext(null); diff --git a/packages/features/insights/filters/DateSelect.css b/packages/features/insights/filters/DateSelect.css index cc70b13b45..c71f910f94 100644 --- a/packages/features/insights/filters/DateSelect.css +++ b/packages/features/insights/filters/DateSelect.css @@ -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 } diff --git a/packages/features/insights/filters/DateSelect.tsx b/packages/features/insights/filters/DateSelect.tsx index 3471a64ec6..b4795287e5 100644 --- a/packages/features/insights/filters/DateSelect.tsx +++ b/packages/features/insights/filters/DateSelect.tsx @@ -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 ( -
+
{ 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]" />
); diff --git a/packages/features/insights/filters/EventTypeList.tsx b/packages/features/insights/filters/EventTypeList.tsx index 805dc9a447..cb14ec4940 100644 --- a/packages/features/insights/filters/EventTypeList.tsx +++ b/packages/features/insights/filters/EventTypeList.tsx @@ -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 ( - - 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={ -
-

{t("select_event_type")}

-
- } - /> + + + {filterOptions?.map((eventType) => ( +
+ { + 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} + /> +
+ ))} + {filterOptions?.length === 0 && ( +

{t("no_options_available")}

+ )} +
+
); }; diff --git a/packages/features/insights/filters/FilterType.tsx b/packages/features/insights/filters/FilterType.tsx index 4995db3007..6350d329cf 100644 --- a/packages/features/insights/filters/FilterType.tsx +++ b/packages/features/insights/filters/FilterType.tsx @@ -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 ( - - 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={ -
- - {t("add_filter")} + + +
+ + +
{t("add_filter")}
+
- } - /> +
+ + {filterOptions?.map((option) => ( + + { + // 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"> +
+ {t(option.label)} + {filterValue?.value === option.value && } +
+
+
+ ))} +
+
); }; diff --git a/packages/features/insights/filters/TeamAndSelfList.tsx b/packages/features/insights/filters/TeamAndSelfList.tsx index 27548fce89..192302a23d 100644 --- a/packages/features/insights/filters/TeamAndSelfList.tsx +++ b/packages/features/insights/filters/TeamAndSelfList.tsx @@ -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 ( - <> - - 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); - } - }} - /> - + + + {isSuccess && data?.length > 0 && data[0].isOrg && ( + } + 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")} + /> + )} + + + {data?.map((team) => ( + { + 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={ + + } + /> + ))} + + + } + 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")} + /> + + ); }; diff --git a/packages/features/insights/filters/UsersListInTeam.tsx b/packages/features/insights/filters/UsersListInTeam.tsx index fc72d1404a..9994e10607 100644 --- a/packages/features/insights/filters/UsersListInTeam.tsx +++ b/packages/features/insights/filters/UsersListInTeam.tsx @@ -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 ( - - 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={ -
-

{t("select_user")}

-
- } - /> + + + {userListOptions?.map((member) => ( + { + if (e.target.checked) { + setConfigFilters({ + selectedMemberUserId: member.value, + }); + } else if (!e.target.checked) { + setConfigFilters({ + selectedMemberUserId: undefined, + }); + } + }} + icon={ + + } + /> + ))} + {userListOptions?.length === 0 && ( +

{t("no_options_available")}

+ )} +
+
); }; diff --git a/packages/features/insights/filters/index.tsx b/packages/features/insights/filters/index.tsx index 80d23a74cb..6c11839a8c 100644 --- a/packages/features/insights/filters/index.tsx +++ b/packages/features/insights/filters/index.tsx @@ -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")} + + {t("clear")} ); @@ -34,18 +36,18 @@ const ClearFilters = () => { export const Filters = () => { return ( -
- +
+
+ - + - + - + - - - + +
{/* @NOTE: To be released in next iteration */} {/* @@ -70,6 +72,7 @@ export const Filters = () => { /> */} +
); }; diff --git a/packages/features/insights/server/events.ts b/packages/features/insights/server/events.ts index 5c9f878dee..e6ef45e069 100644 --- a/packages/features/insights/server/events.ts +++ b/packages/features/insights/server/events.ts @@ -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; } diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index 15671e2e35..87b3ca0508 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -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; }), }); diff --git a/packages/features/schedules/components/ScheduleListItem.tsx b/packages/features/schedules/components/ScheduleListItem.tsx index 5ac10beebb..4bb0566842 100644 --- a/packages/features/schedules/components/ScheduleListItem.tsx +++ b/packages/features/schedules/components/ScheduleListItem.tsx @@ -15,7 +15,6 @@ import { DropdownMenuTrigger, showToast, } from "@calcom/ui"; - import { Globe, MoreHorizontal, Trash, Star, Copy } from "@calcom/ui/components/icon"; export function ScheduleListItem({ diff --git a/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts b/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts index 13660c3a6a..d2acf1d91e 100644 --- a/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts @@ -37,16 +37,20 @@ export const listMembersHandler = async ({ ctx, input }: ListMembersOptions) => username: true, }, }, + accepted: true, }, }, }, }); - type UserMap = Record; + type UserMap = Record; // 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); }; diff --git a/packages/ui/components/form/dropdown/Dropdown.tsx b/packages/ui/components/form/dropdown/Dropdown.tsx index 945f2de115..4416e7ec86 100644 --- a/packages/ui/components/form/dropdown/Dropdown.tsx +++ b/packages/ui/components/form/dropdown/Dropdown.tsx @@ -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 ( { )}> <> {StartIcon && } -
{children}
+
{children}
{EndIcon && }