From 7e917cdcbb026980ad0983164bfe67b96ca83e78 Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Mon, 12 Sep 2022 18:04:33 -0400 Subject: [PATCH] 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 * 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 Co-authored-by: Joe Au-Yeung Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> Co-authored-by: Peer Richelsen 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 --- apps/web/components/ImageUploader.tsx | 1 + .../steps-views/UserProfile.tsx | 2 +- .../team/DisableTeamImpersonation.tsx | 1 + .../components/team/MemberChangeRoleModal.tsx | 1 + .../components/team/MemberInvitationModal.tsx | 1 + apps/web/components/team/MemberListItem.tsx | 1 + apps/web/components/team/TeamPill.tsx | 1 + .../team/UpgradeToFlexibleProModal.tsx | 1 + apps/web/middleware.ts | 1 + .../pages/v2/settings/my-account/profile.tsx | 2 +- .../v2/settings/teams/[id]/appearance.tsx | 1 + .../pages/v2/settings/teams/[id]/members.tsx | 1 + .../pages/v2/settings/teams/[id]/profile.tsx | 1 + apps/web/public/static/locales/en/common.json | 22 +- .../components/DisableTeamImpersonation.tsx | 63 ++++ .../components/MemberChangeRoleModal.tsx | 113 +++++++ .../components/MemberInvitationModal.tsx | 153 ++++++++++ .../ee/teams/components/MemberListItem.tsx | 275 +++++++++++++++++ .../ee/teams/components/TeamInviteList.tsx | 64 ++++ .../teams/components/TeamInviteListItem.tsx | 141 +++++++++ .../features/ee/teams/components/TeamPill.tsx | 35 +++ .../components/UpgradeToFlexibleProModal.tsx | 68 +++++ .../components/v2/TeamAvailabilityModal.tsx | 103 +++++++ .../components/v2/TeamAvailabilityScreen.tsx | 118 ++++++++ .../components/v2/TeamAvailabilityTimes.tsx | 71 +++++ .../ee/teams/pages/team-appearance-view.tsx | 101 +++++++ .../ee/teams/pages/team-members-view.tsx | 146 +++++++++ .../ee/teams/pages/team-profile-view.tsx | 279 ++++++++++++++++++ packages/lib/server/queries/teams/index.ts | 9 + packages/trpc/server/routers/viewer/teams.tsx | 11 +- packages/ui/v2/core/Avatar.tsx | 7 +- packages/ui/v2/core/Dialog.tsx | 4 +- .../ui/v2/core}/ImageUploader.tsx | 135 ++++++++- packages/ui/v2/core/LinkIconButton.tsx | 2 +- packages/ui/v2/core/form/DatePicker.tsx | 5 +- packages/ui/v2/core/form/fields.tsx | 8 +- 36 files changed, 1917 insertions(+), 31 deletions(-) create mode 100644 apps/web/pages/v2/settings/teams/[id]/appearance.tsx create mode 100644 apps/web/pages/v2/settings/teams/[id]/members.tsx create mode 100644 apps/web/pages/v2/settings/teams/[id]/profile.tsx create mode 100644 packages/features/ee/teams/components/DisableTeamImpersonation.tsx create mode 100644 packages/features/ee/teams/components/MemberChangeRoleModal.tsx create mode 100644 packages/features/ee/teams/components/MemberInvitationModal.tsx create mode 100644 packages/features/ee/teams/components/MemberListItem.tsx create mode 100644 packages/features/ee/teams/components/TeamInviteList.tsx create mode 100644 packages/features/ee/teams/components/TeamInviteListItem.tsx create mode 100644 packages/features/ee/teams/components/TeamPill.tsx create mode 100644 packages/features/ee/teams/components/UpgradeToFlexibleProModal.tsx create mode 100644 packages/features/ee/teams/components/v2/TeamAvailabilityModal.tsx create mode 100644 packages/features/ee/teams/components/v2/TeamAvailabilityScreen.tsx create mode 100644 packages/features/ee/teams/components/v2/TeamAvailabilityTimes.tsx create mode 100644 packages/features/ee/teams/pages/team-appearance-view.tsx create mode 100644 packages/features/ee/teams/pages/team-members-view.tsx create mode 100644 packages/features/ee/teams/pages/team-profile-view.tsx rename {apps/web/components/v2/settings => packages/ui/v2/core}/ImageUploader.tsx (56%) diff --git a/apps/web/components/ImageUploader.tsx b/apps/web/components/ImageUploader.tsx index c8ded6c160..525224de95 100644 --- a/apps/web/components/ImageUploader.tsx +++ b/apps/web/components/ImageUploader.tsx @@ -63,6 +63,7 @@ function CropContainer({ ); } +/** @deprecated Use `packages/ui/v2/core/ImageUploader.tsx` */ export default function ImageUploader({ target, id, diff --git a/apps/web/components/getting-started/steps-views/UserProfile.tsx b/apps/web/components/getting-started/steps-views/UserProfile.tsx index a05de87381..018df639b7 100644 --- a/apps/web/components/getting-started/steps-views/UserProfile.tsx +++ b/apps/web/components/getting-started/steps-views/UserProfile.tsx @@ -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; diff --git a/apps/web/components/team/DisableTeamImpersonation.tsx b/apps/web/components/team/DisableTeamImpersonation.tsx index c88cf569bf..e2569d7cb6 100644 --- a/apps/web/components/team/DisableTeamImpersonation.tsx +++ b/apps/web/components/team/DisableTeamImpersonation.tsx @@ -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(); diff --git a/apps/web/components/team/MemberChangeRoleModal.tsx b/apps/web/components/team/MemberChangeRoleModal.tsx index c9d16b682b..fe7dda2776 100644 --- a/apps/web/components/team/MemberChangeRoleModal.tsx +++ b/apps/web/components/team/MemberChangeRoleModal.tsx @@ -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; diff --git a/apps/web/components/team/MemberInvitationModal.tsx b/apps/web/components/team/MemberInvitationModal.tsx index 651527bbda..0836c84fda 100644 --- a/apps/web/components/team/MemberInvitationModal.tsx +++ b/apps/web/components/team/MemberInvitationModal.tsx @@ -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(); diff --git a/apps/web/components/team/MemberListItem.tsx b/apps/web/components/team/MemberListItem.tsx index 6e374d4777..b5e38cdeaa 100644 --- a/apps/web/components/team/MemberListItem.tsx +++ b/apps/web/components/team/MemberListItem.tsx @@ -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(); diff --git a/apps/web/components/team/TeamPill.tsx b/apps/web/components/team/TeamPill.tsx index 1ceb05575f..b7abb99077 100644 --- a/apps/web/components/team/TeamPill.tsx +++ b/apps/web/components/team/TeamPill.tsx @@ -10,6 +10,7 @@ interface Props { color?: PillColor; } +/** @deprecated Use `packages/features/ee/teams/components/TeamPill.tsx` */ export default function TeamPill(props: Props) { return (
(null); diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index b00d073d89..7b836e3121 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -11,6 +11,7 @@ const V2_WHITELIST = [ "/settings/developer/api-keys", "/settings/my-account", "/settings/security", + "/settings/teams", "/availability", "/bookings", "/event-types", diff --git a/apps/web/pages/v2/settings/my-account/profile.tsx b/apps/web/pages/v2/settings/my-account/profile.tsx index 61c5099961..799fc0869d 100644 --- a/apps/web/pages/v2/settings/my-account/profile.tsx +++ b/apps/web/pages/v2/settings/my-account/profile.tsx @@ -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; diff --git a/apps/web/pages/v2/settings/teams/[id]/appearance.tsx b/apps/web/pages/v2/settings/teams/[id]/appearance.tsx new file mode 100644 index 0000000000..a5514889ac --- /dev/null +++ b/apps/web/pages/v2/settings/teams/[id]/appearance.tsx @@ -0,0 +1 @@ +export { default } from "@calcom/features/ee/teams/pages/team-appearance-view"; diff --git a/apps/web/pages/v2/settings/teams/[id]/members.tsx b/apps/web/pages/v2/settings/teams/[id]/members.tsx new file mode 100644 index 0000000000..cce1f79bd4 --- /dev/null +++ b/apps/web/pages/v2/settings/teams/[id]/members.tsx @@ -0,0 +1 @@ +export { default } from "@calcom/features/ee/teams/pages/team-members-view"; diff --git a/apps/web/pages/v2/settings/teams/[id]/profile.tsx b/apps/web/pages/v2/settings/teams/[id]/profile.tsx new file mode 100644 index 0000000000..5318cc8478 --- /dev/null +++ b/apps/web/pages/v2/settings/teams/[id]/profile.tsx @@ -0,0 +1 @@ +export { default } from "@calcom/features/ee/teams/pages/team-profile-view"; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 498ed65e2d..43b0576752 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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.", @@ -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", diff --git a/packages/features/ee/teams/components/DisableTeamImpersonation.tsx b/packages/features/ee/teams/components/DisableTeamImpersonation.tsx new file mode 100644 index 0000000000..6be33590a7 --- /dev/null +++ b/packages/features/ee/teams/components/DisableTeamImpersonation.tsx @@ -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 ( + <> +
+
+
+

+ {t("user_impersonation_heading")} +

+
+

+ {t("team_impersonation_description")} +

+
+
+ { + mutation.mutate({ teamId, memberId, disableImpersonation: isChecked }); + }} + /> +
+
+ + ); +}; + +export default DisableTeamImpersonation; diff --git a/packages/features/ee/teams/components/MemberChangeRoleModal.tsx b/packages/features/ee/teams/components/MemberChangeRoleModal.tsx new file mode 100644 index 0000000000..e5a430ae58 --- /dev/null +++ b/packages/features/ee/teams/components/MemberChangeRoleModal.tsx @@ -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( + 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 ( + + + <> +
+
+ +
+
+
+
+ + {/* - needs dialog to confirm change of ownership */} + +
+
+
+ +
+
+ +
+
+
+
+
+ {errorMessage && ( +

+ Error: + {errorMessage} +

+ )} + + + + + + + + ); +} diff --git a/packages/features/ee/teams/components/MemberListItem.tsx b/packages/features/ee/teams/components/MemberListItem.tsx new file mode 100644 index 0000000000..ae631e1552 --- /dev/null +++ b/packages/features/ee/teams/components/MemberListItem.tsx @@ -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 ( +
  • +
    +
    +
    + + +
    +
    + {name} + + {props.member.isMissingSeat && } + {!props.member.accepted && } + {props.member.role && } +
    + + {props.member.email} + +
    +
    +
    + {props.team.membership.accepted && ( +
    + +
    + + + + + + {t("remove_member_confirmation_message")} + + + + + )} + + +
    +
    + )} +
    + {showChangeMemberRoleModal && ( + setShowChangeMemberRoleModal(false)} + /> + )} + {showTeamAvailabilityModal && ( + setShowTeamAvailabilityModal(false)}> + + +
    + +
    +
    +
    + )} +
  • + ); +} diff --git a/packages/features/ee/teams/components/TeamInviteList.tsx b/packages/features/ee/teams/components/TeamInviteList.tsx new file mode 100644 index 0000000000..b03fb4b9f5 --- /dev/null +++ b/packages/features/ee/teams/components/TeamInviteList.tsx @@ -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 ( +
    +
      + {props.teams.map((team) => ( + selectAction(action, team?.id as number)} + isLoading={deleteTeamMutation.isLoading} + hideDropdown={hideDropdown} + setHideDropdown={setHideDropdown} + /> + ))} +
    +
    + ); +} diff --git a/packages/features/ee/teams/components/TeamInviteListItem.tsx b/packages/features/ee/teams/components/TeamInviteListItem.tsx new file mode 100644 index 0000000000..05421ae835 --- /dev/null +++ b/packages/features/ee/teams/components/TeamInviteListItem.tsx @@ -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 = ( +
    + +
    + {team.name} + + {t("invited_by_team", { teamName: team.name, role: t(team.role.toLocaleLowerCase()) })} + +
    +
    + ); + + return ( +
  • +
    + {teamInfo} +
    + <> +
    +
    +
    + + + + + + + + + +
    + +
    +
    +
  • + ); +} diff --git a/packages/features/ee/teams/components/TeamPill.tsx b/packages/features/ee/teams/components/TeamPill.tsx new file mode 100644 index 0000000000..92937fdd16 --- /dev/null +++ b/packages/features/ee/teams/components/TeamPill.tsx @@ -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 ( +
    + {props.text} +
    + ); +} + +export function TeamRole(props: { role: MembershipRole }) { + const { t } = useLocale(); + const keys: Record = { + [MembershipRole.OWNER]: "blue", + [MembershipRole.ADMIN]: "red", + [MembershipRole.MEMBER]: undefined, + }; + return ; +} diff --git a/packages/features/ee/teams/components/UpgradeToFlexibleProModal.tsx b/packages/features/ee/teams/components/UpgradeToFlexibleProModal.tsx new file mode 100644 index 0000000000..f289e94166 --- /dev/null +++ b/packages/features/ee/teams/components/UpgradeToFlexibleProModal.tsx @@ -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(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 ( + + + Upgrade Now + + upgrade()}> +

    {t("changed_team_billing_info")}test

    + {data && ( +

    + {t("team_upgrade_seats_details", { + memberCount: data.totalMembers, + unpaidCount: data.missingSeats, + seatPrice: 12, + totalCost: (data.totalMembers - data.freeSeats) * 12 + 12, + })} +

    + )} + {errorMessage && ( + + )} +
    +
    + ); +} diff --git a/packages/features/ee/teams/components/v2/TeamAvailabilityModal.tsx b/packages/features/ee/teams/components/v2/TeamAvailabilityModal.tsx new file mode 100644 index 0000000000..67c6e75d18 --- /dev/null +++ b/packages/features/ee/teams/components/v2/TeamAvailabilityModal.tsx @@ -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( + 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 ( + + <> +
    +
    +
    + +
    + + {props.member?.name} + +
    +
    +
    +
    {t("availability")}
    + { + setSelectedDate(dayjs(newDate)); + }} + /> + + + setSelectedTimeZone(timezone.value)} + classNamePrefix="react-select" + /> +
    +
    + + setFrequency(newFrequency?.value ?? 30)} + /> +
    +
    +
    + + {({ height, width }) => ( + + {Item} + + )} + +
    +
    + ); +} diff --git a/packages/features/ee/teams/components/v2/TeamAvailabilityTimes.tsx b/packages/features/ee/teams/components/v2/TeamAvailabilityTimes.tsx new file mode 100644 index 0000000000..ca18bd899f --- /dev/null +++ b/packages/features/ee/teams/components/v2/TeamAvailabilityTimes.tsx @@ -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 ( +
    + {props.HeaderComponent} + {isLoading && times.length === 0 && } + {!isLoading && times.length === 0 ? ( +
    + No Available slots +
    + ) : ( + <>{!isLoading &&

    Time available

    } + )} + +
    + ); +} diff --git a/packages/features/ee/teams/pages/team-appearance-view.tsx b/packages/features/ee/teams/pages/team-appearance-view.tsx new file mode 100644 index 0000000000..ba16ce06e9 --- /dev/null +++ b/packages/features/ee/teams/pages/team-appearance-view.tsx @@ -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(); + + 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 ( + <> + + {!isLoading && ( + <> + {isAdmin ? ( +
    { + if (team) { + const hideBranding = form.getValues("hideBranding"); + if (team.hideBranding !== hideBranding) { + mutation.mutate({ id: team.id, hideBranding }); + } + } + }}> +
    +
    + +

    {t("team_disable_cal_branding_description")}

    +
    +
    + ( + { + form.setValue("hideBranding", isChecked); + }} + /> + )} + /> +
    +
    + +
    + ) : ( +
    + {t("only_owner_change")} +
    + )} + + )} + + ); +}; + +ProfileView.getLayout = getLayout; + +export default ProfileView; diff --git a/packages/features/ee/teams/pages/team-members-view.tsx b/packages/features/ee/teams/pages/team-members-view.tsx new file mode 100644 index 0000000000..7ce3062c15 --- /dev/null +++ b/packages/features/ee/teams/pages/team-members-view.tsx @@ -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 ( + <> + + {!isLoading && ( + <> +
    + {team && ( + <> + {isInviteOpen && ( + + )} + {team.membership.role === MembershipRole.OWNER && + team.membership.isMissingSeat && + team.requiresUpgrade ? ( + + {t("hidden_team_owner_message")} + + } + className="mb-4 " + /> + ) : ( + <> + {team.membership.isMissingSeat && ( + + )} + {team.membership.role === MembershipRole.OWNER && team.requiresUpgrade && ( + + {t("upgrade_to_flexible_pro_message")}
    + + + } + className="mb-4" + /> + )} + + )} + + )} + {isAdmin && ( +
    + +
    + )} +
    +
      + {team?.members.map((member) => { + return ; + })} +
    +
    +
    + + {team && session.data && ( + + )} +
    +
    + {showMemberInvitationModal && team && ( + setShowMemberInvitationModal(false)} + /> + )} + + )} + + ); +}; + +MembersView.getLayout = getLayout; + +export default MembersView; diff --git a/packages/features/ee/teams/pages/team-profile-view.tsx b/packages/features/ee/teams/pages/team-profile-view.tsx new file mode 100644 index 0000000000..702fea3616 --- /dev/null +++ b/packages/features/ee/teams/pages/team-profile-view.tsx @@ -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(); + + 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 ( + <> + + {!isLoading && ( + <> + {isAdmin ? ( +
    { + 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 }); + } + }}> +
    + ( + <> + +
    + { + form.setValue("logo", newLogo); + }} + imageSrc={value} + /> +
    + + )} + /> +
    + +
    + + ( +
    + { + form.setValue("name", e?.target.value); + }} + /> +
    + )} + /> + ( +
    + { + form.setValue("url", e?.target.value); + }} + /> +
    + )} + /> + ( +
    + +