Admin can check in and check out teams

This commit is contained in:
Kalle (Sendou)
2021-12-08 01:11:31 +02:00
parent 14bf713094
commit a9384ca099
11 changed files with 160 additions and 33 deletions

View File

@@ -24,10 +24,12 @@ Prerequisites: [Node.js 16.13](https://nodejs.org/en/)
- [x] Captain can remove players from roster
- [ ] Add info about architecture to README
- [ ] Move away from MyForm
- [x] Make description mandatory + overview tab
- [x] Captain can check in
- [ ] Admin can check teams in (and out)
- [x] Admin can check teams in / out
- [ ] Admin can drop people out (show on bracket somehow that they dropped)
- [ ] Admin can change team captain
- [ ] Admin can randomize and rerandomize maps
- [ ] Admin can change seeding
- [ ] Admin can start the tournament

View File

@@ -2,7 +2,7 @@ import classNames from "classnames";
import * as React from "react";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "outlined" | "destructive";
variant?: "outlined" | "destructive" | "minimal" | "minimal-destructive";
tiny?: boolean;
loading?: boolean;
loadingText?: string;
@@ -17,6 +17,8 @@ export function Button(props: ButtonProps) {
className={classNames(className, {
outlined: variant === "outlined",
destructive: variant === "destructive",
"minimal-destructive": variant === "minimal-destructive",
minimal: variant === "minimal",
loading: loading,
tiny,
})}

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { useFetcher, useLoaderData, useLocation } from "remix";
import { useFetcher, useLoaderData } from "remix";
import {
checkInClosesDate,
TOURNAMENT_TEAM_ROSTER_MIN_SIZE,
@@ -20,7 +20,6 @@ export function ActionSectionBeforeStartContent({
}) {
const fetcher = useFetcher();
const tournament = useLoaderData<FindTournamentByNameForUrlI>();
const location = useLocation();
const timeInMinutesBeforeCheckInCloses = () => {
return Math.floor(
@@ -113,7 +112,7 @@ export function ActionSectionBeforeStartContent({
>
<Button
variant="outlined"
loadingText="Checking-in..."
loadingText="Checking in..."
type="submit"
loading={fetcher.state !== "idle"}
>

View File

@@ -105,11 +105,6 @@ function dateYYYYMMDD(date: string) {
function InfoBannerActionButton() {
const data = useLoaderData<FindTournamentByNameForUrlI>();
const user = useUser();
const now = new Date();
// TODO: special case - tournament has started but can manage roster
const tournamentHasConcluded = new Date(data.startTime) < now;
if (tournamentHasConcluded) return null;
if (!user) {
return (

View File

@@ -0,0 +1,11 @@
import type { Organization } from ".prisma/client";
export const isTournamentAdmin = ({
userId,
organization,
}: {
userId: string;
organization: Organization;
}) => {
return organization.ownerId === userId;
};

View File

@@ -0,0 +1,2 @@
export const checkInHasStarted = (checkInStartTime: string) =>
new Date(checkInStartTime) < new Date();

View File

@@ -0,0 +1,12 @@
import type { ActionFunction } from "remix";
import { checkOut } from "~/services/tournament";
import { requireUser } from "~/utils";
export const action: ActionFunction = async ({ params, context }) => {
const teamId = params.teamId!;
const user = requireUser(context);
await checkOut({ teamId, userId: user.id });
return new Response(undefined, { status: 200 });
};

View File

@@ -1,4 +1,4 @@
import { useMatches } from "remix";
import { useFetcher, useMatches } from "remix";
import type { LinksFunction } from "remix";
import { Button } from "~/components/Button";
import type { FindTournamentByNameForUrlI } from "~/services/tournament";
@@ -7,6 +7,8 @@ import * as React from "react";
import classNames from "classnames";
import { TOURNAMENT_TEAM_ROSTER_MIN_SIZE } from "~/constants";
import { Alert } from "~/components/Alert";
import { MyForm } from "~/components/MyForm";
import { checkInHasStarted } from "~/core/tournament/utils";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
@@ -14,7 +16,8 @@ export const links: LinksFunction = () => {
export default function AdminPage() {
const [, parentRoute] = useMatches();
const { teams } = parentRoute.data as FindTournamentByNameForUrlI;
const { teams, checkInStartTime } =
parentRoute.data as FindTournamentByNameForUrlI;
return (
<>
@@ -29,33 +32,31 @@ export default function AdminPage() {
<div className="tournament__admin__teams-container__header">Seed</div>
<div className="tournament__admin__teams-container__header">Name</div>
<div className="tournament__admin__teams-container__header">
Registered at
</div>
<div className="tournament__admin__teams-container__header">
Check-in time
{checkInHasStarted(checkInStartTime) ? "" : "Registered at"}
</div>
<div className="tournament__admin__teams-container__header">
Roster size
</div>
{/* TODO: order by seed */}
{teams.map((team, i) => (
<React.Fragment key={team.id}>
<div>{i + 1}</div>
<div>{team.name}</div>
<div>
{new Date(team.createdAt).toLocaleString("en-US", {
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
})}
</div>
<div>
{team.checkedInTime
? new Date(team.checkedInTime).toLocaleString("en-US", {
{!checkInHasStarted(checkInStartTime) ? (
<>
{new Date(team.createdAt).toLocaleString("en-US", {
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
})
: null}
})}
</>
) : team.checkedInTime ? (
<CheckOutButton teamId={team.id} />
) : team.members.length >= TOURNAMENT_TEAM_ROSTER_MIN_SIZE ? (
<CheckInButton teamId={team.id} />
) : null}
</div>
<div
className={classNames({
@@ -73,3 +74,45 @@ export default function AdminPage() {
</>
);
}
function CheckOutButton({ teamId }: { teamId: string }) {
const fetcher = useFetcher();
return (
<MyForm
action={`/api/tournament/${teamId}/check-out`}
className="tournament__action-section__button-container"
fetcher={fetcher}
>
<Button
tiny
variant="minimal-destructive"
loading={fetcher.state !== "idle"}
loadingText="Checking out"
type="submit"
>
Check-out
</Button>
</MyForm>
);
}
function CheckInButton({ teamId }: { teamId: string }) {
const fetcher = useFetcher();
return (
<MyForm
action={`/api/tournament/${teamId}/check-in`}
className="tournament__action-section__button-container"
fetcher={fetcher}
>
<Button
tiny
variant="minimal"
loading={fetcher.state !== "idle"}
loadingText="Checking in"
type="submit"
>
Check-in
</Button>
</MyForm>
);
}

View File

@@ -4,6 +4,7 @@ import {
TOURNAMENT_TEAM_ROSTER_MAX_SIZE,
TOURNAMENT_TEAM_ROSTER_MIN_SIZE,
} from "~/constants";
import { isTournamentAdmin } from "~/core/tournament/permissions";
import { Serialized } from "~/utils";
import { db } from "~/utils/db.server";
@@ -408,13 +409,16 @@ export async function checkIn({
}) {
const tournamentTeam = await db.tournamentTeam.findUnique({
where: { id: teamId },
include: { tournament: true, members: true },
include: { tournament: { include: { organizer: true } }, members: true },
});
if (!tournamentTeam) throw new Response("Invalid team id", { status: 400 });
if (tournamentTeam.checkedInTime)
throw new Response("Already checked in", { status: 400 });
if (
!isTournamentAdmin({
userId,
organization: tournamentTeam.tournament.organizer,
}) &&
!tournamentTeam.members.some(
({ memberId, captain }) => captain && memberId === userId
)
@@ -424,12 +428,18 @@ export async function checkIn({
// cut them some slack so UI never shows you can check in when you can't
const checkInCutOff = TOURNAMENT_CHECK_IN_CLOSING_MINUTES_FROM_START - 2;
if (
!isTournamentAdmin({
userId,
organization: tournamentTeam.tournament.organizer,
}) &&
tournamentTeam.tournament.startTime.getTime() - checkInCutOff * 60000 <
new Date().getTime()
new Date().getTime()
) {
throw new Response("Check in time has passed", { status: 400 });
}
// TODO: fail if tournament has started
return db.tournamentTeam.update({
where: {
id: teamId,
@@ -439,3 +449,36 @@ export async function checkIn({
},
});
}
export async function checkOut({
teamId,
userId,
}: {
teamId: string;
userId: string;
}) {
const tournamentTeam = await db.tournamentTeam.findUnique({
where: { id: teamId },
include: { tournament: { include: { organizer: true } }, members: true },
});
if (!tournamentTeam) throw new Response("Invalid team id", { status: 400 });
if (
!isTournamentAdmin({
organization: tournamentTeam.tournament.organizer,
userId,
})
) {
throw new Response("Not tournament admin", { status: 401 });
}
// TODO: fail if tournament has started
return db.tournamentTeam.update({
where: {
id: teamId,
},
data: {
checkedInTime: null,
},
});
}

View File

@@ -116,6 +116,15 @@ button.tiny {
padding-inline: var(--s-2);
}
button.minimal {
padding: 0;
border: none;
background-color: transparent;
/* TODO: fix bad default */
color: var(--theme-success);
}
button.destructive {
border-color: var(--theme-error);
background-color: transparent;
@@ -123,6 +132,14 @@ button.destructive {
outline-color: var(--theme-error);
}
button.minimal-destructive {
padding: 0;
border: none;
background-color: transparent;
color: var(--theme-error);
outline-color: var(--theme-error);
}
button.loading {
cursor: not-allowed;
opacity: 0.6;

View File

@@ -2,6 +2,7 @@
display: flex;
justify-content: space-evenly;
gap: var(--s-6);
margin-block-end: var(--s-8);
}
.tournament__admin__alert {
@@ -15,7 +16,7 @@
width: 100%;
column-gap: var(--s-1);
font-size: var(--fonts-sm);
grid-template-columns: 1fr 3fr 2fr 2fr 1fr;
grid-template-columns: 1fr 3fr 3fr 1fr;
margin-block-start: var(--s-4);
row-gap: var(--s-1-5);
}