mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-14 07:06:14 -05:00
Admin can check in and check out teams
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
|
||||
@@ -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"}
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
11
app/core/tournament/permissions.ts
Normal file
11
app/core/tournament/permissions.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Organization } from ".prisma/client";
|
||||
|
||||
export const isTournamentAdmin = ({
|
||||
userId,
|
||||
organization,
|
||||
}: {
|
||||
userId: string;
|
||||
organization: Organization;
|
||||
}) => {
|
||||
return organization.ownerId === userId;
|
||||
};
|
||||
2
app/core/tournament/utils.ts
Normal file
2
app/core/tournament/utils.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const checkInHasStarted = (checkInStartTime: string) =>
|
||||
new Date(checkInStartTime) < new Date();
|
||||
12
app/routes/api/tournament/$teamId/check-out.ts
Normal file
12
app/routes/api/tournament/$teamId/check-out.ts
Normal 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 });
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user