Even Better Teams (#1304)
- dropdown improvements - Improve performance of team availability - Fix default timezone - Allow team admins to edit event types - Change team availability slot input to dropdown select (15,30,60) - Prevent teams from access if not pro user
This commit is contained in:
parent
4ce879e5dc
commit
c21f0c2d49
16 changed files with 141 additions and 73 deletions
|
@ -39,7 +39,7 @@ import { useViewerI18n } from "./I18nLanguageHandler";
|
||||||
import Logo from "./Logo";
|
import Logo from "./Logo";
|
||||||
import Button from "./ui/Button";
|
import Button from "./ui/Button";
|
||||||
|
|
||||||
function useMeQuery() {
|
export function useMeQuery() {
|
||||||
const meQuery = trpc.useQuery(["viewer.me"], {
|
const meQuery = trpc.useQuery(["viewer.me"], {
|
||||||
retry(failureCount) {
|
retry(failureCount) {
|
||||||
return failureCount > 3;
|
return failureCount > 3;
|
||||||
|
|
|
@ -17,7 +17,12 @@ import Avatar from "@components/ui/Avatar";
|
||||||
import Button from "@components/ui/Button";
|
import Button from "@components/ui/Button";
|
||||||
import ModalContainer from "@components/ui/ModalContainer";
|
import ModalContainer from "@components/ui/ModalContainer";
|
||||||
|
|
||||||
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/Dropdown";
|
import Dropdown, {
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "../ui/Dropdown";
|
||||||
import MemberChangeRoleModal from "./MemberChangeRoleModal";
|
import MemberChangeRoleModal from "./MemberChangeRoleModal";
|
||||||
import TeamRole from "./TeamRole";
|
import TeamRole from "./TeamRole";
|
||||||
import { MembershipRole } from ".prisma/client";
|
import { MembershipRole } from ".prisma/client";
|
||||||
|
@ -91,13 +96,13 @@ export default function MemberListItem(props: Props) {
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<Link href={"/" + props.member.username}>
|
<Link href={"/" + props.member.username}>
|
||||||
<a target="_blank">
|
<a target="_blank">
|
||||||
<Button color="minimal" StartIcon={ExternalLinkIcon} className="w-full">
|
<Button color="minimal" StartIcon={ExternalLinkIcon} className="w-full font-normal">
|
||||||
{t("view_public_page")}
|
{t("view_public_page")}
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||||
{(props.team.membership.role === MembershipRole.OWNER ||
|
{(props.team.membership.role === MembershipRole.OWNER ||
|
||||||
props.team.membership.role === MembershipRole.ADMIN) && (
|
props.team.membership.role === MembershipRole.ADMIN) && (
|
||||||
<>
|
<>
|
||||||
|
@ -106,10 +111,11 @@ export default function MemberListItem(props: Props) {
|
||||||
onClick={() => setShowChangeMemberRoleModal(true)}
|
onClick={() => setShowChangeMemberRoleModal(true)}
|
||||||
color="minimal"
|
color="minimal"
|
||||||
StartIcon={PencilIcon}
|
StartIcon={PencilIcon}
|
||||||
className="flex-shrink-0 w-full">
|
className="flex-shrink-0 w-full font-normal">
|
||||||
{t("edit_role")}
|
{t("edit_role")}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
|
@ -119,7 +125,7 @@ export default function MemberListItem(props: Props) {
|
||||||
}}
|
}}
|
||||||
color="warn"
|
color="warn"
|
||||||
StartIcon={UserRemoveIcon}
|
StartIcon={UserRemoveIcon}
|
||||||
className="w-full">
|
className="w-full font-normal">
|
||||||
{t("remove_member")}
|
{t("remove_member")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
@ -151,9 +157,11 @@ export default function MemberListItem(props: Props) {
|
||||||
<TeamAvailabilityModal team={props.team} member={props.member} />
|
<TeamAvailabilityModal team={props.team} member={props.member} />
|
||||||
<div className="p-5 space-x-2 border-t">
|
<div className="p-5 space-x-2 border-t">
|
||||||
<Button onClick={() => setShowTeamAvailabilityModal(false)}>{t("done")}</Button>
|
<Button onClick={() => setShowTeamAvailabilityModal(false)}>{t("done")}</Button>
|
||||||
<Link href={`/settings/teams/${props.team.id}/availability`}>
|
{props.team.membership.role !== MembershipRole.MEMBER && (
|
||||||
<Button color="secondary">{t("Open Team Availability")}</Button>
|
<Link href={`/settings/teams/${props.team.id}/availability`}>
|
||||||
</Link>
|
<Button color="secondary">{t("Open Team Availability")}</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ModalContainer>
|
</ModalContainer>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -17,6 +17,7 @@ import Dropdown, {
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuSeparator,
|
||||||
} from "@components/ui/Dropdown";
|
} from "@components/ui/Dropdown";
|
||||||
|
|
||||||
import TeamRole from "./TeamRole";
|
import TeamRole from "./TeamRole";
|
||||||
|
@ -123,23 +124,33 @@ export default function TeamListItem(props: Props) {
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<Link href={"/settings/teams/" + team.id}>
|
<Link href={"/settings/teams/" + team.id}>
|
||||||
<a>
|
<a>
|
||||||
<Button type="button" color="minimal" className="w-full" StartIcon={PencilIcon}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="minimal"
|
||||||
|
className="w-full font-normal"
|
||||||
|
StartIcon={PencilIcon}>
|
||||||
{t("edit_team")}
|
{t("edit_team")}
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && <DropdownMenuSeparator className="h-px bg-gray-200" />}
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<Link href={`${process.env.NEXT_PUBLIC_APP_URL}/team/${team.slug}`} passHref={true}>
|
<Link href={`${process.env.NEXT_PUBLIC_APP_URL}/team/${team.slug}`} passHref={true}>
|
||||||
<a target="_blank">
|
<a target="_blank">
|
||||||
<Button type="button" color="minimal" className="w-full" StartIcon={ExternalLinkIcon}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="minimal"
|
||||||
|
className="w-full font-normal"
|
||||||
|
StartIcon={ExternalLinkIcon}>
|
||||||
{" "}
|
{" "}
|
||||||
{t("preview_team")}
|
{t("preview_team")}
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<Dialog>
|
<Dialog>
|
||||||
|
@ -150,7 +161,7 @@ export default function TeamListItem(props: Props) {
|
||||||
}}
|
}}
|
||||||
color="warn"
|
color="warn"
|
||||||
StartIcon={TrashIcon}
|
StartIcon={TrashIcon}
|
||||||
className="w-full">
|
className="w-full font-normal">
|
||||||
{t("disband_team")}
|
{t("disband_team")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
@ -164,6 +175,7 @@ export default function TeamListItem(props: Props) {
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isOwner && (
|
{!isOwner && (
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<Dialog>
|
<Dialog>
|
||||||
|
|
|
@ -87,7 +87,7 @@ export default function TeamSettings(props: Props) {
|
||||||
htmlFor="team-url"
|
htmlFor="team-url"
|
||||||
Input={
|
Input={
|
||||||
<TextField
|
<TextField
|
||||||
name="team-url"
|
name="" // typescript requires name but we don't want component to render name label
|
||||||
id="team-url"
|
id="team-url"
|
||||||
addOnLeading={
|
addOnLeading={
|
||||||
<span className="inline-flex items-center px-3 text-gray-500 border border-r-0 border-gray-300 rounded-l-sm bg-gray-50 sm:text-sm">
|
<span className="inline-flex items-center px-3 text-gray-500 border border-r-0 border-gray-300 rounded-l-sm bg-gray-50 sm:text-sm">
|
||||||
|
|
|
@ -109,8 +109,7 @@ export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers;
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* TODO: Team availability */}
|
{props.team?.id && props.role !== MembershipRole.MEMBER && (
|
||||||
{props.team?.id && (
|
|
||||||
<Link href={`/settings/teams/${props.team.id}/availability`}>
|
<Link href={`/settings/teams/${props.team.id}/availability`}>
|
||||||
<div className="mt-5 space-y-1">
|
<div className="mt-5 space-y-1">
|
||||||
<LinkIconButton Icon={ClockIcon}>{"View Availability"}</LinkIconButton>
|
<LinkIconButton Icon={ClockIcon}>{"View Availability"}</LinkIconButton>
|
||||||
|
|
|
@ -33,7 +33,7 @@ export function Alert(props: AlertProps) {
|
||||||
<CheckCircleIcon className={classNames("h-5 w-5 text-gray-400")} aria-hidden="true" />
|
<CheckCircleIcon className={classNames("h-5 w-5 text-gray-400")} aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3 flex-grow">
|
<div className="flex-grow ml-3">
|
||||||
<h3 className="text-sm font-medium">{props.title}</h3>
|
<h3 className="text-sm font-medium">{props.title}</h3>
|
||||||
<div className="text-sm">{props.message}</div>
|
<div className="text-sm">{props.message}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||||
|
|
||||||
import Avatar from "@components/ui/Avatar";
|
import Avatar from "@components/ui/Avatar";
|
||||||
import { DatePicker } from "@components/ui/form/DatePicker";
|
import { DatePicker } from "@components/ui/form/DatePicker";
|
||||||
import MinutesField from "@components/ui/form/MinutesField";
|
import Select from "@components/ui/form/Select";
|
||||||
|
|
||||||
import TeamAvailabilityTimes from "./TeamAvailabilityTimes";
|
import TeamAvailabilityTimes from "./TeamAvailabilityTimes";
|
||||||
|
|
||||||
|
@ -22,8 +22,10 @@ interface Props {
|
||||||
export default function TeamAvailabilityModal(props: Props) {
|
export default function TeamAvailabilityModal(props: Props) {
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const [selectedDate, setSelectedDate] = useState(dayjs());
|
const [selectedDate, setSelectedDate] = useState(dayjs());
|
||||||
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(dayjs.tz.guess);
|
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(
|
||||||
const [frequency, setFrequency] = useState<number>(30);
|
localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()
|
||||||
|
);
|
||||||
|
const [frequency, setFrequency] = useState<15 | 30 | 60>(30);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
utils.invalidateQueries(["viewer.teams.getMemberAvailability"]);
|
utils.invalidateQueries(["viewer.teams.getMemberAvailability"]);
|
||||||
|
@ -64,24 +66,25 @@ export default function TeamAvailabilityModal(props: Props) {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-bold text-gray-600">Slot Length</span>
|
<span className="font-bold text-gray-600">Slot Length</span>
|
||||||
<MinutesField
|
<Select
|
||||||
id="length"
|
options={[
|
||||||
label=""
|
{ value: 15, label: "15 minutes" },
|
||||||
required
|
{ value: 30, label: "30 minutes" },
|
||||||
min="10"
|
{ value: 60, label: "60 minutes" },
|
||||||
placeholder="15"
|
]}
|
||||||
defaultValue={frequency}
|
isSearchable={false}
|
||||||
onChange={(e) => {
|
classNamePrefix="react-select"
|
||||||
setFrequency(Number(e.target.value));
|
className="flex-1 block w-full min-w-0 border border-gray-300 rounded-sm react-select-container focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||||
}}
|
value={{ value: frequency, label: `${frequency} minutes` }}
|
||||||
|
onChange={(newFrequency) => setFrequency(newFrequency?.value ?? 30)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{props.team && props.member && (
|
{props.team && props.member && (
|
||||||
<TeamAvailabilityTimes
|
<TeamAvailabilityTimes
|
||||||
className="overflow-scroll"
|
className="overflow-scroll"
|
||||||
team={props.team}
|
teamId={props.team.id}
|
||||||
member={props.member}
|
memberId={props.member.id}
|
||||||
frequency={frequency}
|
frequency={frequency}
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
selectedTimeZone={selectedTimeZone}
|
selectedTimeZone={selectedTimeZone}
|
||||||
|
|
|
@ -9,22 +9,21 @@ import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||||
|
|
||||||
import Avatar from "@components/ui/Avatar";
|
import Avatar from "@components/ui/Avatar";
|
||||||
import { DatePicker } from "@components/ui/form/DatePicker";
|
import { DatePicker } from "@components/ui/form/DatePicker";
|
||||||
import MinutesField from "@components/ui/form/MinutesField";
|
import Select from "@components/ui/form/Select";
|
||||||
|
|
||||||
import TeamAvailabilityTimes from "./TeamAvailabilityTimes";
|
import TeamAvailabilityTimes from "./TeamAvailabilityTimes";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
team?: inferQueryOutput<"viewer.teams.get">;
|
team?: inferQueryOutput<"viewer.teams.get">;
|
||||||
members?: inferQueryOutput<"viewer.teams.get">["members"];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// type Member = inferQueryOutput<"viewer.teams.get">["members"][number];
|
|
||||||
|
|
||||||
export default function TeamAvailabilityScreen(props: Props) {
|
export default function TeamAvailabilityScreen(props: Props) {
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const [selectedDate, setSelectedDate] = useState(dayjs());
|
const [selectedDate, setSelectedDate] = useState(dayjs());
|
||||||
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(dayjs.tz.guess());
|
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(
|
||||||
const [frequency, setFrequency] = useState<number>(30);
|
localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()
|
||||||
|
);
|
||||||
|
const [frequency, setFrequency] = useState<15 | 30 | 60>(30);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
utils.invalidateQueries(["viewer.teams.getMemberAvailability"]);
|
utils.invalidateQueries(["viewer.teams.getMemberAvailability"]);
|
||||||
|
@ -32,14 +31,14 @@ export default function TeamAvailabilityScreen(props: Props) {
|
||||||
}, [selectedTimeZone, selectedDate]);
|
}, [selectedTimeZone, selectedDate]);
|
||||||
|
|
||||||
const Item = ({ index, style }: { index: number; style: CSSProperties }) => {
|
const Item = ({ index, style }: { index: number; style: CSSProperties }) => {
|
||||||
const member = props.members?.[index];
|
const member = props.team?.members?.[index];
|
||||||
if (!member) return <></>;
|
if (!member) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={member.id} style={style} className="flex pl-4 border-r border-gray-200 ">
|
<div key={member.id} style={style} className="flex pl-4 border-r border-gray-200 ">
|
||||||
<TeamAvailabilityTimes
|
<TeamAvailabilityTimes
|
||||||
team={props.team as inferQueryOutput<"viewer.teams.get">}
|
teamId={props.team?.id as number}
|
||||||
member={member}
|
memberId={member.id}
|
||||||
frequency={frequency}
|
frequency={frequency}
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
selectedTimeZone={selectedTimeZone}
|
selectedTimeZone={selectedTimeZone}
|
||||||
|
@ -86,17 +85,17 @@ export default function TeamAvailabilityScreen(props: Props) {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-bold text-gray-600">Slot Length</span>
|
<span className="font-bold text-gray-600">Slot Length</span>
|
||||||
<MinutesField
|
<Select
|
||||||
id="length"
|
options={[
|
||||||
label=""
|
{ value: 15, label: "15 minutes" },
|
||||||
required
|
{ value: 30, label: "30 minutes" },
|
||||||
min="10"
|
{ value: 60, label: "60 minutes" },
|
||||||
className="p-2.5"
|
]}
|
||||||
placeholder="15"
|
isSearchable={false}
|
||||||
defaultValue={frequency}
|
classNamePrefix="react-select"
|
||||||
onChange={(e) => {
|
className="flex-1 block w-full min-w-0 border border-gray-300 rounded-sm react-select-container focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||||
setFrequency(Number(e.target.value));
|
value={{ value: frequency, label: `${frequency} minutes` }}
|
||||||
}}
|
onChange={(newFrequency) => setFrequency(newFrequency?.value ?? 30)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -105,10 +104,10 @@ export default function TeamAvailabilityScreen(props: Props) {
|
||||||
{({ height, width }) => (
|
{({ height, width }) => (
|
||||||
<List
|
<List
|
||||||
itemSize={240}
|
itemSize={240}
|
||||||
itemCount={props.members?.length || 0}
|
itemCount={props.team?.members?.length ?? 0}
|
||||||
className="List"
|
className="List"
|
||||||
height={height}
|
height={height}
|
||||||
direction="horizontal"
|
layout="horizontal"
|
||||||
width={width}>
|
width={width}>
|
||||||
{Item}
|
{Item}
|
||||||
</List>
|
</List>
|
||||||
|
|
|
@ -5,13 +5,13 @@ import React from "react";
|
||||||
import { ITimezone } from "react-timezone-select";
|
import { ITimezone } from "react-timezone-select";
|
||||||
|
|
||||||
import getSlots from "@lib/slots";
|
import getSlots from "@lib/slots";
|
||||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
import { trpc } from "@lib/trpc";
|
||||||
|
|
||||||
import Loader from "@components/Loader";
|
import Loader from "@components/Loader";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
team: inferQueryOutput<"viewer.teams.get">;
|
teamId: number;
|
||||||
member: inferQueryOutput<"viewer.teams.get">["members"][number];
|
memberId: number;
|
||||||
selectedDate: Dayjs;
|
selectedDate: Dayjs;
|
||||||
selectedTimeZone: ITimezone;
|
selectedTimeZone: ITimezone;
|
||||||
frequency: number;
|
frequency: number;
|
||||||
|
@ -26,8 +26,8 @@ export default function TeamAvailabilityTimes(props: Props) {
|
||||||
[
|
[
|
||||||
"viewer.teams.getMemberAvailability",
|
"viewer.teams.getMemberAvailability",
|
||||||
{
|
{
|
||||||
teamId: props.team.id,
|
teamId: props.teamId,
|
||||||
memberId: props.member.id,
|
memberId: props.memberId,
|
||||||
dateFrom: props.selectedDate.toString(),
|
dateFrom: props.selectedDate.toString(),
|
||||||
dateTo: props.selectedDate.add(1, "day").toString(),
|
dateTo: props.selectedDate.add(1, "day").toString(),
|
||||||
timezone: `${props.selectedTimeZone.toString()}`,
|
timezone: `${props.selectedTimeZone.toString()}`,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
import TeamAvailabilityScreen from "@ee/components/team/availability/TeamAvailabilityScreen";
|
import TeamAvailabilityScreen from "@ee/components/team/availability/TeamAvailabilityScreen";
|
||||||
|
|
||||||
|
@ -7,15 +7,18 @@ import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||||
import { trpc } from "@lib/trpc";
|
import { trpc } from "@lib/trpc";
|
||||||
|
|
||||||
import Loader from "@components/Loader";
|
import Loader from "@components/Loader";
|
||||||
import Shell from "@components/Shell";
|
import Shell, { useMeQuery } from "@components/Shell";
|
||||||
import { Alert } from "@components/ui/Alert";
|
import { Alert } from "@components/ui/Alert";
|
||||||
import Avatar from "@components/ui/Avatar";
|
import Avatar from "@components/ui/Avatar";
|
||||||
|
|
||||||
export function TeamSettingsPage() {
|
export function TeamAvailabilityPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
|
const me = useMeQuery();
|
||||||
|
const isFreeUser = me.data?.plan === "FREE";
|
||||||
|
|
||||||
const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: Number(router.query.id) }], {
|
const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: Number(router.query.id) }], {
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
onError: (e) => {
|
onError: (e) => {
|
||||||
|
@ -23,14 +26,20 @@ export function TeamSettingsPage() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// prevent unnecessary re-renders due to shell queries
|
||||||
|
const TeamAvailability = useMemo(() => {
|
||||||
|
return <TeamAvailabilityScreen team={team} />;
|
||||||
|
}, [team]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell
|
<Shell
|
||||||
showBackButton={!errorMessage}
|
showBackButton={!errorMessage}
|
||||||
heading={team?.name}
|
heading={!isFreeUser && team?.name}
|
||||||
flexChildrenContainer
|
flexChildrenContainer
|
||||||
subtitle={team && "Your team's availability at a glance"}
|
subtitle={team && !isFreeUser && "Your team's availability at a glance"}
|
||||||
HeadingLeftIcon={
|
HeadingLeftIcon={
|
||||||
team && (
|
team &&
|
||||||
|
!isFreeUser && (
|
||||||
<Avatar
|
<Avatar
|
||||||
size={12}
|
size={12}
|
||||||
imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)}
|
imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)}
|
||||||
|
@ -41,9 +50,17 @@ export function TeamSettingsPage() {
|
||||||
}>
|
}>
|
||||||
{!!errorMessage && <Alert className="-mt-24 border" severity="error" title={errorMessage} />}
|
{!!errorMessage && <Alert className="-mt-24 border" severity="error" title={errorMessage} />}
|
||||||
{isLoading && <Loader />}
|
{isLoading && <Loader />}
|
||||||
{team && <TeamAvailabilityScreen team={team} members={team.members} />}
|
{isFreeUser ? (
|
||||||
|
<Alert
|
||||||
|
className="-mt-24 border"
|
||||||
|
severity="warning"
|
||||||
|
title="This is a pro feature. Upgrade to pro to see your team's availability."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
TeamAvailability
|
||||||
|
)}
|
||||||
</Shell>
|
</Shell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TeamSettingsPage;
|
export default TeamAvailabilityPage;
|
||||||
|
|
|
@ -86,7 +86,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
const isAuthorized = (function () {
|
const isAuthorized = (function () {
|
||||||
if (event.team) {
|
if (event.team) {
|
||||||
return event.team.members
|
return event.team.members
|
||||||
.filter((member) => member.role === MembershipRole.OWNER)
|
.filter((member) => member.role === MembershipRole.OWNER || member.role === MembershipRole.ADMIN)
|
||||||
.map((member) => member.userId)
|
.map((member) => member.userId)
|
||||||
.includes(session.user.id);
|
.includes(session.user.id);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { PlusIcon } from "@heroicons/react/solid";
|
import { PlusIcon } from "@heroicons/react/solid";
|
||||||
|
import classNames from "classnames";
|
||||||
import { useSession } from "next-auth/client";
|
import { useSession } from "next-auth/client";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
@ -7,7 +8,7 @@ import { trpc } from "@lib/trpc";
|
||||||
|
|
||||||
import Loader from "@components/Loader";
|
import Loader from "@components/Loader";
|
||||||
import SettingsShell from "@components/SettingsShell";
|
import SettingsShell from "@components/SettingsShell";
|
||||||
import Shell from "@components/Shell";
|
import Shell, { useMeQuery } from "@components/Shell";
|
||||||
import TeamCreateModal from "@components/team/TeamCreateModal";
|
import TeamCreateModal from "@components/team/TeamCreateModal";
|
||||||
import TeamList from "@components/team/TeamList";
|
import TeamList from "@components/team/TeamList";
|
||||||
import { Alert } from "@components/ui/Alert";
|
import { Alert } from "@components/ui/Alert";
|
||||||
|
@ -19,6 +20,8 @@ export default function Teams() {
|
||||||
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
|
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
|
const me = useMeQuery();
|
||||||
|
|
||||||
const { data } = trpc.useQuery(["viewer.teams.list"], {
|
const { data } = trpc.useQuery(["viewer.teams.list"], {
|
||||||
onError: (e) => {
|
onError: (e) => {
|
||||||
setErrorMessage(e.message);
|
setErrorMessage(e.message);
|
||||||
|
@ -29,15 +32,34 @@ export default function Teams() {
|
||||||
|
|
||||||
const teams = data?.filter((m) => m.accepted) || [];
|
const teams = data?.filter((m) => m.accepted) || [];
|
||||||
const invites = data?.filter((m) => !m.accepted) || [];
|
const invites = data?.filter((m) => !m.accepted) || [];
|
||||||
|
const isFreePlan = me.data?.plan === "FREE";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell heading={t("teams")} subtitle={t("create_manage_teams_collaborative")}>
|
<Shell heading={t("teams")} subtitle={t("create_manage_teams_collaborative")}>
|
||||||
<SettingsShell>
|
<SettingsShell>
|
||||||
{!!errorMessage && <Alert severity="error" title={errorMessage} />}
|
{!!errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||||
|
{isFreePlan && (
|
||||||
|
<Alert
|
||||||
|
severity="warning"
|
||||||
|
title={<>{t("plan_upgrade_teams")}</>}
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
{t("to_upgrade_go_to")}{" "}
|
||||||
|
<a href={"https://cal.com/upgrade"} className="underline">
|
||||||
|
{"https://cal.com/upgrade"}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
className="my-4"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{showCreateTeamModal && <TeamCreateModal onClose={() => setShowCreateTeamModal(false)} />}
|
{showCreateTeamModal && <TeamCreateModal onClose={() => setShowCreateTeamModal(false)} />}
|
||||||
<div className="flex justify-end my-4">
|
<div className={classNames("flex justify-end my-4", isFreePlan && "opacity-50")}>
|
||||||
<Button type="button" className="btn btn-white" onClick={() => setShowCreateTeamModal(true)}>
|
<Button
|
||||||
|
disabled={isFreePlan}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-white"
|
||||||
|
onClick={() => setShowCreateTeamModal(true)}>
|
||||||
<PlusIcon className="group-hover:text-black text-gray-700 w-3.5 h-3.5 mr-2 inline-block" />
|
<PlusIcon className="group-hover:text-black text-gray-700 w-3.5 h-3.5 mr-2 inline-block" />
|
||||||
{t("new_team")}
|
{t("new_team")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -445,6 +445,7 @@
|
||||||
"hidden": "Hidden",
|
"hidden": "Hidden",
|
||||||
"readonly": "Readonly",
|
"readonly": "Readonly",
|
||||||
"plan_upgrade": "You need to upgrade your plan to have more than one active event type.",
|
"plan_upgrade": "You need to upgrade your plan to have more than one active event type.",
|
||||||
|
"plan_upgrade_teams": "You need to upgrade your plan to create a team.",
|
||||||
"plan_upgrade_instructions": "To upgrade, go to <a href=\"https://cal.com/upgrade\" className=\"underline\">https://cal.com/upgrade</a>",
|
"plan_upgrade_instructions": "To upgrade, go to <a href=\"https://cal.com/upgrade\" className=\"underline\">https://cal.com/upgrade</a>",
|
||||||
"event_types_page_title": "Event Types",
|
"event_types_page_title": "Event Types",
|
||||||
"event_types_page_subtitle": "Create events to share for people to book on your calendar.",
|
"event_types_page_subtitle": "Create events to share for people to book on your calendar.",
|
||||||
|
|
|
@ -44,6 +44,7 @@ async function getUserFromSession({
|
||||||
avatar: true,
|
avatar: true,
|
||||||
twoFactorEnabled: true,
|
twoFactorEnabled: true,
|
||||||
brandColor: true,
|
brandColor: true,
|
||||||
|
plan: true,
|
||||||
credentials: {
|
credentials: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { BookingStatus, Prisma } from "@prisma/client";
|
import { BookingStatus, MembershipRole, Prisma } from "@prisma/client";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
@ -57,6 +57,7 @@ const loggedInViewerRouter = createProtectedRouter()
|
||||||
completedOnboarding,
|
completedOnboarding,
|
||||||
twoFactorEnabled,
|
twoFactorEnabled,
|
||||||
brandColor,
|
brandColor,
|
||||||
|
plan,
|
||||||
} = ctx.user;
|
} = ctx.user;
|
||||||
const me = {
|
const me = {
|
||||||
id,
|
id,
|
||||||
|
@ -72,6 +73,7 @@ const loggedInViewerRouter = createProtectedRouter()
|
||||||
completedOnboarding,
|
completedOnboarding,
|
||||||
twoFactorEnabled,
|
twoFactorEnabled,
|
||||||
brandColor,
|
brandColor,
|
||||||
|
plan,
|
||||||
};
|
};
|
||||||
return me;
|
return me;
|
||||||
},
|
},
|
||||||
|
@ -231,7 +233,7 @@ const loggedInViewerRouter = createProtectedRouter()
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
membershipCount: membership.team.members.length,
|
membershipCount: membership.team.members.length,
|
||||||
readOnly: membership.role !== "OWNER",
|
readOnly: membership.role === MembershipRole.MEMBER,
|
||||||
},
|
},
|
||||||
eventTypes: membership.team.eventTypes,
|
eventTypes: membership.team.eventTypes,
|
||||||
}))
|
}))
|
||||||
|
|
|
@ -58,6 +58,10 @@ export const viewerTeamsRouter = createProtectedRouter()
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
}),
|
}),
|
||||||
async resolve({ ctx, input }) {
|
async resolve({ ctx, input }) {
|
||||||
|
if (ctx.user.plan === "FREE") {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not a pro user." });
|
||||||
|
}
|
||||||
|
|
||||||
const slug = slugify(input.name);
|
const slug = slugify(input.name);
|
||||||
|
|
||||||
const nameCollisions = await ctx.prisma.team.count({
|
const nameCollisions = await ctx.prisma.team.count({
|
||||||
|
|
Loading…
Reference in a new issue