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
|
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 ====================
|
// ==================== 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
|
round: GwRound
|
||||||
score: number
|
score: number
|
||||||
isCumulative: boolean
|
isCumulative: boolean
|
||||||
|
excused: boolean
|
||||||
|
excuseReason?: string // Only returned to crew officers
|
||||||
playerName: string
|
playerName: string
|
||||||
playerType: PlayerType
|
playerType: PlayerType
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
|
|
@ -125,6 +127,8 @@ export interface CreateIndividualScoreInput {
|
||||||
round: GwRound
|
round: GwRound
|
||||||
score: number
|
score: number
|
||||||
isCumulative?: boolean
|
isCumulative?: boolean
|
||||||
|
excused?: boolean
|
||||||
|
excuseReason?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch score entry
|
// Batch score entry
|
||||||
|
|
@ -135,6 +139,8 @@ export interface BatchScoreEntry {
|
||||||
round: GwRound
|
round: GwRound
|
||||||
score: number
|
score: number
|
||||||
isCumulative?: boolean
|
isCumulative?: boolean
|
||||||
|
excused?: boolean
|
||||||
|
excuseReason?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchIndividualScoresInput {
|
export interface BatchIndividualScoresInput {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<svelte:options runes={true} />
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation'
|
import { goto, replaceState } from '$app/navigation'
|
||||||
import { page } from '$app/stores'
|
import { page } from '$app/stores'
|
||||||
import { createQuery, createMutation, useQueryClient } from '@tanstack/svelte-query'
|
import { createQuery, createMutation, useQueryClient } from '@tanstack/svelte-query'
|
||||||
import { gwAdapter } from '$lib/api/adapters/gw.adapter'
|
import { gwAdapter } from '$lib/api/adapters/gw.adapter'
|
||||||
|
|
@ -18,7 +18,17 @@
|
||||||
import Checkbox from '$lib/components/ui/checkbox/Checkbox.svelte'
|
import Checkbox from '$lib/components/ui/checkbox/Checkbox.svelte'
|
||||||
import CrewHeader from '$lib/components/crew/CrewHeader.svelte'
|
import CrewHeader from '$lib/components/crew/CrewHeader.svelte'
|
||||||
import EditScoreModal from '$lib/components/crew/EditScoreModal.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'
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -44,6 +54,26 @@
|
||||||
const membersDuringEvent = $derived(eventQuery.data?.membersDuringEvent ?? [])
|
const membersDuringEvent = $derived(eventQuery.data?.membersDuringEvent ?? [])
|
||||||
const phantomPlayers = $derived(eventQuery.data?.phantomPlayers ?? [])
|
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)
|
// Element labels (matches GranblueEnums::ELEMENTS)
|
||||||
const elementLabels: Record<number, string> = {
|
const elementLabels: Record<number, string> = {
|
||||||
0: 'Null',
|
0: 'Null',
|
||||||
|
|
@ -156,6 +186,8 @@
|
||||||
4: '',
|
4: '',
|
||||||
5: ''
|
5: ''
|
||||||
})
|
})
|
||||||
|
let isExcused = $state(false)
|
||||||
|
let excuseReason = $state('')
|
||||||
let isSubmitting = $state(false)
|
let isSubmitting = $state(false)
|
||||||
|
|
||||||
// Player type tracking - value format is "member:id" or "phantom:id"
|
// Player type tracking - value format is "member:id" or "phantom:id"
|
||||||
|
|
@ -255,7 +287,9 @@
|
||||||
...input,
|
...input,
|
||||||
round: 0, // Cumulative scores go to preliminaries
|
round: 0, // Cumulative scores go to preliminaries
|
||||||
score,
|
score,
|
||||||
isCumulative: true
|
isCumulative: true,
|
||||||
|
excused: isExcused,
|
||||||
|
excuseReason: isExcused ? excuseReason : undefined
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Batch add per-round scores
|
// Batch add per-round scores
|
||||||
|
|
@ -272,7 +306,9 @@
|
||||||
...entry,
|
...entry,
|
||||||
round,
|
round,
|
||||||
score: value,
|
score: value,
|
||||||
isCumulative: false
|
isCumulative: false,
|
||||||
|
excused: isExcused,
|
||||||
|
excuseReason: isExcused ? excuseReason : undefined
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -305,6 +341,8 @@
|
||||||
isCumulative = true
|
isCumulative = true
|
||||||
cumulativeScore = ''
|
cumulativeScore = ''
|
||||||
roundScores = { 0: '', 1: '', 2: '', 3: '', 4: '', 5: '' }
|
roundScores = { 0: '', 1: '', 2: '', 3: '', 4: '', 5: '' }
|
||||||
|
isExcused = false
|
||||||
|
excuseReason = ''
|
||||||
isSubmitting = false
|
isSubmitting = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -325,6 +363,23 @@
|
||||||
editingPlayer = player
|
editingPlayer = player
|
||||||
showEditScoreModal = true
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
@ -343,7 +398,7 @@
|
||||||
<Button variant="secondary" size="small" onclick={handleBack}>Back to Crew</Button>
|
<Button variant="secondary" size="small" onclick={handleBack}>Back to Crew</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<CrewHeader title="GW #{gwEvent.eventNumber}">
|
<CrewHeader title="GW #{gwEvent.eventNumber}" backHref="/crew">
|
||||||
{#snippet belowTitle()}
|
{#snippet belowTitle()}
|
||||||
<div class="event-meta">
|
<div class="event-meta">
|
||||||
<span class="element-badge element-{elementColors[gwEvent.element]}">
|
<span class="element-badge element-{elementColors[gwEvent.element]}">
|
||||||
|
|
@ -353,9 +408,15 @@
|
||||||
{formatDate(gwEvent.startDate)} – {formatDate(gwEvent.endDate)}
|
{formatDate(gwEvent.startDate)} – {formatDate(gwEvent.endDate)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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}
|
||||||
{#snippet actions()}
|
{#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>
|
<Button variant="primary" size="small" onclick={openScoreModal}>Add Score</Button>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
@ -369,8 +430,8 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Score summary -->
|
<!-- Score summary (crew tab only) -->
|
||||||
{#if participation.totalScore !== undefined}
|
{#if activeTab === 'crew' && participation.totalScore !== undefined}
|
||||||
<div class="stats-row">
|
<div class="stats-row">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<span class="stat-value">{formatScore(participation.totalScore)}</span>
|
<span class="stat-value">{formatScore(participation.totalScore)}</span>
|
||||||
|
|
@ -387,52 +448,142 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Player scores -->
|
<!-- Individual Honors Tab -->
|
||||||
<div class="section-header">
|
{#if activeTab === 'individual'}
|
||||||
<span class="section-title">Individual Scores</span>
|
<div class="section-header">
|
||||||
</div>
|
<span class="section-title">Individual Scores</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if playerScores.length > 0}
|
{#if playerScores.length > 0}
|
||||||
<ul class="player-list">
|
<ul class="player-list">
|
||||||
{#each playerScores as player, index}
|
{#each playerScores as player, index}
|
||||||
<li class="player-item" class:retired={player.isRetired}>
|
<li class="player-item" class:retired={player.isRetired}>
|
||||||
<div class="player-info">
|
<div class="player-info">
|
||||||
<span class="player-rank">{index + 1}</span>
|
<span class="player-rank">{index + 1}</span>
|
||||||
<span class="player-name">{player.name}</span>
|
<span class="player-name">{player.name}</span>
|
||||||
{#if player.isRetired}
|
{#if player.isRetired}
|
||||||
<span class="player-badge retired">Retired</span>
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if player.type === 'phantom'}
|
<div class="col-our-score">
|
||||||
<span class="player-type">Phantom</span>
|
{#if score}
|
||||||
{/if}
|
<span class="score-value" class:winner={score.victory === true}>{formatScore(score.crewScore)}</span>
|
||||||
<span class="player-score">{formatScore(player.totalScore)}</span>
|
{:else}
|
||||||
{#if crewStore.isOfficer && player.scores.length > 0}
|
<span class="score-empty">—</span>
|
||||||
<DropdownMenu>
|
{/if}
|
||||||
{#snippet trigger({ props })}
|
</div>
|
||||||
<Button
|
<div class="col-their-score">
|
||||||
variant="secondary"
|
{#if score}
|
||||||
size="small"
|
{#if score.opponentScore !== null}
|
||||||
iconOnly
|
<span class="score-value" class:winner={score.victory === false}>{formatScore(score.opponentScore)}</span>
|
||||||
icon="ellipsis"
|
{/if}
|
||||||
{...props}
|
{#if score.opponentName}
|
||||||
/>
|
<span class="opponent-name">({score.opponentName})</span>
|
||||||
{/snippet}
|
{/if}
|
||||||
{#snippet menu()}
|
{:else}
|
||||||
<DropdownMenuBase.Item
|
<span class="score-empty">—</span>
|
||||||
class="dropdown-menu-item"
|
{/if}
|
||||||
onclick={() => openEditScoreModal(player)}
|
</div>
|
||||||
>
|
<div class="col-actions">
|
||||||
Edit score...
|
{#if score}
|
||||||
</DropdownMenuBase.Item>
|
{#if crewStore.isOfficer}
|
||||||
{/snippet}
|
<DropdownMenu>
|
||||||
</DropdownMenu>
|
{#snippet trigger({ props })}
|
||||||
{/if}
|
<Button
|
||||||
</li>
|
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}
|
{/each}
|
||||||
</ul>
|
</div>
|
||||||
{:else}
|
|
||||||
<p class="empty-state">No scores recorded yet</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -485,6 +636,21 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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}
|
{#if addScoreMutation.isError}
|
||||||
<p class="error-message">
|
<p class="error-message">
|
||||||
{addScoreMutation.error?.message ?? 'Failed to add score'}
|
{addScoreMutation.error?.message ?? 'Failed to add score'}
|
||||||
|
|
@ -505,6 +671,28 @@
|
||||||
/>
|
/>
|
||||||
</Dialog>
|
</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">
|
<style lang="scss">
|
||||||
@use '$src/themes/colors' as colors;
|
@use '$src/themes/colors' as colors;
|
||||||
@use '$src/themes/effects' as effects;
|
@use '$src/themes/effects' as effects;
|
||||||
|
|
@ -710,6 +898,11 @@
|
||||||
background: rgba(0, 0, 0, 0.04);
|
background: rgba(0, 0, 0, 0.04);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.excused {
|
||||||
|
background: var(--color-yellow-light, #fef9c3);
|
||||||
|
color: var(--color-yellow-dark, #854d0e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-type {
|
.player-type {
|
||||||
|
|
@ -719,6 +912,12 @@
|
||||||
margin-right: spacing.$unit;
|
margin-right: spacing.$unit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
}
|
||||||
|
|
||||||
.player-score {
|
.player-score {
|
||||||
font-size: typography.$font-small;
|
font-size: typography.$font-small;
|
||||||
font-weight: typography.$medium;
|
font-weight: typography.$medium;
|
||||||
|
|
@ -758,9 +957,153 @@
|
||||||
border-radius: layout.$card-corner;
|
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 {
|
.error-message {
|
||||||
color: colors.$error;
|
color: colors.$error;
|
||||||
font-size: typography.$font-small;
|
font-size: typography.$font-small;
|
||||||
margin: 0;
|
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>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue