V2 settings teams (Profil, Members, Appearance View) (#4350)
* add team profile * first version for team members page * finish up design of member list item * fix dialog buttons * add missing seats and upgrading information * add v2 dialog for changing role * finish basic version of member's schedule * remove modalContainer * design fixes team profile page * show only team info to non admins * allow all member to check availabilities * make time available heading sticky * add dropdown for mobile view * create team appearance view * finish appearance page * use settings layout and add danger zone for member * add fallback logo * Add teams to sidebar and fix UI * add team invitations * Clean up * code clean up * add impersontation and disable autofocus on calendar * improve team info * refactor teaminvitelist code and fix leaving a team * add team pages to settings shell * add link to create new team * small fixes * clean up comments * V2 Multi-select (Team Select) (#4324) * --init * design improved * further fine tuning * more fixes * removed extra JSX tag * added story * NIT * revert to use of CheckedTeamSelect * Removes comments Co-authored-by: Peer Richelsen <peeroke@gmail.com> * fix: toggle alligment (#4361) * fix: add checked tranform for switch (#4357) * fixed input size on mobile, fixed settings (#4360) * fix image uploader button in safari * code clean up * fixing type errors * Moved v2 team components to features Adds deprecation notices * Update SettingsLayout.tsx * Migrated to features and build fixes Co-authored-by: CarinaWolli <wollencarina@gmail.com> Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com> Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: zomars <zomars@me.com>pull/4369/head
parent
50f63ef000
commit
7e917cdcbb
|
@ -63,6 +63,7 @@ function CropContainer({
|
|||
);
|
||||
}
|
||||
|
||||
/** @deprecated Use `packages/ui/v2/core/ImageUploader.tsx` */
|
||||
export default function ImageUploader({
|
||||
target,
|
||||
id,
|
||||
|
|
|
@ -7,9 +7,9 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||
import { User } from "@calcom/prisma/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, showToast, TextArea } from "@calcom/ui/v2";
|
||||
import ImageUploader from "@calcom/ui/v2/core/ImageUploader";
|
||||
|
||||
import { AvatarSSR } from "@components/ui/AvatarSSR";
|
||||
import ImageUploader from "@components/v2/settings/ImageUploader";
|
||||
|
||||
interface IUserProfile {
|
||||
user?: User;
|
||||
|
|
|
@ -4,6 +4,7 @@ import { trpc } from "@calcom/trpc/react";
|
|||
import Badge from "@calcom/ui/Badge";
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
||||
/** @deprecated Use `packages/features/ee/teams/components/DisableTeamImpersonation.tsx` */
|
||||
const DisableTeamImpersonation = ({ teamId, memberId }: { teamId: number; memberId: number }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ type MembershipRoleOption = {
|
|||
value: MembershipRole;
|
||||
};
|
||||
|
||||
/** @deprecated Use `packages/features/ee/teams/components/MemberChangeRoleModal.tsx` */
|
||||
export default function MemberChangeRoleModal(props: {
|
||||
isOpen: boolean;
|
||||
currentMember: MembershipRole;
|
||||
|
|
|
@ -26,6 +26,7 @@ type MembershipRoleOption = {
|
|||
|
||||
const _options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }, { value: "OWNER" }];
|
||||
|
||||
/** @deprecated Use `packages/features/ee/teams/components/MemberInvitationModal.tsx` */
|
||||
export default function MemberInvitationModal(props: MemberInvitationModalProps) {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const { t, i18n } = useLocale();
|
||||
|
|
|
@ -33,6 +33,7 @@ interface Props {
|
|||
member: inferQueryOutput<"viewer.teams.get">["members"][number];
|
||||
}
|
||||
|
||||
/** @deprecated Use `packages/features/ee/teams/components/MemberListItem.tsx` */
|
||||
export default function MemberListItem(props: Props) {
|
||||
const { t } = useLocale();
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ interface Props {
|
|||
color?: PillColor;
|
||||
}
|
||||
|
||||
/** @deprecated Use `packages/features/ee/teams/components/TeamPill.tsx` */
|
||||
export default function TeamPill(props: Props) {
|
||||
return (
|
||||
<div
|
||||
|
|
|
@ -19,6 +19,7 @@ interface Props {
|
|||
teamId: number;
|
||||
}
|
||||
|
||||
/** @deprecated Use `packages/features/ee/teams/components/UpgradeToFlexibleProModal.tsx` */
|
||||
export function UpgradeToFlexibleProModal(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
|
|
@ -11,6 +11,7 @@ const V2_WHITELIST = [
|
|||
"/settings/developer/api-keys",
|
||||
"/settings/my-account",
|
||||
"/settings/security",
|
||||
"/settings/teams",
|
||||
"/availability",
|
||||
"/bookings",
|
||||
"/event-types",
|
||||
|
|
|
@ -15,6 +15,7 @@ import { Alert } from "@calcom/ui/Alert";
|
|||
import Avatar from "@calcom/ui/v2/core/Avatar";
|
||||
import { Button } from "@calcom/ui/v2/core/Button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@calcom/ui/v2/core/Dialog";
|
||||
import ImageUploader from "@calcom/ui/v2/core/ImageUploader";
|
||||
import Meta from "@calcom/ui/v2/core/Meta";
|
||||
import { Form, Label, TextField, PasswordField } from "@calcom/ui/v2/core/form/fields";
|
||||
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
|
||||
|
@ -23,7 +24,6 @@ import showToast from "@calcom/ui/v2/core/notifications";
|
|||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
import ImageUploader from "@components/v2/settings/ImageUploader";
|
||||
|
||||
interface DeleteAccountValues {
|
||||
totpCode: string;
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export { default } from "@calcom/features/ee/teams/pages/team-appearance-view";
|
|
@ -0,0 +1 @@
|
|||
export { default } from "@calcom/features/ee/teams/pages/team-members-view";
|
|
@ -0,0 +1 @@
|
|||
export { default } from "@calcom/features/ee/teams/pages/team-profile-view";
|
|
@ -481,12 +481,12 @@
|
|||
"leave": "Leave",
|
||||
"profile": "Profile",
|
||||
"my_team_url": "My team URL",
|
||||
"team_name": "Team name",
|
||||
"team_name": "Team Name",
|
||||
"your_team_name": "Your team name",
|
||||
"team_updated_successfully": "Team updated successfully",
|
||||
"your_team_updated_successfully": "Your team has been updated successfully.",
|
||||
"about": "About",
|
||||
"team_description": "A few sentences about your team. This will appear on your team's URL page.",
|
||||
"team_description": "A few sentences about your team. This will appear on your team's url page.",
|
||||
"members": "Members",
|
||||
"member": "Member",
|
||||
"owner": "Owner",
|
||||
|
@ -498,9 +498,9 @@
|
|||
"invite_new_member": "Invite a new member",
|
||||
"invite_new_team_member": "Invite someone to your team.",
|
||||
"change_member_role": "Change team member role",
|
||||
"disable_cal_branding": "Disable Cal.com branding",
|
||||
"disable_cal_branding": "Disable Cal branding",
|
||||
"disable_cal_branding_description": "Hide all Cal.com branding from your public pages.",
|
||||
"danger_zone": "Danger Zone",
|
||||
"danger_zone": "Danger zone",
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"cancel_all_remaining": "Cancel all remaining",
|
||||
|
@ -877,7 +877,7 @@
|
|||
"impersonate": "Impersonate",
|
||||
"user_impersonation_heading": "User Impersonation",
|
||||
"user_impersonation_description": "Allows our support team to temporarily sign in as you to help us quickly resolve any issues you report to us.",
|
||||
"team_impersonation_description": "Allows your team admins to temporarily sign in as you.",
|
||||
"team_impersonation_description": "Allows your team members to temporarily sign in as you.",
|
||||
"impersonate_user_tip": "All uses of this feature is audited.",
|
||||
"impersonating_user_warning": "Impersonating username \"{{user}}\".",
|
||||
"impersonating_stop_instructions": "<0>Click Here to stop</0>.",
|
||||
|
@ -1171,6 +1171,11 @@
|
|||
"for_a_maximum_of": "For a maximum of",
|
||||
"event_one": "event",
|
||||
"event_other": "events",
|
||||
"profile_team_description": "Manage settings for your team profile",
|
||||
"members_team_description": "Users that are in the group. Apes together strong!",
|
||||
"team_url": "Team URL",
|
||||
"delete_team": "Delete Team",
|
||||
"team_members": "Team members",
|
||||
"more": "More",
|
||||
"more_page_footer": "We view the mobile application as an extension of the web application. If you are performing any complication actions, please refer back to the web application.",
|
||||
"workflow_example_1": "Send email reminder 24 hours before event starts to host",
|
||||
|
@ -1184,6 +1189,13 @@
|
|||
"connect_calendar_later": "I'll connect my calendar later",
|
||||
"set_my_availability_later": "I'll set my availability later",
|
||||
"problem_saving_user_profile": "There was a problem saving your data. Please try again or reach out to customer support.",
|
||||
"purchase_missing_seats": "Purchase missing seats",
|
||||
"slot_length": "Slot length",
|
||||
"booking_appearance": "Booking Appearance",
|
||||
"appearance_team_description": "Manage settings for your team's booking appearance",
|
||||
"only_owner_change": "Only the owner of this team can make changes to the team's booking ",
|
||||
"team_disable_cal_branding_description": "Removes any Cal related brandings, i.e. 'Powered by Cal'",
|
||||
"invited_by_team": "{{teamName}} has invited you to join their team as a {{role}}",
|
||||
"token_invalid_expired": "Token is either invalid or expired.",
|
||||
"routing_forms_description": "You can see all forms and routes you have created here.",
|
||||
"add_new_form": "Add new form",
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast, Switch } from "@calcom/ui/v2/core";
|
||||
|
||||
const DisableTeamImpersonation = ({
|
||||
teamId,
|
||||
memberId,
|
||||
disabled,
|
||||
}: {
|
||||
teamId: number;
|
||||
memberId: number;
|
||||
disabled: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const query = trpc.useQuery(["viewer.teams.getMembershipbyUser", { teamId, memberId }]);
|
||||
|
||||
const mutation = trpc.useMutation("viewer.teams.updateMembership", {
|
||||
onSuccess: async () => {
|
||||
showToast(t("your_user_profile_updated_successfully"), "success");
|
||||
await utils.invalidateQueries(["viewer.teams.getMembershipbyUser"]);
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.public.i18n"]);
|
||||
},
|
||||
});
|
||||
if (query.isLoading) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col justify-between sm:flex-row">
|
||||
<div>
|
||||
<div className="flex flex-row items-center">
|
||||
<h2
|
||||
className={classNames(
|
||||
"font-cal mb-0.5 text-sm font-semibold leading-6",
|
||||
disabled ? "text-gray-400 " : "text-gray-900 "
|
||||
)}>
|
||||
{t("user_impersonation_heading")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className={classNames("text-sm leading-5 ", disabled ? "text-gray-300" : "text-gray-600")}>
|
||||
{t("team_impersonation_description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-0 sm:self-center">
|
||||
<Switch
|
||||
disabled={disabled}
|
||||
defaultChecked={query.data?.disableImpersonation}
|
||||
onCheckedChange={(isChecked) => {
|
||||
mutation.mutate({ teamId, memberId, disableImpersonation: isChecked });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisableTeamImpersonation;
|
|
@ -0,0 +1,113 @@
|
|||
import { MembershipRole } from "@prisma/client";
|
||||
import { SyntheticEvent, useMemo, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, Dialog, DialogContent, Select } from "@calcom/ui/v2";
|
||||
|
||||
type MembershipRoleOption = {
|
||||
label: string;
|
||||
value: MembershipRole;
|
||||
};
|
||||
|
||||
export default function MemberChangeRoleModal(props: {
|
||||
isOpen: boolean;
|
||||
currentMember: MembershipRole;
|
||||
memberId: number;
|
||||
teamId: number;
|
||||
initialRole: MembershipRole;
|
||||
onExit: () => void;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const options = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: t("member"),
|
||||
value: MembershipRole.MEMBER,
|
||||
},
|
||||
{
|
||||
label: t("admin"),
|
||||
value: MembershipRole.ADMIN,
|
||||
},
|
||||
{
|
||||
label: t("owner"),
|
||||
value: MembershipRole.OWNER,
|
||||
},
|
||||
].filter(({ value }) => value !== MembershipRole.OWNER || props.currentMember === MembershipRole.OWNER);
|
||||
}, [t, props.currentMember]);
|
||||
|
||||
const [role, setRole] = useState<MembershipRoleOption>(
|
||||
options.find((option) => option.value === props.initialRole) || {
|
||||
label: t("member"),
|
||||
value: MembershipRole.MEMBER,
|
||||
}
|
||||
);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const changeRoleMutation = trpc.useMutation("viewer.teams.changeMemberRole", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
props.onExit();
|
||||
},
|
||||
async onError(err) {
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
function changeRole(e: SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
changeRoleMutation.mutate({
|
||||
teamId: props.teamId,
|
||||
memberId: props.memberId,
|
||||
role: role.value,
|
||||
});
|
||||
}
|
||||
return (
|
||||
<Dialog open={props.isOpen} onOpenChange={props.onExit}>
|
||||
<DialogContent type="creation" useOwnActionButtons size="md">
|
||||
<>
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="text-center sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
{t("change_member_role")}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={changeRole}>
|
||||
<div className="mb-4">
|
||||
<label className="mb-2 block text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
|
||||
{t("role")}
|
||||
</label>
|
||||
{/*<option value="OWNER">{t("owner")}</option> - needs dialog to confirm change of ownership */}
|
||||
<Select
|
||||
isSearchable={false}
|
||||
options={options}
|
||||
value={role}
|
||||
onChange={(option) => option && setRole(option)}
|
||||
id="role"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<p className="text-sm text-red-700">
|
||||
<span className="font-bold">Error: </span>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<Button type="submit" color="primary" className="ltr:ml-2 rtl:mr-2">
|
||||
{t("save")}
|
||||
</Button>
|
||||
<Button type="button" color="secondary" onClick={props.onExit}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,153 @@
|
|||
import { MembershipRole } from "@prisma/client";
|
||||
import React, { useState, SyntheticEvent, useMemo } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { TeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, Select, TextField } from "@calcom/ui/v2";
|
||||
|
||||
type MemberInvitationModalProps = {
|
||||
isOpen: boolean;
|
||||
team: TeamWithMembers | null;
|
||||
currentMember: MembershipRole;
|
||||
onExit: () => void;
|
||||
};
|
||||
|
||||
type MembershipRoleOption = {
|
||||
value: MembershipRole;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const _options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }, { value: "OWNER" }];
|
||||
|
||||
export default function MemberInvitationModal(props: MemberInvitationModalProps) {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const { t, i18n } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const options = useMemo(() => {
|
||||
_options.forEach((option, i) => {
|
||||
_options[i].label = t(option.value.toLowerCase());
|
||||
});
|
||||
return _options;
|
||||
}, [t]);
|
||||
|
||||
const inviteMemberMutation = trpc.useMutation("viewer.teams.inviteMember", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
props.onExit();
|
||||
},
|
||||
async onError(err) {
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
function inviteMember(e: SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
if (!props.team) return;
|
||||
|
||||
const target = e.target as typeof e.target & {
|
||||
elements: {
|
||||
role: { value: MembershipRole };
|
||||
inviteUser: { value: string };
|
||||
sendInviteEmail: { checked: boolean };
|
||||
};
|
||||
};
|
||||
|
||||
inviteMemberMutation.mutate({
|
||||
teamId: props.team.id,
|
||||
language: i18n.language,
|
||||
role: target.elements["role"].value,
|
||||
usernameOrEmail: target.elements["inviteUser"].value,
|
||||
sendEmailInvitation: target.elements["sendInviteEmail"].checked,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={props.isOpen} onOpenChange={props.onExit}>
|
||||
<DialogContent type="creation" useOwnActionButtons>
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<Icon.FiUser className="text-brandcontrast h-6 w-6" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
{t("invite_new_member")}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{t("invite_new_team_member")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={inviteMember}>
|
||||
<div className="space-y-4">
|
||||
<TextField
|
||||
label={t("email_or_username")}
|
||||
id="inviteUser"
|
||||
name="inviteUser"
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
|
||||
{t("role")}
|
||||
</label>
|
||||
<Select
|
||||
defaultValue={options[0]}
|
||||
options={props.currentMember !== MembershipRole.OWNER ? options.slice(0, 2) : options}
|
||||
id="role"
|
||||
name="role"
|
||||
className="mt-1 block w-full rounded-sm border-gray-300 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="sendInviteEmail"
|
||||
defaultChecked
|
||||
id="sendInviteEmail"
|
||||
className="rounded-sm border-gray-300 text-sm text-black"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm ltr:ml-2 rtl:mr-2">
|
||||
<label htmlFor="sendInviteEmail" className="font-medium text-gray-700">
|
||||
{t("send_invite_email")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row rounded-md bg-gray-50 px-3 py-2">
|
||||
<Icon.FiInfo className="h-5 w-5 flex-shrink-0 fill-gray-400" aria-hidden="true" />
|
||||
<span className="ml-2 text-sm leading-tight text-gray-500">
|
||||
Note: This will cost an extra seat ($12/m) on your subscription if this invitee does not have
|
||||
a pro account.{" "}
|
||||
{/* <a href="#" className="underline">
|
||||
Learn More
|
||||
</a> */}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<p className="text-sm text-red-700">
|
||||
<span className="font-bold">Error: </span>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" color="secondary" onClick={props.onExit}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
className="ltr:ml-2 rtl:mr-2"
|
||||
data-testid="invite-new-member-button">
|
||||
{t("invite")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,275 @@
|
|||
import { MembershipRole } from "@prisma/client";
|
||||
import classNames from "classnames";
|
||||
import { useState } from "react";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTrigger,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
showToast,
|
||||
Tooltip,
|
||||
} from "@calcom/ui/v2/core";
|
||||
import Avatar from "@calcom/ui/v2/core/Avatar";
|
||||
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
|
||||
|
||||
import MemberChangeRoleModal from "./MemberChangeRoleModal";
|
||||
import TeamPill, { TeamRole } from "./TeamPill";
|
||||
import TeamAvailabilityModal from "./v2/TeamAvailabilityModal";
|
||||
|
||||
interface Props {
|
||||
team: inferQueryOutput<"viewer.teams.get">;
|
||||
member: inferQueryOutput<"viewer.teams.get">["members"][number];
|
||||
}
|
||||
|
||||
/** TODO: Migrate the one in apps/web to tRPC package */
|
||||
const useCurrentUserId = () => {
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
return user?.id;
|
||||
};
|
||||
|
||||
export default function MemberListItem(props: Props) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const utils = trpc.useContext();
|
||||
const [showChangeMemberRoleModal, setShowChangeMemberRoleModal] = useState(false);
|
||||
const [showTeamAvailabilityModal, setShowTeamAvailabilityModal] = useState(false);
|
||||
const [showImpersonateModal, setShowImpersonateModal] = useState(false);
|
||||
|
||||
const removeMemberMutation = trpc.useMutation("viewer.teams.removeMember", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
showToast(t("success"), "success");
|
||||
},
|
||||
async onError(err) {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const ownersInTeam = () => {
|
||||
const { members } = props.team;
|
||||
const owners = members.filter((member) => member["role"] === MembershipRole.OWNER && member["accepted"]);
|
||||
return owners.length;
|
||||
};
|
||||
|
||||
const currentUserId = useCurrentUserId();
|
||||
|
||||
const name =
|
||||
props.member.name ||
|
||||
(() => {
|
||||
const emailName = props.member.email.split("@")[0];
|
||||
return emailName.charAt(0).toUpperCase() + emailName.slice(1);
|
||||
})();
|
||||
|
||||
const removeMember = () =>
|
||||
removeMemberMutation.mutate({ teamId: props.team?.id, memberId: props.member.id });
|
||||
|
||||
const editMode =
|
||||
(props.team.membership.role === MembershipRole.OWNER &&
|
||||
(props.member.role !== MembershipRole.OWNER ||
|
||||
ownersInTeam() > 1 ||
|
||||
props.member.id !== currentUserId)) ||
|
||||
(props.team.membership.role === MembershipRole.ADMIN && props.member.role !== MembershipRole.OWNER);
|
||||
|
||||
return (
|
||||
<li className="divide-y px-5">
|
||||
<div className="my-4 flex justify-between">
|
||||
<div className="flex w-full flex-col justify-between sm:flex-row">
|
||||
<div className="flex">
|
||||
<Avatar
|
||||
size="sm"
|
||||
imageSrc={WEBAPP_URL + "/" + props.member.username + "/avatar.png"}
|
||||
alt={name || ""}
|
||||
className="h-10 w-10 rounded-full"
|
||||
/>
|
||||
|
||||
<div className="ml-3 inline-block">
|
||||
<div className="mb-1 flex">
|
||||
<span className="mr-1 text-sm font-bold leading-4">{name}</span>
|
||||
|
||||
{props.member.isMissingSeat && <TeamPill color="red" text={t("hidden")} />}
|
||||
{!props.member.accepted && <TeamPill color="orange" text={t("pending")} />}
|
||||
{props.member.role && <TeamRole role={props.member.role} />}
|
||||
</div>
|
||||
<span
|
||||
className="block text-sm text-gray-600"
|
||||
data-testid="member-email"
|
||||
data-email={props.member.email}>
|
||||
{props.member.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{props.team.membership.accepted && (
|
||||
<div className="flex items-center justify-center">
|
||||
<ButtonGroup combined containerProps={{ className: "border-gray-300 hidden md:flex" }}>
|
||||
<Tooltip
|
||||
content={
|
||||
props.member.accepted
|
||||
? t("team_view_user_availability")
|
||||
: t("team_view_user_availability_disabled")
|
||||
}>
|
||||
<Button
|
||||
disabled={!props.member.accepted}
|
||||
onClick={() => (props.member.accepted ? setShowTeamAvailabilityModal(true) : null)}
|
||||
color="secondary"
|
||||
size="icon"
|
||||
StartIcon={Icon.FiClock}
|
||||
combined
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("view_public_page")}>
|
||||
<Button
|
||||
target="_blank"
|
||||
href={"/" + props.member.username}
|
||||
color="secondary"
|
||||
className={classNames(!editMode ? "rounded-r-md" : "")}
|
||||
size="icon"
|
||||
StartIcon={Icon.FiExternalLink}
|
||||
combined
|
||||
/>
|
||||
</Tooltip>
|
||||
{editMode && (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger className="h-[36px] w-[36px] bg-transparent px-0 py-0 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0">
|
||||
<Button
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className="rounded-r-md"
|
||||
StartIcon={Icon.FiMoreHorizontal}
|
||||
combined
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() => setShowChangeMemberRoleModal(true)}
|
||||
StartIcon={Icon.FiEdit2}>
|
||||
{t("edit") as string}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild className="p-0">
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
color="destructive"
|
||||
StartIcon={Icon.FiTrash}
|
||||
className="px-3 py-2 font-normal">
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("remove_member")}
|
||||
confirmBtnText={t("confirm_remove_member")}
|
||||
onConfirm={removeMember}>
|
||||
{t("remove_member_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
<div className="flex md:hidden">
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" size="icon" color="minimal" StartIcon={Icon.FiMoreHorizontal} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{props.member.accepted && (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem type="button" StartIcon={Icon.FiClock}>
|
||||
{t("team_view_user_availability")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem type="button" StartIcon={Icon.FiExternalLink}>
|
||||
{t("view_public_page")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
{editMode && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() => setShowChangeMemberRoleModal(true)}
|
||||
StartIcon={Icon.FiEdit2}>
|
||||
{t("edit") as string}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
|
||||
<DropdownMenuItem>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild className="p-0">
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
color="destructive"
|
||||
StartIcon={Icon.FiTrash}
|
||||
className="px-3 py-2 font-normal">
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("remove_member")}
|
||||
confirmBtnText={t("confirm_remove_member")}
|
||||
onConfirm={removeMember}>
|
||||
{t("remove_member_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showChangeMemberRoleModal && (
|
||||
<MemberChangeRoleModal
|
||||
isOpen={showChangeMemberRoleModal}
|
||||
currentMember={props.team.membership.role}
|
||||
teamId={props.team?.id}
|
||||
memberId={props.member.id}
|
||||
initialRole={props.member.role as MembershipRole}
|
||||
onExit={() => setShowChangeMemberRoleModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showTeamAvailabilityModal && (
|
||||
<Dialog open={showTeamAvailabilityModal} onOpenChange={() => setShowTeamAvailabilityModal(false)}>
|
||||
<DialogContent type="creation" useOwnActionButtons size="md">
|
||||
<TeamAvailabilityModal team={props.team} member={props.member} />
|
||||
<div className="flex justify-end border-t pt-5">
|
||||
<Button onClick={() => setShowTeamAvailabilityModal(false)}>{t("done")}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
import { useState } from "react";
|
||||
|
||||
import { MembershipRole } from "@calcom/prisma/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/v2";
|
||||
|
||||
import TeamInviteListItem from "./TeamInviteListItem";
|
||||
|
||||
interface Props {
|
||||
teams: {
|
||||
id?: number;
|
||||
name?: string | null;
|
||||
slug?: string | null;
|
||||
logo?: string | null;
|
||||
bio?: string | null;
|
||||
hideBranding?: boolean | undefined;
|
||||
role: MembershipRole;
|
||||
accepted: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export default function TeamInviteList(props: Props) {
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const [hideDropdown, setHideDropdown] = useState(false);
|
||||
|
||||
function selectAction(action: string, teamId: number) {
|
||||
switch (action) {
|
||||
case "disband":
|
||||
deleteTeam(teamId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.list"]);
|
||||
},
|
||||
async onError(err) {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
function deleteTeam(teamId: number) {
|
||||
deleteTeamMutation.mutate({ teamId });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ul className="mb-8 divide-y divide-neutral-200 rounded bg-white">
|
||||
{props.teams.map((team) => (
|
||||
<TeamInviteListItem
|
||||
key={team?.id as number}
|
||||
team={team}
|
||||
onActionSelect={(action: string) => selectAction(action, team?.id as number)}
|
||||
isLoading={deleteTeamMutation.isLoading}
|
||||
hideDropdown={hideDropdown}
|
||||
setHideDropdown={setHideDropdown}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,141 @@
|
|||
import { MembershipRole } from "@prisma/client";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Dropdown,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@calcom/ui/v2";
|
||||
|
||||
interface Props {
|
||||
team: {
|
||||
id?: number;
|
||||
name?: string | null;
|
||||
slug?: string | null;
|
||||
logo?: string | null;
|
||||
bio?: string | null;
|
||||
hideBranding?: boolean | undefined;
|
||||
role: MembershipRole;
|
||||
accepted: boolean;
|
||||
};
|
||||
key: number;
|
||||
onActionSelect: (text: string) => void;
|
||||
isLoading?: boolean;
|
||||
hideDropdown: boolean;
|
||||
setHideDropdown: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export default function TeamInviteListItem(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const team = props.team;
|
||||
|
||||
const acceptOrLeaveMutation = trpc.useMutation("viewer.teams.acceptOrLeave", {
|
||||
onSuccess: async () => {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
await utils.invalidateQueries(["viewer.teams.list"]);
|
||||
},
|
||||
});
|
||||
|
||||
function acceptOrLeave(accept: boolean) {
|
||||
acceptOrLeaveMutation.mutate({
|
||||
teamId: team?.id as number,
|
||||
accept,
|
||||
});
|
||||
}
|
||||
|
||||
const acceptInvite = () => acceptOrLeave(true);
|
||||
const declineInvite = () => acceptOrLeave(false);
|
||||
|
||||
const isOwner = props.team.role === MembershipRole.OWNER;
|
||||
const isInvitee = !props.team.accepted;
|
||||
const isAdmin = props.team.role === MembershipRole.OWNER || props.team.role === MembershipRole.ADMIN;
|
||||
const { hideDropdown, setHideDropdown } = props;
|
||||
|
||||
if (!team) return <></>;
|
||||
|
||||
const teamInfo = (
|
||||
<div className="flex">
|
||||
<Avatar
|
||||
size="mdLg"
|
||||
imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)}
|
||||
alt="Team Logo"
|
||||
className=""
|
||||
/>
|
||||
<div className="ml-3 inline-block">
|
||||
<span className="text-sm font-semibold text-black">{team.name}</span>
|
||||
<span className="block text-sm leading-5 text-gray-700">
|
||||
{t("invited_by_team", { teamName: team.name, role: t(team.role.toLocaleLowerCase()) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<li className="divide-y rounded-md border border-gray-400 bg-gray-100 px-5 py-4">
|
||||
<div
|
||||
className={classNames(
|
||||
"flex items-center justify-between",
|
||||
!isInvitee && "group hover:bg-neutral-50"
|
||||
)}>
|
||||
{teamInfo}
|
||||
<div>
|
||||
<>
|
||||
<div className="hidden sm:flex">
|
||||
<Button
|
||||
type="button"
|
||||
className="mr-3 border-gray-700"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={declineInvite}
|
||||
StartIcon={Icon.FiSlash}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
className="border-gray-700"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={acceptInvite}
|
||||
StartIcon={Icon.FiCheck}
|
||||
/>
|
||||
</div>
|
||||
<div className="block sm:hidden">
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" color="minimal" size="icon" StartIcon={Icon.FiMoreHorizontal} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
color="destructive"
|
||||
className="w-full rounded-none font-medium"
|
||||
StartIcon={Icon.FiCheck}
|
||||
onClick={acceptInvite}>
|
||||
{t("accept")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
color="destructive"
|
||||
className="w-full rounded-none font-medium"
|
||||
StartIcon={Icon.FiX}
|
||||
onClick={declineInvite}>
|
||||
{t("reject")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import { MembershipRole } from "@prisma/client";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
type PillColor = "blue" | "green" | "red" | "orange";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
color?: PillColor;
|
||||
}
|
||||
|
||||
export default function TeamPill(props: Props) {
|
||||
return (
|
||||
<div
|
||||
className={classNames("text-medium self-center rounded-md px-1 py-0.5 text-xs ltr:mr-1 rtl:ml-1", {
|
||||
" bg-gray-100 text-gray-800": !props.color,
|
||||
" bg-blue-100 text-blue-800": props.color === "blue",
|
||||
" bg-red-100 text-red-800 ": props.color === "red",
|
||||
" bg-orange-100 text-orange-800": props.color === "orange",
|
||||
})}>
|
||||
{props.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TeamRole(props: { role: MembershipRole }) {
|
||||
const { t } = useLocale();
|
||||
const keys: Record<MembershipRole, PillColor | undefined> = {
|
||||
[MembershipRole.OWNER]: "blue",
|
||||
[MembershipRole.ADMIN]: "red",
|
||||
[MembershipRole.MEMBER]: undefined,
|
||||
};
|
||||
return <TeamPill text={t(props.role.toLowerCase())} color={keys[props.role]} />;
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert, Dialog, DialogContent, DialogTrigger, showToast } from "@calcom/ui/v2/core";
|
||||
|
||||
interface Props {
|
||||
teamId: number;
|
||||
}
|
||||
|
||||
export function UpgradeToFlexibleProModal(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const utils = trpc.useContext();
|
||||
const { data } = trpc.useQuery(["viewer.teams.getTeamSeats", { teamId: props.teamId }], {
|
||||
onError: (err) => {
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
});
|
||||
const mutation = trpc.useMutation(["viewer.teams.upgradeTeam"], {
|
||||
onSuccess: (data) => {
|
||||
// if the user does not already have a Stripe subscription, this wi
|
||||
if (data?.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
if (data?.success) {
|
||||
utils.invalidateQueries(["viewer.teams.get"]);
|
||||
showToast(t("team_upgraded_successfully"), "success");
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
function upgrade() {
|
||||
setErrorMessage(null);
|
||||
mutation.mutate({ teamId: props.teamId });
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<a className="cursor-pointer underline">Upgrade Now</a>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
type="creation"
|
||||
title={t("purchase_missing_seats")}
|
||||
actionText={t("upgrade_to_per_seat")}
|
||||
actionOnClick={() => upgrade()}>
|
||||
<p className="mt-6 text-sm text-gray-600">{t("changed_team_billing_info")}test</p>
|
||||
{data && (
|
||||
<p className="mt-2 text-sm italic text-gray-700">
|
||||
{t("team_upgrade_seats_details", {
|
||||
memberCount: data.totalMembers,
|
||||
unpaidCount: data.missingSeats,
|
||||
seatPrice: 12,
|
||||
totalCost: (data.totalMembers - data.freeSeats) * 12 + 12,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<Alert severity="error" title={errorMessage} message={t("further_billing_help")} className="my-4" />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,103 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import TimezoneSelect, { ITimezone } from "@calcom/ui/form/TimezoneSelect";
|
||||
import { Avatar, Label, Select } from "@calcom/ui/v2";
|
||||
import { DatePicker } from "@calcom/ui/v2";
|
||||
|
||||
import LicenseRequired from "../../../common/components/LicenseRequired";
|
||||
import TeamAvailabilityTimes from "./TeamAvailabilityTimes";
|
||||
|
||||
interface Props {
|
||||
team?: inferQueryOutput<"viewer.teams.get">;
|
||||
member?: inferQueryOutput<"viewer.teams.get">["members"][number];
|
||||
}
|
||||
|
||||
export default function TeamAvailabilityModal(props: Props) {
|
||||
const utils = trpc.useContext();
|
||||
const [selectedDate, setSelectedDate] = useState(dayjs());
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(
|
||||
localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()
|
||||
);
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
const [frequency, setFrequency] = useState<15 | 30 | 60>(30);
|
||||
|
||||
useEffect(() => {
|
||||
utils.invalidateQueries(["viewer.teams.getMemberAvailability"]);
|
||||
}, [utils, selectedTimeZone, selectedDate]);
|
||||
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<>
|
||||
<div className="grid h-[400px] w-[36.7rem] grid-cols-2 space-x-11 rtl:space-x-reverse">
|
||||
<div className="col-span-1">
|
||||
<div className="flex">
|
||||
<Avatar
|
||||
size="md"
|
||||
imageSrc={WEBAPP_URL + "/" + props.member?.username + "/avatar.png"}
|
||||
alt={props.member?.name || ""}
|
||||
/>
|
||||
<div className="flex items-center justify-center ">
|
||||
<span className="ml-2 text-base font-semibold leading-4 text-gray-500">
|
||||
{props.member?.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-brand-900 mt-4 mb-5 text-2xl font-semibold">{t("availability")}</div>
|
||||
<DatePicker
|
||||
minDate={new Date()}
|
||||
date={selectedDate.toDate() || dayjs().toDate()}
|
||||
onDatesChange={(newDate) => {
|
||||
setSelectedDate(dayjs(newDate));
|
||||
}}
|
||||
/>
|
||||
|
||||
<Label className="mt-4">{t("timezone")}</Label>
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
autoFocus
|
||||
value={selectedTimeZone}
|
||||
className="w-64 rounded-md"
|
||||
onChange={(timezone) => setSelectedTimeZone(timezone.value)}
|
||||
classNamePrefix="react-select"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Label>{t("slot_length")}</Label>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 15, label: "15 minutes" },
|
||||
{ value: 30, label: "30 minutes" },
|
||||
{ value: 60, label: "60 minutes" },
|
||||
]}
|
||||
isSearchable={false}
|
||||
classNamePrefix="react-select"
|
||||
className="w-64"
|
||||
value={{ value: frequency, label: `${frequency} minutes` }}
|
||||
onChange={(newFrequency) => setFrequency(newFrequency?.value ?? 30)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 max-h-[500px]">
|
||||
{props.team && props.member && (
|
||||
<TeamAvailabilityTimes
|
||||
teamId={props.team.id}
|
||||
memberId={props.member.id}
|
||||
frequency={frequency}
|
||||
selectedDate={selectedDate}
|
||||
selectedTimeZone={selectedTimeZone}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</LicenseRequired>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,118 @@
|
|||
import React, { useState, useEffect, CSSProperties } from "react";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { FixedSizeList as List } from "react-window";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import Select from "@calcom/ui/form/Select";
|
||||
import TimezoneSelect, { ITimezone } from "@calcom/ui/form/TimezoneSelect";
|
||||
import { Avatar } from "@calcom/ui/v2";
|
||||
import DatePicker from "@calcom/ui/v2/core/form/DatePicker";
|
||||
|
||||
import TeamAvailabilityTimes from "./TeamAvailabilityTimes";
|
||||
|
||||
interface Props {
|
||||
team?: inferQueryOutput<"viewer.teams.get">;
|
||||
}
|
||||
|
||||
export default function TeamAvailabilityScreen(props: Props) {
|
||||
const utils = trpc.useContext();
|
||||
const [selectedDate, setSelectedDate] = useState(dayjs());
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(
|
||||
localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()
|
||||
);
|
||||
const [frequency, setFrequency] = useState<15 | 30 | 60>(30);
|
||||
|
||||
useEffect(() => {
|
||||
utils.invalidateQueries(["viewer.teams.getMemberAvailability"]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedTimeZone, selectedDate]);
|
||||
|
||||
const Item = ({ index, style }: { index: number; style: CSSProperties }) => {
|
||||
const member = props.team?.members?.[index];
|
||||
if (!member) return <></>;
|
||||
|
||||
return (
|
||||
<div key={member.id} style={style} className="flex border-r border-gray-200 pl-4 ">
|
||||
<TeamAvailabilityTimes
|
||||
teamId={props.team?.id as number}
|
||||
memberId={member.id}
|
||||
frequency={frequency}
|
||||
selectedDate={selectedDate}
|
||||
selectedTimeZone={selectedTimeZone}
|
||||
HeaderComponent={
|
||||
<div className="mb-6 flex items-center">
|
||||
<Avatar
|
||||
size="sm"
|
||||
imageSrc={CAL_URL + "/" + member.username + "/avatar.png"}
|
||||
alt={member?.name || ""}
|
||||
className="min-w-10 min-h-10 mt-1 h-10 w-10 rounded-full"
|
||||
/>
|
||||
<div className="ml-3 inline-block overflow-hidden pt-1">
|
||||
<span className="truncate text-lg font-bold text-neutral-700">{member?.name}</span>
|
||||
<span className="-mt-1 block truncate text-sm text-gray-400">{member?.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col rounded-sm border border-neutral-200 bg-white">
|
||||
<div className="flex w-full space-x-5 border-b border-gray-200 p-4 rtl:space-x-reverse">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-neutral-700">Date</span>
|
||||
<DatePicker
|
||||
date={selectedDate.toDate()}
|
||||
className="p-1.5"
|
||||
onDatesChange={(newDate) => {
|
||||
setSelectedDate(dayjs(newDate));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-neutral-700">Timezone</span>
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={(timezone) => setSelectedTimeZone(timezone.value)}
|
||||
classNamePrefix="react-select"
|
||||
className="react-select-container w-full rounded-sm border border-gray-300 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<span className="text-sm font-medium text-neutral-700">Slot Length</span>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 15, label: "15 minutes" },
|
||||
{ value: 30, label: "30 minutes" },
|
||||
{ value: 60, label: "60 minutes" },
|
||||
]}
|
||||
isSearchable={false}
|
||||
className="block w-full min-w-0 flex-1 rounded-sm border border-gray-300 text-sm"
|
||||
value={{ value: frequency, label: `${frequency} minutes` }}
|
||||
onChange={(newFrequency) => setFrequency(newFrequency?.value ?? 30)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full flex-1">
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<List
|
||||
itemSize={240}
|
||||
itemCount={props.team?.members?.length ?? 0}
|
||||
className="List"
|
||||
height={height}
|
||||
layout="horizontal"
|
||||
width={width}>
|
||||
{Item}
|
||||
</List>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
import classNames from "classnames";
|
||||
import React from "react";
|
||||
import { ITimezone } from "react-timezone-select";
|
||||
|
||||
import { Dayjs } from "@calcom/dayjs";
|
||||
import getSlots from "@calcom/lib/slots";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Loader } from "@calcom/ui/v2";
|
||||
|
||||
interface Props {
|
||||
teamId: number;
|
||||
memberId: number;
|
||||
selectedDate: Dayjs;
|
||||
selectedTimeZone: ITimezone;
|
||||
frequency: number;
|
||||
HeaderComponent?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function TeamAvailabilityTimes(props: Props) {
|
||||
const { data, isLoading } = trpc.useQuery(
|
||||
[
|
||||
"viewer.teams.getMemberAvailability",
|
||||
{
|
||||
teamId: props.teamId,
|
||||
memberId: props.memberId,
|
||||
dateFrom: props.selectedDate.toString(),
|
||||
dateTo: props.selectedDate.add(1, "day").toString(),
|
||||
timezone: `${props.selectedTimeZone.toString()}`,
|
||||
},
|
||||
],
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
const times = !isLoading
|
||||
? getSlots({
|
||||
frequency: props.frequency,
|
||||
inviteeDate: props.selectedDate,
|
||||
workingHours: data?.workingHours || [],
|
||||
minimumBookingNotice: 0,
|
||||
eventLength: props.frequency,
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className={classNames("min-w-60 flex-grow pl-0", props.className)}>
|
||||
{props.HeaderComponent}
|
||||
{isLoading && times.length === 0 && <Loader />}
|
||||
{!isLoading && times.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center pt-4">
|
||||
<span className="text-sm text-gray-500">No Available slots</span>
|
||||
</div>
|
||||
) : (
|
||||
<>{!isLoading && <p className="mb-3 text-sm text-gray-600">Time available</p>}</>
|
||||
)}
|
||||
<div className="max-h-[390px] overflow-scroll">
|
||||
{times.map((time) => (
|
||||
<div key={time.format()} className="flex flex-row items-center ">
|
||||
<a
|
||||
className="min-w-48 border-brand text-bookingdarker mb-2 mr-3 block flex-grow rounded-md border bg-white py-2 text-center font-medium dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 "
|
||||
data-testid="time">
|
||||
{time.tz(props.selectedTimeZone.toString()).format("HH:mm")}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
import { MembershipRole } from "@prisma/client";
|
||||
import { useRouter } from "next/router";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, Form, showToast, Switch } from "@calcom/ui/v2/core";
|
||||
import Meta from "@calcom/ui/v2/core/Meta";
|
||||
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
|
||||
|
||||
interface TeamAppearanceValues {
|
||||
hideBranding: boolean;
|
||||
}
|
||||
|
||||
const ProfileView = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const mutation = trpc.useMutation("viewer.teams.update", {
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<TeamAppearanceValues>();
|
||||
|
||||
const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: Number(router.query.id) }], {
|
||||
onError: () => {
|
||||
router.push("/settings");
|
||||
},
|
||||
onSuccess: (team) => {
|
||||
if (team) {
|
||||
form.setValue("hideBranding", team.hideBranding);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const isAdmin =
|
||||
team && (team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title="booking_appearance" description="appearance_team_description" />
|
||||
{!isLoading && (
|
||||
<>
|
||||
{isAdmin ? (
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={(values) => {
|
||||
if (team) {
|
||||
const hideBranding = form.getValues("hideBranding");
|
||||
if (team.hideBranding !== hideBranding) {
|
||||
mutation.mutate({ id: team.id, hideBranding });
|
||||
}
|
||||
}
|
||||
}}>
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex-grow text-sm">
|
||||
<label htmlFor="hide-branding" className="font-medium text-gray-700">
|
||||
{t("disable_cal_branding")}
|
||||
</label>
|
||||
<p className="text-gray-500">{t("team_disable_cal_branding_description")}</p>
|
||||
</div>
|
||||
<div className="flex-none">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="hideBranding"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
defaultChecked={field.value}
|
||||
onCheckedChange={(isChecked) => {
|
||||
form.setValue("hideBranding", isChecked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button color="primary" className="mt-8" type="submit" loading={mutation.isLoading}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
</Form>
|
||||
) : (
|
||||
<div className="rounded-md border border-gray-200 p-5">
|
||||
<span className="text-sm text-gray-600">{t("only_owner_change")}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ProfileView.getLayout = getLayout;
|
||||
|
||||
export default ProfileView;
|
|
@ -0,0 +1,146 @@
|
|||
import { MembershipRole } from "@prisma/client";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Alert, Button } from "@calcom/ui/v2/core";
|
||||
import Meta from "@calcom/ui/v2/core/Meta";
|
||||
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
|
||||
|
||||
import DisableTeamImpersonation from "../components/DisableTeamImpersonation";
|
||||
import MemberInvitationModal from "../components/MemberInvitationModal";
|
||||
import MemberListItem from "../components/MemberListItem";
|
||||
import TeamInviteList from "../components/TeamInviteList";
|
||||
import { UpgradeToFlexibleProModal } from "../components/UpgradeToFlexibleProModal";
|
||||
|
||||
const MembersView = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const session = useSession();
|
||||
|
||||
const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: Number(router.query.id) }], {
|
||||
onError: () => {
|
||||
router.push("/settings");
|
||||
},
|
||||
});
|
||||
|
||||
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
|
||||
|
||||
const isInviteOpen = !team?.membership.accepted;
|
||||
|
||||
const isAdmin =
|
||||
team && (team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title="team_members" description="members_team_description" />
|
||||
{!isLoading && (
|
||||
<>
|
||||
<div>
|
||||
{team && (
|
||||
<>
|
||||
{isInviteOpen && (
|
||||
<TeamInviteList
|
||||
teams={[
|
||||
{
|
||||
id: team.id,
|
||||
accepted: team.membership.accepted || false,
|
||||
logo: team.logo,
|
||||
name: team.name,
|
||||
slug: team.slug,
|
||||
role: team.membership.role,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{team.membership.role === MembershipRole.OWNER &&
|
||||
team.membership.isMissingSeat &&
|
||||
team.requiresUpgrade ? (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title={t("hidden_team_member_title")}
|
||||
message={
|
||||
<>
|
||||
{t("hidden_team_owner_message")} <UpgradeToFlexibleProModal teamId={team.id} />
|
||||
</>
|
||||
}
|
||||
className="mb-4 "
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{team.membership.isMissingSeat && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title={t("hidden_team_member_title")}
|
||||
message={t("hidden_team_member_message")}
|
||||
className="mb-4 "
|
||||
/>
|
||||
)}
|
||||
{team.membership.role === MembershipRole.OWNER && team.requiresUpgrade && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title={t("upgrade_to_flexible_pro_title")}
|
||||
message={
|
||||
<span>
|
||||
{t("upgrade_to_flexible_pro_message")} <br />
|
||||
<UpgradeToFlexibleProModal teamId={team.id} />
|
||||
</span>
|
||||
}
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<div className="relative mb-5 flex w-full items-center ">
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
StartIcon={Icon.FiPlus}
|
||||
className="ml-auto"
|
||||
onClick={() => setShowMemberInvitationModal(true)}
|
||||
data-testid="new-member-button">
|
||||
{t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<ul className="divide-y divide-gray-200 rounded-md border ">
|
||||
{team?.members.map((member) => {
|
||||
return <MemberListItem key={member.id} team={team} member={member} />;
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<hr className="my-8 border-gray-200" />
|
||||
|
||||
{team && session.data && (
|
||||
<DisableTeamImpersonation
|
||||
teamId={team.id}
|
||||
memberId={session.data.user.id}
|
||||
disabled={isInviteOpen}
|
||||
/>
|
||||
)}
|
||||
<hr className="my-8 border-gray-200" />
|
||||
</div>
|
||||
{showMemberInvitationModal && team && (
|
||||
<MemberInvitationModal
|
||||
isOpen={showMemberInvitationModal}
|
||||
team={team}
|
||||
currentMember={team.membership.role}
|
||||
onExit={() => setShowMemberInvitationModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
MembersView.getLayout = getLayout;
|
||||
|
||||
export default MembersView;
|
|
@ -0,0 +1,279 @@
|
|||
import { MembershipRole } from "@prisma/client";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import objectKeys from "@calcom/lib/objectKeys";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Form,
|
||||
LinkIconButton,
|
||||
showToast,
|
||||
TextField,
|
||||
} from "@calcom/ui/v2/core";
|
||||
import Avatar from "@calcom/ui/v2/core/Avatar";
|
||||
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
|
||||
import ImageUploader from "@calcom/ui/v2/core/ImageUploader";
|
||||
import Meta from "@calcom/ui/v2/core/Meta";
|
||||
import { Label, TextArea } from "@calcom/ui/v2/core/form/fields";
|
||||
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
|
||||
|
||||
interface TeamProfileValues {
|
||||
name: string;
|
||||
url: string;
|
||||
logo: string;
|
||||
bio: string;
|
||||
}
|
||||
|
||||
const ProfileView = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const utils = trpc.useContext();
|
||||
const session = useSession();
|
||||
|
||||
const mutation = trpc.useMutation("viewer.teams.update", {
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<TeamProfileValues>();
|
||||
|
||||
const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: Number(router.query.id) }], {
|
||||
onError: () => {
|
||||
router.push("/settings");
|
||||
},
|
||||
onSuccess: (team) => {
|
||||
if (team) {
|
||||
form.setValue("name", team.name || "");
|
||||
form.setValue("url", team.slug || "");
|
||||
form.setValue("logo", team.logo || "");
|
||||
form.setValue("bio", team.bio || "");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const isAdmin =
|
||||
team && (team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN);
|
||||
|
||||
const permalink = `${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${team?.slug}`;
|
||||
|
||||
const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
await utils.invalidateQueries(["viewer.teams.list"]);
|
||||
router.push(`/settings`);
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
},
|
||||
});
|
||||
|
||||
const removeMemberMutation = trpc.useMutation("viewer.teams.removeMember", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
await utils.invalidateQueries(["viewer.teams.list"]);
|
||||
showToast(t("success"), "success");
|
||||
},
|
||||
async onError(err) {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
function deleteTeam() {
|
||||
if (team?.id) deleteTeamMutation.mutate({ teamId: team.id });
|
||||
}
|
||||
|
||||
function leaveTeam() {
|
||||
if (team?.id && session.data)
|
||||
removeMemberMutation.mutate({
|
||||
teamId: team.id,
|
||||
memberId: session.data.user.id,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title="profile" description="profile_team_description" />
|
||||
{!isLoading && (
|
||||
<>
|
||||
{isAdmin ? (
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={(values) => {
|
||||
if (team) {
|
||||
const variables = {
|
||||
logo: values.logo,
|
||||
name: values.name,
|
||||
slug: values.url,
|
||||
bio: values.bio,
|
||||
};
|
||||
objectKeys(variables).forEach((key) => {
|
||||
if (variables[key as keyof typeof variables] === team?.[key]) delete variables[key];
|
||||
});
|
||||
mutation.mutate({ id: team.id, ...variables });
|
||||
}
|
||||
}}>
|
||||
<div className="flex items-center">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="logo"
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Avatar alt="" imageSrc={getPlaceholderAvatar(value, team?.name as string)} size="lg" />
|
||||
<div className="ml-4">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
buttonMsg={t("update")}
|
||||
handleAvatarChange={(newLogo) => {
|
||||
form.setValue("logo", newLogo);
|
||||
}}
|
||||
imageSrc={value}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr className="my-8 border-gray-200" />
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="mt-8">
|
||||
<TextField
|
||||
name="name"
|
||||
label={t("team_name")}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
form.setValue("name", e?.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="mt-8">
|
||||
<TextField
|
||||
name="url"
|
||||
label={t("team_url")}
|
||||
value={value}
|
||||
addOnLeading="https://cal.com/"
|
||||
onChange={(e) => {
|
||||
form.setValue("url", e?.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="bio"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="mt-8">
|
||||
<Label>{t("about")}</Label>
|
||||
<TextArea
|
||||
name="bio"
|
||||
value={value}
|
||||
className="h-14"
|
||||
onChange={(e) => {
|
||||
form.setValue("bio", e?.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<p className="mt-2 text-sm text-gray-600">{t("team_description")}</p>
|
||||
<Button color="primary" className="mt-8" type="submit" loading={mutation.isLoading}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
</Form>
|
||||
) : (
|
||||
<div className="flex">
|
||||
<div className="flex-grow">
|
||||
<div>
|
||||
<Label className="text-black">{t("team_name")}</Label>
|
||||
<p className="text-sm text-gray-800">{team?.name}</p>
|
||||
</div>
|
||||
{team?.bio && (
|
||||
<>
|
||||
<Label className="mt-5 text-black">{t("about")}</Label>
|
||||
<p className="text-sm text-gray-800">{team.bio}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="">
|
||||
<Link href={permalink} passHref={true}>
|
||||
<a target="_blank">
|
||||
<LinkIconButton Icon={Icon.FiExternalLink}>{t("preview")}</LinkIconButton>
|
||||
</a>
|
||||
</Link>
|
||||
<LinkIconButton
|
||||
Icon={Icon.FiLink}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(permalink);
|
||||
showToast("Copied to clipboard", "success");
|
||||
}}>
|
||||
{t("copy_link_team")}
|
||||
</LinkIconButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<hr className="border-1 my-8 border-gray-200" />
|
||||
|
||||
<div className="mb-3 text-base font-semibold">{t("danger_zone")}</div>
|
||||
{team?.membership.role === "OWNER" ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button color="destructive" className="border" StartIcon={Icon.FiTrash2}>
|
||||
{t("delete_team")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("disband_team")}
|
||||
confirmBtnText={t("confirm_disband_team")}
|
||||
onConfirm={deleteTeam}>
|
||||
{t("disband_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button color="destructive" className="border" StartIcon={Icon.FiLogOut}>
|
||||
{t("leave_team")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("leave_team")}
|
||||
confirmBtnText={t("confirm_leave_team")}
|
||||
onConfirm={leaveTeam}>
|
||||
{t("leave_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ProfileView.getLayout = getLayout;
|
||||
|
||||
export default ProfileView;
|
|
@ -88,3 +88,12 @@ export async function isTeamOwner(userId: number, teamId: number) {
|
|||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export async function isTeamMember(userId: number, teamId: number) {
|
||||
return !!(await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ import { getUserAvailability } from "@calcom/core/getUserAvailability";
|
|||
import { sendTeamInviteEmail } from "@calcom/emails";
|
||||
import { HOSTED_CAL_FEATURES, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { getTeamWithMembers, isTeamAdmin, isTeamOwner } from "@calcom/lib/server/queries/teams";
|
||||
import { getTeamWithMembers, isTeamAdmin, isTeamOwner, isTeamMember } from "@calcom/lib/server/queries/teams";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import {
|
||||
closeComDeleteTeam,
|
||||
|
@ -40,11 +40,13 @@ export const viewerTeamsRouter = createProtectedRouter()
|
|||
throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not a member of this team." });
|
||||
}
|
||||
const membership = team?.members.find((membership) => membership.id === ctx.user.id);
|
||||
|
||||
return {
|
||||
...team,
|
||||
membership: {
|
||||
role: membership?.role as MembershipRole,
|
||||
isMissingSeat: membership?.plan === UserPlan.FREE,
|
||||
accepted: membership?.accepted,
|
||||
},
|
||||
requiresUpgrade: HOSTED_CAL_FEATURES ? !!team.members.find((m) => m.plan !== UserPlan.PRO) : false,
|
||||
};
|
||||
|
@ -194,14 +196,15 @@ export const viewerTeamsRouter = createProtectedRouter()
|
|||
memberId: z.number(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
const isAdmin = await isTeamAdmin(ctx.user?.id, input.teamId);
|
||||
if (!isAdmin && ctx.user?.id !== input.memberId) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
// Only a team owner can remove another team owner.
|
||||
if (
|
||||
(await isTeamOwner(input.memberId, input.teamId)) &&
|
||||
!(await isTeamOwner(ctx.user?.id, input.teamId))
|
||||
)
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
if (ctx.user?.id === input.memberId)
|
||||
if (ctx.user?.id === input.memberId && isAdmin)
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You can not remove yourself from a team you own.",
|
||||
|
@ -449,7 +452,7 @@ export const viewerTeamsRouter = createProtectedRouter()
|
|||
dateTo: z.string(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const team = await isTeamAdmin(ctx.user?.id, input.teamId);
|
||||
const team = await isTeamMember(ctx.user?.id, input.teamId);
|
||||
if (!team) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
// verify member is in team
|
||||
|
|
|
@ -9,7 +9,7 @@ import { Maybe } from "@trpc/server";
|
|||
|
||||
export type AvatarProps = {
|
||||
className?: string;
|
||||
size: "sm" | "md" | "lg";
|
||||
size: "sm" | "md" | "mdLg" | "lg";
|
||||
imageSrc?: Maybe<string>;
|
||||
title?: string;
|
||||
alt: string;
|
||||
|
@ -20,6 +20,7 @@ export type AvatarProps = {
|
|||
const sizesPropsBySize = {
|
||||
sm: "w-6", // 24px
|
||||
md: "w-8", // 32px
|
||||
mdLg: "w-10", //40px
|
||||
lg: "w-16", // 64px
|
||||
} as const;
|
||||
|
||||
|
@ -31,7 +32,7 @@ export default function Avatar(props: AvatarProps) {
|
|||
<AvatarPrimitive.Root
|
||||
className={classNames(
|
||||
sizeClassname,
|
||||
"dark:bg-darkgray-300 relative inline-block aspect-square overflow-hidden rounded-full bg-gray-300"
|
||||
"dark:bg-darkgray-300 relative inline-block aspect-square overflow-hidden rounded-full"
|
||||
)}>
|
||||
<AvatarPrimitive.Image src={imageSrc ?? undefined} alt={alt} className={rootClass} />
|
||||
<AvatarPrimitive.Fallback delayMs={600}>
|
||||
|
|
|
@ -61,7 +61,7 @@ export function Dialog(props: DialogProps) {
|
|||
);
|
||||
}
|
||||
type DialogContentProps = React.ComponentProps<typeof DialogPrimitive["Content"]> & {
|
||||
size?: "xl" | "lg";
|
||||
size?: "xl" | "lg" | "md";
|
||||
type: "creation" | "confirmation";
|
||||
title?: string;
|
||||
description?: string | undefined;
|
||||
|
@ -88,6 +88,8 @@ export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps
|
|||
? "p-0.5 sm:max-w-[98vw]"
|
||||
: props.size == "lg"
|
||||
? "p-8 sm:max-w-[70rem]"
|
||||
: props.size == "md"
|
||||
? "p-8 sm:max-w-[40rem]"
|
||||
: "p-8 sm:max-w-[35rem]",
|
||||
"max-h-[560px] overflow-visible overscroll-auto md:h-auto md:max-h-[inherit]",
|
||||
`${props.className || ""}`
|
||||
|
|
|
@ -1,14 +1,61 @@
|
|||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import { FormEvent, useCallback, useEffect, useState } from "react";
|
||||
import Cropper from "react-easy-crop";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import Button from "@calcom/ui/v2/core/Button";
|
||||
|
||||
import { Area, getCroppedImg } from "@lib/cropImage";
|
||||
import { useFileReader } from "@lib/hooks/useFileReader";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
type ReadAsMethod = "readAsText" | "readAsDataURL" | "readAsArrayBuffer" | "readAsBinaryString";
|
||||
|
||||
import Slider from "@components/Slider";
|
||||
type UseFileReaderProps = {
|
||||
method: ReadAsMethod;
|
||||
onLoad?: (result: unknown) => void;
|
||||
};
|
||||
|
||||
type Area = {
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
const MAX_IMAGE_SIZE = 512;
|
||||
|
||||
const useFileReader = (options: UseFileReaderProps) => {
|
||||
const { method = "readAsText", onLoad } = options;
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<DOMException | null>(null);
|
||||
const [result, setResult] = useState<string | ArrayBuffer | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!file && result) {
|
||||
setResult(null);
|
||||
}
|
||||
}, [file, result]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadstart = () => setLoading(true);
|
||||
reader.onloadend = () => setLoading(false);
|
||||
reader.onerror = () => setError(reader.error);
|
||||
|
||||
reader.onload = (e: ProgressEvent<FileReader>) => {
|
||||
setResult(e.target?.result ?? null);
|
||||
if (onLoad) {
|
||||
onLoad(e.target?.result ?? null);
|
||||
}
|
||||
};
|
||||
reader[method](file);
|
||||
}, [file, method, onLoad]);
|
||||
|
||||
return [{ result, error, file, loading }, setFile] as const;
|
||||
};
|
||||
|
||||
type ImageUploaderProps = {
|
||||
id: string;
|
||||
|
@ -112,11 +159,9 @@ export default function ImageUploader({
|
|||
(opened) => !opened && setFile(null) // unset file on close
|
||||
}>
|
||||
<DialogTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Button color="secondary" type="button" className="py-1 text-sm">
|
||||
{buttonMsg}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
|
@ -167,3 +212,75 @@ export default function ImageUploader({
|
|||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const createImage = (url: string) =>
|
||||
new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.addEventListener("load", () => resolve(image));
|
||||
image.addEventListener("error", (error) => reject(error));
|
||||
image.setAttribute("crossOrigin", "anonymous"); // needed to avoid cross-origin issues on CodeSandbox
|
||||
image.src = url;
|
||||
});
|
||||
|
||||
async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<string> {
|
||||
const image = await createImage(imageSrc);
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Context is null, this should never happen.");
|
||||
|
||||
const maxSize = Math.max(image.naturalWidth, image.naturalHeight);
|
||||
const resizeRatio = MAX_IMAGE_SIZE / maxSize < 1 ? Math.max(MAX_IMAGE_SIZE / maxSize, 0.75) : 1;
|
||||
// huh, what? - Having this turned off actually improves image quality as otherwise anti-aliasing is applied
|
||||
// this reduces the quality of the image overall because it anti-aliases the existing, copied image; blur results
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
// pixelCrop is always 1:1 - width = height
|
||||
canvas.width = canvas.height = Math.min(maxSize * resizeRatio, pixelCrop.width);
|
||||
|
||||
ctx.drawImage(
|
||||
image,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height
|
||||
);
|
||||
|
||||
// on very low ratios, the quality of the resize becomes awful. For this reason the resizeRatio is limited to 0.75
|
||||
if (resizeRatio <= 0.75) {
|
||||
// With a smaller image, thus improved ratio. Keep doing this until the resizeRatio > 0.75.
|
||||
return getCroppedImg(canvas.toDataURL("image/png"), {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
const Slider = ({
|
||||
value,
|
||||
label,
|
||||
changeHandler,
|
||||
...props
|
||||
}: Omit<SliderPrimitive.SliderProps, "value"> & {
|
||||
value: number;
|
||||
label: string;
|
||||
changeHandler: (value: number) => void;
|
||||
}) => (
|
||||
<SliderPrimitive.Root
|
||||
className="slider mt-2"
|
||||
value={[value]}
|
||||
aria-label={label}
|
||||
onValueChange={(value: number[]) => changeHandler(value[0] ?? value)}
|
||||
{...props}>
|
||||
<SliderPrimitive.Track className="slider-track">
|
||||
<SliderPrimitive.Range className="slider-range" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="slider-thumb" />
|
||||
</SliderPrimitive.Root>
|
||||
);
|
|
@ -12,7 +12,7 @@ export default function LinkIconButton(props: LinkIconButtonProps) {
|
|||
<button
|
||||
type="button"
|
||||
{...props}
|
||||
className="text-md flex items-center rounded-sm px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-200 hover:text-gray-900">
|
||||
className="text-md flex items-center rounded-md px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-200 hover:text-gray-900">
|
||||
<props.Icon className="h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />
|
||||
{props.children}
|
||||
</button>
|
||||
|
|
|
@ -17,11 +17,12 @@ const DatePicker = ({ minDate, disabled, date, onDatesChange, className }: Props
|
|||
return (
|
||||
<PrimitiveDatePicker
|
||||
className={classNames(
|
||||
"focus:ring-primary-500 focus:border-primary-500 rounded-sm border border-gray-300 p-1 pl-2 shadow-sm sm:text-sm",
|
||||
"focus:ring-primary-500 focus:border-primary-500 rounded-md border border-gray-300 p-1 pl-2 shadow-sm sm:text-sm",
|
||||
className
|
||||
)}
|
||||
calendarClassName="rounded-md"
|
||||
clearIcon={null}
|
||||
calendarIcon={<Calendar className="h-5 w-5 text-gray-500" />}
|
||||
calendarIcon={<Calendar className="h-5 w-5 rounded-md text-gray-500" />}
|
||||
value={date}
|
||||
minDate={minDate}
|
||||
disabled={disabled}
|
||||
|
|
|
@ -88,12 +88,12 @@ function HintsOrErrors<T extends FieldValues = FieldValues>(props: {
|
|||
className={error !== undefined ? (submitted ? "text-red-700" : "") : "text-green-600"}>
|
||||
{error !== undefined ? (
|
||||
submitted ? (
|
||||
<X size="12" strokeWidth="3" className="-ml-1 mr-2 inline-block" />
|
||||
<X size="12" strokeWidth="3" className="mr-2 -ml-1 inline-block" />
|
||||
) : (
|
||||
<Circle fill="currentColor" size="5" className="mr-2 inline-block" />
|
||||
)
|
||||
) : (
|
||||
<Check size="12" strokeWidth="3" className="-ml-1 mr-2 inline-block" />
|
||||
<Check size="12" strokeWidth="3" className="mr-2 -ml-1 inline-block" />
|
||||
)}
|
||||
{t(`${fieldName}_hint_${key}`)}
|
||||
</li>
|
||||
|
@ -126,7 +126,7 @@ function HintsOrErrors<T extends FieldValues = FieldValues>(props: {
|
|||
return (
|
||||
<li key={key} className={!!dirty ? "text-green-600" : ""}>
|
||||
{!!dirty ? (
|
||||
<Check size="12" strokeWidth="3" className="-ml-1 mr-2 inline-block" />
|
||||
<Check size="12" strokeWidth="3" className="mr-2 -ml-1 inline-block" />
|
||||
) : (
|
||||
<Circle fill="currentColor" size="5" className="mr-2 inline-block" />
|
||||
)}
|
||||
|
@ -215,7 +215,7 @@ const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputF
|
|||
"flex h-full flex-col justify-center px-1 text-sm",
|
||||
props.error && "text-red-900"
|
||||
)}>
|
||||
<span className="whitespace-nowrap">{addOnLeading || addOnSuffix}</span>
|
||||
<span className="whitespace-nowrap py-2.5 px-3">{addOnLeading || addOnSuffix}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
|
|
Loading…
Reference in New Issue