Files
calendar/apps/web/components/dialog/WrongAssignmentDialog.tsx
Benny JooGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
ab21c7f805 refactor: Cal.diy (#28903)
* feat: Cal.diy — community-driven MIT-licensed fork of Cal.com

This squashed commit contains all Cal.diy changes applied on top of calcom/cal.com main:

- Rebrand Cal.com to Cal.diy across the entire codebase
- Remove Enterprise Edition (EE) features, license checks, and AGPL restrictions
- Switch license from AGPL-3.0 to MIT
- Remove docs/ directory (migrated to Nextra at cal.diy)
- Remove dead code: org tests, EE tips, platform nav, premium username, SAML/SSO, etc.
- Clean up .env.example for self-hosted Cal.diy
- Update Docker image references to calcom/cal.diy
- Update README, CONTRIBUTING.md, and issue templates for Cal.diy community fork
- Add PR welcome bot for Cal.diy contributors
- Fix API v2 breaking changes oasdiff ignore entries
- Replace Blacksmith CI runners with default GitHub Actions

3893 files changed, 20789 insertions(+), 411020 deletions(-)

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* refactor: remove org-specific /organizations/:orgId endpoints from API v2 atoms controllers (#1701)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: revert Cal.diy Inc to Cal.com, Inc. in license files, copyright notices, and package metadata (#1702)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* rip out org related comments in api v2

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-04-15 09:52:36 -03:00

366 lines
12 KiB
TypeScript

import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { AssignmentReasonEnum } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { Alert } from "@calcom/ui/components/alert";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog";
import { Label, Select, TextArea } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { showToast } from "@calcom/ui/components/toast";
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
import type { Dispatch, SetStateAction } from "react";
import type { Control, ControllerRenderProps } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
interface BookingData {
uid: string;
eventType?: {
team?: {
id: number;
} | null;
} | null;
user?: {
email: string;
name: string | null;
} | null;
assignmentReasonSortedByCreatedAt: Array<{
reasonString: string | null;
reasonEnum: AssignmentReasonEnum | null;
}>;
attendees: Array<{
email: string;
}>;
}
interface IWrongAssignmentDialog {
isOpenDialog: boolean;
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
booking: BookingData;
}
interface FormValues {
correctAssignee: string;
additionalNotes: string;
}
interface TeamMemberOption {
label: string;
value: string;
email: string;
}
interface RoutingInfoSectionProps {
routingReason: string | null;
routingReasonEnum: AssignmentReasonEnum | null;
noRoutingReasonText: string;
routingReasonLabel: string;
guestEmail: string;
whoBookedItLabel: string;
hostEmail: string;
hostName: string | null;
whoReceivedItLabel: string;
copyToClipboard: (text: string) => void;
isCopied: boolean;
}
function RoutingInfoSection(props: RoutingInfoSectionProps): JSX.Element {
const { t } = useLocale();
const {
routingReason,
routingReasonEnum,
noRoutingReasonText,
routingReasonLabel,
guestEmail,
whoBookedItLabel,
hostEmail,
hostName,
whoReceivedItLabel,
copyToClipboard,
isCopied,
} = props;
let copyIconName: "check" | "copy" = "copy";
if (isCopied) {
copyIconName = "check";
}
let hostDisplay = hostEmail;
if (hostName) {
hostDisplay = `${hostName} (${hostEmail})`;
}
const handleCopyClick = (): void => {
copyToClipboard(guestEmail);
};
return (
<div className="-mt-2 mb-4 space-y-3">
<div>
<Label className="text-emphasis mb-1 block text-sm font-medium">{routingReasonLabel}</Label>
<div className="text-default bg-muted flex items-center gap-2 rounded-md px-3 py-2 text-sm">
{routingReasonEnum && (
<Badge variant="gray" className="shrink-0">
{t(assignmentReasonBadgeTitleMap(routingReasonEnum))}
</Badge>
)}
<span className="flex-1">{routingReason || noRoutingReasonText}</span>
</div>
</div>
<div>
<Label className="text-emphasis mb-1 block text-sm font-medium">{whoBookedItLabel}</Label>
<div className="text-default bg-muted flex items-center justify-between rounded-md px-3 py-2 text-sm">
<span>{guestEmail}</span>
<button type="button" className="text-subtle hover:text-emphasis ml-2" onClick={handleCopyClick}>
<Icon name={copyIconName} className="h-4 w-4" />
</button>
</div>
</div>
<div>
<Label className="text-emphasis mb-1 block text-sm font-medium">{whoReceivedItLabel}</Label>
<p className="text-default bg-muted rounded-md px-3 py-2 text-sm">{hostDisplay}</p>
</div>
</div>
);
}
interface AssigneeSectionProps {
teamId: number | null;
teamMemberOptions: TeamMemberOption[];
control: Control<FormValues>;
whoShouldHaveReceivedItLabel: string;
optionalLabel: string;
selectTeamMemberPlaceholder: string;
enterEmailPlaceholder: string;
}
interface AdditionalNotesSectionProps {
control: Control<FormValues>;
additionalNotesLabel: string;
placeholder: string;
fieldRequiredText: string;
errorMessage: string | undefined;
}
function AdditionalNotesSection(props: AdditionalNotesSectionProps): JSX.Element {
const { control, additionalNotesLabel, placeholder, fieldRequiredText, errorMessage } = props;
const renderTextArea = ({
field,
}: {
field: ControllerRenderProps<FormValues, "additionalNotes">;
}): JSX.Element => <TextArea {...field} placeholder={placeholder} rows={3} />;
return (
<div className="mb-4">
<Label htmlFor="additionalNotes" className="text-emphasis mb-2 block text-sm font-medium">
{additionalNotesLabel} <span className="text-error">*</span>
</Label>
<Controller
name="additionalNotes"
control={control}
rules={{ required: fieldRequiredText }}
render={renderTextArea}
/>
{errorMessage && <p className="text-error mt-1 text-sm">{errorMessage}</p>}
</div>
);
}
function AssigneeSection(props: AssigneeSectionProps): JSX.Element {
const {
teamId,
teamMemberOptions,
control,
whoShouldHaveReceivedItLabel,
optionalLabel,
selectTeamMemberPlaceholder,
enterEmailPlaceholder,
} = props;
const showTeamSelector = teamId && teamMemberOptions.length > 0;
const renderTeamSelect = ({
field,
}: {
field: ControllerRenderProps<FormValues, "correctAssignee">;
}): JSX.Element => {
const handleChange = (option: TeamMemberOption | null): void => {
field.onChange(option ? option.value : "");
};
return (
<Select
options={teamMemberOptions}
placeholder={selectTeamMemberPlaceholder}
onChange={handleChange}
value={teamMemberOptions.find((opt) => opt.value === field.value) || null}
isClearable
/>
);
};
const renderEmailInput = ({
field,
}: {
field: ControllerRenderProps<FormValues, "correctAssignee">;
}): JSX.Element => (
<input
{...field}
type="email"
placeholder={enterEmailPlaceholder}
className="border-default bg-default text-emphasis placeholder:text-muted focus:border-emphasis focus:ring-emphasis block w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-1"
/>
);
let assigneeField: JSX.Element;
if (showTeamSelector) {
assigneeField = <Controller name="correctAssignee" control={control} render={renderTeamSelect} />;
} else {
assigneeField = <Controller name="correctAssignee" control={control} render={renderEmailInput} />;
}
return (
<div className="mb-4">
<Label htmlFor="correctAssignee" className="text-emphasis mb-2 block text-sm font-medium">
{whoShouldHaveReceivedItLabel} <span className="text-subtle font-normal">({optionalLabel})</span>
</Label>
{assigneeField}
</div>
);
}
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: Dialog component with hooks and state management - already split into 3 sub-components
export function WrongAssignmentDialog(props: IWrongAssignmentDialog): JSX.Element {
const { t } = useLocale();
const utils = trpc.useUtils();
const { copyToClipboard, isCopied } = useCopy();
const { isOpenDialog, setIsOpenDialog, booking } = props;
const bookingUid = booking.uid;
const teamId = booking.eventType?.team?.id ?? null;
const routingReason = booking.assignmentReasonSortedByCreatedAt[0]?.reasonString ?? null;
const routingReasonEnum = booking.assignmentReasonSortedByCreatedAt[0]?.reasonEnum ?? null;
const guestEmail = booking.attendees[0]?.email ?? "";
const hostEmail = booking.user?.email ?? "";
const hostName = booking.user?.name ?? null;
const {
control,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
defaultValues: {
correctAssignee: "",
additionalNotes: "",
},
});
const { data: existingReport, isPending: isCheckingReport } =
trpc.viewer.bookings.hasWrongAssignmentReport.useQuery({ bookingUid }, { enabled: isOpenDialog });
const alreadyReported = existingReport?.hasReport ?? false;
const teamIdForQuery = teamId ?? 0;
const teamMembersData = undefined as { members: Array<{ name: string | null; email: string }> } | undefined;
const teamMemberOptions: TeamMemberOption[] =
teamMembersData?.members.map((member: { name: string | null; email: string }) => ({
label: member.name || member.email,
value: member.email,
email: member.email,
})) ?? [];
const { mutate: reportWrongAssignment, isPending } = trpc.viewer.bookings.reportWrongAssignment.useMutation(
{
async onSuccess(): Promise<void> {
showToast(t("wrong_assignment_reported"), "success");
setIsOpenDialog(false);
await utils.viewer.bookings.invalidate();
},
onError(error: { message?: string }): void {
showToast(error.message || t("unexpected_error_try_again"), "error");
},
}
);
const onSubmit = (data: FormValues): void => {
reportWrongAssignment({
bookingUid,
correctAssignee: data.correctAssignee || undefined,
additionalNotes: data.additionalNotes,
});
};
const handleCloseClick = (): void => {
setIsOpenDialog(false);
};
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent enableOverflow>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-row space-x-3">
<div className="w-full">
<DialogHeader title={t("wrong_assignment")} />
<RoutingInfoSection
routingReason={routingReason}
routingReasonEnum={routingReasonEnum}
noRoutingReasonText={t("no_routing_reason")}
routingReasonLabel={t("first_assignment_reason")}
guestEmail={guestEmail}
whoBookedItLabel={t("who_booked_it")}
hostEmail={hostEmail}
hostName={hostName}
whoReceivedItLabel={t("who_received_it")}
copyToClipboard={copyToClipboard}
isCopied={isCopied}
/>
<AssigneeSection
teamId={teamId}
teamMemberOptions={teamMemberOptions}
control={control}
whoShouldHaveReceivedItLabel={t("who_should_have_received_it")}
optionalLabel={t("optional")}
selectTeamMemberPlaceholder={t("select_team_member")}
enterEmailPlaceholder={t("enter_email")}
/>
<AdditionalNotesSection
control={control}
additionalNotesLabel={t("additional_notes")}
placeholder={t("wrong_assignment_notes_placeholder")}
fieldRequiredText={t("field_required")}
errorMessage={errors.additionalNotes?.message}
/>
{alreadyReported && (
<Alert severity="warning" message={t("wrong_assignment_already_reported")} className="mb-4" />
)}
<Alert severity="info" title={t("did_you_know")} message={t("wrong_assignment_crm_info")} />
</div>
</div>
<DialogFooter showDivider className="mt-8">
<Button type="button" color="secondary" onClick={handleCloseClick} disabled={isPending}>
{t("close")}
</Button>
<Button
type="submit"
color="primary"
disabled={isPending || alreadyReported || isCheckingReport}
loading={isPending || isCheckingReport}>
{t("submit")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}