import { useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import { getCourtAssignments, getReceivingPlayer, getServiceCourt, getServingPlayer, getTeamDisplayName, } from '../lib/match' import type { CourtSide, GroupTeam, Matchup, PlayerSlot, RoundGroup, ScoreSide, ScoreState, } from '../types' type ScoreboardPageProps = { finishDialogError: string finishDialogOpen: boolean finishDialogUploading: boolean groupSource: 'idle' | 'db' | 'manual' hasRecordedPoint: boolean leftTeam: GroupTeam | null matchup: Matchup rightTeam: GroupTeam | null scoreState: ScoreState selectedGroup: RoundGroup | null targetDate: string onApplyMatchup: (leftTeamId: number, rightTeamId: number) => void onCloseFinishDialog: () => void onConfirmUpload: () => void onOpenFinishDialog: () => void onRecordPoint: (side: ScoreSide) => void onSetServing: (side: ScoreSide) => void onSkipUpload: () => void onSwapMatchup: () => void onSwapTeamPlayers: (side: ScoreSide) => void onUndoLastPoint: () => void } export function ScoreboardPage({ finishDialogError, finishDialogOpen, finishDialogUploading, groupSource, hasRecordedPoint, leftTeam, matchup, rightTeam, scoreState, selectedGroup, targetDate, onApplyMatchup, onCloseFinishDialog, onConfirmUpload, onOpenFinishDialog, onRecordPoint, onSetServing, onSkipUpload, onSwapMatchup, onSwapTeamPlayers, onUndoLastPoint, }: ScoreboardPageProps) { const [pickerOpen, setPickerOpen] = useState(false) const [draftTeamIds, setDraftTeamIds] = useState([]) const [clock, setClock] = useState(() => formatClock()) useEffect(() => { const timer = window.setInterval(() => { setClock(formatClock()) }, 1000) return () => window.clearInterval(timer) }, []) const canArrangeMatch = !hasRecordedPoint const canScore = scoreState.serving !== null const servingScore = scoreState.serving === 'left' ? scoreState.scoreLeft : scoreState.scoreRight const servingCourt = scoreState.serving === null ? null : getServiceCourt(servingScore) const leftAssignments = useMemo( () => leftTeam ? getCourtAssignments(leftTeam, scoreState.leftRightCourtPlayer) : [], [leftTeam, scoreState.leftRightCourtPlayer], ) const rightAssignments = useMemo( () => rightTeam ? getCourtAssignments(rightTeam, scoreState.rightRightCourtPlayer) : [], [rightTeam, scoreState.rightRightCourtPlayer], ) const currentServer = scoreState.serving === 'left' ? leftTeam ? getServingPlayer(leftTeam, scoreState.leftRightCourtPlayer, scoreState.scoreLeft) : null : scoreState.serving === 'right' ? rightTeam ? getServingPlayer( rightTeam, scoreState.rightRightCourtPlayer, scoreState.scoreRight, ) : null : null const currentReceiver = scoreState.serving === 'left' ? rightTeam ? getReceivingPlayer( rightTeam, scoreState.rightRightCourtPlayer, scoreState.scoreLeft, ) : null : scoreState.serving === 'right' ? leftTeam ? getReceivingPlayer( leftTeam, scoreState.leftRightCourtPlayer, scoreState.scoreRight, ) : null : null if (!selectedGroup) { return (

Step 3

先到選隊伍頁面建立對戰組合

目前還沒有可用的組別。先載入指定日期資料,或手動建立分組後,再回來開始記分。

前往選隊伍
) } const matchupLabel = leftTeam && rightTeam ? `${getTeamDisplayName(leftTeam)} vs ${getTeamDisplayName(rightTeam)}` : '尚未設定對戰隊伍' const openPicker = () => { const next = [matchup.leftTeamId, matchup.rightTeamId].filter( (value): value is number => value !== null, ) setDraftTeamIds(next) setPickerOpen(true) } const toggleDraftTeam = (teamId: number) => { setDraftTeamIds((current) => { if (current.includes(teamId)) { return current.filter((value) => value !== teamId) } if (current.length >= 2) { return [current[1], teamId] } return [...current, teamId] }) } const confirmDraftTeams = () => { if (draftTeamIds.length !== 2) { return } onApplyMatchup(draftTeamIds[0], draftTeamIds[1]) setPickerOpen(false) } const autoPickDraftTeams = () => { const shuffled = [...selectedGroup.teams] for (let index = shuffled.length - 1; index > 0; index -= 1) { const swapIndex = Math.floor(Math.random() * (index + 1)) ;[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]] } setDraftTeamIds(shuffled.slice(0, 2).map((team) => team.id)) } return ( <>
onRecordPoint('left')} onSetServing={() => onSetServing('left')} onSwapPlayers={() => onSwapTeamPlayers('left')} onSwapTeams={onSwapMatchup} score={scoreState.scoreLeft} serviceCourt={scoreState.serving === 'left' ? servingCourt : null} team={leftTeam} teamSlot="top" />

{scoreState.serving === null ? '請設定發球方' : '點擊分數開始計分'}

{scoreState.serving === null ? '先在上方或下方按下先攻' : `目前發球:${currentServer?.name ?? '-'}${ currentReceiver ? ` / 接發:${currentReceiver.name}` : '' }`}
onRecordPoint('right')} onSetServing={() => onSetServing('right')} onSwapPlayers={() => onSwapTeamPlayers('right')} onSwapTeams={onSwapMatchup} score={scoreState.scoreRight} serviceCourt={scoreState.serving === 'right' ? servingCourt : null} team={rightTeam} teamSlot="bottom" />
{pickerOpen ? ( setDraftTeamIds([])} onClose={() => setPickerOpen(false)} onConfirm={confirmDraftTeams} onToggleTeam={toggleDraftTeam} /> ) : null} {finishDialogOpen ? ( ) : null} ) } type ScoreboardTeamPanelProps = { assignments: Array<{ slot: PlayerSlot; name: string; court: CourtSide }> canArrangeMatch: boolean canScore: boolean currentReceiver: string | null currentServer: string | null onRecordPoint: () => void onSetServing: () => void onSwapPlayers: () => void onSwapTeams: () => void score: number serviceCourt: CourtSide | null team: GroupTeam | null teamSlot: 'top' | 'bottom' } function ScoreboardTeamPanel({ assignments, canArrangeMatch, canScore, currentReceiver, currentServer, onRecordPoint, onSetServing, onSwapPlayers, onSwapTeams, score, serviceCourt, team, teamSlot, }: ScoreboardTeamPanelProps) { const orderedAssignments = [...assignments].sort((left, right) => { if (left.court === right.court) { return 0 } return left.court === 'left' ? -1 : 1 }) const header = (
{orderedAssignments.map((assignment) => (
{getPlayerNumber(teamSlot, assignment.slot)} {assignment.name}
))}
) const serveBar = ( ) const scoreBoard = ( ) return (
{teamSlot === 'top' ? ( <> {header} {serveBar} {scoreBoard} ) : ( <> {scoreBoard} {serveBar} {header} )}
) } type TeamPickerModalProps = { currentLeftTeamId: number | null currentRightTeamId: number | null draftTeamIds: number[] group: RoundGroup selectionCount: number sourceLabel: string targetDate: string onAutoPick: () => void onClear: () => void onClose: () => void onConfirm: () => void onToggleTeam: (teamId: number) => void } function TeamPickerModal({ currentLeftTeamId, currentRightTeamId, draftTeamIds, group, selectionCount, sourceLabel, targetDate, onAutoPick, onClear, onClose, onConfirm, onToggleTeam, }: TeamPickerModalProps) { return (
event.stopPropagation()} role="dialog" >
{selectionCount >= 2 ? '已完成選擇' : '請選擇 2 隊'}
{selectionCount}/2
從這一組挑選要對打的隊伍

第 {group.id} 組 / {sourceLabel} / {targetDate || '-'}

{group.teams.map((team) => { const checked = draftTeamIds.includes(team.id) const selectedOrder = checked ? draftTeamIds.indexOf(team.id) + 1 : null return ( ) })}
) } type FinishDialogProps = { error: string leftScore: number leftTeamName: string matchupLabel: string rightScore: number rightTeamName: string uploading: boolean onClose: () => void onConfirm: () => void onSkip: () => void } function FinishDialog({ error, leftScore, leftTeamName, matchupLabel, rightScore, rightTeamName, uploading, onClose, onConfirm, onSkip, }: FinishDialogProps) { return (

比賽結算

{matchupLabel}

{leftScore} {leftTeamName}
:
{rightScore} {rightTeamName}

要把這場戰績上傳到 DB 嗎?

{error ?

{error}

: null}
) } function getPlayerNumber(teamSlot: 'top' | 'bottom', slot: PlayerSlot) { if (teamSlot === 'top') { return slot === 'playerA' ? 1 : 2 } return slot === 'playerA' ? 4 : 3 } function formatClock() { return new Date().toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit', hour12: false, }) }