diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/index.ts b/apps/backend/src/lib/graphql/resolvers/mutations/index.ts index 7c4e69396..356380b6a 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/index.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/index.ts @@ -1,4 +1,5 @@ import { teamArrivedResolver } from './team-arrived'; +import { teamNotArrivedResolver } from './team-not-arrived'; import { disqualifyTeamResolver } from './disqualify-team'; import { assignPersonalAwardResolver } from './assign-personal-award'; import { @@ -53,6 +54,7 @@ import { updatePracticeTableAssignmentResolver } from './practice-tables'; export const mutationResolvers = { teamArrived: teamArrivedResolver, + teamNotArrived: teamNotArrivedResolver, disqualifyTeam: disqualifyTeamResolver, assignPersonalAward: assignPersonalAwardResolver, startJudgingSession: startJudgingSessionResolver, diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/team-arrived.ts b/apps/backend/src/lib/graphql/resolvers/mutations/team-arrived.ts index b479dace4..7a38963e6 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/team-arrived.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/team-arrived.ts @@ -67,7 +67,7 @@ export const teamArrivedResolver: GraphQLFieldResolver< .execute(); const pubSub = getRedisPubSub(); - await pubSub.publish(divisionId, RedisEventTypes.TEAM_ARRIVED, { teamId }); + await pubSub.publish(divisionId, RedisEventTypes.TEAM_ARRIVED, { teamId, arrived: true }); return { teamId }; } catch (error) { diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/team-not-arrived.ts b/apps/backend/src/lib/graphql/resolvers/mutations/team-not-arrived.ts new file mode 100644 index 000000000..761e750a1 --- /dev/null +++ b/apps/backend/src/lib/graphql/resolvers/mutations/team-not-arrived.ts @@ -0,0 +1,89 @@ +import { GraphQLFieldResolver } from 'graphql'; +import { RedisEventTypes } from '@lems/types/api/lems/redis'; +import { MutationError, MutationErrorCode } from '@lems/types/api/lems'; +import type { GraphQLContext } from '../../apollo-server'; +import db from '../../../database'; +import { getRedisPubSub } from '../../../redis/redis-pubsub'; + +interface TeamNotArrivedArgs { + teamId: string; + divisionId: string; +} + +interface TeamEvent { + teamId: string; +} + +/** + * Resolver for Mutation.teamNotArrived + * Marks that a team has not arrived at a division (reverses arrival) and publishes an event. + */ +export const teamNotArrivedResolver: GraphQLFieldResolver< + unknown, + GraphQLContext, + TeamNotArrivedArgs, + Promise +> = async (_root, { teamId, divisionId }, context) => { + try { + if (!context.user) { + throw new MutationError(MutationErrorCode.UNAUTHORIZED, 'Authentication required'); + } + + if (context.user.role !== 'pit-admin') { + throw new MutationError( + MutationErrorCode.FORBIDDEN, + 'User does not have permission to mark teams as not arrived' + ); + } + + if (!context.user.divisions.includes(divisionId)) { + throw new MutationError( + MutationErrorCode.FORBIDDEN, + 'User does not have access to this division' + ); + } + + const existing = await db.raw.sql + .selectFrom('team_divisions') + .select(['pk', 'arrived']) + .where('team_id', '=', teamId) + .where('division_id', '=', divisionId) + .executeTakeFirst(); + + if (!existing) { + throw new MutationError( + MutationErrorCode.NOT_FOUND, + `Team #${teamId} is not registered in this division` + ); + } + + if (!existing.arrived) { + throw new MutationError( + MutationErrorCode.CONFLICT, + `Team #${teamId} has not arrived at this division yet` + ); + } + + const update: Record = { arrived: false, arrived_at: null }; + await db.raw.sql + .updateTable('team_divisions') + .set(update) + .where('team_id', '=', teamId) + .where('division_id', '=', divisionId) + .execute(); + + const pubSub = getRedisPubSub(); + await pubSub.publish(divisionId, RedisEventTypes.TEAM_ARRIVED, { teamId, arrived: false }); + + return { teamId }; + } catch (error) { + console.error( + 'Error updating team arrival status for team:', + teamId, + 'in division:', + divisionId, + error + ); + throw error; + } +}; diff --git a/apps/backend/src/lib/graphql/resolvers/subscriptions/team-arrived.ts b/apps/backend/src/lib/graphql/resolvers/subscriptions/team-arrived.ts index 9baecf837..f8b3a8f27 100644 --- a/apps/backend/src/lib/graphql/resolvers/subscriptions/team-arrived.ts +++ b/apps/backend/src/lib/graphql/resolvers/subscriptions/team-arrived.ts @@ -1,8 +1,9 @@ import { RedisEventTypes } from '@lems/types/api/lems/redis'; import { getRedisPubSub } from '../../../redis/redis-pubsub'; -interface TeamEvent { +interface TeamArrivalEvent { teamId: string; + arrived: boolean; } interface TeamArrivalUpdatedSubscribeArgs { @@ -22,19 +23,21 @@ const teamArrivalUpdatedSubscribe = ( }; /** - * Transforms raw Redis events into minimal TeamEvent objects + * Transforms raw Redis events into TeamArrivalEvent objects */ const processTeamArrivalEvent = async ( event: Record -): Promise => { - const teamId = ((event.data as Record).teamId as string) || ''; +): Promise => { + const data = event.data as Record; + const teamId = (data.teamId as string) || ''; if (!teamId) { return null; } - const result: TeamEvent = { - teamId + const result: TeamArrivalEvent = { + teamId, + arrived: data.arrived !== false }; return result; diff --git a/apps/frontend/locale/en.json b/apps/frontend/locale/en.json index 40a81356c..7b7f323ec 100644 --- a/apps/frontend/locale/en.json +++ b/apps/frontend/locale/en.json @@ -112,7 +112,9 @@ "arrived": "Arrived", "all-arrived": "All teams arrived! Great job 🎉", "waiting-for": "Waiting for {count, plural, one {# team} other {# teams}}", - "teams-pending": "{count, plural, one {# team pending} other {# teams pending}}" + "teams-pending": "{count, plural, one {# team pending} other {# teams pending}}", + "arrived-teams": "{count, plural, one {# Arrived Team} other {# Arrived Teams}}", + "mark-not-arrived": "Mark as not arrived" }, "team-input": { "title": "Register Team Arrival", diff --git a/apps/frontend/locale/he.json b/apps/frontend/locale/he.json index 348730516..c38e0c42c 100644 --- a/apps/frontend/locale/he.json +++ b/apps/frontend/locale/he.json @@ -112,7 +112,9 @@ "arrived": "הגיעו", "all-arrived": "כל הקבוצות הגיעו! עבודה מעולה 🎉", "waiting-for": "-מחכים ל {count, plural, one {קבוצה} other {# קבוצות}}", - "teams-pending": "{count, plural, one {קבוצה טרם הגיעו} other {# קבוצות טרם הגיעו}}" + "teams-pending": "{count, plural, one {קבוצה טרם הגיעו} other {# קבוצות טרם הגיעו}}", + "arrived-teams": "{count, plural, one {קבוצה הגיעה} other {# קבוצות הגיעו}}", + "mark-not-arrived": "סמן כלא הגיעה" }, "team-input": { "title": "רישום הגעת קבוצה", diff --git a/apps/frontend/locale/pl.json b/apps/frontend/locale/pl.json index 34a581ed6..941c5c250 100644 --- a/apps/frontend/locale/pl.json +++ b/apps/frontend/locale/pl.json @@ -112,7 +112,9 @@ "arrived": "Przybyli", "all-arrived": "Wszystkie drużyny przybyły! Świetna robota 🎉", "waiting-for": "Oczekiwanie na {count, plural, one {# drużynę} other {# drużyny}}", - "teams-pending": "{count, plural, one {# drużyna w oczekiwaniu} other {# drużyny w oczekiwaniu}}" + "teams-pending": "{count, plural, one {# drużyna w oczekiwaniu} other {# drużyny w oczekiwaniu}}", + "arrived-teams": "{count, plural, one {# drużyna przybyła} other {# drużyny przybyły}}", + "mark-not-arrived": "Oznacz jako nieprzybyłą" }, "team-input": { "title": "Zarejestruj przybycie drużyny", diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge-advisor/graphql/subscriptions/team-arrival-updated.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge-advisor/graphql/subscriptions/team-arrival-updated.ts index aaa6a5541..58093534f 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge-advisor/graphql/subscriptions/team-arrival-updated.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge-advisor/graphql/subscriptions/team-arrival-updated.ts @@ -5,6 +5,7 @@ import type { SubscriptionVars, QueryData, JudgingSession } from '../types'; interface TeamEvent { teamId: string; + arrived: boolean; } interface TeamArrivalSubscriptionData { @@ -18,6 +19,7 @@ export const TEAM_ARRIVAL_UPDATED_SUBSCRIPTION: TypedDocumentNode< subscription TeamArrivalUpdated($divisionId: String!) { teamArrivalUpdated(divisionId: $divisionId) { teamId + arrived } } `; @@ -73,13 +75,13 @@ export function createTeamArrivalSubscription( const updateQuery = (prev: QueryData, { data }: { data?: unknown }) => { if (!data) return prev; - const { teamId } = (data as TeamArrivalSubscriptionData).teamArrivalUpdated; + const { teamId, arrived } = (data as TeamArrivalSubscriptionData).teamArrivalUpdated; return updateJudgingSessions(prev, sessions => updateInArray( sessions, session => session.team.id === teamId, - session => merge(session, { team: { arrived: true } }) + session => merge(session, { team: { arrived } }) ) ); }; diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge/graphql/subscriptions/team-arrival-updated.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge/graphql/subscriptions/team-arrival-updated.ts index 52f59403b..ec5e893c6 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge/graphql/subscriptions/team-arrival-updated.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge/graphql/subscriptions/team-arrival-updated.ts @@ -5,6 +5,7 @@ import type { SubscriptionVars, QueryData, JudgingSession } from '../types'; interface TeamEvent { teamId: string; + arrived: boolean; } interface TeamArrivalSubscriptionData { @@ -18,6 +19,7 @@ export const TEAM_ARRIVAL_UPDATED_SUBSCRIPTION: TypedDocumentNode< subscription TeamArrivalUpdated($divisionId: String!) { teamArrivalUpdated(divisionId: $divisionId) { teamId + arrived } } `; @@ -74,13 +76,13 @@ export function createTeamArrivalSubscription( const updateQuery = (prev: QueryData, { data }: { data?: unknown }) => { if (!data) return prev; - const { teamId } = (data as TeamArrivalSubscriptionData).teamArrivalUpdated; + const { teamId, arrived } = (data as TeamArrivalSubscriptionData).teamArrivalUpdated; return updateJudgingSessions(prev, sessions => updateInArray( sessions, session => session.team.id === teamId, - session => merge(session, { team: { arrived: true } }) + session => merge(session, { team: { arrived } }) ) ); }; diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/lead-judge/graphql/subscriptions/team-arrival-updated.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/lead-judge/graphql/subscriptions/team-arrival-updated.ts index 52f59403b..ec5e893c6 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/lead-judge/graphql/subscriptions/team-arrival-updated.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/lead-judge/graphql/subscriptions/team-arrival-updated.ts @@ -5,6 +5,7 @@ import type { SubscriptionVars, QueryData, JudgingSession } from '../types'; interface TeamEvent { teamId: string; + arrived: boolean; } interface TeamArrivalSubscriptionData { @@ -18,6 +19,7 @@ export const TEAM_ARRIVAL_UPDATED_SUBSCRIPTION: TypedDocumentNode< subscription TeamArrivalUpdated($divisionId: String!) { teamArrivalUpdated(divisionId: $divisionId) { teamId + arrived } } `; @@ -74,13 +76,13 @@ export function createTeamArrivalSubscription( const updateQuery = (prev: QueryData, { data }: { data?: unknown }) => { if (!data) return prev; - const { teamId } = (data as TeamArrivalSubscriptionData).teamArrivalUpdated; + const { teamId, arrived } = (data as TeamArrivalSubscriptionData).teamArrivalUpdated; return updateJudgingSessions(prev, sessions => updateInArray( sessions, session => session.team.id === teamId, - session => merge(session, { team: { arrived: true } }) + session => merge(session, { team: { arrived } }) ) ); }; diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/mc/graphql/subscriptions/team-arrival-updated.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/mc/graphql/subscriptions/team-arrival-updated.ts index cafafdc59..0b76698a0 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/mc/graphql/subscriptions/team-arrival-updated.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/mc/graphql/subscriptions/team-arrival-updated.ts @@ -4,13 +4,12 @@ import type { SubscriptionConfig } from '../../../../hooks/use-page-data'; import type { McData } from '../types'; interface TeamArrivedEvent { - matchId: string; teamId: string; arrived: boolean; } interface TeamArrivedSubscriptionData { - teamArrived: TeamArrivedEvent; + teamArrivalUpdated: TeamArrivedEvent; } interface SubscriptionVars { @@ -23,7 +22,6 @@ export const TEAM_ARRIVED_SUBSCRIPTION: TypedDocumentNode< > = gql` subscription TeamArrived($divisionId: String!) { teamArrivalUpdated(divisionId: $divisionId) { - matchId teamId arrived } @@ -36,22 +34,18 @@ export function createTeamArrivedSubscription(divisionId: string) { subscriptionVariables: { divisionId }, updateQuery: (prev: McData, { data }: { data?: unknown }) => { if (!prev.division?.field || !data) return prev; - const event = (data as TeamArrivedSubscriptionData).teamArrived; + const event = (data as TeamArrivedSubscriptionData).teamArrivalUpdated; return merge(prev, { division: { field: { - matches: prev.division.field.matches.map(_match => - _match.id === event.matchId - ? { - ..._match, - participants: _match.participants.map(p => - p.team?.id === event.teamId - ? { ...p, team: p.team ? { ...p.team, arrived: event.arrived } : null } - : p - ) - } - : _match - ) + matches: prev.division.field.matches.map(_match => ({ + ..._match, + participants: _match.participants.map(p => + p.team?.id === event.teamId + ? { ...p, team: { ...p.team, arrived: event.arrived } } + : p + ) + })) } } }); diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/components/arrivals-stats.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/components/arrivals-stats.tsx index 663dd07ee..07a12bb0a 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/components/arrivals-stats.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/components/arrivals-stats.tsx @@ -1,30 +1,53 @@ 'use client'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; -import { Box, Paper, Stack, Typography, LinearProgress, Chip, useTheme } from '@mui/material'; +import { + Box, + Paper, + Stack, + Typography, + LinearProgress, + Chip, + useTheme, + IconButton, + Collapse, + List, + ListItem, + ListItemText, + ListItemAvatar, + Avatar, + Tooltip +} from '@mui/material'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import GroupIcon from '@mui/icons-material/Group'; import WarningIcon from '@mui/icons-material/Warning'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import CancelIcon from '@mui/icons-material/Cancel'; import { Locale, Locales } from '@lems/localization'; -import { ResponsiveComponent } from '@lems/shared'; +import { ResponsiveComponent, Flag } from '@lems/shared'; import type { Team } from '../graphql'; interface ArrivalsStatsProps { teams: Team[]; + onTeamNotArrival: (team: Team) => Promise; loading?: boolean; } -export function ArrivalsStats({ teams, loading = false }: ArrivalsStatsProps) { +export function ArrivalsStats({ teams, onTeamNotArrival, loading = false }: ArrivalsStatsProps) { const theme = useTheme(); const currentLocale = useLocale() as Locale; const direction = Locales[currentLocale].direction; const t = useTranslations('pages.pit-admin.stats'); + const [showArrivedTeams, setShowArrivedTeams] = useState(false); + const [removingTeamId, setRemovingTeamId] = useState(null); const stats = useMemo(() => { const total = teams.length; - const arrived = teams.filter(t => t.arrived).length; + const arrivedTeams = teams.filter(t => t.arrived); + const arrived = arrivedTeams.length; const pending = total - arrived; const percentage = total === 0 ? 0 : Math.round((arrived / total) * 100); const isHighArrival = percentage >= 70; @@ -36,7 +59,8 @@ export function ArrivalsStats({ teams, loading = false }: ArrivalsStatsProps) { pending, percentage, isHighArrival, - missingTeams + missingTeams, + arrivedTeams }; }, [teams]); @@ -46,6 +70,15 @@ export function ArrivalsStats({ teams, loading = false }: ArrivalsStatsProps) { return 'error'; }, []); + const handleRemoveTeam = useCallback( + async (team: Team) => { + setRemovingTeamId(team.id); + await onTeamNotArrival(team); + setRemovingTeamId(null); + }, + [onTeamNotArrival] + ); + if (loading) { return ( @@ -222,6 +255,103 @@ export function ArrivalsStats({ teams, loading = false }: ArrivalsStatsProps) { )} + + {stats.arrivedTeams.length > 0 && ( + + setShowArrivedTeams(!showArrivedTeams)} + > + + + + {t('arrived-teams', { count: stats.arrivedTeams.length })} + + + + {showArrivedTeams ? : } + + + + + + {stats.arrivedTeams.map(team => ( + + handleRemoveTeam(team)} + disabled={removingTeamId === team.id} + sx={{ + color: 'white', + '&:hover': { + bgcolor: 'rgba(255, 255, 255, 0.1)', + color: '#f44336' + } + }} + > + + + + } + > + + + + + + + #{team.number} - {team.name} + + + } + secondary={ + + {team.affiliation}, {team.city} + + } + /> + + ))} + + + + )} ); diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/mutations/index.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/mutations/index.ts index 4b4ede82d..1a7efa054 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/mutations/index.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/mutations/index.ts @@ -1 +1,2 @@ export * from './team-arrived'; +export * from './team-not-arrived'; diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/mutations/team-arrived.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/mutations/team-arrived.ts index ff9c3a7b7..1dd108c36 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/mutations/team-arrived.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/mutations/team-arrived.ts @@ -1,6 +1,5 @@ import { gql, TypedDocumentNode, ApolloCache } from '@apollo/client'; -import { merge, updateById } from '@lems/shared/utils'; -import type { TeamEvent, Team } from '../types'; +import type { TeamEvent } from '../types'; interface TeamArrivedMutationData { teamArrived: TeamEvent; @@ -25,15 +24,10 @@ export const TEAM_ARRIVED_MUTATION: TypedDocumentNode< export function createTeamArrivedCacheUpdate(teamId: string) { return (cache: ApolloCache) => { cache.modify({ + id: cache.identify({ __typename: 'Team', id: teamId }), fields: { - division(existingDivision = {}) { - const division = existingDivision as { teams?: Team[] }; - if (!division.teams) { - return existingDivision; - } - return merge(existingDivision, { - teams: updateById(division.teams, teamId, team => merge(team, { arrived: true })) - }); + arrived() { + return true; } } }); diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/mutations/team-not-arrived.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/mutations/team-not-arrived.ts new file mode 100644 index 000000000..289ec88b5 --- /dev/null +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/mutations/team-not-arrived.ts @@ -0,0 +1,35 @@ +import { gql, TypedDocumentNode, ApolloCache } from '@apollo/client'; +import type { TeamEvent } from '../types'; + +interface TeamNotArrivedMutationData { + teamNotArrived: TeamEvent; +} + +interface TeamNotArrivedMutationVars { + teamId: string; + divisionId: string; +} + +export const TEAM_NOT_ARRIVED_MUTATION: TypedDocumentNode< + TeamNotArrivedMutationData, + TeamNotArrivedMutationVars +> = gql` + mutation TeamNotArrived($teamId: String!, $divisionId: String!) { + teamNotArrived(teamId: $teamId, divisionId: $divisionId) { + teamId + } + } +`; + +export function createTeamNotArrivedCacheUpdate(teamId: string) { + return (cache: ApolloCache) => { + cache.modify({ + id: cache.identify({ __typename: 'Team', id: teamId }), + fields: { + arrived() { + return false; + } + } + }); + }; +} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/query.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/query.ts index 2fa3381ca..343cbbd0f 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/query.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/query.ts @@ -21,6 +21,7 @@ export const GET_DIVISION_TEAMS: TypedDocumentNode = gql` city arrived region + logoUrl } } } diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/subscriptions/team-arrival-updated.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/subscriptions/team-arrival-updated.ts index 302db09b8..cec19b4c5 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/subscriptions/team-arrival-updated.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/subscriptions/team-arrival-updated.ts @@ -1,11 +1,11 @@ import { gql, TypedDocumentNode } from '@apollo/client'; import { merge, updateById, Reconciler } from '@lems/shared/utils'; import type { SubscriptionConfig } from '../../../../hooks/use-page-data'; -import type { TeamEvent } from '../types'; +import type { TeamArrivalEvent } from '../types'; import type { QueryData } from '../query'; interface SubscriptionData { - teamArrivalUpdated: TeamEvent; + teamArrivalUpdated: TeamArrivalEvent; } interface SubscriptionVars { @@ -19,6 +19,7 @@ export const TEAM_ARRIVAL_UPDATED_SUBSCRIPTION: TypedDocumentNode< subscription TeamArrivalUpdated($divisionId: String!) { teamArrivalUpdated(divisionId: $divisionId) { teamId + arrived } } `; @@ -26,13 +27,13 @@ export const TEAM_ARRIVAL_UPDATED_SUBSCRIPTION: TypedDocumentNode< const teamArrivalReconciler: Reconciler = (prev, { data }) => { if (!data) return prev; - const { teamId } = data.teamArrivalUpdated; + const { teamId, arrived } = data.teamArrivalUpdated; if (prev.division) { return merge(prev, { division: { id: prev.division.id, - teams: updateById(prev.division.teams, teamId, team => merge(team, { arrived: true })) + teams: updateById(prev.division.teams, teamId, team => merge(team, { arrived })) } }); } diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/types.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/types.ts index a7d69b7f0..318e849ce 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/types.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/types.ts @@ -6,8 +6,13 @@ export interface Team { city: string; region: string; arrived: boolean; + logoUrl: string | null; } export interface TeamEvent { teamId: string; } + +export interface TeamArrivalEvent extends TeamEvent { + arrived: boolean; +} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/page.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/page.tsx index 302b337c1..60e6caccf 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/page.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/page.tsx @@ -11,10 +11,12 @@ import { usePageData } from '../../hooks/use-page-data'; import { GET_DIVISION_TEAMS, TEAM_ARRIVED_MUTATION, + TEAM_NOT_ARRIVED_MUTATION, type Team, parseDivisionTeams, createTeamArrivalSubscription, - createTeamArrivedCacheUpdate + createTeamArrivedCacheUpdate, + createTeamNotArrivedCacheUpdate } from './graphql'; import { TeamArrivalInput } from './components/team-arrival-input'; import { ArrivalsStats } from './components/arrivals-stats'; @@ -28,6 +30,11 @@ export default function PitAdminPage() { toast.error(t('error')); } }); + const [teamNotArrivedMutation] = useMutation(TEAM_NOT_ARRIVED_MUTATION, { + onError: () => { + toast.error(t('error')); + } + }); const subscriptions = useMemo( () => [createTeamArrivalSubscription(currentDivision.id)], @@ -53,6 +60,16 @@ export default function PitAdminPage() { [teamArrivedMutation, currentDivision.id] ); + const handleTeamNotArrival = useCallback( + async (team: Team) => { + await teamNotArrivedMutation({ + variables: { teamId: team.id, divisionId: currentDivision.id }, + update: createTeamNotArrivedCacheUpdate(team.id) + }); + }, + [teamNotArrivedMutation, currentDivision.id] + ); + return ( <> @@ -65,7 +82,7 @@ export default function PitAdminPage() { disabled={loading} /> - + ); diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/referee/graphql/subscriptions/team-arrival-updated.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/referee/graphql/subscriptions/team-arrival-updated.ts index b748da82e..e596b08e6 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/referee/graphql/subscriptions/team-arrival-updated.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/referee/graphql/subscriptions/team-arrival-updated.ts @@ -4,7 +4,7 @@ import type { SubscriptionConfig } from '../../../../hooks/use-page-data'; import type { TeamArrivedEvent, RefereeData } from '../types'; interface TeamArrivedSubscriptionData { - teamArrived: TeamArrivedEvent; + teamArrivalUpdated: TeamArrivedEvent; } interface SubscriptionVars { @@ -18,6 +18,7 @@ export const TEAM_ARRIVED_SUBSCRIPTION: TypedDocumentNode< subscription TeamArrived($divisionId: String!) { teamArrivalUpdated(divisionId: $divisionId) { teamId + arrived } } `; @@ -28,22 +29,18 @@ export function createTeamArrivedSubscription(divisionId: string) { subscriptionVariables: { divisionId }, updateQuery: (prev: RefereeData, { data }: { data?: unknown }) => { if (!prev.division?.field || !data) return prev; - const event = (data as TeamArrivedSubscriptionData).teamArrived; + const event = (data as TeamArrivedSubscriptionData).teamArrivalUpdated; return merge(prev, { division: { field: { - matches: prev.division.field.matches.map(_match => - _match.id === event.matchId - ? { - ..._match, - participants: _match.participants.map(p => - p.team?.id === event.teamId - ? { ...p, team: p.team ? { ...p.team, arrived: event.arrived } : null } - : p - ) - } - : _match - ) + matches: prev.division.field.matches.map(_match => ({ + ..._match, + participants: _match.participants.map(p => + p.team?.id === event.teamId + ? { ...p, team: { ...p.team, arrived: event.arrived } } + : p + ) + })) } } }); diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/referee/graphql/types.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/referee/graphql/types.ts index 94d5c0dcd..02d81053b 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/referee/graphql/types.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/referee/graphql/types.ts @@ -76,7 +76,6 @@ export interface MatchEvent { export interface TeamArrivedEvent { teamId: string; - matchId: string; arrived: boolean; } diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-schedule/graphql/subscriptions/team-arrival.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-schedule/graphql/subscriptions/team-arrival.ts index e5e2b2a3c..849650c90 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-schedule/graphql/subscriptions/team-arrival.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/field-schedule/graphql/subscriptions/team-arrival.ts @@ -18,6 +18,7 @@ export const TEAM_ARRIVED_SUBSCRIPTION: TypedDocumentNode { if (!data) return prev; - const { teamId } = (data as TeamArrivalSubscriptionData).teamArrivalUpdated; + const { teamId, arrived } = (data as TeamArrivalSubscriptionData).teamArrivalUpdated; return updateJudgingSessions(prev, sessions => updateInArray( sessions, session => session.team.id === teamId, - session => merge(session, { team: { arrived: true } }) + session => merge(session, { team: { arrived } }) ) ); }; diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/graphql/subscriptions/team-arrival-updated.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/graphql/subscriptions/team-arrival-updated.ts index d35efed81..06ac1651c 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/graphql/subscriptions/team-arrival-updated.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/judging-status/graphql/subscriptions/team-arrival-updated.ts @@ -5,6 +5,7 @@ import type { SubscriptionVars, QueryData, JudgingSession } from '../types'; interface TeamEvent { teamId: string; + arrived: boolean; } interface TeamArrivalSubscriptionData { @@ -18,6 +19,7 @@ export const TEAM_ARRIVAL_UPDATED_SUBSCRIPTION: TypedDocumentNode< subscription TeamArrivalUpdated($divisionId: String!) { teamArrivalUpdated(divisionId: $divisionId) { teamId + arrived } } `; @@ -51,7 +53,7 @@ export function createTeamArrivalSubscription( updateQuery: (prev: QueryData, { data }: { data?: unknown }) => { if (!data) return prev; - const { teamId } = (data as TeamArrivalSubscriptionData).teamArrivalUpdated; + const { teamId, arrived } = (data as TeamArrivalSubscriptionData).teamArrivalUpdated; return updateJudgingSessions(prev, sessions => updateInArray( @@ -59,7 +61,7 @@ export function createTeamArrivalSubscription( session => session.team?.id === teamId, session => merge(session, { - team: session.team ? merge(session.team, { arrived: true }) : null + team: session.team ? merge(session.team, { arrived }) : null }) ) ); diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/team-list/graphql/subscriptions/team-arrival.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/team-list/graphql/subscriptions/team-arrival.ts index 643e84dc4..168b0e423 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/team-list/graphql/subscriptions/team-arrival.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/team-list/graphql/subscriptions/team-arrival.ts @@ -10,6 +10,7 @@ export const TEAM_ARRIVAL_UPDATED_SUBSCRIPTION: TypedDocumentNode< subscription TeamArrivalUpdated($divisionId: String!) { teamArrivalUpdated(divisionId: $divisionId) { teamId + arrived } } `; @@ -17,13 +18,13 @@ export const TEAM_ARRIVAL_UPDATED_SUBSCRIPTION: TypedDocumentNode< const teamRegistrationReconciler: Reconciler = (prev, { data }) => { if (!data) return prev; - const { teamId } = data.teamArrivalUpdated; + const { teamId, arrived } = data.teamArrivalUpdated; if (prev.division) { return merge(prev, { division: { id: prev.division.id, - teams: updateById(prev.division.teams, teamId, team => merge(team, { arrived: true })) + teams: updateById(prev.division.teams, teamId, team => merge(team, { arrived })) } }); } diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/team-list/graphql/types.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/team-list/graphql/types.ts index 71dc5c4e5..c88f4ee85 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/team-list/graphql/types.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/reports/team-list/graphql/types.ts @@ -10,6 +10,7 @@ export interface Team { export interface TeamEvent { teamId: string; + arrived: boolean; } export type QueryData = { division?: { id: string; teams: Team[] } | null }; diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/graphql/subscriptions/team-arrival-updated.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/graphql/subscriptions/team-arrival-updated.ts index 7844baa99..e1dc78a42 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/graphql/subscriptions/team-arrival-updated.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/graphql/subscriptions/team-arrival-updated.ts @@ -5,6 +5,7 @@ import type { ScorekeeperData } from '../types'; interface TeamEvent { teamId: string; + arrived: boolean; } interface TeamArrivalSubscriptionData { @@ -22,6 +23,7 @@ export const TEAM_ARRIVAL_UPDATED_SUBSCRIPTION: TypedDocumentNode< subscription TeamArrivalUpdated($divisionId: String!) { teamArrivalUpdated(divisionId: $divisionId) { teamId + arrived } } `; @@ -45,7 +47,7 @@ export function createTeamArrivalSubscription(divisionId: string) { ...participant, team: { ...participant.team, - arrived: true + arrived: teamArrivalUpdated.arrived } }; } diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/tournament-manager/graphql/subscriptions/team-arrived.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/tournament-manager/graphql/subscriptions/team-arrived.ts index c35c6b873..674634bc9 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/tournament-manager/graphql/subscriptions/team-arrived.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/tournament-manager/graphql/subscriptions/team-arrived.ts @@ -4,8 +4,9 @@ import type { SubscriptionConfig } from '../../../../hooks/use-page-data'; import type { SubscriptionVars, QueryData } from '../types'; interface SubscriptionData { - teamArrived: { + teamArrivalUpdated: { teamId: string; + arrived: boolean; }; } @@ -13,18 +14,19 @@ export const TEAM_ARRIVED_SUBSCRIPTION: TypedDocumentNode void + onTeamArrived?: (event: SubscriptionData['teamArrivalUpdated']) => void ): SubscriptionConfig { const updateQuery = (prev: QueryData, { data }: { data?: unknown }) => { if (!data) return prev; - const { teamId } = (data as SubscriptionData).teamArrived; + const { teamId, arrived } = (data as SubscriptionData).teamArrivalUpdated; if (!prev.division) { return prev; @@ -34,7 +36,7 @@ export function createTeamArrivedSubscription( const updatedTeams = updateInArray( prev.division.teams, team => team.id === teamId, - team => ({ ...team, arrived: true }) + team => ({ ...team, arrived }) ); // Update match participants' teams @@ -50,7 +52,7 @@ export function createTeamArrivedSubscription( participant.team?.id === teamId ? { ...participant, - team: { ...participant.team, arrived: true } + team: { ...participant.team, arrived } } : participant ) @@ -65,7 +67,7 @@ export function createTeamArrivedSubscription( session.team?.id === teamId ? { ...session, - team: { ...session.team, arrived: true } + team: { ...session.team, arrived } } : session ); @@ -102,7 +104,7 @@ export function createTeamArrivedSubscription( const originalUpdateQuery = baseConfig.updateQuery; baseConfig.updateQuery = (prev: QueryData, subscriptionData: { data?: unknown }) => { if (subscriptionData.data) { - onTeamArrived((subscriptionData.data as SubscriptionData).teamArrived); + onTeamArrived((subscriptionData.data as SubscriptionData).teamArrivalUpdated); } return originalUpdateQuery(prev, subscriptionData); }; diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/deliberation/[category]/graphql/subscriptions/team-arrived.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/deliberation/[category]/graphql/subscriptions/team-arrived.ts index fff989373..4c5c4c32b 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/deliberation/[category]/graphql/subscriptions/team-arrived.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/deliberation/[category]/graphql/subscriptions/team-arrived.ts @@ -2,11 +2,14 @@ import { gql, type TypedDocumentNode } from '@apollo/client'; import { merge, type Reconciler } from '@lems/shared/utils'; import type { CategoryDeliberationData } from '../types'; +interface TeamArrivalEvent { + teamId: string; + arrived: boolean; +} + export const TEAM_ARRIVAL_UPDATED_SUBSCRIPTION: TypedDocumentNode< { - teamArrivalUpdated: { - teamId: string; - }; + teamArrivalUpdated: TeamArrivalEvent; }, { divisionId: string; @@ -15,30 +18,29 @@ export const TEAM_ARRIVAL_UPDATED_SUBSCRIPTION: TypedDocumentNode< subscription TeamArrivalUpdated($divisionId: String!) { teamArrivalUpdated(divisionId: $divisionId) { teamId + arrived } } `; const teamArrivalUpdatedReconciler: Reconciler< CategoryDeliberationData, - { teamArrivalUpdated: { teamId: string } } + { teamArrivalUpdated: TeamArrivalEvent } > = (prev, { data }) => { if (!data?.teamArrivalUpdated) return prev; - const { teamId } = data.teamArrivalUpdated; + const { teamId, arrived } = data.teamArrivalUpdated; const teamIndex = prev.division.teams.findIndex(t => t.id === teamId); if (teamIndex === -1) return prev; - const team = prev.division.teams[teamIndex]; - return merge(prev, { division: { teams: [ ...prev.division.teams.slice(0, teamIndex), { ...prev.division.teams[teamIndex], - arrived: !team.arrived + arrived }, ...prev.division.teams.slice(teamIndex + 1) ] diff --git a/libs/types/src/lib/api/lems/graphql/team.graphql b/libs/types/src/lib/api/lems/graphql/team.graphql index 7ea2cf28d..41a0bf861 100644 --- a/libs/types/src/lib/api/lems/graphql/team.graphql +++ b/libs/types/src/lib/api/lems/graphql/team.graphql @@ -56,6 +56,17 @@ type TeamEvent { teamId: String! } +""" +Result of a team's arrival status changing +""" +type TeamArrivalEvent { + "Team identifier" + teamId: String! + + "Whether the team is now marked as arrived" + arrived: Boolean! +} + extend type Mutation { """ Update that a team has arrived in a specific division. @@ -68,6 +79,17 @@ extend type Mutation { divisionId: String! ): TeamEvent! + """ + Mark a team as not arrived in a specific division (reverses teamArrived). + """ + teamNotArrived( + "The team ID to update" + teamId: String! + + "Relevant division ID" + divisionId: String! + ): TeamEvent! + """ Disqualify a team in a specific division. This action is irreversible. Only Judge Advisors can perform this action on their assigned divisions. @@ -83,9 +105,12 @@ extend type Mutation { extend type Subscription { """ - Subscribe to events when teams arrive at a specific division. + Subscribe to events when a team's arrival status changes in a specific division. """ - teamArrivalUpdated("The division ID to listen for updates in" divisionId: String!): TeamEvent! + teamArrivalUpdated( + "The division ID to listen for updates in" + divisionId: String! + ): TeamArrivalEvent! """ Subscribe to events when teams are disqualified in a specific division.