|
|
|
@@ -496,7 +496,7 @@ function WorkflowControl({ eventId }: { eventId: string }) {
|
|
|
|
{state === 'RegistrationOpen' && <WorkflowAction title="Close registration" description="Freeze the Going participant list before balancing." button="Close registration" pending={close.isPending} onClick={() => close.mutate()} />}
|
|
|
|
{state === 'RegistrationOpen' && <WorkflowAction title="Close registration" description="Freeze the Going participant list before balancing." button="Close registration" pending={close.isPending} onClick={() => close.mutate()} />}
|
|
|
|
{state === 'RegistrationClosed' && <WorkflowAction title="Generate fair teams" description="The optimizer considers ranks, preferred roles, and preferred teammates." button="Generate balance" pending={generate.isPending} onClick={() => generate.mutate()} />}
|
|
|
|
{state === 'RegistrationClosed' && <WorkflowAction title="Generate fair teams" description="The optimizer considers ranks, preferred roles, and preferred teammates." button="Generate balance" pending={generate.isPending} onClick={() => generate.mutate()} />}
|
|
|
|
{state === 'Balancing' && candidates.length === 0 && <WorkflowAction title="Generate balance candidates" description="Generate again after a refresh to choose one of the server candidates." button="Generate candidates" pending={generate.isPending} onClick={() => generate.mutate()} />}
|
|
|
|
{state === 'Balancing' && candidates.length === 0 && <WorkflowAction title="Generate balance candidates" description="Generate again after a refresh to choose one of the server candidates." button="Generate candidates" pending={generate.isPending} onClick={() => generate.mutate()} />}
|
|
|
|
{state === 'Balancing' && candidates.length > 0 && <div className="balance-layout"><aside className="candidate-list">{candidates.map((candidate, index) => <button className={`candidate-card ${selectedCandidate === index ? 'active' : ''}`} key={candidate.id} onClick={() => setSelectedCandidate(index)}><span className="candidate-rank">0{index + 1}</span><span><strong>Score {candidate.score}</strong><small>{candidate.explanations[0]}</small></span><ChevronRight /></button>)}</aside><section><div className="teams-grid">{candidates[selectedCandidate].teams.map((team) => <TeamCard key={team.id} team={team} eventId={eventId} />)}</div><div className="sticky-action"><p>Selecting creates a versioned roster draft.</p><button className="button primary" disabled={select.isPending} onClick={() => select.mutate()}>Use this balance</button></div></section></div>}
|
|
|
|
{state === 'Balancing' && candidates.length > 0 && <div className="balance-layout"><aside className="candidate-list">{candidates.map((candidate, index) => <button className={`candidate-card ${selectedCandidate === index ? 'active' : ''}`} key={candidate.id} onClick={() => setSelectedCandidate(index)}><span className="candidate-rank">0{index + 1}</span><span><strong>Score {candidate.score}</strong><small>{candidate.explanations[0]}</small></span><ChevronRight /></button>)}</aside><section><div className="teams-grid">{candidates[selectedCandidate].teams.map((team) => <TeamCard key={team.id} team={team} eventId={eventId} allowCaptainAssignment={false} />)}</div><div className="sticky-action"><p>Selecting creates a versioned roster draft.</p><button className="button primary" disabled={select.isPending} onClick={() => select.mutate()}>Use this balance</button></div></section></div>}
|
|
|
|
{roster.data && ['RostersDraft', 'RostersConfirmed', 'Live', 'Completed'].includes(state) && <WorkflowRosterEditor eventId={eventId} roster={roster.data} players={players.data} locked={state !== 'RostersDraft'} onChanged={refresh} />}
|
|
|
|
{roster.data && ['RostersDraft', 'RostersConfirmed', 'Live', 'Completed'].includes(state) && <WorkflowRosterEditor eventId={eventId} roster={roster.data} players={players.data} locked={state !== 'RostersDraft'} onChanged={refresh} />}
|
|
|
|
{state === 'RostersDraft' && roster.data && <WorkflowAction title="Confirm rosters" description="Every team must have a valid 1/2/2 lineup and a captain." button="Confirm rosters" pending={confirm.isPending} onClick={() => confirm.mutate()} />}
|
|
|
|
{state === 'RostersDraft' && roster.data && <WorkflowAction title="Confirm rosters" description="Every team must have a valid 1/2/2 lineup and a captain." button="Confirm rosters" pending={confirm.isPending} onClick={() => confirm.mutate()} />}
|
|
|
|
{state === 'RostersConfirmed' && <WorkflowAction title="Start scrim" description="This atomically creates the Bo3 series or tournament bracket." button="Start scrim" pending={start.isPending} onClick={() => start.mutate()} />}
|
|
|
|
{state === 'RostersConfirmed' && <WorkflowAction title="Start scrim" description="This atomically creates the Bo3 series or tournament bracket." button="Start scrim" pending={start.isPending} onClick={() => start.mutate()} />}
|
|
|
|
@@ -563,7 +563,7 @@ function WorkflowRosterEditor({ eventId, roster, players, locked, onChanged }: {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return <section className="card roster-editor">
|
|
|
|
return <section className="card roster-editor">
|
|
|
|
<div className="section-title"><div><h2>Roster editor</h2><p>Version {roster.version} · drag players between equal-role slots or through reserve.</p></div>{roster.confirmed && <Badge tone="success"><Check />Locked</Badge>}</div>
|
|
|
|
<div className="section-title"><div><h2>Roster editor</h2><p>Version {roster.version} · drag players into any role slot or through reserve.</p></div>{roster.confirmed && <Badge tone="success"><Check />Locked</Badge>}</div>
|
|
|
|
<div className="roster-editor-body"><div className="teams-grid">{roster.teams.map((team) => <article className="workflow-team" key={team.id}><header><h3>{team.name}</h3><label>Captain<select disabled={locked || mutate.isPending} value={team.captainPlayerId} onChange={(event) => mutate.mutate({ kind: 'captain', teamId: team.id, playerId: event.target.value })}><option value="">Choose captain</option>{team.slots.filter((slot) => slot.playerId).map((slot) => <option value={slot.playerId} key={slot.playerId}>{names.get(slot.playerId) ?? slot.playerId}</option>)}</select></label></header>{team.slots.map((slot, slotIndex) => <div className={`roster-slot ${slot.playerId ? '' : 'empty'}`} key={`${slot.role}-${slotIndex}`} onDragOver={(event) => { if (!locked) event.preventDefault() }} onDrop={(event) => dropOnSlot(event, team.id, slot)}><span className={`role-chip ${slot.role.toLowerCase()}`}>{slot.role[0]}</span>{slot.playerId ? <><strong draggable={!locked} onDragStart={(event) => startDrag(event, { kind: 'team', teamId: team.id, playerId: slot.playerId, role: slot.role })}>{names.get(slot.playerId) ?? slot.playerId}</strong><small>{rankLabel(slot.rating, language)}</small>{!locked && <button className="icon-button slot-remove" type="button" aria-label={`Move ${names.get(slot.playerId) ?? slot.playerId} to reserve`} onClick={() => mutate.mutate({ kind: 'remove', teamId: team.id, playerId: slot.playerId })}><X /></button>}</> : <><strong>Empty</strong><small>{slot.role}</small></>}</div>)}</article>)}</div>
|
|
|
|
<div className="roster-editor-body"><div className="teams-grid">{roster.teams.map((team) => <article className="workflow-team" key={team.id}><header><h3>{team.name}</h3><label>Captain<select disabled={locked || mutate.isPending} value={team.captainPlayerId} onChange={(event) => mutate.mutate({ kind: 'captain', teamId: team.id, playerId: event.target.value })}><option value="">Choose captain</option>{team.slots.filter((slot) => slot.playerId).map((slot) => <option value={slot.playerId} key={slot.playerId}>{names.get(slot.playerId) ?? slot.playerId}</option>)}</select></label></header>{team.slots.map((slot, slotIndex) => <div className={`roster-slot ${slot.playerId ? '' : 'empty'}`} key={`${slot.role}-${slotIndex}`} onDragOver={(event) => { if (!locked) event.preventDefault() }} onDrop={(event) => dropOnSlot(event, team.id, slot)}><span className={`role-chip ${slot.role.toLowerCase()}`}>{slot.role[0]}</span>{slot.playerId ? <><strong draggable={!locked} onDragStart={(event) => startDrag(event, { kind: 'team', teamId: team.id, playerId: slot.playerId, role: slot.role })}>{names.get(slot.playerId) ?? slot.playerId}</strong><small>{rankLabel(slot.rating, language)}</small>{!locked && <button className="icon-button slot-remove" type="button" aria-label={`Move ${names.get(slot.playerId) ?? slot.playerId} to reserve`} onClick={() => mutate.mutate({ kind: 'remove', teamId: team.id, playerId: slot.playerId })}><X /></button>}</> : <><strong>Empty</strong><small>{slot.role}</small></>}</div>)}</article>)}</div>
|
|
|
|
<aside className="roster-reserve" onDragOver={(event) => { if (!locked) event.preventDefault() }} onDrop={(event) => { event.preventDefault(); const source = readDrag(event); if (!locked && source?.kind === 'team') mutate.mutate({ kind: 'remove', teamId: source.teamId, playerId: source.playerId }) }}><div><UsersRound /><span><strong>Reserve</strong><small>Drop a player here to remove them from a team</small></span></div><div className="reserve-list">{roster.reserve.length === 0 ? <p>Reserve is empty</p> : roster.reserve.map((id) => <button type="button" draggable={!locked} onDragStart={(event) => startDrag(event, { kind: 'reserve', playerId: id })} key={id}><span className="mini-avatar">{(names.get(id) ?? id).slice(0, 2).toUpperCase()}</span><strong>{names.get(id) ?? id}</strong></button>)}</div></aside>
|
|
|
|
<aside className="roster-reserve" onDragOver={(event) => { if (!locked) event.preventDefault() }} onDrop={(event) => { event.preventDefault(); const source = readDrag(event); if (!locked && source?.kind === 'team') mutate.mutate({ kind: 'remove', teamId: source.teamId, playerId: source.playerId }) }}><div><UsersRound /><span><strong>Reserve</strong><small>Drop a player here to remove them from a team</small></span></div><div className="reserve-list">{roster.reserve.length === 0 ? <p>Reserve is empty</p> : roster.reserve.map((id) => <button type="button" draggable={!locked} onDragStart={(event) => startDrag(event, { kind: 'reserve', playerId: id })} key={id}><span className="mini-avatar">{(names.get(id) ?? id).slice(0, 2).toUpperCase()}</span><strong>{names.get(id) ?? id}</strong></button>)}</div></aside>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
@@ -588,7 +588,7 @@ function LegacyBalanceView({ eventId }: { eventId: string }) {
|
|
|
|
return <div className="balance-layout"><aside className="candidate-list"><div className="section-title"><div><h2>Server candidates</h2><p>Lower imbalance score is better</p></div><button className="icon-button" aria-label="Generate again" disabled={generate.isPending} onClick={() => generate.mutate()}><RefreshCw /></button></div>{candidates.map((item, i) => <button key={item.id} className={`candidate-card ${selected === item.id ? 'active' : ''}`} onClick={() => setSelected(item.id)}><span className="candidate-rank">0{i + 1}</span><span><strong>Score {item.score}</strong><small>Computed by backend balancer</small></span><ChevronRight /></button>)}</aside><section className="balance-main"><div className="balance-score card"><div><span className="eyebrow">Imbalance score</span><strong>{candidate.score}</strong></div><div className="balance-explanations"><span>Preference fit</span>{candidate.explanations.map((explanation) => <small key={explanation}><Check />{explanation}</small>)}</div><p>Lower scores represent closer team and role totals. The server remains the source of truth for roster validity.</p></div><div className="teams-grid">{candidate.teams.map((team) => <TeamCard key={team.id} team={team} eventId={eventId} />)}</div><div className="reserve-bar card"><div><UsersRound /><span><strong>Reserve · {candidate.reserve.length}</strong><small>Not assigned to a complete 5v5 roster</small></span></div><div>{candidate.reserve.map((p) => <Badge key={p.id}>{p.displayName}</Badge>)}</div></div><div className="sticky-action"><p>{select.isError ? select.error.message : 'The server will save these rosters and unlock captain assignment.'}</p><button className="button primary" disabled={select.isPending} onClick={() => select.mutate()}><Check />{select.isPending ? 'Saving…' : 'Use this balance'}</button></div></section></div>
|
|
|
|
return <div className="balance-layout"><aside className="candidate-list"><div className="section-title"><div><h2>Server candidates</h2><p>Lower imbalance score is better</p></div><button className="icon-button" aria-label="Generate again" disabled={generate.isPending} onClick={() => generate.mutate()}><RefreshCw /></button></div>{candidates.map((item, i) => <button key={item.id} className={`candidate-card ${selected === item.id ? 'active' : ''}`} onClick={() => setSelected(item.id)}><span className="candidate-rank">0{i + 1}</span><span><strong>Score {item.score}</strong><small>Computed by backend balancer</small></span><ChevronRight /></button>)}</aside><section className="balance-main"><div className="balance-score card"><div><span className="eyebrow">Imbalance score</span><strong>{candidate.score}</strong></div><div className="balance-explanations"><span>Preference fit</span>{candidate.explanations.map((explanation) => <small key={explanation}><Check />{explanation}</small>)}</div><p>Lower scores represent closer team and role totals. The server remains the source of truth for roster validity.</p></div><div className="teams-grid">{candidate.teams.map((team) => <TeamCard key={team.id} team={team} eventId={eventId} />)}</div><div className="reserve-bar card"><div><UsersRound /><span><strong>Reserve · {candidate.reserve.length}</strong><small>Not assigned to a complete 5v5 roster</small></span></div><div>{candidate.reserve.map((p) => <Badge key={p.id}>{p.displayName}</Badge>)}</div></div><div className="sticky-action"><p>{select.isError ? select.error.message : 'The server will save these rosters and unlock captain assignment.'}</p><button className="button primary" disabled={select.isPending} onClick={() => select.mutate()}><Check />{select.isPending ? 'Saving…' : 'Use this balance'}</button></div></section></div>
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function TeamCard({ team, eventId }: { team: Team; eventId: string }) {
|
|
|
|
function TeamCard({ team, eventId, allowCaptainAssignment = true }: { team: Team; eventId: string; allowCaptainAssignment?: boolean }) {
|
|
|
|
const client = useQueryClient()
|
|
|
|
const client = useQueryClient()
|
|
|
|
const language = useLanguage()
|
|
|
|
const language = useLanguage()
|
|
|
|
const [captain, setCaptain] = useState(team.captainId ?? '')
|
|
|
|
const [captain, setCaptain] = useState(team.captainId ?? '')
|
|
|
|
@@ -597,7 +597,7 @@ function TeamCard({ team, eventId }: { team: Team; eventId: string }) {
|
|
|
|
mutationFn: (playerId: string) => api.assignCaptain(team.id, playerId),
|
|
|
|
mutationFn: (playerId: string) => api.assignCaptain(team.id, playerId),
|
|
|
|
onSuccess: () => void client.invalidateQueries({ queryKey: ['teams', eventId] }),
|
|
|
|
onSuccess: () => void client.invalidateQueries({ queryKey: ['teams', eventId] }),
|
|
|
|
})
|
|
|
|
})
|
|
|
|
return <article className={`card team-card team-${team.side}`}><header><div><span>Team {team.side === 'alpha' ? 'A' : 'B'}</span><h3>{team.name}</h3></div><strong className="team-rank">{rankLabel(team.averageRating, language)}<small> avg</small></strong></header><div className="team-members">{team.members.map(({ player, assignedRole }) => <div key={player.id}><span className={`role-chip ${assignedRole}`}>{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'}</span><strong>{player.displayName}</strong><span>{rankLabel(player.ratings[assignedRole], language)}</span></div>)}</div><label className="captain-select"><span><Trophy />Team captain</span><select value={captain} disabled={assign.isPending} onChange={(e) => { setCaptain(e.target.value); assign.mutate(e.target.value) }}><option value="">Select from roster</option>{team.members.map(({ player }) => <option value={player.id} key={player.id}>{player.displayName}</option>)}</select>{assign.isError && <small className="error-note">{assign.error.message}</small>}</label></article>
|
|
|
|
return <article className={`card team-card team-${team.side}`}><header><div><span>Team {team.side === 'alpha' ? 'A' : 'B'}</span><h3>{team.name}</h3></div><strong className="team-rank">{rankLabel(team.averageRating, language)}<small> avg</small></strong></header><div className="team-members">{team.members.map(({ player, assignedRole }) => <div key={player.id}><span className={`role-chip ${assignedRole}`}>{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'}</span><strong>{player.displayName}</strong><span>{rankLabel(player.ratings[assignedRole], language)}</span></div>)}</div>{allowCaptainAssignment && <label className="captain-select"><span><Trophy />Team captain</span><select value={captain} disabled={assign.isPending} onChange={(e) => { setCaptain(e.target.value); assign.mutate(e.target.value) }}><option value="">Select from roster</option>{team.members.map(({ player }) => <option value={player.id} key={player.id}>{player.displayName}</option>)}</select>{assign.isError && <small className="error-note">{assign.error.message}</small>}</label>}</article>
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function LiveSeriesPage() {
|
|
|
|
function LiveSeriesPage() {
|
|
|
|
|