add crew scores tab to gw events page
This commit is contained in:
parent
60b31e2a71
commit
2800bf0554
5 changed files with 1050 additions and 50 deletions
|
|
@ -194,6 +194,24 @@ export class GwAdapter extends BaseAdapter {
|
|||
return response.crewScore
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a crew score
|
||||
*/
|
||||
async deleteCrewScore(
|
||||
participationId: string,
|
||||
scoreId: string,
|
||||
options?: RequestOptions
|
||||
): Promise<void> {
|
||||
await this.request<void>(
|
||||
`/crew/gw_participations/${participationId}/crew_scores/${scoreId}`,
|
||||
{
|
||||
...options,
|
||||
method: 'DELETE'
|
||||
}
|
||||
)
|
||||
this.clearCache(`/crew/gw_participations/${participationId}`)
|
||||
}
|
||||
|
||||
// ==================== Individual Score Operations ====================
|
||||
|
||||
/**
|
||||
|
|
|
|||
267
src/lib/components/crew/EditCrewScoreModal.svelte
Normal file
267
src/lib/components/crew/EditCrewScoreModal.svelte
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
<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 Button from '$lib/components/ui/Button.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 { GW_ROUND_LABELS, type GwCrewScore, type GwRound } from '$lib/types/api/gw'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
participationId: string
|
||||
eventNumber: string
|
||||
round: GwRound
|
||||
existingScore?: GwCrewScore | null
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
participationId,
|
||||
eventNumber,
|
||||
round,
|
||||
existingScore = null
|
||||
}: Props = $props()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Form state
|
||||
let crewScore = $state('')
|
||||
let opponentScore = $state('')
|
||||
let opponentName = $state('')
|
||||
let opponentCrewId = $state('')
|
||||
let isSubmitting = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
// Mutations
|
||||
const addCrewScoreMutation = createMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
const score = parseScore(crewScore)
|
||||
if (isNaN(score)) throw new Error('Invalid crew score')
|
||||
|
||||
const oppScore = opponentScore ? parseScore(opponentScore) : undefined
|
||||
|
||||
return gwAdapter.addCrewScore(participationId, {
|
||||
round,
|
||||
crewScore: score,
|
||||
opponentScore: oppScore,
|
||||
opponentName: opponentName || undefined,
|
||||
opponentGranblueId: opponentCrewId || undefined
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
const updateCrewScoreMutation = createMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
if (!existingScore) throw new Error('No score to update')
|
||||
|
||||
const score = parseScore(crewScore)
|
||||
if (isNaN(score)) throw new Error('Invalid crew score')
|
||||
|
||||
const oppScore = opponentScore ? parseScore(opponentScore) : undefined
|
||||
|
||||
return gwAdapter.updateCrewScore(participationId, existingScore.id, {
|
||||
crewScore: score,
|
||||
opponentScore: oppScore,
|
||||
opponentName: opponentName || undefined,
|
||||
opponentGranblueId: opponentCrewId || undefined
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
const deleteCrewScoreMutation = createMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
if (!existingScore) throw new Error('No score to delete')
|
||||
return gwAdapter.deleteCrewScore(participationId, existingScore.id)
|
||||
}
|
||||
}))
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Initialize form values
|
||||
function initializeForm() {
|
||||
if (existingScore) {
|
||||
crewScore = formatScore(existingScore.crewScore)
|
||||
opponentScore = existingScore.opponentScore !== null ? formatScore(existingScore.opponentScore) : ''
|
||||
opponentName = existingScore.opponentName ?? ''
|
||||
opponentCrewId = existingScore.opponentGranblueId ?? ''
|
||||
} else {
|
||||
crewScore = ''
|
||||
opponentScore = ''
|
||||
opponentName = ''
|
||||
opponentCrewId = ''
|
||||
}
|
||||
error = null
|
||||
}
|
||||
|
||||
// Check if form has changes
|
||||
const hasChanges = $derived.by(() => {
|
||||
if (!existingScore) {
|
||||
return crewScore.length > 0
|
||||
}
|
||||
|
||||
const currentCrewScore = parseScore(crewScore)
|
||||
const currentOppScore = opponentScore ? parseScore(opponentScore) : null
|
||||
|
||||
if (currentCrewScore !== existingScore.crewScore) return true
|
||||
if (currentOppScore !== existingScore.opponentScore) return true
|
||||
if (opponentName !== (existingScore.opponentName ?? '')) return true
|
||||
if (opponentCrewId !== (existingScore.opponentGranblueId ?? '')) return true
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
async function handleSave() {
|
||||
isSubmitting = true
|
||||
error = null
|
||||
try {
|
||||
if (existingScore) {
|
||||
await updateCrewScoreMutation.mutateAsync()
|
||||
} else {
|
||||
await addCrewScoreMutation.mutateAsync()
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['crew', 'gw', 'event', eventNumber] })
|
||||
open = false
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to save score'
|
||||
} finally {
|
||||
isSubmitting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('Are you sure you want to delete this crew score?')) return
|
||||
|
||||
try {
|
||||
await deleteCrewScoreMutation.mutateAsync()
|
||||
queryClient.invalidateQueries({ queryKey: ['crew', 'gw', 'event', eventNumber] })
|
||||
open = false
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to delete score'
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
open = false
|
||||
}
|
||||
|
||||
// Initialize values when modal opens
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
initializeForm()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Dialog bind:open>
|
||||
<ModalHeader
|
||||
title={GW_ROUND_LABELS[round]}
|
||||
description={existingScore ? 'Edit score' : 'Add score'}
|
||||
/>
|
||||
|
||||
<ModalBody>
|
||||
<div class="form-content">
|
||||
<Input
|
||||
label="Crew Score"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
bind:value={crewScore}
|
||||
placeholder="Enter your crew's score"
|
||||
fullWidth
|
||||
contained
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Opponent Score"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
bind:value={opponentScore}
|
||||
placeholder="Enter opponent's score (optional)"
|
||||
fullWidth
|
||||
contained
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Opponent Name"
|
||||
type="text"
|
||||
bind:value={opponentName}
|
||||
placeholder="Enter opponent crew name (optional)"
|
||||
fullWidth
|
||||
contained
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Opponent Crew ID"
|
||||
type="text"
|
||||
bind:value={opponentCrewId}
|
||||
placeholder="Enter opponent crew ID (optional)"
|
||||
fullWidth
|
||||
contained
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter
|
||||
onCancel={handleCancel}
|
||||
primaryAction={{
|
||||
label: isSubmitting ? 'Saving...' : 'Save',
|
||||
onclick: handleSave,
|
||||
disabled: isSubmitting || !hasChanges || !crewScore
|
||||
}}
|
||||
>
|
||||
{#snippet left()}
|
||||
{#if existingScore}
|
||||
<Button
|
||||
variant="destructive-ghost"
|
||||
onclick={handleDelete}
|
||||
disabled={deleteCrewScoreMutation.isPending}
|
||||
>
|
||||
Delete score
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ModalFooter>
|
||||
</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;
|
||||
|
||||
.form-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: spacing.$unit;
|
||||
background: colors.$error--bg--light;
|
||||
border-radius: layout.$item-corner-small;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: typography.$font-small;
|
||||
color: colors.$error;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
366
src/lib/components/crew/EditScoreModal.svelte
Normal file
366
src/lib/components/crew/EditScoreModal.svelte
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
<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 Button from '$lib/components/ui/Button.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 Checkbox from '$lib/components/ui/checkbox/Checkbox.svelte'
|
||||
import { GW_ROUND_LABELS, type GwIndividualScore, type GwRound } from '$lib/types/api/gw'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
participationId: string
|
||||
eventNumber: string
|
||||
playerName: string
|
||||
scores: GwIndividualScore[]
|
||||
}
|
||||
|
||||
let { open = $bindable(false), participationId, eventNumber, playerName, scores }: Props =
|
||||
$props()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Edit state - maps scoreId to edited values
|
||||
let editValues = $state<Record<string, string>>({})
|
||||
let excusedValues = $state<Record<string, boolean>>({})
|
||||
let excuseReasonValues = $state<Record<string, string>>({})
|
||||
let isSubmitting = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
// Update individual score mutation
|
||||
const updateScoreMutation = createMutation(() => ({
|
||||
mutationFn: async ({
|
||||
scoreId,
|
||||
score,
|
||||
excused,
|
||||
excuseReason
|
||||
}: {
|
||||
scoreId: string
|
||||
score: number
|
||||
excused: boolean
|
||||
excuseReason: string
|
||||
}) => {
|
||||
return gwAdapter.updateIndividualScore(participationId, scoreId, {
|
||||
score,
|
||||
excused,
|
||||
excuseReason: excused ? excuseReason : undefined
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
// Delete individual score mutation
|
||||
const deleteScoreMutation = createMutation(() => ({
|
||||
mutationFn: async (scoreId: string) => {
|
||||
return gwAdapter.deleteIndividualScore(participationId, scoreId)
|
||||
}
|
||||
}))
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Get label for a score
|
||||
function getScoreLabel(score: GwIndividualScore): string {
|
||||
if (score.isCumulative) {
|
||||
return 'Total'
|
||||
}
|
||||
return GW_ROUND_LABELS[score.round as GwRound]
|
||||
}
|
||||
|
||||
// Initialize edit values from current scores
|
||||
function initializeEditValues() {
|
||||
const values: Record<string, string> = {}
|
||||
const excused: Record<string, boolean> = {}
|
||||
const reasons: Record<string, string> = {}
|
||||
for (const score of scores) {
|
||||
values[score.id] = formatScore(score.score)
|
||||
excused[score.id] = score.excused
|
||||
reasons[score.id] = score.excuseReason ?? ''
|
||||
}
|
||||
editValues = values
|
||||
excusedValues = excused
|
||||
excuseReasonValues = reasons
|
||||
error = null
|
||||
}
|
||||
|
||||
// Check if any values have changed
|
||||
const hasChanges = $derived.by(() => {
|
||||
for (const score of scores) {
|
||||
const editedValue = editValues[score.id]
|
||||
if (editedValue !== undefined) {
|
||||
const newValue = parseScore(editedValue)
|
||||
if (!isNaN(newValue) && newValue !== score.score) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Check excused changes
|
||||
if (excusedValues[score.id] !== score.excused) {
|
||||
return true
|
||||
}
|
||||
// Check excuse reason changes
|
||||
if (excuseReasonValues[score.id] !== (score.excuseReason ?? '')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
async function handleSave() {
|
||||
isSubmitting = true
|
||||
error = null
|
||||
try {
|
||||
// Update each score that has changed
|
||||
for (const score of scores) {
|
||||
const editedValue = editValues[score.id]
|
||||
const newScoreValue = editedValue !== undefined ? parseScore(editedValue) : score.score
|
||||
const newExcused = excusedValues[score.id] ?? score.excused
|
||||
const newExcuseReason = excuseReasonValues[score.id] ?? ''
|
||||
|
||||
// Check if anything changed for this score
|
||||
const scoreChanged = !isNaN(newScoreValue) && newScoreValue !== score.score
|
||||
const excusedChanged = newExcused !== score.excused
|
||||
const reasonChanged = newExcuseReason !== (score.excuseReason ?? '')
|
||||
|
||||
if (scoreChanged || excusedChanged || reasonChanged) {
|
||||
await updateScoreMutation.mutateAsync({
|
||||
scoreId: score.id,
|
||||
score: newScoreValue,
|
||||
excused: newExcused,
|
||||
excuseReason: newExcuseReason
|
||||
})
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['crew', 'gw', 'event', eventNumber] })
|
||||
open = false
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to save scores'
|
||||
} finally {
|
||||
isSubmitting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(scoreId: string) {
|
||||
if (!confirm('Are you sure you want to delete this score?')) return
|
||||
|
||||
try {
|
||||
await deleteScoreMutation.mutateAsync(scoreId)
|
||||
// Remove from edit values
|
||||
const { [scoreId]: _, ...rest } = editValues
|
||||
editValues = rest
|
||||
queryClient.invalidateQueries({ queryKey: ['crew', 'gw', 'event', eventNumber] })
|
||||
// Close modal if no scores left
|
||||
if (Object.keys(editValues).length === 0) {
|
||||
open = false
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to delete score'
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAll() {
|
||||
if (!confirm('Are you sure you want to delete all scores for this player?')) return
|
||||
|
||||
try {
|
||||
for (const score of scores) {
|
||||
await deleteScoreMutation.mutateAsync(score.id)
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['crew', 'gw', 'event', eventNumber] })
|
||||
open = false
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to delete scores'
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
open = false
|
||||
}
|
||||
|
||||
// Initialize values when modal opens
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
initializeEditValues()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Dialog bind:open>
|
||||
<ModalHeader title={playerName} description="Edit score" />
|
||||
|
||||
<ModalBody>
|
||||
<div class="edit-content">
|
||||
{#if scores.length === 0}
|
||||
<p class="empty-message">No scores to edit</p>
|
||||
{:else}
|
||||
<div class="score-list">
|
||||
{#each scores as score (score.id)}
|
||||
{#if editValues[score.id] !== undefined}
|
||||
<div class="score-entry">
|
||||
<Input
|
||||
label={getScoreLabel(score)}
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
bind:value={editValues[score.id]}
|
||||
fullWidth
|
||||
contained
|
||||
/>
|
||||
<div class="excused-section">
|
||||
<label class="checkbox-row">
|
||||
<Checkbox bind:checked={excusedValues[score.id]} size="small" contained />
|
||||
<span>Excused?</span>
|
||||
</label>
|
||||
{#if excusedValues[score.id]}
|
||||
<textarea
|
||||
class="excuse-textarea"
|
||||
bind:value={excuseReasonValues[score.id]}
|
||||
placeholder="Excusal reason (optional)"
|
||||
rows="2"
|
||||
></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter
|
||||
onCancel={handleCancel}
|
||||
primaryAction={{
|
||||
label: isSubmitting ? 'Saving...' : 'Save',
|
||||
onclick: handleSave,
|
||||
disabled: isSubmitting || !hasChanges
|
||||
}}
|
||||
>
|
||||
{#snippet left()}
|
||||
{#if scores.length === 1}
|
||||
<Button
|
||||
variant="destructive-ghost"
|
||||
onclick={() => handleDelete(scores[0].id)}
|
||||
disabled={deleteScoreMutation.isPending}
|
||||
>
|
||||
Delete score
|
||||
</Button>
|
||||
{:else if scores.length > 1}
|
||||
<Button
|
||||
variant="destructive-ghost"
|
||||
onclick={handleDeleteAll}
|
||||
disabled={deleteScoreMutation.isPending}
|
||||
>
|
||||
Delete all scores
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ModalFooter>
|
||||
</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;
|
||||
|
||||
.edit-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
margin: 0;
|
||||
font-size: typography.$font-small;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
padding: spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.score-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.score-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit;
|
||||
padding-bottom: spacing.$unit-2x;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.excused-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: spacing.$unit;
|
||||
cursor: pointer;
|
||||
font-size: typography.$font-regular;
|
||||
}
|
||||
|
||||
.excuse-textarea {
|
||||
width: 100%;
|
||||
padding: calc(spacing.$unit * 1.25) spacing.$unit-2x;
|
||||
border: none;
|
||||
border-radius: layout.$input-corner;
|
||||
font-size: typography.$font-regular;
|
||||
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 {
|
||||
padding: spacing.$unit;
|
||||
background: colors.$error--bg--light;
|
||||
border-radius: layout.$item-corner-small;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: typography.$font-small;
|
||||
color: colors.$error;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -72,6 +72,8 @@ export interface GwIndividualScore {
|
|||
round: GwRound
|
||||
score: number
|
||||
isCumulative: boolean
|
||||
excused: boolean
|
||||
excuseReason?: string // Only returned to crew officers
|
||||
playerName: string
|
||||
playerType: PlayerType
|
||||
createdAt?: string
|
||||
|
|
@ -125,6 +127,8 @@ export interface CreateIndividualScoreInput {
|
|||
round: GwRound
|
||||
score: number
|
||||
isCumulative?: boolean
|
||||
excused?: boolean
|
||||
excuseReason?: string
|
||||
}
|
||||
|
||||
// Batch score entry
|
||||
|
|
@ -135,6 +139,8 @@ export interface BatchScoreEntry {
|
|||
round: GwRound
|
||||
score: number
|
||||
isCumulative?: boolean
|
||||
excused?: boolean
|
||||
excuseReason?: string
|
||||
}
|
||||
|
||||
export interface BatchIndividualScoresInput {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { goto, replaceState } from '$app/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import { createQuery, createMutation, useQueryClient } from '@tanstack/svelte-query'
|
||||
import { gwAdapter } from '$lib/api/adapters/gw.adapter'
|
||||
|
|
@ -18,7 +18,17 @@
|
|||
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 { GW_ROUND_LABELS, type GwRound, type GwIndividualScore } from '$lib/types/api/gw'
|
||||
import EditCrewScoreModal from '$lib/components/crew/EditCrewScoreModal.svelte'
|
||||
import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte'
|
||||
import Segment from '$lib/components/ui/segmented-control/Segment.svelte'
|
||||
import {
|
||||
GW_ROUND_LABELS,
|
||||
type GwRound,
|
||||
type GwIndividualScore,
|
||||
type GwCrewScore,
|
||||
type CreateCrewScoreInput,
|
||||
type UpdateCrewScoreInput
|
||||
} from '$lib/types/api/gw'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -44,6 +54,26 @@
|
|||
const membersDuringEvent = $derived(eventQuery.data?.membersDuringEvent ?? [])
|
||||
const phantomPlayers = $derived(eventQuery.data?.phantomPlayers ?? [])
|
||||
|
||||
// Tab state for switching between Individual and Crew honors
|
||||
// Initialize from URL query param
|
||||
const urlHasCrew = $page.url.searchParams.has('crew')
|
||||
let activeTab = $state<'individual' | 'crew'>(urlHasCrew ? 'crew' : 'individual')
|
||||
|
||||
// Update URL when tab changes
|
||||
function handleTabChange(newTab: 'individual' | 'crew') {
|
||||
activeTab = newTab
|
||||
const url = new URL($page.url)
|
||||
if (newTab === 'crew') {
|
||||
url.searchParams.set('crew', '')
|
||||
} else {
|
||||
url.searchParams.delete('crew')
|
||||
}
|
||||
replaceState(url, {})
|
||||
}
|
||||
|
||||
// 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',
|
||||
|
|
@ -156,6 +186,8 @@
|
|||
4: '',
|
||||
5: ''
|
||||
})
|
||||
let isExcused = $state(false)
|
||||
let excuseReason = $state('')
|
||||
let isSubmitting = $state(false)
|
||||
|
||||
// Player type tracking - value format is "member:id" or "phantom:id"
|
||||
|
|
@ -255,7 +287,9 @@
|
|||
...input,
|
||||
round: 0, // Cumulative scores go to preliminaries
|
||||
score,
|
||||
isCumulative: true
|
||||
isCumulative: true,
|
||||
excused: isExcused,
|
||||
excuseReason: isExcused ? excuseReason : undefined
|
||||
})
|
||||
} else {
|
||||
// Batch add per-round scores
|
||||
|
|
@ -272,7 +306,9 @@
|
|||
...entry,
|
||||
round,
|
||||
score: value,
|
||||
isCumulative: false
|
||||
isCumulative: false,
|
||||
excused: isExcused,
|
||||
excuseReason: isExcused ? excuseReason : undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -305,6 +341,8 @@
|
|||
isCumulative = true
|
||||
cumulativeScore = ''
|
||||
roundScores = { 0: '', 1: '', 2: '', 3: '', 4: '', 5: '' }
|
||||
isExcused = false
|
||||
excuseReason = ''
|
||||
isSubmitting = false
|
||||
}
|
||||
|
||||
|
|
@ -325,6 +363,23 @@
|
|||
editingPlayer = player
|
||||
showEditScoreModal = true
|
||||
}
|
||||
|
||||
// ==================== Crew Score Modal ====================
|
||||
let showCrewScoreModal = $state(false)
|
||||
let editingCrewScoreRound = $state<GwRound | null>(null)
|
||||
let editingCrewScore = $state<GwCrewScore | null>(null)
|
||||
|
||||
function openCrewScoreModal(round: GwRound, existingScore?: GwCrewScore) {
|
||||
editingCrewScoreRound = round
|
||||
editingCrewScore = existingScore ?? null
|
||||
showCrewScoreModal = true
|
||||
}
|
||||
|
||||
function closeCrewScoreModal() {
|
||||
showCrewScoreModal = false
|
||||
editingCrewScoreRound = null
|
||||
editingCrewScore = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -343,7 +398,7 @@
|
|||
<Button variant="secondary" size="small" onclick={handleBack}>Back to Crew</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<CrewHeader title="GW #{gwEvent.eventNumber}">
|
||||
<CrewHeader title="GW #{gwEvent.eventNumber}" backHref="/crew">
|
||||
{#snippet belowTitle()}
|
||||
<div class="event-meta">
|
||||
<span class="element-badge element-{elementColors[gwEvent.element]}">
|
||||
|
|
@ -353,9 +408,15 @@
|
|||
{formatDate(gwEvent.startDate)} – {formatDate(gwEvent.endDate)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tab-control">
|
||||
<SegmentedControl value={activeTab} onValueChange={(v) => handleTabChange(v as 'individual' | 'crew')} size="small" variant="background" grow>
|
||||
<Segment value="individual">Individual</Segment>
|
||||
<Segment value="crew">Crew</Segment>
|
||||
</SegmentedControl>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
{#if crewStore.isOfficer && gwEvent.status !== 'upcoming'}
|
||||
{#if crewStore.isOfficer && gwEvent.status !== 'upcoming' && activeTab === 'individual'}
|
||||
<Button variant="primary" size="small" onclick={openScoreModal}>Add Score</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
|
@ -369,8 +430,8 @@
|
|||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Score summary -->
|
||||
{#if participation.totalScore !== undefined}
|
||||
<!-- Score summary (crew tab only) -->
|
||||
{#if activeTab === 'crew' && participation.totalScore !== undefined}
|
||||
<div class="stats-row">
|
||||
<div class="stat">
|
||||
<span class="stat-value">{formatScore(participation.totalScore)}</span>
|
||||
|
|
@ -387,52 +448,142 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Player scores -->
|
||||
<div class="section-header">
|
||||
<span class="section-title">Individual Scores</span>
|
||||
</div>
|
||||
<!-- Individual Honors Tab -->
|
||||
{#if activeTab === 'individual'}
|
||||
<div class="section-header">
|
||||
<span class="section-title">Individual Scores</span>
|
||||
</div>
|
||||
|
||||
{#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 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="secondary"
|
||||
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>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="empty-state">No scores recorded yet</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Crew Honors Tab -->
|
||||
{#if activeTab === 'crew'}
|
||||
<div class="crew-score-table">
|
||||
<div class="crew-score-header">
|
||||
<span class="col-round">Round</span>
|
||||
<span class="col-our-score">Our Score</span>
|
||||
<span class="col-their-score">Their Score</span>
|
||||
<span class="col-actions"></span>
|
||||
</div>
|
||||
{#each [2, 3, 4, 5] as round (round)}
|
||||
{@const score = crewScores.find((s) => s.round === round)}
|
||||
<div class="crew-score-row">
|
||||
<div class="col-round">
|
||||
<span class="round-label">{GW_ROUND_LABELS[round as GwRound]}</span>
|
||||
{#if score?.victory !== undefined && score.victory !== null}
|
||||
<span class="result-badge" class:win={score.victory} class:loss={!score.victory}>
|
||||
{score.victory ? 'Win' : 'Loss'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if player.type === 'phantom'}
|
||||
<span class="player-type">Phantom</span>
|
||||
{/if}
|
||||
<span class="player-score">{formatScore(player.totalScore)}</span>
|
||||
{#if crewStore.isOfficer && player.scores.length > 0}
|
||||
<DropdownMenu>
|
||||
{#snippet trigger({ props })}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
iconOnly
|
||||
icon="ellipsis"
|
||||
{...props}
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet menu()}
|
||||
<DropdownMenuBase.Item
|
||||
class="dropdown-menu-item"
|
||||
onclick={() => openEditScoreModal(player)}
|
||||
>
|
||||
Edit score...
|
||||
</DropdownMenuBase.Item>
|
||||
{/snippet}
|
||||
</DropdownMenu>
|
||||
{/if}
|
||||
</li>
|
||||
<div class="col-our-score">
|
||||
{#if score}
|
||||
<span class="score-value" class:winner={score.victory === true}>{formatScore(score.crewScore)}</span>
|
||||
{:else}
|
||||
<span class="score-empty">—</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="col-their-score">
|
||||
{#if score}
|
||||
{#if score.opponentScore !== null}
|
||||
<span class="score-value" class:winner={score.victory === false}>{formatScore(score.opponentScore)}</span>
|
||||
{/if}
|
||||
{#if score.opponentName}
|
||||
<span class="opponent-name">({score.opponentName})</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="score-empty">—</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="col-actions">
|
||||
{#if score}
|
||||
{#if crewStore.isOfficer}
|
||||
<DropdownMenu>
|
||||
{#snippet trigger({ props })}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
iconOnly
|
||||
icon="ellipsis"
|
||||
{...props}
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet menu()}
|
||||
<DropdownMenuBase.Item
|
||||
class="dropdown-menu-item"
|
||||
onclick={() => openCrewScoreModal(round as GwRound, score)}
|
||||
>
|
||||
Edit score...
|
||||
</DropdownMenuBase.Item>
|
||||
{/snippet}
|
||||
</DropdownMenu>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if crewStore.isOfficer}
|
||||
<Button size="small" onclick={() => openCrewScoreModal(round as GwRound)}>
|
||||
Record
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="empty-state">No scores recorded yet</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
@ -485,6 +636,21 @@
|
|||
</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'}
|
||||
|
|
@ -505,6 +671,28 @@
|
|||
/>
|
||||
</Dialog>
|
||||
|
||||
<!-- Edit Score Modal -->
|
||||
{#if participation?.id && editingPlayer}
|
||||
<EditScoreModal
|
||||
bind:open={showEditScoreModal}
|
||||
participationId={participation.id}
|
||||
eventNumber={eventNumber ?? ''}
|
||||
playerName={editingPlayer.name}
|
||||
scores={editingPlayer.scores}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Edit Crew Score Modal -->
|
||||
{#if participation?.id && editingCrewScoreRound !== null}
|
||||
<EditCrewScoreModal
|
||||
bind:open={showCrewScoreModal}
|
||||
participationId={participation.id}
|
||||
eventNumber={eventNumber ?? ''}
|
||||
round={editingCrewScoreRound}
|
||||
existingScore={editingCrewScore}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/colors' as colors;
|
||||
@use '$src/themes/effects' as effects;
|
||||
|
|
@ -710,6 +898,11 @@
|
|||
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 {
|
||||
|
|
@ -719,6 +912,12 @@
|
|||
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;
|
||||
|
|
@ -758,9 +957,153 @@
|
|||
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;
|
||||
}
|
||||
|
||||
// Crew scores table
|
||||
.crew-score-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.crew-score-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.crew-score-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
.col-round {
|
||||
flex: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: spacing.$unit;
|
||||
}
|
||||
|
||||
.col-our-score {
|
||||
width: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: spacing.$unit-half;
|
||||
}
|
||||
|
||||
.col-their-score {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: spacing.$unit-half;
|
||||
}
|
||||
|
||||
.col-actions {
|
||||
width: 80px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.round-label {
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-size: typography.$font-small;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&.winner {
|
||||
font-weight: typography.$medium;
|
||||
}
|
||||
}
|
||||
|
||||
.score-empty {
|
||||
font-size: typography.$font-small;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.opponent-name {
|
||||
font-size: typography.$font-small;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.result-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
padding: 0 spacing.$unit-half;
|
||||
border-radius: layout.$item-corner-small;
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
|
||||
&.win {
|
||||
background: var(--color-green-light, #dcfce7);
|
||||
color: var(--color-green-dark, #166534);
|
||||
}
|
||||
|
||||
&.loss {
|
||||
background: colors.$error--bg--light;
|
||||
color: colors.$error;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in a new issue