Refine scoreboard flow and update ports
This commit is contained in:
49
src/pages/HistoryPage.tsx
Normal file
49
src/pages/HistoryPage.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { MatchHistoryItem } from '../types'
|
||||
|
||||
type HistoryPageProps = {
|
||||
history: MatchHistoryItem[]
|
||||
}
|
||||
|
||||
export function HistoryPage({ history }: HistoryPageProps) {
|
||||
return (
|
||||
<section className="page-grid">
|
||||
<article className="panel panel-hero">
|
||||
<p className="panel-kicker">History</p>
|
||||
<h2>歷史戰績</h2>
|
||||
<p className="panel-copy">這裡會顯示本機目前這次操作中,已經成功上傳到 DB 的比賽結果。</p>
|
||||
</article>
|
||||
|
||||
<article className="panel full-span">
|
||||
{history.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>目前還沒有戰績</h3>
|
||||
<p>完成比賽結算並上傳到 DB 後,這裡就會看到紀錄。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="history-list">
|
||||
{history.map((item) => (
|
||||
<article className="history-card" key={item.id}>
|
||||
<div className="history-head">
|
||||
<div>
|
||||
<p className="panel-kicker">{item.playedAt}</p>
|
||||
<h3>
|
||||
{item.leftTeamName} vs {item.rightTeamName}
|
||||
</h3>
|
||||
</div>
|
||||
<span className="winner-badge">勝方:{item.winner}</span>
|
||||
</div>
|
||||
|
||||
<div className="history-meta">
|
||||
<span>比賽日期:{item.matchDate || '-'}</span>
|
||||
<span>資料來源:{item.source === 'db' ? 'DB' : item.source === 'manual' ? '手動' : '-'}</span>
|
||||
<span>第 {item.groupId} 組</span>
|
||||
<span>比分:{item.scoreLeft} - {item.scoreRight}</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
671
src/pages/ScoreboardPage.tsx
Normal file
671
src/pages/ScoreboardPage.tsx
Normal file
@@ -0,0 +1,671 @@
|
||||
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<number[]>([])
|
||||
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 (
|
||||
<section className="page-grid">
|
||||
<article className="panel panel-hero">
|
||||
<p className="panel-kicker">Step 3</p>
|
||||
<h2>先到選隊伍頁面建立對戰組合</h2>
|
||||
<p className="panel-copy">
|
||||
目前還沒有可用的組別。先載入指定日期資料,或手動建立分組後,再回來開始記分。
|
||||
</p>
|
||||
<Link className="primary-button inline-link" to="/teams">
|
||||
前往選隊伍
|
||||
</Link>
|
||||
</article>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<section className="scoreboard-screen">
|
||||
<div className="scoreboard-court">
|
||||
<ScoreboardTeamPanel
|
||||
assignments={leftAssignments}
|
||||
canArrangeMatch={canArrangeMatch}
|
||||
canScore={canScore}
|
||||
currentReceiver={scoreState.serving === 'right' ? currentReceiver?.name ?? null : null}
|
||||
currentServer={scoreState.serving === 'left' ? currentServer?.name ?? null : null}
|
||||
onRecordPoint={() => onRecordPoint('left')}
|
||||
onSetServing={() => onSetServing('left')}
|
||||
onSwapPlayers={() => onSwapTeamPlayers('left')}
|
||||
onSwapTeams={onSwapMatchup}
|
||||
score={scoreState.scoreLeft}
|
||||
serviceCourt={scoreState.serving === 'left' ? servingCourt : null}
|
||||
team={leftTeam}
|
||||
teamSlot="top"
|
||||
/>
|
||||
|
||||
<div className="scoreboard-center-banner">
|
||||
<p>{scoreState.serving === null ? '請設定發球方' : '點擊分數開始計分'}</p>
|
||||
<small>
|
||||
{scoreState.serving === null
|
||||
? '先在上方或下方按下先攻'
|
||||
: `目前發球:${currentServer?.name ?? '-'}${
|
||||
currentReceiver ? ` / 接發:${currentReceiver.name}` : ''
|
||||
}`}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<ScoreboardTeamPanel
|
||||
assignments={rightAssignments}
|
||||
canArrangeMatch={canArrangeMatch}
|
||||
canScore={canScore}
|
||||
currentReceiver={scoreState.serving === 'left' ? currentReceiver?.name ?? null : null}
|
||||
currentServer={scoreState.serving === 'right' ? currentServer?.name ?? null : null}
|
||||
onRecordPoint={() => onRecordPoint('right')}
|
||||
onSetServing={() => onSetServing('right')}
|
||||
onSwapPlayers={() => onSwapTeamPlayers('right')}
|
||||
onSwapTeams={onSwapMatchup}
|
||||
score={scoreState.scoreRight}
|
||||
serviceCourt={scoreState.serving === 'right' ? servingCourt : null}
|
||||
team={rightTeam}
|
||||
teamSlot="bottom"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside className="scoreboard-rail">
|
||||
<div className="rail-icon-grid">
|
||||
{hasRecordedPoint ? (
|
||||
<button className="rail-square-button" type="button" onClick={onUndoLastPoint}>
|
||||
上一步
|
||||
</button>
|
||||
) : (
|
||||
<button className="rail-square-button" type="button" onClick={openPicker}>
|
||||
設定隊伍
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rail-clock">{clock}</div>
|
||||
|
||||
<button className="rail-pill rail-pill-danger" type="button" onClick={onOpenFinishDialog}>
|
||||
比賽結算
|
||||
</button>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
{pickerOpen ? (
|
||||
<TeamPickerModal
|
||||
currentLeftTeamId={matchup.leftTeamId}
|
||||
currentRightTeamId={matchup.rightTeamId}
|
||||
draftTeamIds={draftTeamIds}
|
||||
group={selectedGroup}
|
||||
selectionCount={draftTeamIds.length}
|
||||
sourceLabel={groupSource === 'db' ? 'DB' : groupSource === 'manual' ? '手動' : '-'}
|
||||
targetDate={targetDate}
|
||||
onAutoPick={autoPickDraftTeams}
|
||||
onClear={() => setDraftTeamIds([])}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onConfirm={confirmDraftTeams}
|
||||
onToggleTeam={toggleDraftTeam}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{finishDialogOpen ? (
|
||||
<FinishDialog
|
||||
error={finishDialogError}
|
||||
leftScore={scoreState.scoreLeft}
|
||||
leftTeamName={leftTeam ? getTeamDisplayName(leftTeam) : '未設定'}
|
||||
matchupLabel={matchupLabel}
|
||||
rightScore={scoreState.scoreRight}
|
||||
rightTeamName={rightTeam ? getTeamDisplayName(rightTeam) : '未設定'}
|
||||
uploading={finishDialogUploading}
|
||||
onClose={onCloseFinishDialog}
|
||||
onConfirm={onConfirmUpload}
|
||||
onSkip={onSkipUpload}
|
||||
/>
|
||||
) : 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 = (
|
||||
<div className="scoreboard-team-head">
|
||||
<div className="team-head-main">
|
||||
{orderedAssignments.map((assignment) => (
|
||||
<div
|
||||
className={
|
||||
assignment.name === currentServer
|
||||
? 'scoreboard-name-chip scoreboard-name-chip-serving'
|
||||
: 'scoreboard-name-chip'
|
||||
}
|
||||
key={assignment.slot}
|
||||
>
|
||||
<span className="team-number">{getPlayerNumber(teamSlot, assignment.slot)}</span>
|
||||
<strong>{assignment.name}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="team-head-buttons">
|
||||
<button
|
||||
aria-label="上下交換隊伍"
|
||||
className="team-icon-button"
|
||||
disabled={!canArrangeMatch}
|
||||
type="button"
|
||||
onClick={onSwapTeams}
|
||||
>
|
||||
↕
|
||||
</button>
|
||||
<button
|
||||
aria-label="左右交換隊員"
|
||||
className="team-icon-button"
|
||||
disabled={!canArrangeMatch}
|
||||
type="button"
|
||||
onClick={onSwapPlayers}
|
||||
>
|
||||
↔
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const serveBar = (
|
||||
<button
|
||||
className={
|
||||
currentServer && !canArrangeMatch ? 'serve-lane serve-lane-locked' : 'serve-lane'
|
||||
}
|
||||
disabled={!canArrangeMatch || !team}
|
||||
type="button"
|
||||
onClick={onSetServing}
|
||||
>
|
||||
<span className="serve-lane-box" />
|
||||
<span>先攻</span>
|
||||
{currentServer ? (
|
||||
<small>
|
||||
發球位 {serviceCourt === 'left' ? '左' : serviceCourt === 'right' ? '右' : '-'}
|
||||
{currentReceiver ? ` / 接發 ${currentReceiver}` : ''}
|
||||
</small>
|
||||
) : (
|
||||
<small>選擇這一隊先攻</small>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
const scoreBoard = (
|
||||
<button
|
||||
className={canScore ? 'score-panel-surface score-panel-surface-live' : 'score-panel-surface'}
|
||||
disabled={!canScore || !team}
|
||||
type="button"
|
||||
onClick={onRecordPoint}
|
||||
>
|
||||
<span className="score-panel-value">{score}</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="scoreboard-team-section">
|
||||
{teamSlot === 'top' ? (
|
||||
<>
|
||||
{header}
|
||||
{serveBar}
|
||||
{scoreBoard}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{scoreBoard}
|
||||
{serveBar}
|
||||
{header}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="team-picker-overlay" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
aria-label="設定對戰隊伍"
|
||||
aria-modal="true"
|
||||
className="team-picker-shell"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
role="dialog"
|
||||
>
|
||||
<button aria-label="關閉選隊視窗" className="team-picker-close" type="button" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
|
||||
<div className="team-picker-ribbon">
|
||||
<span>{selectionCount >= 2 ? '已完成選擇' : '請選擇 2 隊'}</span>
|
||||
</div>
|
||||
|
||||
<div className="team-picker-layout">
|
||||
<section className="team-picker-panel team-picker-list-panel">
|
||||
<div className="team-picker-title">
|
||||
<span className="team-picker-count">{selectionCount}/2</span>
|
||||
<div>
|
||||
<strong>從這一組挑選要對打的隊伍</strong>
|
||||
<p>
|
||||
第 {group.id} 組 / {sourceLabel} / {targetDate || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="team-picker-list">
|
||||
{group.teams.map((team) => {
|
||||
const checked = draftTeamIds.includes(team.id)
|
||||
const selectedOrder = checked ? draftTeamIds.indexOf(team.id) + 1 : null
|
||||
|
||||
return (
|
||||
<button
|
||||
className={
|
||||
checked
|
||||
? 'team-picker-option team-picker-option-active'
|
||||
: 'team-picker-option'
|
||||
}
|
||||
key={`team-option-${team.id}`}
|
||||
type="button"
|
||||
onClick={() => onToggleTeam(team.id)}
|
||||
>
|
||||
<span className="team-picker-checkbox">
|
||||
{checked ? String(selectedOrder) : ''}
|
||||
</span>
|
||||
<div className="team-picker-option-text">
|
||||
<strong>{getTeamDisplayName(team)}</strong>
|
||||
<small>隊伍編號 {team.id}</small>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="team-picker-actions">
|
||||
<button className="team-picker-ghost" type="button" onClick={onAutoPick}>
|
||||
自動選擇
|
||||
</button>
|
||||
<button
|
||||
className="team-picker-confirm"
|
||||
disabled={draftTeamIds.length !== 2}
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
確認
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="team-picker-panel team-picker-side-panel">
|
||||
<div className="picked-team-list">
|
||||
{[0, 1].map((slotIndex) => {
|
||||
const teamId = draftTeamIds[slotIndex] ?? null
|
||||
const team = group.teams.find((item) => item.id === teamId) ?? null
|
||||
const isCurrent =
|
||||
teamId !== null &&
|
||||
teamId === (slotIndex === 0 ? currentLeftTeamId : currentRightTeamId)
|
||||
|
||||
return (
|
||||
<div className="picked-team-card" key={`picked-${slotIndex}`}>
|
||||
<span className="picked-team-index">{slotIndex + 1}</span>
|
||||
<div>
|
||||
<strong>{team ? getTeamDisplayName(team) : '尚未選擇'}</strong>
|
||||
<small>
|
||||
{slotIndex === 0 ? '上方隊伍' : '下方隊伍'}
|
||||
{isCurrent ? ' / 目前使用中' : ''}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<label className="picker-mode-toggle">
|
||||
<input disabled type="checkbox" />
|
||||
<span>單打模式</span>
|
||||
</label>
|
||||
|
||||
<button className="team-picker-clear" type="button" onClick={onClear}>
|
||||
清空
|
||||
</button>
|
||||
|
||||
<p className="picker-side-hint">
|
||||
選好 2 隊後按確認,會直接帶入記分板的上方與下方位置。
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="finish-dialog-overlay" role="presentation">
|
||||
<div aria-modal="true" className="finish-dialog" role="dialog">
|
||||
<button
|
||||
aria-label="關閉結算視窗"
|
||||
className="finish-dialog-close"
|
||||
disabled={uploading}
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<p className="panel-kicker">比賽結算</p>
|
||||
<h3>{matchupLabel}</h3>
|
||||
|
||||
<div className="finish-score">
|
||||
<div>
|
||||
<strong>{leftScore}</strong>
|
||||
<span>{leftTeamName}</span>
|
||||
</div>
|
||||
<div className="finish-score-divider">:</div>
|
||||
<div>
|
||||
<strong>{rightScore}</strong>
|
||||
<span>{rightTeamName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="finish-dialog-copy">要把這場戰績上傳到 DB 嗎?</p>
|
||||
|
||||
{error ? <p className="finish-dialog-error">{error}</p> : null}
|
||||
|
||||
<div className="finish-dialog-actions">
|
||||
<button
|
||||
className="team-picker-ghost"
|
||||
disabled={uploading}
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
>
|
||||
不上傳
|
||||
</button>
|
||||
<button
|
||||
className="team-picker-confirm"
|
||||
disabled={uploading}
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{uploading ? '上傳中...' : '上傳戰績'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
196
src/pages/TeamSelectionPage.tsx
Normal file
196
src/pages/TeamSelectionPage.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getTeamDisplayName } from '../lib/match'
|
||||
import type { LoadStatus, RoundGroup } from '../types'
|
||||
|
||||
type TeamSelectionPageProps = {
|
||||
areaAInput: string
|
||||
areaBInput: string
|
||||
groups: RoundGroup[]
|
||||
groupSource: 'idle' | 'db' | 'manual'
|
||||
loadMessage: string
|
||||
loadStatus: LoadStatus
|
||||
parsedAreaA: string[]
|
||||
parsedAreaB: string[]
|
||||
selectedGroupId: number | null
|
||||
targetDate: string
|
||||
onAreaAInputChange: (value: string) => void
|
||||
onAreaBInputChange: (value: string) => void
|
||||
onGenerateManualGroups: () => void
|
||||
onLoadGroupsFromDb: () => void
|
||||
onSelectGroup: (groupId: number) => void
|
||||
onTargetDateChange: (value: string) => void
|
||||
onUseGroup: (groupId: number) => void
|
||||
}
|
||||
|
||||
export function TeamSelectionPage({
|
||||
areaAInput,
|
||||
areaBInput,
|
||||
groups,
|
||||
groupSource,
|
||||
loadMessage,
|
||||
loadStatus,
|
||||
parsedAreaA,
|
||||
parsedAreaB,
|
||||
selectedGroupId,
|
||||
targetDate,
|
||||
onAreaAInputChange,
|
||||
onAreaBInputChange,
|
||||
onGenerateManualGroups,
|
||||
onLoadGroupsFromDb,
|
||||
onSelectGroup,
|
||||
onTargetDateChange,
|
||||
onUseGroup,
|
||||
}: TeamSelectionPageProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<section className="selection-shell">
|
||||
<article className="panel panel-hero selection-hero">
|
||||
<div>
|
||||
<p className="panel-kicker">步驟 1</p>
|
||||
<h2>載入分組與選擇組別</h2>
|
||||
<p className="panel-copy">
|
||||
先用日期從 DB 讀取分組;如果指定日期沒有資料,就改用 A 區與 B 區名單手動產生配對。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="summary-grid">
|
||||
<article className="mini-stat">
|
||||
<span>A 區隊數</span>
|
||||
<strong>{parsedAreaA.length}</strong>
|
||||
</article>
|
||||
<article className="mini-stat">
|
||||
<span>B 區隊數</span>
|
||||
<strong>{parsedAreaB.length}</strong>
|
||||
</article>
|
||||
<article className="mini-stat">
|
||||
<span>目前組數</span>
|
||||
<strong>{groups.length}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{loadMessage ? (
|
||||
<p className={`status-banner status-banner-${loadStatus}`}>{loadMessage}</p>
|
||||
) : null}
|
||||
</article>
|
||||
|
||||
<article className="panel">
|
||||
<div className="selection-form">
|
||||
<div className="selection-toolbar">
|
||||
<label className="field">
|
||||
<span>指定日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={targetDate}
|
||||
onChange={(event) => onTargetDateChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="button-stack">
|
||||
<button className="primary-button" type="button" onClick={onLoadGroupsFromDb}>
|
||||
讀取指定日期
|
||||
</button>
|
||||
<button className="secondary-button" type="button" onClick={onGenerateManualGroups}>
|
||||
手動產生配對
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="double-grid">
|
||||
<label className="field">
|
||||
<span>A 區名單</span>
|
||||
<textarea
|
||||
rows={8}
|
||||
value={areaAInput}
|
||||
onChange={(event) => onAreaAInputChange(event.target.value)}
|
||||
placeholder={'每行一隊,例如:\n柏威'}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>B 區名單</span>
|
||||
<textarea
|
||||
rows={8}
|
||||
value={areaBInput}
|
||||
onChange={(event) => onAreaBInputChange(event.target.value)}
|
||||
placeholder={'每行一隊,例如:\nRURU'}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="selection-hint">
|
||||
<span>
|
||||
來源:
|
||||
{groupSource === 'db' ? 'DB' : groupSource === 'manual' ? '手動配對' : '尚未載入'}
|
||||
</span>
|
||||
<span>從下方選擇要帶進記分板的第幾組。</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="panel full-span">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="panel-kicker">步驟 2</p>
|
||||
<h2>選擇第幾組帶進記分板</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{groups.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>目前還沒有分組</h3>
|
||||
<p>先讀取指定日期資料,或手動輸入名單後產生配對。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="group-board">
|
||||
{groups.map((group) => (
|
||||
<article
|
||||
className={
|
||||
group.id === selectedGroupId
|
||||
? 'group-card group-card-active group-card-stage'
|
||||
: 'group-card group-card-stage'
|
||||
}
|
||||
key={group.id}
|
||||
>
|
||||
<div className="group-head">
|
||||
<div>
|
||||
<p className="panel-kicker">第 {group.id} 組</p>
|
||||
<h3>{group.teams.length} 隊可選</h3>
|
||||
</div>
|
||||
<div className="group-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={() => onSelectGroup(group.id)}
|
||||
>
|
||||
先選這組
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onUseGroup(group.id)
|
||||
navigate('/scoreboard')
|
||||
}}
|
||||
>
|
||||
帶進記分板
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="team-stage-grid">
|
||||
{group.teams.map((team) => (
|
||||
<article className="team-stage-card" key={`${group.id}-${team.id}`}>
|
||||
<span className="team-index">第 {team.id} 隊</span>
|
||||
<p className="team-name">{getTeamDisplayName(team)}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user