extract AddScoreModal and PlayerScoreRow from events page
This commit is contained in:
parent
00813ddd58
commit
d23e1db7d1
3 changed files with 586 additions and 551 deletions
382
src/lib/components/crew/AddScoreModal.svelte
Normal file
382
src/lib/components/crew/AddScoreModal.svelte
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { createMutation, useQueryClient } from '@tanstack/svelte-query'
|
||||
import { gwAdapter } from '$lib/api/adapters/gw.adapter'
|
||||
import Dialog from '$lib/components/ui/Dialog.svelte'
|
||||
import ModalHeader from '$lib/components/ui/ModalHeader.svelte'
|
||||
import ModalBody from '$lib/components/ui/ModalBody.svelte'
|
||||
import ModalFooter from '$lib/components/ui/ModalFooter.svelte'
|
||||
import Input from '$lib/components/ui/Input.svelte'
|
||||
import Select from '$lib/components/ui/Select.svelte'
|
||||
import Checkbox from '$lib/components/ui/checkbox/Checkbox.svelte'
|
||||
import { formatScore, parseScore } from '$lib/utils/gw'
|
||||
import { GW_ROUND_LABELS, type GwRound, type GwIndividualScore } from '$lib/types/api/gw'
|
||||
|
||||
// Simplified types matching the API response
|
||||
interface MemberDuringEvent {
|
||||
id: string
|
||||
user?: { id: string; username: string }
|
||||
retired: boolean
|
||||
}
|
||||
|
||||
interface PhantomPlayerMinimal {
|
||||
id: string
|
||||
name: string
|
||||
retired: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
eventId: string
|
||||
eventNumber: string
|
||||
membersDuringEvent: MemberDuringEvent[]
|
||||
phantomPlayers: PhantomPlayerMinimal[]
|
||||
existingScores: GwIndividualScore[]
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
eventId,
|
||||
eventNumber,
|
||||
membersDuringEvent,
|
||||
phantomPlayers,
|
||||
existingScores
|
||||
}: Props = $props()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Form state
|
||||
let selectedPlayerId = $state<string | undefined>(undefined)
|
||||
let isCumulative = $state(true)
|
||||
let cumulativeScore = $state('')
|
||||
let roundScores = $state<Record<GwRound, string>>({
|
||||
0: '',
|
||||
1: '',
|
||||
2: '',
|
||||
3: '',
|
||||
4: '',
|
||||
5: ''
|
||||
})
|
||||
let isExcused = $state(false)
|
||||
let excuseReason = $state('')
|
||||
let isSubmitting = $state(false)
|
||||
|
||||
// Player type tracking - value format is "member:id" or "phantom:id"
|
||||
const selectedPlayerType = $derived.by(() => {
|
||||
if (!selectedPlayerId) return null
|
||||
const [type] = selectedPlayerId.split(':')
|
||||
return type as 'member' | 'phantom'
|
||||
})
|
||||
|
||||
const selectedPlayerActualId = $derived.by(() => {
|
||||
if (!selectedPlayerId) return null
|
||||
const [, id] = selectedPlayerId.split(':')
|
||||
return id
|
||||
})
|
||||
|
||||
// Track which players already have scores for this event
|
||||
const playersWithScores = $derived.by(() => {
|
||||
const ids = new Set<string>()
|
||||
for (const score of existingScores) {
|
||||
if (score.member?.id) {
|
||||
ids.add(`member:${score.member.id}`)
|
||||
} else if (score.phantom?.id) {
|
||||
ids.add(`phantom:${score.phantom.id}`)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
})
|
||||
|
||||
// Player options for dropdown - members first, then phantoms
|
||||
// Excludes players who already have scores for this event
|
||||
const playerOptions = $derived.by(() => {
|
||||
const options: Array<{ value: string; label: string; suffix?: string }> = []
|
||||
|
||||
// Add members (skip those with scores)
|
||||
for (const m of membersDuringEvent) {
|
||||
if (m.user) {
|
||||
const hasScore = playersWithScores.has(`member:${m.id}`)
|
||||
if (hasScore) continue
|
||||
options.push({
|
||||
value: `member:${m.id}`,
|
||||
label: m.user.username + (m.retired ? ' (Retired)' : '')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add phantoms (skip those with scores)
|
||||
for (const p of phantomPlayers) {
|
||||
const hasScore = playersWithScores.has(`phantom:${p.id}`)
|
||||
if (hasScore) continue
|
||||
options.push({
|
||||
value: `phantom:${p.id}`,
|
||||
label: p.name + (p.retired ? ' (Retired)' : ''),
|
||||
suffix: 'Phantom'
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
// Calculate cumulative from round scores
|
||||
const calculatedCumulativeScore = $derived.by(() => {
|
||||
if (isCumulative) return 0
|
||||
let total = 0
|
||||
for (const round of [0, 1, 2, 3, 4, 5] as GwRound[]) {
|
||||
const value = parseScore(roundScores[round] || '0')
|
||||
if (!isNaN(value)) total += value
|
||||
}
|
||||
return total
|
||||
})
|
||||
|
||||
// Sync cumulative when round scores change
|
||||
$effect(() => {
|
||||
if (!isCumulative) {
|
||||
cumulativeScore = formatScore(calculatedCumulativeScore)
|
||||
}
|
||||
})
|
||||
|
||||
// Add score mutation
|
||||
const addScoreMutation = createMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
if (!eventId || !selectedPlayerActualId || !selectedPlayerType) {
|
||||
throw new Error('Missing event or player')
|
||||
}
|
||||
|
||||
if (isCumulative) {
|
||||
// Single cumulative score
|
||||
const score = parseScore(cumulativeScore)
|
||||
if (isNaN(score)) throw new Error('Invalid score')
|
||||
|
||||
const input =
|
||||
selectedPlayerType === 'member'
|
||||
? { crewMembershipId: selectedPlayerActualId }
|
||||
: { phantomPlayerId: selectedPlayerActualId }
|
||||
|
||||
return gwAdapter.addIndividualScoreByEvent(eventId, {
|
||||
...input,
|
||||
round: 0, // Cumulative scores go to preliminaries
|
||||
score,
|
||||
isCumulative: true,
|
||||
excused: isExcused,
|
||||
excuseReason: isExcused ? excuseReason : undefined
|
||||
})
|
||||
} else {
|
||||
// Batch add per-round scores
|
||||
const scores = []
|
||||
for (const round of [0, 1, 2, 3, 4, 5] as GwRound[]) {
|
||||
const value = parseScore(roundScores[round] || '0')
|
||||
if (!isNaN(value) && value > 0) {
|
||||
const entry =
|
||||
selectedPlayerType === 'member'
|
||||
? { crewMembershipId: selectedPlayerActualId }
|
||||
: { phantomPlayerId: selectedPlayerActualId }
|
||||
|
||||
scores.push({
|
||||
...entry,
|
||||
round,
|
||||
score: value,
|
||||
isCumulative: false,
|
||||
excused: isExcused,
|
||||
excuseReason: isExcused ? excuseReason : undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (scores.length === 0) {
|
||||
throw new Error('No scores entered')
|
||||
}
|
||||
|
||||
return gwAdapter.batchAddIndividualScoresByEvent(eventId, { scores })
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['crew', 'gw', 'event', eventNumber] })
|
||||
handleClose()
|
||||
}
|
||||
}))
|
||||
|
||||
function resetForm() {
|
||||
selectedPlayerId = undefined
|
||||
isCumulative = true
|
||||
cumulativeScore = ''
|
||||
roundScores = { 0: '', 1: '', 2: '', 3: '', 4: '', 5: '' }
|
||||
isExcused = false
|
||||
excuseReason = ''
|
||||
isSubmitting = false
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
open = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
isSubmitting = true
|
||||
try {
|
||||
await addScoreMutation.mutateAsync()
|
||||
} finally {
|
||||
isSubmitting = false
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize form when modal opens
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
resetForm()
|
||||
}
|
||||
})
|
||||
|
||||
// Validation
|
||||
const canSubmit = $derived(
|
||||
!isSubmitting &&
|
||||
selectedPlayerId &&
|
||||
((isCumulative && cumulativeScore) || (!isCumulative && calculatedCumulativeScore > 0))
|
||||
)
|
||||
</script>
|
||||
|
||||
<Dialog bind:open onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<ModalHeader title="Add Score" />
|
||||
<ModalBody>
|
||||
<div class="score-form">
|
||||
<Select
|
||||
options={playerOptions}
|
||||
bind:value={selectedPlayerId}
|
||||
placeholder="Select player"
|
||||
label="Player"
|
||||
fullWidth
|
||||
contained
|
||||
portal
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Cumulative Score"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
bind:value={cumulativeScore}
|
||||
placeholder="Enter total score"
|
||||
disabled={!isCumulative}
|
||||
fullWidth
|
||||
contained
|
||||
/>
|
||||
|
||||
<label class="checkbox-row">
|
||||
<Checkbox bind:checked={isCumulative} size="small" />
|
||||
<span>Cumulative score</span>
|
||||
</label>
|
||||
|
||||
{#if !isCumulative}
|
||||
<div class="round-scores">
|
||||
{#each [0, 1, 2, 3, 4, 5] as round (round)}
|
||||
<Input
|
||||
label={GW_ROUND_LABELS[round as GwRound]}
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
bind:value={roundScores[round as GwRound]}
|
||||
placeholder="0"
|
||||
fullWidth
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="excused-section">
|
||||
<label class="checkbox-row">
|
||||
<Checkbox bind:checked={isExcused} size="small" contained />
|
||||
<span>Excused?</span>
|
||||
</label>
|
||||
{#if isExcused}
|
||||
<textarea
|
||||
class="excuse-textarea"
|
||||
bind:value={excuseReason}
|
||||
placeholder="Excusal reason (optional)"
|
||||
rows="2"
|
||||
></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if addScoreMutation.isError}
|
||||
<p class="error-message">
|
||||
{addScoreMutation.error?.message ?? 'Failed to add score'}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
onCancel={handleClose}
|
||||
primaryAction={{
|
||||
label: isSubmitting ? 'Saving...' : 'Save',
|
||||
onclick: handleSubmit,
|
||||
disabled: !canSubmit
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/colors' as colors;
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/typography' as typography;
|
||||
@use '$src/themes/layout' as layout;
|
||||
|
||||
.score-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: spacing.$unit;
|
||||
cursor: pointer;
|
||||
font-size: typography.$font-small;
|
||||
}
|
||||
|
||||
.round-scores {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit;
|
||||
padding: spacing.$unit-2x;
|
||||
background: var(--input-section-bg);
|
||||
border-radius: layout.$card-corner;
|
||||
}
|
||||
|
||||
.excused-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit;
|
||||
}
|
||||
|
||||
.excuse-textarea {
|
||||
width: 100%;
|
||||
padding: spacing.$unit;
|
||||
border: none;
|
||||
border-radius: layout.$input-corner;
|
||||
font-size: typography.$font-small;
|
||||
font-family: inherit;
|
||||
background: var(--input-bound-bg);
|
||||
color: var(--text-primary);
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--input-bound-bg-hover);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
background: var(--input-bound-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: colors.$error;
|
||||
font-size: typography.$font-small;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
181
src/lib/components/crew/PlayerScoreRow.svelte
Normal file
181
src/lib/components/crew/PlayerScoreRow.svelte
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import DropdownMenu from '$lib/components/ui/DropdownMenu.svelte'
|
||||
import { DropdownMenu as DropdownMenuBase } from 'bits-ui'
|
||||
import { formatScore } from '$lib/utils/gw'
|
||||
import type { GwIndividualScore } from '$lib/types/api/gw'
|
||||
|
||||
export interface PlayerScore {
|
||||
id: string
|
||||
name: string
|
||||
type: 'member' | 'phantom'
|
||||
totalScore: number
|
||||
isRetired?: boolean
|
||||
scores: GwIndividualScore[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
player: PlayerScore
|
||||
rank: number
|
||||
isOfficer: boolean
|
||||
onEditScore: () => void
|
||||
}
|
||||
|
||||
let { player, rank, isOfficer, onEditScore }: Props = $props()
|
||||
|
||||
const hasScores = $derived(player.scores.length > 0)
|
||||
const isExcused = $derived(player.scores.some((s) => s.excused))
|
||||
const isTopFive = $derived(rank <= 5)
|
||||
|
||||
function handleRowClick() {
|
||||
const path =
|
||||
player.type === 'member'
|
||||
? `/crew/members/${player.id}`
|
||||
: `/crew/phantoms/${player.id}`
|
||||
goto(path)
|
||||
}
|
||||
|
||||
function handleDropdownClick(event: MouseEvent) {
|
||||
event.stopPropagation()
|
||||
}
|
||||
</script>
|
||||
|
||||
<li
|
||||
class="player-item"
|
||||
class:retired={player.isRetired}
|
||||
onclick={handleRowClick}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleRowClick()}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="player-info">
|
||||
<span class="player-rank">{rank}</span>
|
||||
<span class="player-name">{player.name}{#if isTopFive}<span class="star">★</span>{/if}</span>
|
||||
{#if player.isRetired}
|
||||
<span class="player-badge retired">Retired</span>
|
||||
{/if}
|
||||
{#if isExcused}
|
||||
<span class="player-badge excused">Excused</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if player.type === 'phantom'}
|
||||
<span class="player-type">Phantom</span>
|
||||
{/if}
|
||||
<div class="player-actions">
|
||||
<span class="player-score">{formatScore(player.totalScore)}</span>
|
||||
{#if isOfficer && hasScores}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div onclick={handleDropdownClick}>
|
||||
<DropdownMenu>
|
||||
{#snippet trigger({ props })}
|
||||
<Button variant="ghost" size="small" iconOnly icon="ellipsis" {...props} />
|
||||
{/snippet}
|
||||
{#snippet menu()}
|
||||
<DropdownMenuBase.Item class="dropdown-menu-item" onclick={onEditScore}>
|
||||
Edit score...
|
||||
</DropdownMenuBase.Item>
|
||||
{/snippet}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/layout' as layout;
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/typography' as typography;
|
||||
|
||||
.player-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
border-radius: layout.$item-corner;
|
||||
transition: background-color 0.15s;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--focus-ring-color, #3b82f6);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&.retired {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.player-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: spacing.$unit;
|
||||
}
|
||||
|
||||
.player-rank {
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
color: var(--text-secondary);
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
|
||||
.star {
|
||||
color: #f5a623;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.player-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: layout.$item-corner-small;
|
||||
font-size: typography.$font-small;
|
||||
|
||||
&.phantom {
|
||||
background: var(--color-purple-light, #ede9fe);
|
||||
color: var(--color-purple-dark, #7c3aed);
|
||||
}
|
||||
|
||||
&.retired {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
&.excused {
|
||||
background: var(--color-yellow-light, #fef9c3);
|
||||
color: var(--color-yellow-dark, #854d0e);
|
||||
}
|
||||
}
|
||||
|
||||
.player-type {
|
||||
font-size: typography.$font-small;
|
||||
color: var(--text-tertiary);
|
||||
margin-left: auto;
|
||||
margin-right: spacing.$unit;
|
||||
}
|
||||
|
||||
.player-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: spacing.$unit;
|
||||
}
|
||||
|
||||
.player-score {
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 108px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -3,33 +3,23 @@
|
|||
<script lang="ts">
|
||||
import { goto, replaceState } from '$app/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import { createQuery, createMutation, useQueryClient } from '@tanstack/svelte-query'
|
||||
import { createQuery } from '@tanstack/svelte-query'
|
||||
import { gwAdapter } from '$lib/api/adapters/gw.adapter'
|
||||
import { crewStore } from '$lib/stores/crew.store.svelte'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import Dialog from '$lib/components/ui/Dialog.svelte'
|
||||
import DropdownMenu from '$lib/components/ui/DropdownMenu.svelte'
|
||||
import { DropdownMenu as DropdownMenuBase } from 'bits-ui'
|
||||
import ModalHeader from '$lib/components/ui/ModalHeader.svelte'
|
||||
import ModalBody from '$lib/components/ui/ModalBody.svelte'
|
||||
import ModalFooter from '$lib/components/ui/ModalFooter.svelte'
|
||||
import Input from '$lib/components/ui/Input.svelte'
|
||||
import Select from '$lib/components/ui/Select.svelte'
|
||||
import Checkbox from '$lib/components/ui/checkbox/Checkbox.svelte'
|
||||
import CrewHeader from '$lib/components/crew/CrewHeader.svelte'
|
||||
import EditScoreModal from '$lib/components/crew/EditScoreModal.svelte'
|
||||
import EditCrewScoreModal from '$lib/components/crew/EditCrewScoreModal.svelte'
|
||||
import AddScoreModal from '$lib/components/crew/AddScoreModal.svelte'
|
||||
import PlayerScoreRow, { type PlayerScore } from '$lib/components/crew/PlayerScoreRow.svelte'
|
||||
import ElementBadge from '$lib/components/ui/ElementBadge.svelte'
|
||||
import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte'
|
||||
import Segment from '$lib/components/ui/segmented-control/Segment.svelte'
|
||||
import { formatDateJST } from '$lib/utils/date'
|
||||
import {
|
||||
GW_ROUND_LABELS,
|
||||
type GwRound,
|
||||
type GwIndividualScore,
|
||||
type GwCrewScore,
|
||||
type CreateCrewScoreInput,
|
||||
type UpdateCrewScoreInput
|
||||
} from '$lib/types/api/gw'
|
||||
import { formatScore } from '$lib/utils/gw'
|
||||
import { GW_ROUND_LABELS, type GwRound, type GwCrewScore } from '$lib/types/api/gw'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -38,8 +28,6 @@
|
|||
|
||||
let { data }: Props = $props()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Get event number from URL
|
||||
const eventNumber = $derived($page.params.eventNumber)
|
||||
|
||||
|
|
@ -75,37 +63,6 @@
|
|||
// Crew scores from participation (Finals Day 1-4 only: rounds 2-5)
|
||||
const crewScores = $derived(participation?.crewScores ?? [])
|
||||
|
||||
// Element labels (matches GranblueEnums::ELEMENTS)
|
||||
const elementLabels: Record<number, string> = {
|
||||
0: 'Null',
|
||||
1: 'Wind',
|
||||
2: 'Fire',
|
||||
3: 'Water',
|
||||
4: 'Earth',
|
||||
5: 'Dark',
|
||||
6: 'Light'
|
||||
}
|
||||
|
||||
const elementColors: Record<number, string> = {
|
||||
0: 'null',
|
||||
1: 'wind',
|
||||
2: 'fire',
|
||||
3: 'water',
|
||||
4: 'earth',
|
||||
5: 'dark',
|
||||
6: 'light'
|
||||
}
|
||||
|
||||
// Aggregate scores by player, including members with 0 score
|
||||
interface PlayerScore {
|
||||
id: string
|
||||
name: string
|
||||
type: 'member' | 'phantom'
|
||||
totalScore: number
|
||||
isRetired?: boolean
|
||||
scores: GwIndividualScore[] // Individual score records for this player
|
||||
}
|
||||
|
||||
const playerScores = $derived.by(() => {
|
||||
const scoreMap = new Map<string, PlayerScore>()
|
||||
|
||||
|
|
@ -150,16 +107,6 @@
|
|||
return Array.from(scoreMap.values()).sort((a, b) => b.totalScore - a.totalScore)
|
||||
})
|
||||
|
||||
// Format number with commas
|
||||
function formatScore(score: number): string {
|
||||
return score.toLocaleString()
|
||||
}
|
||||
|
||||
// Parse score string, removing commas
|
||||
function parseScore(value: string): number {
|
||||
return parseInt(value.replace(/,/g, ''), 10)
|
||||
}
|
||||
|
||||
// Navigate back
|
||||
function handleBack() {
|
||||
goto('/crew')
|
||||
|
|
@ -167,186 +114,6 @@
|
|||
|
||||
// ==================== Add Score Modal ====================
|
||||
let showScoreModal = $state(false)
|
||||
let selectedPlayerId = $state<string | undefined>(undefined)
|
||||
let isCumulative = $state(true)
|
||||
let cumulativeScore = $state('')
|
||||
let roundScores = $state<Record<GwRound, string>>({
|
||||
0: '',
|
||||
1: '',
|
||||
2: '',
|
||||
3: '',
|
||||
4: '',
|
||||
5: ''
|
||||
})
|
||||
let isExcused = $state(false)
|
||||
let excuseReason = $state('')
|
||||
let isSubmitting = $state(false)
|
||||
|
||||
// Player type tracking - value format is "member:id" or "phantom:id"
|
||||
const selectedPlayerType = $derived.by(() => {
|
||||
if (!selectedPlayerId) return null
|
||||
const [type] = selectedPlayerId.split(':')
|
||||
return type as 'member' | 'phantom'
|
||||
})
|
||||
|
||||
const selectedPlayerActualId = $derived.by(() => {
|
||||
if (!selectedPlayerId) return null
|
||||
const [, id] = selectedPlayerId.split(':')
|
||||
return id
|
||||
})
|
||||
|
||||
// Track which players already have scores for this event
|
||||
const playersWithScores = $derived.by(() => {
|
||||
const ids = new Set<string>()
|
||||
if (participation?.individualScores) {
|
||||
for (const score of participation.individualScores) {
|
||||
if (score.member?.id) {
|
||||
ids.add(`member:${score.member.id}`)
|
||||
} else if (score.phantom?.id) {
|
||||
ids.add(`phantom:${score.phantom.id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
})
|
||||
|
||||
// Player options for dropdown - members first, then phantoms
|
||||
// Excludes players who already have scores for this event
|
||||
const playerOptions = $derived.by(() => {
|
||||
const options: Array<{ value: string; label: string; suffix?: string }> = []
|
||||
|
||||
// Add members (skip those with scores)
|
||||
for (const m of membersDuringEvent) {
|
||||
if (m.user) {
|
||||
const hasScore = playersWithScores.has(`member:${m.id}`)
|
||||
if (hasScore) continue
|
||||
options.push({
|
||||
value: `member:${m.id}`,
|
||||
label: m.user.username + (m.retired ? ' (Retired)' : '')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add phantoms (skip those with scores)
|
||||
for (const p of phantomPlayers) {
|
||||
const hasScore = playersWithScores.has(`phantom:${p.id}`)
|
||||
if (hasScore) continue
|
||||
options.push({
|
||||
value: `phantom:${p.id}`,
|
||||
label: p.name + (p.retired ? ' (Retired)' : ''),
|
||||
suffix: 'Phantom'
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
// Calculate cumulative from round scores
|
||||
const calculatedCumulativeScore = $derived.by(() => {
|
||||
if (isCumulative) return 0
|
||||
let total = 0
|
||||
for (const round of [0, 1, 2, 3, 4, 5] as GwRound[]) {
|
||||
const value = parseScore(roundScores[round] || '0')
|
||||
if (!isNaN(value)) total += value
|
||||
}
|
||||
return total
|
||||
})
|
||||
|
||||
// Sync cumulative when round scores change
|
||||
$effect(() => {
|
||||
if (!isCumulative) {
|
||||
cumulativeScore = formatScore(calculatedCumulativeScore)
|
||||
}
|
||||
})
|
||||
|
||||
// Add score mutation - uses by-event endpoint which auto-creates participation
|
||||
const addScoreMutation = createMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
if (!gwEvent?.id || !selectedPlayerActualId || !selectedPlayerType) {
|
||||
throw new Error('Missing event or player')
|
||||
}
|
||||
|
||||
if (isCumulative) {
|
||||
// Single cumulative score
|
||||
const score = parseScore(cumulativeScore)
|
||||
if (isNaN(score)) throw new Error('Invalid score')
|
||||
|
||||
const input =
|
||||
selectedPlayerType === 'member'
|
||||
? { crewMembershipId: selectedPlayerActualId }
|
||||
: { phantomPlayerId: selectedPlayerActualId }
|
||||
|
||||
return gwAdapter.addIndividualScoreByEvent(gwEvent.id, {
|
||||
...input,
|
||||
round: 0, // Cumulative scores go to preliminaries
|
||||
score,
|
||||
isCumulative: true,
|
||||
excused: isExcused,
|
||||
excuseReason: isExcused ? excuseReason : undefined
|
||||
})
|
||||
} else {
|
||||
// Batch add per-round scores
|
||||
const scores = []
|
||||
for (const round of [0, 1, 2, 3, 4, 5] as GwRound[]) {
|
||||
const value = parseScore(roundScores[round] || '0')
|
||||
if (!isNaN(value) && value > 0) {
|
||||
const entry =
|
||||
selectedPlayerType === 'member'
|
||||
? { crewMembershipId: selectedPlayerActualId }
|
||||
: { phantomPlayerId: selectedPlayerActualId }
|
||||
|
||||
scores.push({
|
||||
...entry,
|
||||
round,
|
||||
score: value,
|
||||
isCumulative: false,
|
||||
excused: isExcused,
|
||||
excuseReason: isExcused ? excuseReason : undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (scores.length === 0) {
|
||||
throw new Error('No scores entered')
|
||||
}
|
||||
|
||||
return gwAdapter.batchAddIndividualScoresByEvent(gwEvent.id, { scores })
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['crew', 'gw', 'event', eventNumber] })
|
||||
closeScoreModal()
|
||||
}
|
||||
}))
|
||||
|
||||
function openScoreModal() {
|
||||
showScoreModal = true
|
||||
resetScoreForm()
|
||||
}
|
||||
|
||||
function closeScoreModal() {
|
||||
showScoreModal = false
|
||||
resetScoreForm()
|
||||
}
|
||||
|
||||
function resetScoreForm() {
|
||||
selectedPlayerId = undefined
|
||||
isCumulative = true
|
||||
cumulativeScore = ''
|
||||
roundScores = { 0: '', 1: '', 2: '', 3: '', 4: '', 5: '' }
|
||||
isExcused = false
|
||||
excuseReason = ''
|
||||
isSubmitting = false
|
||||
}
|
||||
|
||||
async function handleSubmitScore() {
|
||||
isSubmitting = true
|
||||
try {
|
||||
await addScoreMutation.mutateAsync()
|
||||
} finally {
|
||||
isSubmitting = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Edit Score Modal ====================
|
||||
let showEditScoreModal = $state(false)
|
||||
|
|
@ -394,9 +161,7 @@
|
|||
<CrewHeader title="GW #{gwEvent.eventNumber}" backHref="/crew">
|
||||
{#snippet belowTitle()}
|
||||
<div class="event-meta">
|
||||
<span class="element-badge element-{elementColors[gwEvent.element]}">
|
||||
{elementLabels[gwEvent.element] ?? 'Unknown'}
|
||||
</span>
|
||||
<ElementBadge element={gwEvent.element} />
|
||||
<span class="event-dates">
|
||||
{formatDateJST(gwEvent.startDate)} – {formatDateJST(gwEvent.endDate)}
|
||||
</span>
|
||||
|
|
@ -410,7 +175,7 @@
|
|||
{/snippet}
|
||||
{#snippet actions()}
|
||||
{#if crewStore.isOfficer && gwEvent.status !== 'upcoming' && activeTab === 'individual'}
|
||||
<Button variant="primary" size="small" onclick={openScoreModal}>Add Score</Button>
|
||||
<Button variant="primary" size="small" onclick={() => (showScoreModal = true)}>Add Score</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</CrewHeader>
|
||||
|
|
@ -450,53 +215,12 @@
|
|||
{#if playerScores.length > 0}
|
||||
<ul class="player-list">
|
||||
{#each playerScores as player, index}
|
||||
<li class="player-item" class:retired={player.isRetired}>
|
||||
<div class="player-info">
|
||||
<span class="player-rank">{index + 1}</span>
|
||||
<span class="player-name">{player.name}</span>
|
||||
{#if player.isRetired}
|
||||
<span class="player-badge retired">Retired</span>
|
||||
{/if}
|
||||
{#if player.scores.some((s) => s.excused)}
|
||||
<span class="player-badge excused">Excused</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if player.type === 'phantom'}
|
||||
<span class="player-type">Phantom</span>
|
||||
{/if}
|
||||
<div class="player-actions">
|
||||
<span class="player-score">{formatScore(player.totalScore)}</span>
|
||||
{#if crewStore.isOfficer && player.scores.length > 0}
|
||||
<DropdownMenu>
|
||||
{#snippet trigger({ props })}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
iconOnly
|
||||
icon="ellipsis"
|
||||
{...props}
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet menu()}
|
||||
{#if player.type === 'member'}
|
||||
<DropdownMenuBase.Item
|
||||
class="dropdown-menu-item"
|
||||
onclick={() => goto(`/${player.name}`)}
|
||||
>
|
||||
View profile
|
||||
</DropdownMenuBase.Item>
|
||||
{/if}
|
||||
<DropdownMenuBase.Item
|
||||
class="dropdown-menu-item"
|
||||
onclick={() => openEditScoreModal(player)}
|
||||
>
|
||||
Edit score...
|
||||
</DropdownMenuBase.Item>
|
||||
{/snippet}
|
||||
</DropdownMenu>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
<PlayerScoreRow
|
||||
{player}
|
||||
rank={index + 1}
|
||||
isOfficer={crewStore.isOfficer}
|
||||
onEditScore={() => openEditScoreModal(player)}
|
||||
/>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
|
|
@ -584,85 +308,16 @@
|
|||
</div>
|
||||
|
||||
<!-- Add Score Modal -->
|
||||
<Dialog bind:open={showScoreModal} onOpenChange={(open) => !open && closeScoreModal()}>
|
||||
<ModalHeader title="Add Score" />
|
||||
<ModalBody>
|
||||
<div class="score-form">
|
||||
<Select
|
||||
options={playerOptions}
|
||||
bind:value={selectedPlayerId}
|
||||
placeholder="Select player"
|
||||
label="Player"
|
||||
fullWidth
|
||||
contained
|
||||
portal
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Cumulative Score"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
bind:value={cumulativeScore}
|
||||
placeholder="Enter total score"
|
||||
disabled={!isCumulative}
|
||||
fullWidth
|
||||
contained
|
||||
/>
|
||||
|
||||
<label class="checkbox-row">
|
||||
<Checkbox bind:checked={isCumulative} size="small" />
|
||||
<span>Cumulative score</span>
|
||||
</label>
|
||||
|
||||
{#if !isCumulative}
|
||||
<div class="round-scores">
|
||||
{#each [0, 1, 2, 3, 4, 5] as round (round)}
|
||||
<Input
|
||||
label={GW_ROUND_LABELS[round as GwRound]}
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
bind:value={roundScores[round as GwRound]}
|
||||
placeholder="0"
|
||||
fullWidth
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="excused-section">
|
||||
<label class="checkbox-row">
|
||||
<Checkbox bind:checked={isExcused} size="small" contained />
|
||||
<span>Excused?</span>
|
||||
</label>
|
||||
{#if isExcused}
|
||||
<textarea
|
||||
class="excuse-textarea"
|
||||
bind:value={excuseReason}
|
||||
placeholder="Excusal reason (optional)"
|
||||
rows="2"
|
||||
></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if addScoreMutation.isError}
|
||||
<p class="error-message">
|
||||
{addScoreMutation.error?.message ?? 'Failed to add score'}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
onCancel={closeScoreModal}
|
||||
primaryAction={{
|
||||
label: isSubmitting ? 'Saving...' : 'Save',
|
||||
onclick: handleSubmitScore,
|
||||
disabled: isSubmitting ||
|
||||
!selectedPlayerId ||
|
||||
(!isCumulative && calculatedCumulativeScore === 0) ||
|
||||
(isCumulative && !cumulativeScore)
|
||||
}}
|
||||
{#if gwEvent}
|
||||
<AddScoreModal
|
||||
bind:open={showScoreModal}
|
||||
eventId={gwEvent.id}
|
||||
eventNumber={eventNumber ?? ''}
|
||||
{membersDuringEvent}
|
||||
{phantomPlayers}
|
||||
existingScores={participation?.individualScores ?? []}
|
||||
/>
|
||||
</Dialog>
|
||||
{/if}
|
||||
|
||||
<!-- Edit Score Modal -->
|
||||
{#if participation?.id && editingPlayer}
|
||||
|
|
@ -730,49 +385,6 @@
|
|||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.element-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: layout.$item-corner-small;
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
|
||||
&.element-null {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
&.element-fire {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&.element-water {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
&.element-earth {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
&.element-wind {
|
||||
background: #d1fae5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
&.element-light {
|
||||
background: #fef9c3;
|
||||
color: #ca8a04;
|
||||
}
|
||||
|
||||
&.element-dark {
|
||||
background: #ede9fe;
|
||||
color: #7c3aed;
|
||||
}
|
||||
}
|
||||
|
||||
.not-participating {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -841,84 +453,6 @@
|
|||
padding: spacing.$unit;
|
||||
}
|
||||
|
||||
.player-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
border-radius: layout.$item-corner;
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
&.retired {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.player-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: spacing.$unit;
|
||||
}
|
||||
|
||||
.player-rank {
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
color: var(--text-secondary);
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
}
|
||||
|
||||
.player-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: layout.$item-corner-small;
|
||||
font-size: typography.$font-small;
|
||||
|
||||
&.phantom {
|
||||
background: var(--color-purple-light, #ede9fe);
|
||||
color: var(--color-purple-dark, #7c3aed);
|
||||
}
|
||||
|
||||
&.retired {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
&.excused {
|
||||
background: var(--color-yellow-light, #fef9c3);
|
||||
color: var(--color-yellow-dark, #854d0e);
|
||||
}
|
||||
}
|
||||
|
||||
.player-type {
|
||||
font-size: typography.$font-small;
|
||||
color: var(--text-tertiary);
|
||||
margin-left: auto;
|
||||
margin-right: spacing.$unit;
|
||||
}
|
||||
|
||||
.player-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: spacing.$unit;
|
||||
}
|
||||
|
||||
.player-score {
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 108px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
|
|
@ -926,68 +460,6 @@
|
|||
font-size: typography.$font-small;
|
||||
}
|
||||
|
||||
// Score form styles
|
||||
.score-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: spacing.$unit;
|
||||
cursor: pointer;
|
||||
font-size: typography.$font-small;
|
||||
}
|
||||
|
||||
.round-scores {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit;
|
||||
padding: spacing.$unit-2x;
|
||||
background: var(--input-section-bg);
|
||||
border-radius: layout.$card-corner;
|
||||
}
|
||||
|
||||
.excused-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit;
|
||||
}
|
||||
|
||||
.excuse-textarea {
|
||||
width: 100%;
|
||||
padding: spacing.$unit;
|
||||
border: none;
|
||||
border-radius: layout.$input-corner;
|
||||
font-size: typography.$font-small;
|
||||
font-family: inherit;
|
||||
background: var(--input-bound-bg);
|
||||
color: var(--text-primary);
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--input-bound-bg-hover);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
background: var(--input-bound-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: colors.$error;
|
||||
font-size: typography.$font-small;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Tab control
|
||||
.tab-control {
|
||||
margin-top: spacing.$unit;
|
||||
|
|
|
|||
Loading…
Reference in a new issue