From a22b125cadcd311f7edb441fe08930af1053b6f2 Mon Sep 17 00:00:00 2001 From: Kyle Flynn Date: Sun, 31 Mar 2024 23:07:20 -0400 Subject: [PATCH] Basic match control layout --- .../scorekeeper/hooks/use-match-control.ts | 152 ++++++++++++++++++ .../match-control/commit-scores-button.tsx | 31 ++++ .../match-control/displays-button.tsx | 20 +++ .../match-control/field-prep-button.tsx | 20 +++ .../match-control/match-control.tsx | 15 +- .../match-control/post-results-button.tsx | 20 +++ .../match-control/prestart-button.tsx | 19 ++- .../match-control/start-match-button.tsx | 31 ++++ .../match-header/alliance-card.tsx | 29 ++-- .../scorekeeper/match-header/match-header.tsx | 15 +- .../src/apps/scorekeeper/scorekeeper-app.tsx | 15 +- .../scorekeeper/tabs/scorekeeper-matches.tsx | 1 + .../scorekeeper/tabs/scorekeeper-tabs.tsx | 5 + .../dropdowns/autocomplete-team.tsx | 76 +++++++++ .../components/tables/match-results-table.tsx | 27 +++- front-end/src/stores/recoil/event-state.ts | 7 +- 16 files changed, 455 insertions(+), 28 deletions(-) create mode 100644 front-end/src/apps/scorekeeper/hooks/use-match-control.ts create mode 100644 front-end/src/apps/scorekeeper/match-control/commit-scores-button.tsx create mode 100644 front-end/src/apps/scorekeeper/match-control/displays-button.tsx create mode 100644 front-end/src/apps/scorekeeper/match-control/field-prep-button.tsx create mode 100644 front-end/src/apps/scorekeeper/match-control/post-results-button.tsx create mode 100644 front-end/src/apps/scorekeeper/match-control/start-match-button.tsx create mode 100644 front-end/src/components/dropdowns/autocomplete-team.tsx diff --git a/front-end/src/apps/scorekeeper/hooks/use-match-control.ts b/front-end/src/apps/scorekeeper/hooks/use-match-control.ts new file mode 100644 index 00000000..0f66e37f --- /dev/null +++ b/front-end/src/apps/scorekeeper/hooks/use-match-control.ts @@ -0,0 +1,152 @@ +import { MatchState } from '@toa-lib/models'; +import { useRecoilState } from 'recoil'; +import { matchStateAtom } from 'src/stores/recoil'; + +interface MatchControlState { + canPrestart: boolean; + canCancelPrestart: boolean; + canSetDisplays: boolean; + canPrepField: boolean; + canStartMatch: boolean; + canAbortMatch: boolean; + canResetField: boolean; + canCommitScores: boolean; + canPostResults: boolean; + setState: (state: MatchState) => void; +} + +export const useMatchControl = (): MatchControlState => { + const [matchState, setState] = useRecoilState(matchStateAtom); + switch (matchState) { + case MatchState.MATCH_NOT_SELECTED: + return { + canPrestart: false, + canCancelPrestart: false, + canSetDisplays: false, + canPrepField: false, + canStartMatch: false, + canAbortMatch: false, + canResetField: false, + canCommitScores: false, + canPostResults: false, + setState + }; + case MatchState.PRESTART_READY: + return { + canPrestart: true, + canCancelPrestart: false, + canSetDisplays: false, + canPrepField: false, + canStartMatch: false, + canAbortMatch: false, + canResetField: false, + canCommitScores: false, + canPostResults: false, + setState + }; + case MatchState.PRESTART_COMPLETE: + return { + canPrestart: false, + canCancelPrestart: true, + canSetDisplays: true, + canPrepField: false, + canStartMatch: false, + canAbortMatch: false, + canResetField: false, + canCommitScores: false, + canPostResults: false, + setState + }; + case MatchState.AUDIENCE_READY: + return { + canPrestart: false, + canCancelPrestart: true, + canSetDisplays: true, + canPrepField: true, + canStartMatch: false, + canAbortMatch: false, + canResetField: false, + canCommitScores: false, + canPostResults: false, + setState + }; + case MatchState.FIELD_READY: + return { + canPrestart: false, + canCancelPrestart: true, + canSetDisplays: true, + canPrepField: true, + canStartMatch: true, + canAbortMatch: false, + canResetField: false, + canCommitScores: false, + canPostResults: false, + setState + }; + case MatchState.MATCH_IN_PROGRESS: + return { + canPrestart: false, + canCancelPrestart: false, + canSetDisplays: false, + canPrepField: false, + canStartMatch: false, + canAbortMatch: true, + canResetField: false, + canCommitScores: false, + canPostResults: false, + setState + }; + case MatchState.MATCH_COMPLETE: + return { + canPrestart: false, + canCancelPrestart: false, + canSetDisplays: false, + canPrepField: false, + canStartMatch: false, + canAbortMatch: false, + canResetField: true, + canCommitScores: true, + canPostResults: false, + setState + }; + case MatchState.RESULTS_READY: + return { + canPrestart: false, + canCancelPrestart: false, + canSetDisplays: false, + canPrepField: false, + canStartMatch: false, + canAbortMatch: false, + canResetField: true, + canCommitScores: true, + canPostResults: false, + setState + }; + case MatchState.RESULTS_COMMITTED: + return { + canPrestart: false, + canCancelPrestart: false, + canSetDisplays: false, + canPrepField: false, + canStartMatch: false, + canAbortMatch: false, + canResetField: false, + canCommitScores: false, + canPostResults: true, + setState + }; + default: + return { + canPrestart: false, + canCancelPrestart: false, + canSetDisplays: false, + canPrepField: false, + canStartMatch: false, + canAbortMatch: false, + canResetField: false, + canCommitScores: false, + canPostResults: false, + setState + }; + } +}; diff --git a/front-end/src/apps/scorekeeper/match-control/commit-scores-button.tsx b/front-end/src/apps/scorekeeper/match-control/commit-scores-button.tsx new file mode 100644 index 00000000..e47f642b --- /dev/null +++ b/front-end/src/apps/scorekeeper/match-control/commit-scores-button.tsx @@ -0,0 +1,31 @@ +import { Button } from '@mui/material'; +import { FC } from 'react'; +import { useMatchControl } from '../hooks/use-match-control'; +import { MatchState } from '@toa-lib/models'; + +export const CommitScoresButton: FC = () => { + const { canResetField, canCommitScores, setState } = useMatchControl(); + const resetField = () => setState(MatchState.RESULTS_READY); + const commitScores = () => setState(MatchState.RESULTS_COMMITTED); + return canCommitScores ? ( + + ) : ( + + ); +}; diff --git a/front-end/src/apps/scorekeeper/match-control/displays-button.tsx b/front-end/src/apps/scorekeeper/match-control/displays-button.tsx new file mode 100644 index 00000000..15b00a97 --- /dev/null +++ b/front-end/src/apps/scorekeeper/match-control/displays-button.tsx @@ -0,0 +1,20 @@ +import { Button } from '@mui/material'; +import { FC } from 'react'; +import { useMatchControl } from '../hooks/use-match-control'; +import { MatchState } from '@toa-lib/models'; + +export const DisplaysButton: FC = () => { + const { canSetDisplays, setState } = useMatchControl(); + const setDisplays = () => setState(MatchState.AUDIENCE_READY); + return ( + + ); +}; diff --git a/front-end/src/apps/scorekeeper/match-control/field-prep-button.tsx b/front-end/src/apps/scorekeeper/match-control/field-prep-button.tsx new file mode 100644 index 00000000..07e2806a --- /dev/null +++ b/front-end/src/apps/scorekeeper/match-control/field-prep-button.tsx @@ -0,0 +1,20 @@ +import { Button } from '@mui/material'; +import { FC } from 'react'; +import { useMatchControl } from '../hooks/use-match-control'; +import { MatchState } from '@toa-lib/models'; + +export const FieldPrepButton: FC = () => { + const { canPrepField, setState } = useMatchControl(); + const prepareField = () => setState(MatchState.FIELD_READY); + return ( + + ); +}; diff --git a/front-end/src/apps/scorekeeper/match-control/match-control.tsx b/front-end/src/apps/scorekeeper/match-control/match-control.tsx index 7501b323..ad9cba80 100644 --- a/front-end/src/apps/scorekeeper/match-control/match-control.tsx +++ b/front-end/src/apps/scorekeeper/match-control/match-control.tsx @@ -1,6 +1,11 @@ import { Grid } from '@mui/material'; import { FC } from 'react'; import { PrestartButton } from './prestart-button'; +import { DisplaysButton } from './displays-button'; +import { FieldPrepButton } from './field-prep-button'; +import { StartMatchButton } from './start-match-button'; +import { CommitScoresButton } from './commit-scores-button'; +import { PostResultsButton } from './post-results-button'; export const MatchControl: FC = () => { return ( @@ -9,19 +14,19 @@ export const MatchControl: FC = () => { - AudienceDisplayButton + - FieldPrepButton + - StartMatchButton + - CommitScoresButton + - PostResultsButton + ); diff --git a/front-end/src/apps/scorekeeper/match-control/post-results-button.tsx b/front-end/src/apps/scorekeeper/match-control/post-results-button.tsx new file mode 100644 index 00000000..603395c6 --- /dev/null +++ b/front-end/src/apps/scorekeeper/match-control/post-results-button.tsx @@ -0,0 +1,20 @@ +import { Button } from '@mui/material'; +import { FC } from 'react'; +import { useMatchControl } from '../hooks/use-match-control'; +import { MatchState } from '@toa-lib/models'; + +export const PostResultsButton: FC = () => { + const { canPostResults, setState } = useMatchControl(); + const postResults = () => setState(MatchState.RESULTS_POSTED); + return ( + + ); +}; diff --git a/front-end/src/apps/scorekeeper/match-control/prestart-button.tsx b/front-end/src/apps/scorekeeper/match-control/prestart-button.tsx index 722036e8..76b0221a 100644 --- a/front-end/src/apps/scorekeeper/match-control/prestart-button.tsx +++ b/front-end/src/apps/scorekeeper/match-control/prestart-button.tsx @@ -1,12 +1,20 @@ import { Button } from '@mui/material'; -import { FC, useState } from 'react'; +import { FC } from 'react'; +import { useMatchControl } from '../hooks/use-match-control'; +import { MatchState } from '@toa-lib/models'; export const PrestartButton: FC = () => { - const [canPrestart, setCanPrestart] = useState(true); - const prestart = () => setCanPrestart(false); - const cancelPrestart = () => setCanPrestart(true); + const { canPrestart, canCancelPrestart, setState } = useMatchControl(); + const prestart = () => setState(MatchState.PRESTART_COMPLETE); + const cancelPrestart = () => setState(MatchState.PRESTART_READY); return canPrestart ? ( - ) : ( @@ -15,6 +23,7 @@ export const PrestartButton: FC = () => { color='error' variant='contained' onClick={cancelPrestart} + disabled={!canCancelPrestart} > Cancel Prestart diff --git a/front-end/src/apps/scorekeeper/match-control/start-match-button.tsx b/front-end/src/apps/scorekeeper/match-control/start-match-button.tsx new file mode 100644 index 00000000..b9acc534 --- /dev/null +++ b/front-end/src/apps/scorekeeper/match-control/start-match-button.tsx @@ -0,0 +1,31 @@ +import { Button } from '@mui/material'; +import { FC } from 'react'; +import { useMatchControl } from '../hooks/use-match-control'; +import { MatchState } from '@toa-lib/models'; + +export const StartMatchButton: FC = () => { + const { canStartMatch, canAbortMatch, setState } = useMatchControl(); + const startMatch = () => setState(MatchState.MATCH_IN_PROGRESS); + const abortMatch = () => setState(MatchState.PRESTART_READY); + return canStartMatch ? ( + + ) : ( + + ); +}; diff --git a/front-end/src/apps/scorekeeper/match-header/alliance-card.tsx b/front-end/src/apps/scorekeeper/match-header/alliance-card.tsx index d7c5f999..31ed4ec3 100644 --- a/front-end/src/apps/scorekeeper/match-header/alliance-card.tsx +++ b/front-end/src/apps/scorekeeper/match-header/alliance-card.tsx @@ -6,11 +6,12 @@ import { Team } from '@toa-lib/models'; import { FC } from 'react'; -import AutocompleteTeam from 'src/components/dropdowns/AutoCompleteTeam'; +import { AutocompleteTeam } from 'src/components/dropdowns/autocomplete-team'; import { ParticipantCardStatus } from './participant-card-status'; import CheckboxStatus from './checkbox-status'; interface Props { + teams?: Team[]; alliance: Alliance; disabled?: boolean; participants?: MatchParticipant[]; @@ -18,6 +19,7 @@ interface Props { } export const AllianceCard: FC = ({ + teams, alliance, disabled, participants, @@ -60,14 +62,17 @@ export const AllianceCard: FC = ({ }; return ( - - - + + + Team - + Card Status @@ -98,15 +103,21 @@ export const AllianceCard: FC = ({ changeDisqualified(p.station, value); }; return ( - - + + - + { +interface Props { + teams?: Team[]; +} + +export const MatchHeader: FC = ({ teams }) => { + const { canPrestart, canResetField } = useMatchControl(); const [match, setMatch] = useRecoilState(matchOccurringAtom); + const canEdit = canPrestart || canResetField; const handleParticipantChange = (participants: MatchParticipant[]) => { if (!match) return; setMatch({ ...match, participants }); @@ -15,15 +22,19 @@ export const MatchHeader: FC = () => { theme.spacing(2) }}> diff --git a/front-end/src/apps/scorekeeper/scorekeeper-app.tsx b/front-end/src/apps/scorekeeper/scorekeeper-app.tsx index 1a5d8aa9..8b788e09 100644 --- a/front-end/src/apps/scorekeeper/scorekeeper-app.tsx +++ b/front-end/src/apps/scorekeeper/scorekeeper-app.tsx @@ -4,18 +4,27 @@ import DefaultLayout from 'src/layouts/DefaultLayout'; import { MatchControl } from './match-control/match-control'; import { ScorekeeperTabs } from './tabs/scorekeeper-tabs'; import { MatchHeader } from './match-header/match-header'; +import { useTeamsForEvent } from 'src/api/use-team-data'; +import { Box } from '@mui/material'; export const ScorekeeperApp: FC = () => { const { data: event } = useCurrentEvent(); + const { data: teams } = useTeamsForEvent(event?.eventKey); return ( - - - + theme.spacing(3) }}> + + + theme.spacing(3) }}> + + + theme.spacing(3) }}> + + ); }; diff --git a/front-end/src/apps/scorekeeper/tabs/scorekeeper-matches.tsx b/front-end/src/apps/scorekeeper/tabs/scorekeeper-matches.tsx index 300b3f79..40953a03 100644 --- a/front-end/src/apps/scorekeeper/tabs/scorekeeper-matches.tsx +++ b/front-end/src/apps/scorekeeper/tabs/scorekeeper-matches.tsx @@ -32,6 +32,7 @@ export const ScorekeeperMatches: FC = ({ /> = ({ eventKey }) => { + const { setState } = useMatchControl(); const [tournamentKey, setTournamentKey] = useRecoilState( currentTournamentKeyAtom ); @@ -34,11 +37,13 @@ export const ScorekeeperTabs: FC = ({ eventKey }) => { setTournamentKey(key); setMatchId(null); setMatchOccurring(null); + setState(MatchState.MATCH_NOT_SELECTED); }; const handleMatchChange = (id: number) => { if (!matches) return null; setMatchId(id); setMatchOccurring(matches.find((m) => m.id === id) ?? null); + setState(MatchState.PRESTART_READY); }; return ( diff --git a/front-end/src/components/dropdowns/autocomplete-team.tsx b/front-end/src/components/dropdowns/autocomplete-team.tsx new file mode 100644 index 00000000..9c74c73c --- /dev/null +++ b/front-end/src/components/dropdowns/autocomplete-team.tsx @@ -0,0 +1,76 @@ +import { FC, SyntheticEvent } from 'react'; +import { useRecoilValue } from 'recoil'; +import Box from '@mui/material/Box'; +import Autocomplete from '@mui/material/Autocomplete'; +import TextField from '@mui/material/TextField'; +import { teamIdentifierAtom } from 'src/stores/NewRecoil'; +import { Team } from '@toa-lib/models'; + +interface Props { + teamKey: number | null; + disabled?: boolean; + white?: boolean; + teams?: Team[]; + onChange: (team: Team | null) => void; +} + +export const AutocompleteTeam: FC = ({ + teamKey, + disabled, + white, + teams, + onChange +}) => { + const identifier = useRecoilValue(teamIdentifierAtom); + const team = teams?.find((t) => t.teamKey === teamKey); + + const handleChange = (e: SyntheticEvent, team: Team | null) => onChange(team); + + return ( + `${option[identifier]}`} + renderOption={(props, option) => ( + + +   + {option[identifier]} + + )} + renderInput={(params) => ( + + )} + onChange={handleChange} + sx={{ + padding: 0, + '& fieldset': { + borderColor: white ? '#ffffff' : '#000000 !important' + }, + '& input': { + padding: '0 !important' + }, + '& .MuiOutlinedInput-root': { + paddingTop: '7px', + paddingBottom: '7px' + } + }} + /> + ); +}; diff --git a/front-end/src/components/tables/match-results-table.tsx b/front-end/src/components/tables/match-results-table.tsx index e56ae36b..4162a8da 100644 --- a/front-end/src/components/tables/match-results-table.tsx +++ b/front-end/src/components/tables/match-results-table.tsx @@ -7,6 +7,7 @@ import { DateTime } from 'luxon'; interface Props { matches: Match[]; teams: Team[]; + colored?: boolean; selected?: (match: Match) => boolean; onSelect?: (id: number) => void; } @@ -14,6 +15,7 @@ interface Props { export const MatchResultsTable: FC = ({ matches, teams, + colored, selected, onSelect }) => { @@ -51,9 +53,28 @@ export const MatchResultsTable: FC = ({ e.name, e.fieldNumber, DateTime.fromISO(e.startTime).toLocaleString(DateTime.DATETIME_SHORT), - ...participants, - e.redScore, - e.blueScore + ...participants.map((p, i) => ( + = allianceSize ? 'blue' : 'red') : undefined + } + > + {p} + + )), + + {e.redScore} + , + + {e.blueScore} + ]; }} /> diff --git a/front-end/src/stores/recoil/event-state.ts b/front-end/src/stores/recoil/event-state.ts index bbef9473..320b11d1 100644 --- a/front-end/src/stores/recoil/event-state.ts +++ b/front-end/src/stores/recoil/event-state.ts @@ -1,4 +1,4 @@ -import { Match, Team, Tournament } from '@toa-lib/models'; +import { Match, MatchState, Team, Tournament } from '@toa-lib/models'; import { atom, atomFamily } from 'recoil'; export const teamsByEventKeyAtomFam = atomFamily({ @@ -25,3 +25,8 @@ export const matchOccurringAtom = atom | null>({ key: 'eventState.matchOccurringAtom', default: null }); + +export const matchStateAtom = atom({ + key: 'eventState.matchStateAtom', + default: MatchState.MATCH_NOT_SELECTED +});