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:
co-authored by
Efraín Rochín
Udit Takkar
Peer Richelsen
parent
44e5002369
commit
d180e14013
@@ -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 }),
|
||||
},
|
||||
});
|
||||
},
|
||||
}}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user