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, PlayerSlot, RoundGroup, ScoreSide, ScoreState, } from '../types' type ScoreboardPageProps = { currentSelectionOrder: string[] finishDialogError: string finishDialogOpen: boolean finishDialogUploading: boolean groupSource: 'idle' | 'db' | 'manual' hasRecordedPoint: boolean leftTeam: GroupTeam | null rightTeam: GroupTeam | null scoreState: ScoreState selectedGroup: RoundGroup | null targetDate: string onApplyMatchup: ( leftTeam: GroupTeam, rightTeam: GroupTeam, targetScore: 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({ currentSelectionOrder, finishDialogError, finishDialogOpen, finishDialogUploading, groupSource, hasRecordedPoint, leftTeam, rightTeam, scoreState, selectedGroup, targetDate, onApplyMatchup, onCloseFinishDialog, onConfirmUpload, onOpenFinishDialog, onRecordPoint, onSetServing, onSkipUpload, onSwapMatchup, onSwapTeamPlayers, onUndoLastPoint, }: ScoreboardPageProps) { const [pickerOpen, setPickerOpen] = useState(false) const [draftPlayers, setDraftPlayers] = useState([]) const [draftTargetScore, setDraftTargetScore] = useState(() => String(scoreState.targetScore)) const [clock, setClock] = useState(() => formatClock()) useEffect(() => { const timer = window.setInterval(() => { setClock(formatClock()) }, 1000) return () => window.clearInterval(timer) }, []) const selectablePlayers = useMemo(() => { if (!selectedGroup) { return [] } const seen = new Set() const players: string[] = [] selectedGroup.teams.forEach((team) => { if (!team.isPlaceholderA && !seen.has(team.playerA)) { seen.add(team.playerA) players.push(team.playerA) } if (!team.isPlaceholderB && !seen.has(team.playerB)) { seen.add(team.playerB) players.push(team.playerB) } }) return players }, [selectedGroup]) const presetTeams = useMemo( () => selectedGroup?.teams.filter((team) => !team.isPlaceholderA && !team.isPlaceholderB) ?? [], [selectedGroup], ) 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 = () => { setDraftPlayers(currentSelectionOrder.filter((player) => selectablePlayers.includes(player))) setDraftTargetScore(String(scoreState.targetScore)) setPickerOpen(true) } const toggleDraftPlayer = (playerName: string) => { setDraftPlayers((current) => { if (current.includes(playerName)) { return current.filter((value) => value !== playerName) } if (current.length >= 4) { return current } return [...current, playerName] }) } const togglePresetTeam = (team: GroupTeam) => { setDraftPlayers((current) => { const removed = removePresetTeamFromDraft(current, team) if (removed.length !== current.length) { return removed } if (current.length >= 4 || current.length % 2 !== 0) { return current } if (current.includes(team.playerA) || current.includes(team.playerB)) { return current } return [...current, team.playerA, team.playerB] }) } const confirmDraftTeams = () => { if (draftPlayers.length !== 4) { return } onApplyMatchup( { id: -1, playerA: draftPlayers[0], playerB: draftPlayers[1], isPlaceholderA: false, isPlaceholderB: false, }, { id: -2, playerA: draftPlayers[3], playerB: draftPlayers[2], isPlaceholderA: false, isPlaceholderB: false, }, sanitizeTargetScore(draftTargetScore), ) setPickerOpen(false) } const autoPickDraftPlayers = () => { const shuffled = [...selectablePlayers] 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]] } setDraftPlayers(shuffled.slice(0, 4)) } 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 ? `本場 ${scoreState.targetScore} 分獲勝` : `發球:${currentServer?.name ?? '-'}${ currentReceiver ? ` / 接發:${currentReceiver.name}` : '' } / 目標 ${scoreState.targetScore} 分`}
onRecordPoint('right')} onSetServing={() => onSetServing('right')} onSwapPlayers={() => onSwapTeamPlayers('right')} onSwapTeams={onSwapMatchup} score={scoreState.scoreRight} serviceCourt={scoreState.serving === 'right' ? servingCourt : null} team={rightTeam} teamSlot="bottom" />
{pickerOpen ? ( setDraftPlayers([])} onClose={() => setPickerOpen(false)} onConfirm={confirmDraftTeams} onDraftTargetScoreChange={setDraftTargetScore} onTogglePlayer={toggleDraftPlayer} onTogglePresetTeam={togglePresetTeam} /> ) : 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 = { draftPlayers: string[] draftTargetScore: string group: RoundGroup presetTeams: GroupTeam[] selectablePlayers: string[] selectionCount: number sourceLabel: string targetDate: string onAutoPick: () => void onClear: () => void onClose: () => void onConfirm: () => void onDraftTargetScoreChange: (value: string) => void onTogglePlayer: (playerName: string) => void onTogglePresetTeam: (team: GroupTeam) => void } function TeamPickerModal({ draftPlayers, draftTargetScore, group, presetTeams, selectablePlayers, selectionCount, sourceLabel, targetDate, onAutoPick, onClear, onClose, onConfirm, onDraftTargetScoreChange, onTogglePlayer, onTogglePresetTeam, }: TeamPickerModalProps) { return (
event.stopPropagation()} role="dialog" >
{selectionCount >= 4 ? '已完成選擇' : '請依序選 4 位球員'}
{selectionCount}/4
左邊逐一選人

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

{selectablePlayers.map((playerName) => { const checked = draftPlayers.includes(playerName) const selectedOrder = checked ? draftPlayers.indexOf(playerName) + 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}

要把這場戰績上傳到資料庫嗎?

{error ?

{error}

: null}
) } function getPlayerNumber(teamSlot: 'top' | 'bottom', slot: PlayerSlot) { if (teamSlot === 'top') { return slot === 'playerA' ? 1 : 2 } return slot === 'playerA' ? 4 : 3 } function sanitizeTargetScore(value: string) { const numeric = Number.parseInt(value.replace(/[^\d]/g, ''), 10) if (Number.isNaN(numeric)) { return 21 } return Math.min(99, Math.max(1, numeric)) } function removePresetTeamFromDraft(players: string[], team: GroupTeam) { const firstPairSelected = players[0] === team.playerA && players[1] === team.playerB const secondPairSelected = players[2] === team.playerA && players[3] === team.playerB if (firstPairSelected) { return players.slice(2) } if (secondPairSelected) { return players.slice(0, 2) } return players } function getPresetTeamSelectionSlot(players: string[], team: GroupTeam) { if (players[0] === team.playerA && players[1] === team.playerB) { return 0 } if (players[2] === team.playerA && players[3] === team.playerB) { return 1 } return null } function formatClock() { return new Date().toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit', hour12: false, }) }