From d8e38c912e497aed7ea7ad55d3e70d66c779c195 Mon Sep 17 00:00:00 2001 From: CoolGame8 Date: Tue, 18 Aug 2026 11:42:13 +0300 Subject: [PATCH 1/6] llow to mark team as not arrived --- .../lib/graphql/resolvers/mutations/index.ts | 2 + .../resolvers/mutations/team-not-arrived.ts | 89 +++++++++++ apps/frontend/locale/en.json | 4 +- apps/frontend/locale/he.json | 4 +- apps/frontend/locale/pl.json | 4 +- .../pit-admin/components/arrivals-stats.tsx | 142 +++++++++++++++++- .../pit-admin/graphql/mutations/index.ts | 1 + .../graphql/mutations/team-arrived.ts | 14 +- .../graphql/mutations/team-not-arrived.ts | 35 +++++ .../(dashboard)/pit-admin/graphql/query.ts | 1 + .../(dashboard)/pit-admin/graphql/types.ts | 1 + .../(dashboard)/pit-admin/page.tsx | 21 ++- .../src/lib/api/lems/graphql/team.graphql | 11 ++ 13 files changed, 308 insertions(+), 21 deletions(-) create mode 100644 apps/backend/src/lib/graphql/resolvers/mutations/team-not-arrived.ts create mode 100644 apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/mutations/team-not-arrived.ts diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/index.ts b/apps/backend/src/lib/graphql/resolvers/mutations/index.ts index 1f7716546..b3c77d6ba 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 { @@ -52,6 +53,7 @@ import { export const mutationResolvers = { teamArrived: teamArrivedResolver, + teamNotArrived: teamNotArrivedResolver, disqualifyTeam: disqualifyTeamResolver, assignPersonalAward: assignPersonalAwardResolver, startJudgingSession: startJudgingSessionResolver, 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..3b5f89b53 --- /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 }); + + return { teamId }; + } catch (error) { + console.error( + 'Error updating team arrival status for team:', + teamId, + 'in division:', + divisionId, + error + ); + throw error; + } +}; diff --git a/apps/frontend/locale/en.json b/apps/frontend/locale/en.json index 6366bf46f..15216b02e 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 4efad86c3..ed053e24d 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 6c2ece0e0..d09a80c84 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)/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/types.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/pit-admin/graphql/types.ts index a7d69b7f0..52e737401 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,6 +6,7 @@ export interface Team { city: string; region: string; arrived: boolean; + logoUrl: string | null; } export interface TeamEvent { 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/libs/types/src/lib/api/lems/graphql/team.graphql b/libs/types/src/lib/api/lems/graphql/team.graphql index 7ea2cf28d..633013637 100644 --- a/libs/types/src/lib/api/lems/graphql/team.graphql +++ b/libs/types/src/lib/api/lems/graphql/team.graphql @@ -68,6 +68,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. From a9bbe00df7452ebfeda3e66b8d3c675670635453 Mon Sep 17 00:00:00 2001 From: CoolGame8 Date: Wed, 30 Sep 2026 21:20:26 +0300 Subject: [PATCH 2/6] Merge remote-tracking branch 'origin/main' into allow-to-mark-team-as-not-arrived --- .../calendar/agenda-block/agenda-block.tsx | 24 +- .../calendar/agenda-column/agenda-column.tsx | 8 +- .../components/calendar/calendar-utils.ts | 89 +++++- apps/frontend/locale/en.json | 8 +- apps/frontend/locale/he.json | 8 +- apps/frontend/locale/pl.json | 8 +- .../disqualification-section.tsx | 22 +- .../disqualified-teams-list.tsx | 4 +- .../components/filters-context.tsx | 20 ++ .../components/rubric-status-grid.tsx | 15 ++ .../components/team-info-cell.tsx | 4 +- apps/portal/locale/en.json | 3 +- apps/portal/locale/he.json | 3 +- apps/portal/locale/pl.json | 3 +- .../homepage/search/search-section.tsx | 254 +++++++++--------- .../[teamSlug]/components/season-selector.tsx | 10 +- .../[teamSlug]/components/team-contents.tsx | 3 +- .../[teamSlug]/components/team-results.tsx | 44 +-- package-lock.json | 90 ++++--- package.json | 6 +- 20 files changed, 431 insertions(+), 195 deletions(-) diff --git a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/calendar/agenda-block/agenda-block.tsx b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/calendar/agenda-block/agenda-block.tsx index a19c9480f..f5d1c7d7b 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/calendar/agenda-block/agenda-block.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/calendar/agenda-block/agenda-block.tsx @@ -5,7 +5,7 @@ import { Dayjs } from 'dayjs'; import { Box } from '@mui/material'; import { useTranslations } from 'next-intl'; import { BLOCK_COLORS, AgendaBlockVisibility, AgendaBlock } from '../calendar-types'; -import { calculateBlockPosition } from '../calendar-utils'; +import { calculateBlockPosition, BlockOverlapInfo } from '../calendar-utils'; import { useCalendar } from '../calendar-context'; import { EditAgendaDialog } from './dialog/edit-agenda-dialog'; import { ResizeHandles } from './resize-handles'; @@ -19,6 +19,7 @@ interface AgendaBlockProps { draggedPosition: number; draggedDuration?: number; // For bottom-edge resize preview draggedStartTime?: Dayjs; // For top-edge resize preview + overlapInfo?: BlockOverlapInfo; // Layout info for overlapping blocks onDragStartBody: (block: AgendaBlock, startY: number) => void; onDragStartTopEdge: (block: AgendaBlock, startY: number) => void; onDragStartBottomEdge: (block: AgendaBlock, startY: number) => void; @@ -32,6 +33,7 @@ export const AgendaBlockComponent: React.FC = ({ draggedPosition, draggedDuration, draggedStartTime, + overlapInfo, onDragStartBody, onDragStartTopEdge, onDragStartBottomEdge @@ -117,13 +119,29 @@ export const AgendaBlockComponent: React.FC = ({ setEditingBlockId(null); }; + const horizontalPosition = useMemo(() => { + if (!overlapInfo || overlapInfo.totalColumns === 1) { + return { left: 8, right: 8 }; + } + + const columnWidth = 100 / overlapInfo.totalColumns; + const leftPercent = columnWidth * overlapInfo.column; + const rightPercent = 100 - (leftPercent + columnWidth); + const gap = 2; + + return { + left: `calc(${leftPercent}% + ${gap}px)`, + right: `calc(${rightPercent}% + ${gap}px)` + }; + }, [overlapInfo]); + return ( = ({ startTime, endTime } const [dragState, setDragState] = useState(null); const columnRef = useRef(null); + const overlapLayout = useMemo(() => { + return calculateOverlapLayout(agendaBlocks as AgendaBlock[]); + }, [agendaBlocks]); + const { handleMouseMove, handleMouseUp } = useDragHandlers({ dragState, startTime, @@ -193,6 +198,7 @@ export const AgendaColumn: React.FC = ({ startTime, endTime } ? dragState.originalStartTime : undefined } + overlapInfo={overlapLayout.get(block.id)} onDragStartBody={handleDragStartBody} onDragStartTopEdge={handleDragStartTopEdge} onDragStartBottomEdge={handleDragStartBottomEdge} diff --git a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/calendar/calendar-utils.ts b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/calendar/calendar-utils.ts index 26f52a688..1e2cb7f91 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/calendar/calendar-utils.ts +++ b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/schedule/components/calendar/calendar-utils.ts @@ -1,5 +1,5 @@ import { Dayjs } from 'dayjs'; -import { ScheduleBlock, ScheduleColumn, TIME_SLOT_HEIGHT } from './calendar-types'; +import { ScheduleBlock, ScheduleColumn, TIME_SLOT_HEIGHT, AgendaBlock } from './calendar-types'; export function getBlockColumn(block: ScheduleBlock): ScheduleColumn { if (block.type === 'judging-session') return 'judging'; @@ -18,3 +18,90 @@ export function calculateBlockPosition(startTime: Dayjs, block: ScheduleBlock) { export function getDuration(date: Dayjs): number { return date.hour() * 3600 + date.minute() * 60 + date.second(); } + +export interface BlockOverlapInfo { + column: number; + totalColumns: number; +} + +/** + * Checks if two blocks overlap in time + */ +function blocksOverlap(block1: AgendaBlock, block2: AgendaBlock): boolean { + const end1 = block1.startTime.add(block1.durationSeconds, 'second'); + const end2 = block2.startTime.add(block2.durationSeconds, 'second'); + + return block1.startTime.isBefore(end2) && block2.startTime.isBefore(end1); +} + +/** + * Calculate layout information for overlapping agenda blocks + * Returns a map of block ID to its column position and total columns in its group + */ +export function calculateOverlapLayout(blocks: AgendaBlock[]): Map { + const layout = new Map(); + + const sortedBlocks = [...blocks].sort((a, b) => a.startTime.diff(b.startTime)); + const groups: AgendaBlock[][] = []; + + for (const block of sortedBlocks) { + let foundGroup = false; + + for (const group of groups) { + if (group.some(groupBlock => blocksOverlap(block, groupBlock))) { + group.push(block); + foundGroup = true; + break; + } + } + + if (!foundGroup) { + groups.push([block]); + } + } + + for (const group of groups) { + if (group.length === 1) { + layout.set(group[0].id, { column: 0, totalColumns: 1 }); + } else { + const sortedGroup = [...group].sort((a, b) => { + const timeDiff = a.startTime.diff(b.startTime); + if (timeDiff !== 0) return timeDiff; + return b.durationSeconds - a.durationSeconds; + }); + + const columns: AgendaBlock[][] = []; + + for (const block of sortedGroup) { + let assignedColumn = -1; + + for (let i = 0; i < columns.length; i++) { + const columnBlocks = columns[i]; + const hasOverlap = columnBlocks.some(colBlock => blocksOverlap(block, colBlock)); + + if (!hasOverlap) { + assignedColumn = i; + columnBlocks.push(block); + break; + } + } + + if (assignedColumn === -1) { + assignedColumn = columns.length; + columns.push([block]); + } + + layout.set(block.id, { column: assignedColumn, totalColumns: columns.length }); + } + + for (const block of group) { + const info = layout.get(block.id); + if (info) { + info.totalColumns = columns.length; + } + } + } + } + + return layout; +} diff --git a/apps/frontend/locale/en.json b/apps/frontend/locale/en.json index 15216b02e..bee8046cb 100644 --- a/apps/frontend/locale/en.json +++ b/apps/frontend/locale/en.json @@ -709,7 +709,9 @@ "session": "Session #", "sessions": "Sessions", "results": "Results", - "clear": "Clear Filters" + "clear": "Clear Filters", + "show-blocked": "Show Blocked", + "hide-blocked": "Hide Blocked" }, "sort": { "room": "Sort by Room", @@ -1280,7 +1282,9 @@ "session": "Session #", "sessions": "Sessions", "results": "Results", - "clear": "Clear Filters" + "clear": "Clear Filters", + "show-blocked": "Show Blocked", + "hide-blocked": "Hide Blocked" }, "sort": { "room": "Sort by Room", diff --git a/apps/frontend/locale/he.json b/apps/frontend/locale/he.json index ed053e24d..5e51e11dd 100644 --- a/apps/frontend/locale/he.json +++ b/apps/frontend/locale/he.json @@ -707,7 +707,9 @@ "session": "סבב #", "sessions": "סבבים", "results": "תוצאות", - "clear": "נקה מסננים" + "clear": "נקה מסננים", + "show-blocked": "הצג פסלות", + "hide-blocked": "הסתר פסלות" }, "sort": { "room": "מיין לפי חדר", @@ -1278,7 +1280,9 @@ "session": "סבב #", "sessions": "סבבים", "results": "תוצאות", - "clear": "נקה מסננים" + "clear": "נקה מסננים", + "show-blocked": "הצג פסולות", + "hide-blocked": "הסתר פסולות" }, "sort": { "room": "מיין לפי חדר", diff --git a/apps/frontend/locale/pl.json b/apps/frontend/locale/pl.json index d09a80c84..4baa07002 100644 --- a/apps/frontend/locale/pl.json +++ b/apps/frontend/locale/pl.json @@ -709,7 +709,9 @@ "session": "Sesja #", "sessions": "Sesje", "results": "Wyniki", - "clear": "Wyczyść filtry" + "clear": "Wyczyść filtry", + "show-blocked": "Pokaż zablokowane", + "hide-blocked": "Ukryj zablokowane" }, "sort": { "room": "Sortuj wg sali", @@ -1279,7 +1281,9 @@ "session": "Sesja #", "sessions": "Sesje", "results": "Wyniki", - "clear": "Wyczyść filtry" + "clear": "Wyczyść filtry", + "show-blocked": "Pokaż zablokowane", + "hide-blocked": "Ukryj zablokowane" }, "sort": { "room": "Sortuj wg sali", diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge-advisor/components/disqualification/disqualification-section.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge-advisor/components/disqualification/disqualification-section.tsx index b0837a3ee..53325e420 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge-advisor/components/disqualification/disqualification-section.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge-advisor/components/disqualification/disqualification-section.tsx @@ -2,14 +2,17 @@ import { useMemo, useState, useCallback } from 'react'; import toast from 'react-hot-toast'; -import { Card, CardContent, CardHeader, Stack, useTheme } from '@mui/material'; +import { Card, CardContent, CardHeader, Stack, useTheme, Button, Tooltip } from '@mui/material'; import { useMutation } from '@apollo/client/react'; import { useTranslations } from 'next-intl'; import SearchIcon from '@mui/icons-material/Search'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; import type { Team } from '../../graphql/types'; import { DISQUALIFY_TEAM } from '../../graphql/mutations/disqualify-team'; import { useEvent } from '../../../../components/event-context'; import { useJudgeAdvisor } from '../judge-advisor-context'; +import { useFilters } from '../filters-context'; import { DisqualifyConfirmationDialog } from './disqualify-confirmation-dialog'; import { SearchTeamSection } from './search-team-section'; import { SelectedTeamPreview } from './selected-team-preview'; @@ -17,9 +20,11 @@ import { DisqualifiedTeamsList } from './disqualified-teams-list'; export function DisqualificationSection() { const t = useTranslations('pages.judge-advisor.awards.disqualification'); + const tGrid = useTranslations('pages.judge-advisor.grid'); const theme = useTheme(); const { currentDivision } = useEvent(); const { sessions, disqualifiedTeams, loading } = useJudgeAdvisor(); + const { showBlocked, setShowBlocked } = useFilters(); const [selectedTeam, setSelectedTeam] = useState(null); const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); @@ -71,6 +76,21 @@ export function DisqualificationSection() { title={t('search-title')} avatar={} slotProps={{ title: { variant: 'h6', sx: { fontWeight: 600 } } }} + action={ + + + + } /> diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge-advisor/components/disqualification/disqualified-teams-list.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge-advisor/components/disqualification/disqualified-teams-list.tsx index 0cdd31343..0275e65b2 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge-advisor/components/disqualification/disqualified-teams-list.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge-advisor/components/disqualification/disqualified-teams-list.tsx @@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl'; import BlockIcon from '@mui/icons-material/Block'; import type { Team } from '../../graphql/types'; import { TeamInfo } from '../../../components/team-info'; +import { useFilters } from '../filters-context'; interface DisqualifiedTeamsListProps { disqualifiedTeams: Team[]; @@ -13,8 +14,9 @@ interface DisqualifiedTeamsListProps { export function DisqualifiedTeamsList({ disqualifiedTeams }: DisqualifiedTeamsListProps) { const t = useTranslations('pages.judge-advisor.awards.disqualification'); const theme = useTheme(); + const { showBlocked } = useFilters(); - if (disqualifiedTeams.length === 0) { + if (disqualifiedTeams.length === 0 || !showBlocked) { return null; } diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge-advisor/components/filters-context.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge-advisor/components/filters-context.tsx index cdc8501b4..4ab6f499e 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge-advisor/components/filters-context.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge-advisor/components/filters-context.tsx @@ -14,6 +14,8 @@ interface FiltersContextType { setSessionNumberFilter: (value: number[]) => void; sortBy: 'room' | 'session'; setSortBy: (value: 'room' | 'session') => void; + showBlocked: boolean; + setShowBlocked: (value: boolean) => void; clearFilters: () => void; } @@ -43,6 +45,7 @@ export const FiltersProvider: React.FC = ({ children }) => .map(s => parseInt(s, 10)) : []; const sortBy = (searchParams.get('sortBy') || 'room') as 'room' | 'session'; + const showBlocked = searchParams.get('showBlocked') === 'true'; const updateUrl = useCallback( (updates: Partial) => { @@ -84,6 +87,14 @@ export const FiltersProvider: React.FC = ({ children }) => newParams.set('sortBy', updates.sortBy); } + if ('showBlocked' in updates) { + if (updates.showBlocked) { + newParams.set('showBlocked', 'true'); + } else { + newParams.delete('showBlocked'); + } + } + const queryString = newParams.toString(); router.push(queryString ? `?${queryString}` : '?'); }, @@ -125,6 +136,13 @@ export const FiltersProvider: React.FC = ({ children }) => [updateUrl] ); + const setShowBlocked = useCallback( + (value: boolean) => { + updateUrl({ showBlocked: value }); + }, + [updateUrl] + ); + const clearFilters = useCallback(() => { router.push('?'); }, [router]); @@ -140,6 +158,8 @@ export const FiltersProvider: React.FC = ({ children }) => setSessionNumberFilter, sortBy, setSortBy, + showBlocked, + setShowBlocked, clearFilters }; diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge-advisor/components/rubric-status-grid.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge-advisor/components/rubric-status-grid.tsx index ebbae9aba..e44ba563d 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge-advisor/components/rubric-status-grid.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/judge-advisor/components/rubric-status-grid.tsx @@ -23,6 +23,8 @@ import { } from '@mui/material'; import { Verified } from '@mui/icons-material'; import FilterAltOffIcon from '@mui/icons-material/FilterAltOff'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; import { JUDGING_CATEGORIES } from '@lems/types/judging'; import { useJudgingCategoryTranslations } from '@lems/localization'; import { hyphensToUnderscores } from '@lems/shared/utils'; @@ -51,6 +53,8 @@ export const RubricStatusGrid = () => { setSessionNumberFilter, sortBy, setSortBy, + showBlocked, + setShowBlocked, clearFilters } = useFilters(); @@ -179,6 +183,17 @@ export const RubricStatusGrid = () => { {t('sort.session')} + + + = ({ team }) => { const t = useTranslations('pages.judge-advisor.team-info-cell'); const { disqualifiedTeams } = useJudgeAdvisor(); + const { showBlocked } = useFilters(); const isDisqualified = disqualifiedTeams.has(team.id); return ( - {isDisqualified && ( + {isDisqualified && showBlocked && ( } label={t('disqualified')} diff --git a/apps/portal/locale/en.json b/apps/portal/locale/en.json index 6838df9d3..9dcc21347 100644 --- a/apps/portal/locale/en.json +++ b/apps/portal/locale/en.json @@ -121,7 +121,8 @@ "event-in-progress": "Event in Progress", "view-live-data": "View Live Data", "view-event-details": "View Event Details", - "view-event-results": "View Event Results" + "view-event-results": "View Event Results", + "no-events-this-season": "This team has not competed in any events this season yet." } }, "teams": { diff --git a/apps/portal/locale/he.json b/apps/portal/locale/he.json index b8782d717..55887c167 100644 --- a/apps/portal/locale/he.json +++ b/apps/portal/locale/he.json @@ -134,7 +134,8 @@ "event-in-progress": "אירוע בעיצומו", "view-live-data": "צפייה בנתונים חיים", "view-event-details": "צפייה בפרטי האירוע", - "view-event-results": "לצפייה בתוצאות האירוע" + "view-event-results": "לצפייה בתוצאות האירוע", + "no-events-this-season": "הקבוצה עדיין לא התחרתה באירועים בעונה זו." } }, "event": { diff --git a/apps/portal/locale/pl.json b/apps/portal/locale/pl.json index 1eef731dd..bc2952210 100644 --- a/apps/portal/locale/pl.json +++ b/apps/portal/locale/pl.json @@ -121,7 +121,8 @@ "event-in-progress": "Wydarzenie w toku", "view-live-data": "Zobacz dane na żywo", "view-event-details": "Zobacz szczegóły wydarzenia", - "view-event-results": "Zobacz wyniki wydarzenia" + "view-event-results": "Zobacz wyniki wydarzenia", + "no-events-this-season": "Ta drużyna nie brała jeszcze udziału w żadnych wydarzeniach w tym sezonie." } }, "teams": { diff --git a/apps/portal/src/app/[locale]/components/homepage/search/search-section.tsx b/apps/portal/src/app/[locale]/components/homepage/search/search-section.tsx index c78cb4213..a45e4d8aa 100644 --- a/apps/portal/src/app/[locale]/components/homepage/search/search-section.tsx +++ b/apps/portal/src/app/[locale]/components/homepage/search/search-section.tsx @@ -15,6 +15,7 @@ import { Popper, ClickAwayListener, MenuItem, + MenuList, Chip, useTheme } from '@mui/material'; @@ -118,151 +119,156 @@ export const SearchSection = () => { boxShadow: theme.shadows[8] }} > - {isSearching && ( - - - {t('searching')}... - - - )} - - {error && ( - - - {t('error', { error })} - - - )} + + {isSearching && ( + + + {t('searching')}... + + + )} - {noResults && ( - - - {t('no-results')} - - - )} + {error && ( + + + {t('error', { error })} + + + )} - {!noResults && ( - <> - - + - - {t('results-count', { count: searchStats.total })} - - {searchStats.teams > 0 && ( - } - label={searchStats.teams} - variant="outlined" - sx={{ height: 20, fontSize: '0.7rem' }} - /> - )} - {searchStats.events > 0 && ( - } - label={searchStats.events} - variant="outlined" - sx={{ height: 20, fontSize: '0.7rem' }} - /> - )} - + {t('no-results')} + + )} - {searchResults.map(result => ( + {!noResults && ( + <> - - - - {highlightText(result.title, query, theme)} - - - + + {t('results-count', { count: searchStats.total })} + + {searchStats.teams > 0 && ( + } + label={searchStats.teams} + variant="outlined" + sx={{ height: 20, fontSize: '0.7rem' }} + /> + )} + {searchStats.events > 0 && ( + } + label={searchStats.events} + variant="outlined" + sx={{ height: 20, fontSize: '0.7rem' }} + /> + )} + + + + {searchResults.map(result => ( + + + + - {highlightText(result.location, query, theme)} + {highlightText(result.title, query, theme)} - - - - - - - ))} + + + + {highlightText(result.location, query, theme)} + + + + + + + + ))} - {searchResults.length > 8 && ( - - - {t('more-results', { count: searchResults.length - 8 })} - - - )} - - )} + {searchResults.length > 8 && ( + + + {t('more-results', { count: searchResults.length - 8 })} + + + )} + + )} + diff --git a/apps/portal/src/app/[locale]/teams/[teamSlug]/components/season-selector.tsx b/apps/portal/src/app/[locale]/teams/[teamSlug]/components/season-selector.tsx index ce402d7cb..89735214f 100644 --- a/apps/portal/src/app/[locale]/teams/[teamSlug]/components/season-selector.tsx +++ b/apps/portal/src/app/[locale]/teams/[teamSlug]/components/season-selector.tsx @@ -4,18 +4,17 @@ import { Box, FormControl, Select, MenuItem } from '@mui/material'; import { useRouter, useSearchParams } from 'next/navigation'; import useSWR from 'swr'; import { Season } from '@lems/types/api/portal'; -import { useTeam } from './team-context'; interface SeasonSelectorProps { currentSeason: string; } export const SeasonSelector: React.FC = ({ currentSeason }) => { - const team = useTeam(); const router = useRouter(); const searchParams = useSearchParams(); - const { data: seasons } = useSWR(() => `/portal/teams/${team.slug}/seasons`, { + // Fetch all seasons, not just the ones the team competed in + const { data: seasons } = useSWR('/portal/seasons', { suspense: true, fallbackData: [] }); @@ -30,6 +29,9 @@ export const SeasonSelector: React.FC = ({ currentSeason }) router.push(`?${params.toString()}`); }; + // Use currentSeason if it exists, otherwise default to the first season + const selectedSeason = currentSeason ?? seasons[0]?.slug ?? ''; + return ( = ({ currentSeason }) >