feat: add availability shown in rr reassign (#17287)
* (feat) add availability status to select * add logic for confirmation modal on unavailable host * fix type error * fix type error * more type fixes * fix types
This commit is contained in:
@@ -429,12 +429,14 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
setIsOpenDialog={setIsOpenRescheduleDialog}
|
||||
bookingUId={booking.uid}
|
||||
/>
|
||||
<ReassignDialog
|
||||
isOpenDialog={isOpenReassignDialog}
|
||||
setIsOpenDialog={setIsOpenReassignDialog}
|
||||
bookingId={booking.id}
|
||||
teamId={booking.eventType?.team?.id || 0}
|
||||
/>
|
||||
{isOpenReassignDialog && (
|
||||
<ReassignDialog
|
||||
isOpenDialog={isOpenReassignDialog}
|
||||
setIsOpenDialog={setIsOpenReassignDialog}
|
||||
bookingId={booking.id}
|
||||
teamId={booking.eventType?.team?.id || 0}
|
||||
/>
|
||||
)}
|
||||
<EditLocationDialog
|
||||
booking={booking}
|
||||
saveLocation={saveLocation}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { components } from "react-select";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
ConfirmationDialogContent,
|
||||
showToast,
|
||||
Select,
|
||||
RadioGroup as RadioArea,
|
||||
@@ -51,12 +53,14 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI
|
||||
{
|
||||
label: "Loading...",
|
||||
value: 0,
|
||||
status: "unavailable",
|
||||
},
|
||||
];
|
||||
|
||||
return teamMembers.data?.map((member) => ({
|
||||
label: member.name,
|
||||
value: member.id,
|
||||
status: member.status,
|
||||
}));
|
||||
}, [teamMembers]);
|
||||
|
||||
@@ -96,12 +100,19 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI
|
||||
},
|
||||
});
|
||||
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
|
||||
const handleSubmit = (values: FormValues) => {
|
||||
if (values.reassignType === "round_robin") {
|
||||
roundRobinReassignMutation.mutate({ teamId, bookingId });
|
||||
} else {
|
||||
if (values.teamMemberId) {
|
||||
roundRobinManualReassignMutation.mutate({ bookingId, teamMemberId: values.teamMemberId });
|
||||
const selectedMember = teamMemberOptions?.find((member) => member.value === values.teamMemberId);
|
||||
if (selectedMember && selectedMember.status === "unavailable") {
|
||||
setShowConfirmation(true);
|
||||
} else {
|
||||
roundRobinManualReassignMutation.mutate({ bookingId, teamMemberId: values.teamMemberId });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -109,61 +120,99 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI
|
||||
const watchedReassignType = form.watch("reassignType");
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpenDialog}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpenDialog(open);
|
||||
}}>
|
||||
<DialogContent
|
||||
title={t("reassign_round_robin_host")}
|
||||
description={t("reassign_to_another_rr_host")}
|
||||
enableOverflow>
|
||||
{/* TODO add team member reassignment*/}
|
||||
<Form form={form} handleSubmit={handleSubmit} ref={animationParentRef}>
|
||||
<RadioArea.Group
|
||||
onValueChange={(val) => {
|
||||
form.setValue("reassignType", val as "team_member" | "round_robin");
|
||||
}}
|
||||
className={classNames("mt-1 flex flex-col gap-4")}>
|
||||
<RadioArea.Item
|
||||
value="round_robin"
|
||||
className={classNames("w-full text-sm")}
|
||||
classNames={{ container: classNames("w-full") }}>
|
||||
<strong className="mb-1 block">{t("round_robin")}</strong>
|
||||
<p>{t("round_robin_reassign_description")}</p>
|
||||
</RadioArea.Item>
|
||||
<RadioArea.Item
|
||||
value="team_member"
|
||||
className={classNames("text-sm")}
|
||||
classNames={{ container: classNames("w-full") }}>
|
||||
<strong className="mb-1 block">{t("team_member_round_robin_reassign")}</strong>
|
||||
<p>{t("team_member_round_robin_reassign_description")}</p>
|
||||
</RadioArea.Item>
|
||||
</RadioArea.Group>
|
||||
<>
|
||||
<Dialog
|
||||
open={isOpenDialog}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpenDialog(open);
|
||||
}}>
|
||||
<DialogContent
|
||||
title={t("reassign_round_robin_host")}
|
||||
description={t("reassign_to_another_rr_host")}
|
||||
enableOverflow>
|
||||
{/* TODO add team member reassignment*/}
|
||||
<Form form={form} handleSubmit={handleSubmit} ref={animationParentRef}>
|
||||
<RadioArea.Group
|
||||
onValueChange={(val) => {
|
||||
form.setValue("reassignType", val as "team_member" | "round_robin");
|
||||
}}
|
||||
className={classNames("mt-1 flex flex-col gap-4")}>
|
||||
<RadioArea.Item
|
||||
value="round_robin"
|
||||
className={classNames("w-full text-sm")}
|
||||
classNames={{ container: classNames("w-full") }}>
|
||||
<strong className="mb-1 block">{t("round_robin")}</strong>
|
||||
<p>{t("round_robin_reassign_description")}</p>
|
||||
</RadioArea.Item>
|
||||
<RadioArea.Item
|
||||
value="team_member"
|
||||
className={classNames("text-sm")}
|
||||
classNames={{ container: classNames("w-full") }}>
|
||||
<strong className="mb-1 block">{t("team_member_round_robin_reassign")}</strong>
|
||||
<p>{t("team_member_round_robin_reassign_description")}</p>
|
||||
</RadioArea.Item>
|
||||
</RadioArea.Group>
|
||||
|
||||
{watchedReassignType === "team_member" && (
|
||||
<div className="mb-2">
|
||||
<Label className="text-emphasis mt-6">{t("select_team_member")}</Label>
|
||||
<Select
|
||||
value={teamMemberOptions?.find((option) => option.value === form.getValues("teamMemberId"))}
|
||||
options={teamMemberOptions}
|
||||
onChange={(event) => {
|
||||
form.setValue("teamMemberId", event?.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
<Button
|
||||
type="submit"
|
||||
data-testid="rejection-confirm"
|
||||
loading={roundRobinReassignMutation.isPending || roundRobinManualReassignMutation.isPending}>
|
||||
{t("reassign")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{watchedReassignType === "team_member" && (
|
||||
<div className="mb-2">
|
||||
<Label className="text-emphasis mt-6">{t("select_team_member")}</Label>
|
||||
<Select
|
||||
value={teamMemberOptions?.find((option) => option.value === form.getValues("teamMemberId"))}
|
||||
options={teamMemberOptions}
|
||||
onChange={(event) => {
|
||||
form.setValue("teamMemberId", event?.value);
|
||||
}}
|
||||
components={{
|
||||
Option: ({ children, ...props }) => {
|
||||
const status = props.data?.status;
|
||||
return (
|
||||
<components.Option {...props}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={classNames(
|
||||
"h-2 w-2 rounded-full",
|
||||
status === "available" ? "bg-green-500" : "bg-red-500"
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
</components.Option>
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
<Button
|
||||
type="submit"
|
||||
data-testid="rejection-confirm"
|
||||
loading={roundRobinReassignMutation.isPending || roundRobinManualReassignMutation.isPending}>
|
||||
{t("reassign")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showConfirmation} onOpenChange={setShowConfirmation}>
|
||||
<ConfirmationDialogContent
|
||||
variety="warning"
|
||||
title={t("confirm_reassign_unavailable")}
|
||||
confirmBtnText={t("yes_reassign")}
|
||||
cancelBtnText={t("cancel")}
|
||||
onConfirm={() => {
|
||||
const teamMemberId = form.getValues("teamMemberId");
|
||||
if (!teamMemberId) {
|
||||
return;
|
||||
}
|
||||
roundRobinManualReassignMutation.mutate({ bookingId, teamMemberId });
|
||||
setShowConfirmation(false);
|
||||
}}>
|
||||
{t("reassign_unavailable_team_member_description")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2694,5 +2694,9 @@
|
||||
"add_new_field": "Add new field",
|
||||
"you_dont_have_access_to_reroute_this_booking": "You don't have access to reroute this booking",
|
||||
"form_response_not_found": "Form response not found",
|
||||
"confirm_reassign_unavailable": "Manual Reassign to unavailable host",
|
||||
"reassign_unavailable_team_member_description": "Are you sure you want to reassign this booking to an unavailable host?",
|
||||
"yes_reassign": "Yes, reassign",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
|
||||
+60
-21
@@ -1,4 +1,10 @@
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { ensureAvailableUsers } from "@calcom/features/bookings/lib/handleNewBooking/ensureAvailableUsers";
|
||||
import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB";
|
||||
import type { IsFixedAwareUser } from "@calcom/features/bookings/lib/handleNewBooking/types";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import type { TGetRoundRobinHostsToReassignInputSchema } from "./getRoundRobinHostsToReasign.schema";
|
||||
@@ -11,21 +17,32 @@ type GetRoundRobinHostsToReassignOptions = {
|
||||
input: TGetRoundRobinHostsToReassignInputSchema;
|
||||
};
|
||||
|
||||
async function isTeamAdmin(prisma: PrismaClient, userId: number, teamId: number) {
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: { userId, teamId, role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] } },
|
||||
});
|
||||
return membership?.role ?? false;
|
||||
}
|
||||
|
||||
export const getRoundRobinHostsToReassign = async ({ ctx, input }: GetRoundRobinHostsToReassignOptions) => {
|
||||
const { prisma } = ctx;
|
||||
const { isOrgAdmin } = ctx.user.organization;
|
||||
const hasPermsToView = !ctx.user.organization.isPrivate || isOrgAdmin;
|
||||
|
||||
if (!hasPermsToView) {
|
||||
return [];
|
||||
}
|
||||
const gettingRoundRobinHostsToReassignLogger = logger.getSubLogger({
|
||||
prefix: ["gettingRoundRobinHostsToReassign", `${input.bookingId}`],
|
||||
});
|
||||
|
||||
const booking = await prisma.booking.findUniqueOrThrow({
|
||||
where: { id: input.bookingId },
|
||||
select: {
|
||||
userId: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
eventType: {
|
||||
select: {
|
||||
id: true,
|
||||
timeZone: true,
|
||||
teamId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -35,27 +52,49 @@ export const getRoundRobinHostsToReassign = async ({ ctx, input }: GetRoundRobin
|
||||
throw new Error("Booking not found");
|
||||
}
|
||||
|
||||
// If input.exclude is "fixedHosts", exclude fixed hosts from the list
|
||||
const excludeFixedHosts = input.exclude === "fixedHosts";
|
||||
// Get event type
|
||||
const eventType = await getEventTypesFromDB(booking.eventType.id);
|
||||
|
||||
const eventTypeHosts = await prisma.host.findMany({
|
||||
where: {
|
||||
eventTypeId: booking.eventType.id,
|
||||
isFixed: excludeFixedHosts ? false : undefined,
|
||||
if (!eventType || !eventType.teamId) {
|
||||
throw new Error("Event type not found");
|
||||
}
|
||||
|
||||
const availableEventTypeUsers = eventType.hosts
|
||||
.filter((h) => !h.isFixed && h.user.id !== booking.userId)
|
||||
.map((host) => ({
|
||||
...host.user,
|
||||
isFixed: host.isFixed,
|
||||
priority: host?.priority ?? 2,
|
||||
}));
|
||||
|
||||
const availableUsers = await ensureAvailableUsers(
|
||||
{ ...eventType, users: availableEventTypeUsers as IsFixedAwareUser[] },
|
||||
{
|
||||
dateFrom: dayjs(booking.startTime).format(),
|
||||
dateTo: dayjs(booking.endTime).format(),
|
||||
timeZone: eventType.timeZone || "UTC",
|
||||
},
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
gettingRoundRobinHostsToReassignLogger
|
||||
);
|
||||
|
||||
const hosts = eventTypeHosts.map((host) => host.user);
|
||||
const availableUserIds = new Set(availableUsers.map((u) => u.id));
|
||||
|
||||
return hosts;
|
||||
const canViewReassignBusyUsers = isOrgAdmin || (await isTeamAdmin(prisma, ctx.user.id, eventType.teamId));
|
||||
|
||||
const roundRobinHostsToReassign = availableEventTypeUsers.reduce((acc, host) => {
|
||||
const status = availableUserIds.has(host.id) ? "available" : "unavailable";
|
||||
if (canViewReassignBusyUsers || status === "available") {
|
||||
acc.push({
|
||||
id: host.id,
|
||||
name: host.name,
|
||||
email: host.email,
|
||||
status,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, [] as { id: number; name: string | null; email: string; status: string }[]);
|
||||
|
||||
return roundRobinHostsToReassign;
|
||||
};
|
||||
|
||||
export default getRoundRobinHostsToReassign;
|
||||
|
||||
Reference in New Issue
Block a user