stammtisch-hersbruck/frontend/src/routes/dashboard/events/[id]/+page.svelte
Daniel Michelberger f773c52b0a feat: mehrere Teilnehmer auf einmal eintragen
Ein Event beginnt mit zwanzig Anmeldungen, nicht mit einer. Bisher hiess
das: zwanzigmal denselben Dialog oeffnen, einen Namen aus einer
Auswahlliste suchen, eine Nummer tippen, speichern.

Jetzt steht der Kader als Liste da — Haken und Startnummer nebeneinander,
darueber eine Suche und "Alle"/"Keinen". Der Knopf zaehlt mit, was
ausgewaehlt ist.

Geprueft wird vor dem Schreiben, nicht dabei: doppelte Nummern innerhalb
der Auswahl und gegen die schon vergebenen. Eine Kollision soll nicht
erst auffallen, wenn die halbe Liste eingetragen ist.

Der alte Dialog bleibt, aber nur noch fuers Aendern einer Startnummer —
sein Anlege-Zweig samt Fahrer-Auswahlliste ist entfallen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 23:57:45 +02:00

702 lines
31 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { page } from '$app/state'
import { goto } from '$app/navigation'
import { getEventContext } from '$lib/stores/events.svelte'
import { getStageContext } from '$lib/stores/stages.svelte'
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte'
import { getTeamContext } from '$lib/stores/teams.svelte'
import {
getEventParticipantContext,
PARTICIPANT_STATUS_LABEL,
type ParticipantStatus,
} from '$lib/stores/eventParticipants.svelte'
import { auth } from '$lib/stores/pocketbase.svelte'
import TimeDialog from '$lib/components/TimeDialog.svelte'
import { formatDateRange } from '$lib/time'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog'
import * as Table from '@/components/ui/table'
import { Separator } from '@/components/ui/separator'
import {
ArrowLeft,
Calendar,
MapPin,
Pencil,
Plus,
Timer,
Trash2,
Users,
UserPlus,
Check,
Search,
X,
} from 'lucide-svelte'
import type { EventParticipantsResponse, StagesResponse } from '$lib/types'
const events = getEventContext()
const stages = getStageContext()
const riders = getRiderContext()
const times = getTimeContext()
const teams = getTeamContext()
const participants = getEventParticipantContext()
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
const eventParticipants = $derived(event ? participants.byEvent(event.id) : [])
const eventStages = $derived(event ? stages.getByEvent(event.id) : [])
/**
* Teilnehmer verwaltet, wer das Event angelegt hat, und die Teamleitung —
* dieselbe Regel wie in der Collection.
*/
const canManage = $derived(
!!event && (event.created_by === auth.user?.id || teams.isAdmin()),
)
/** Eigene Teilnahme, sofern das Konto an einem Fahrer hängt. */
const ownEntry = $derived(event ? participants.own(event.id) : undefined)
const ownRider = $derived(riders.getByUser(auth.user?.id ?? '')[0])
const canSignUp = $derived(
!!event && !!ownRider && !ownEntry && event.participation !== 'geschlossen',
)
// Zeitnahme und Ergebnisse hängen jetzt am jeweiligen Stage bzw. Fahrer.
// Die Übersicht zeigt deshalb pro Zeile, was dort schon passiert ist.
function riderPath(riderId: string) {
return `/dashboard/events/${event?.id}/riders/${riderId}`
}
function stagePath(stageId: string) {
return `/dashboard/events/${event?.id}/stages/${stageId}`
}
// Zeit-Dialog: der einzige Weg, eine Zeit zu starten oder zu stoppen.
let timeDialog = $state(false)
// --- Teilnehmer -------------------------------------------------------
let riderDialog = $state(false)
let editing = $state<EventParticipantsResponse | null>(null)
let form = $state({ number: '' })
let formError = $state<string | null>(null)
const available = $derived(event ? participants.available(event.id) : [])
// --- Mehrere auf einmal eintragen -------------------------------------
//
// Ein Event beginnt mit zwanzig Anmeldungen, nicht mit einer. Zwanzigmal
// denselben Dialog zu oeffnen, einen Namen aus einer Liste zu suchen und
// eine Nummer zu tippen, ist dieselbe Arbeit zwanzigmal — hier steht der
// Kader stattdessen als Liste da, mit der Nummer gleich daneben.
let addDialog = $state(false)
let addQuery = $state('')
let addBusy = $state(false)
let addError = $state<string | null>(null)
/** Auswahl und Startnummer je Fahrer, nach Fahrer-ID. */
let picks = $state<Record<string, { chosen: boolean; number: string }>>({})
const addFiltered = $derived.by(() => {
const q = addQuery.toLowerCase().trim()
return q ? available.filter((r) => riderName(r).toLowerCase().includes(q)) : available
})
const picked = $derived(available.filter((r) => picks[r.id]?.chosen))
function openAdd() {
picks = Object.fromEntries(available.map((r) => [r.id, { chosen: false, number: '' }]))
addQuery = ''
addError = null
addDialog = true
}
function toggleAll(on: boolean) {
const next = { ...picks }
for (const r of addFiltered) {
next[r.id] = { ...(next[r.id] ?? { number: '' }), chosen: on }
}
picks = next
}
async function saveParticipants() {
if (!event || addBusy) return
const chosen = picked
if (chosen.length === 0) {
addError = 'Niemand ausgewählt.'
return
}
// Erst pruefen, dann schreiben: Eine doppelte Nummer soll nicht erst
// auffallen, wenn die halbe Liste schon eingetragen ist.
const seen = new Map<string, string>()
for (const r of chosen) {
const number = (picks[r.id]?.number ?? '').trim()
if (!number) continue
if (seen.has(number)) {
addError = `Startnummer ${number} ist doppelt vergeben (${seen.get(number)} und ${riderName(r)}).`
return
}
if (participants.isNumberTaken(event.id, number)) {
addError = `Startnummer ${number} ist in diesem Event bereits vergeben.`
return
}
seen.set(number, riderName(r))
}
addBusy = true
addError = null
try {
for (const r of chosen) {
await participants.add({
event: event.id,
rider: r.id,
number: (picks[r.id]?.number ?? '').trim(),
})
}
addDialog = false
} catch (e: any) {
addError = e.message ?? 'Nicht alle Teilnehmer konnten eingetragen werden.'
} finally {
addBusy = false
}
}
function openEdit(p: EventParticipantsResponse) {
editing = p
form = { number: p.number ?? '' }
formError = null
riderDialog = true
}
async function saveParticipant() {
if (!event || !editing) return
if (participants.isNumberTaken(event.id, form.number, editing.id)) {
formError = `Startnummer ${form.number} ist in diesem Event bereits vergeben.`
return
}
formError = null
try {
await participants.edit(editing.id, { number: form.number.trim() })
riderDialog = false
} catch (e: any) {
formError = e.message ?? 'Der Teilnehmer konnte nicht gespeichert werden.'
}
}
async function setStatus(p: EventParticipantsResponse, status: ParticipantStatus) {
try {
await participants.setStatus(p.id, status)
} catch (e: any) {
alert(e.message ?? 'Der Status konnte nicht geändert werden.')
}
}
/** Selbst eintragen — im Bestätigungsmodus zunächst nur als Anfrage. */
async function signUp() {
if (!event || !ownRider) return
try {
await participants.add({
event: event.id,
rider: ownRider.id,
status: event.participation === 'bestaetigung' ? 'angefragt' : 'zugesagt',
})
} catch (e: any) {
alert(e.message ?? 'Die Anmeldung ist fehlgeschlagen.')
}
}
async function removeParticipant(p: EventParticipantsResponse) {
if (!confirm('Teilnehmer wirklich entfernen? Seine Zeiten bleiben erhalten.')) return
try {
await participants.remove(p.id)
} catch (e: any) {
alert(e.message ?? 'Der Teilnehmer konnte nicht entfernt werden.')
}
}
// Stage-Dialog
let stageDialog = $state(false)
let stageEdit = $state<StagesResponse | null>(null)
let stageForm = $state({ description: '' })
function openStageCreate() {
stageEdit = null
stageForm = { description: '' }
stageDialog = true
}
function openStageEdit(r: StagesResponse) {
stageEdit = r
stageForm = { description: r.description ?? '' }
stageDialog = true
}
async function saveStage() {
if (!event) return
if (!stageForm.description.trim()) return alert('Bezeichnung ist Pflicht')
try {
if (stageEdit) await stages.edit(stageEdit.id, stageForm)
else await stages.create({ ...stageForm, event: event.id })
stageDialog = false
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
}
</script>
<svelte:head><title>{event?.name ?? 'Event'} Zeitnahme</title></svelte:head>
{#if !event}
<Card>
<CardContent class="py-16 text-center">
<Calendar class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p class="text-lg font-medium">Event nicht gefunden</p>
<p class="text-sm text-muted-foreground mt-1">Vermutlich gehört es nicht zum aktiven Team.</p>
<Button href="/dashboard/events" variant="outline" class="mt-4">
<ArrowLeft class="h-4 w-4 mr-2" />
Zur Eventliste
</Button>
</CardContent>
</Card>
{:else}
<div class="space-y-6">
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<a href="/dashboard/events" class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2">
<ArrowLeft class="h-3 w-3" />
Eventliste
</a>
<div class="flex items-center gap-3">
<h1 class="text-3xl font-bold tracking-tight">{event.name || 'Unbenannt'}</h1>
{#if event.starts}
<Badge variant="secondary" class="gap-1">
<Calendar class="h-3 w-3" />
{formatDateRange(event.starts, event.ends)}
</Badge>
{/if}
</div>
{#if event.location}
<p class="mt-1 text-muted-foreground flex items-center gap-1">
<MapPin class="h-4 w-4" />
{event.location}
</p>
{/if}
{#if event.description && event.description !== event.name}
<p class="mt-2 text-sm text-muted-foreground">{event.description}</p>
{/if}
</div>
<Button
onclick={() => (timeDialog = true)}
disabled={eventParticipants.length === 0 || eventStages.length === 0}
>
<Timer class="h-4 w-4 mr-2" />
Zeit erfassen
</Button>
</div>
<Separator />
<!-- Teilnehmer -->
<section class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold flex items-center gap-2">
<Users class="h-5 w-5" />
Teilnehmer
<span class="text-muted-foreground font-normal text-base">({eventParticipants.length})</span>
</h2>
<p class="text-sm text-muted-foreground">
{#if event.participation === 'offen'}
Jedes Teammitglied kann sich selbst eintragen.
{:else if event.participation === 'bestaetigung'}
Anmeldungen werden vom Ersteller oder der Teamleitung bestätigt.
{:else}
Teilnehmer trägt der Ersteller oder die Teamleitung ein.
{/if}
</p>
</div>
<div class="flex items-center gap-2">
{#if canSignUp}
<Button variant="outline" onclick={signUp}>
<UserPlus class="h-4 w-4 mr-2" />
{event.participation === 'bestaetigung' ? 'Anfragen' : 'Ich bin dabei'}
</Button>
{/if}
{#if canManage}
<Button onclick={openAdd} disabled={available.length === 0}>
<Plus class="h-4 w-4 mr-2" />
Teilnehmer
</Button>
{/if}
</div>
</div>
{#if canManage && available.length === 0 && riders.scoped.length === 0}
<p class="text-sm text-muted-foreground">
Das Team hat noch keine Fahrer.
<a href="/dashboard/team" class="underline">Jetzt anlegen</a>
</p>
{/if}
<Card>
<CardContent class="p-0">
{#if eventParticipants.length === 0}
<div class="py-12 text-center">
<Users class="h-10 w-10 text-muted-foreground mx-auto mb-3" />
<p class="font-medium">Noch niemand dabei</p>
<p class="text-sm text-muted-foreground mt-1">
Fahrer gehören dem Team; hier steht, wer bei diesem Event
mitfährt.
</p>
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-20">#</Table.Head>
<Table.Head>Name</Table.Head>
<Table.Head class="w-32">Status</Table.Head>
{#if event.timing}
<Table.Head class="w-40">Zeiten</Table.Head>
{/if}
<Table.Head class="text-right">Aktionen</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each eventParticipants as p (p.id)}
{@const rider = riders.getById(p.rider)}
{@const done = times.getByRider(p.rider).filter((t) => t.status === 'finished').length}
<Table.Row class="cursor-pointer" onclick={() => goto(riderPath(p.rider))}>
<Table.Cell>
<Badge variant="outline">{p.number || '—'}</Badge>
</Table.Cell>
<Table.Cell class="font-medium">
<!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. -->
<a href={riderPath(p.rider)} class="hover:underline">
{#if rider && riderName(rider)}
{riderName(rider)}
{:else}
<span class="text-muted-foreground italic">— ohne Name —</span>
{/if}
</a>
</Table.Cell>
<Table.Cell>
<Badge
variant={p.status === 'zugesagt'
? 'default'
: p.status === 'angefragt'
? 'secondary'
: 'outline'}
>
{PARTICIPANT_STATUS_LABEL[p.status] ?? p.status}
</Badge>
</Table.Cell>
{#if event.timing}
<Table.Cell class="text-muted-foreground text-sm">
{done} von {eventStages.length} Stages
</Table.Cell>
{/if}
<Table.Cell class="text-right whitespace-nowrap" onclick={(e) => e.stopPropagation()}>
{#if canManage}
{#if p.status === 'angefragt'}
<Button
variant="ghost"
size="icon"
tooltip="Bestätigen"
onclick={() => setStatus(p, 'zugesagt')}
>
<Check class="h-4 w-4 text-primary" />
</Button>
{:else if p.status === 'zugesagt'}
<Button
variant="ghost"
size="icon"
tooltip="Als abgesagt markieren"
onclick={() => setStatus(p, 'abgesagt')}
>
<X class="h-4 w-4" />
</Button>
{:else}
<Button
variant="ghost"
size="icon"
tooltip="Wieder zusagen"
onclick={() => setStatus(p, 'zugesagt')}
>
<Check class="h-4 w-4" />
</Button>
{/if}
<Button
variant="ghost"
size="icon"
tooltip="Startnummer ändern"
onclick={() => openEdit(p)}
>
<Pencil class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
tooltip="Entfernen"
onclick={() => removeParticipant(p)}
>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
{:else if p.id === ownEntry?.id}
<Button
variant="ghost"
size="sm"
onclick={() =>
setStatus(p, p.status === 'abgesagt' ? 'zugesagt' : 'abgesagt')}
>
{p.status === 'abgesagt' ? 'Doch dabei' : 'Absagen'}
</Button>
{/if}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</CardContent>
</Card>
</section>
<!-- Stages — nur bei Events mit Zeitnahme -->
{#if event.timing}
<section class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold flex items-center gap-2">
<Timer class="h-5 w-5" />
Stages
<span class="text-muted-foreground font-normal text-base">({eventStages.length})</span>
</h2>
<p class="text-sm text-muted-foreground">Klick auf eine Stage öffnet die Zeitnahme</p>
</div>
<Button onclick={openStageCreate}>
<Plus class="h-4 w-4 mr-2" />
Stage anlegen
</Button>
</div>
<Card>
<CardContent class="p-0">
{#if eventStages.length === 0}
<div class="py-12 text-center">
<Timer class="h-10 w-10 text-muted-foreground mx-auto mb-3" />
<p class="font-medium">Noch keine Stages</p>
<p class="text-sm text-muted-foreground mt-1 mb-4">Lege den ersten Lauf an.</p>
<Button onclick={openStageCreate}>
<Plus class="h-4 w-4 mr-2" />
Erste Stage anlegen
</Button>
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Bezeichnung</Table.Head>
<Table.Head class="w-48">Zeiten</Table.Head>
<Table.Head class="text-right">Aktionen</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each eventStages as stage (stage.id)}
{@const stageTimes = times.getByStage(stage.id)}
{@const active = stageTimes.filter((t) => t.status === 'active').length}
{@const ranked = stageTimes.filter((t) => t.status === 'finished').length}
<Table.Row class="cursor-pointer" onclick={() => goto(stagePath(stage.id))}>
<Table.Cell class="font-medium">
<a href={stagePath(stage.id)} class="hover:underline">{stage.description}</a>
</Table.Cell>
<Table.Cell>
{#if active > 0}
<Badge class="mr-2">{active} laufend</Badge>
{/if}
<span class="text-muted-foreground text-sm">{ranked} gewertet</span>
</Table.Cell>
<Table.Cell class="text-right" onclick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" onclick={() => openStageEdit(stage)}>
<Pencil class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onclick={() => stages.remove(stage)}>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</CardContent>
</Card>
</section>
{/if}
</div>
{/if}
<!-- Teilnehmer -->
<Dialog.Root bind:open={riderDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Startnummer ändern</Dialog.Title>
<Dialog.Description>
Die Startnummer gilt nur für „{event?.name}".
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="p-number">Startnummer</Label>
<Input id="p-number" bind:value={form.number} placeholder="optional" />
<p class="text-xs text-muted-foreground">
Gilt je Event. Ohne Zeitnahme braucht es sie meist gar nicht.
</p>
</div>
{#if formError}
<p class="text-sm text-destructive">{formError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button>
<Button onclick={saveParticipant}>Speichern</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!--
Teilnehmer eintragen: der Kader als Liste, Haken und Nummer nebeneinander.
Wer zwanzig Leute eintraegt, soll das in einem Durchgang tun koennen und
nicht zwanzigmal denselben Dialog oeffnen.
-->
<Dialog.Root bind:open={addDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Teilnehmer eintragen</Dialog.Title>
<Dialog.Description>
Aus dem Kader des Teams. Die Startnummer gilt je Event und kann leer
bleiben.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-3">
{#if available.length === 0}
<p class="text-sm text-muted-foreground">
Der ganze Kader ist schon eingetragen.
</p>
{:else}
<div class="flex items-center gap-2">
<div class="relative flex-1">
<Search class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input bind:value={addQuery} placeholder="Fahrer suchen" class="pl-9" />
</div>
<Button variant="outline" size="sm" onclick={() => toggleAll(true)}>Alle</Button>
<Button variant="ghost" size="sm" onclick={() => toggleAll(false)}>Keinen</Button>
</div>
<div class="max-h-72 space-y-1 overflow-y-auto rounded-lg border p-2">
{#each addFiltered as r (r.id)}
<div class="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-accent">
<input
id="pick-{r.id}"
type="checkbox"
class="size-4 shrink-0 rounded border-input accent-primary"
checked={picks[r.id]?.chosen ?? false}
onchange={(e) =>
(picks = {
...picks,
[r.id]: {
number: picks[r.id]?.number ?? '',
chosen: e.currentTarget.checked,
},
})}
/>
<label for="pick-{r.id}" class="min-w-0 flex-1 cursor-pointer truncate text-sm">
{riderName(r) || '— ohne Name —'}
</label>
<Input
value={picks[r.id]?.number ?? ''}
placeholder="Nr."
aria-label="Startnummer für {riderName(r)}"
class="h-8 w-20 shrink-0 text-center"
oninput={(e) =>
(picks = {
...picks,
[r.id]: {
chosen: picks[r.id]?.chosen ?? false,
number: e.currentTarget.value,
},
})}
/>
</div>
{:else}
<p class="px-2 py-4 text-center text-sm text-muted-foreground">
Kein Treffer.
</p>
{/each}
</div>
{/if}
{#if addError}
<p class="text-sm text-destructive">{addError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (addDialog = false)}>Abbrechen</Button>
<Button onclick={saveParticipants} disabled={addBusy || picked.length === 0}>
{#if addBusy}
Wird eingetragen …
{:else if picked.length === 0}
Eintragen
{:else}
{picked.length} eintragen
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Stage-Dialog -->
<Dialog.Root bind:open={stageDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{stageEdit ? 'Stage bearbeiten' : 'Neue Stage'}</Dialog.Title>
<Dialog.Description>
Stage für „{event?.name}"
</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault()
saveStage()
}}
class="space-y-4"
>
<div class="space-y-2">
<Label for="stage-desc">Bezeichnung *</Label>
<Input id="stage-desc" bind:value={stageForm.description} placeholder="Stage 1 Downhill" required />
</div>
<Dialog.Footer>
<Button type="button" variant="ghost" onclick={() => (stageDialog = false)}>Abbrechen</Button>
<Button type="submit">{stageEdit ? 'Speichern' : 'Anlegen'}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<TimeDialog bind:open={timeDialog} eventId={event?.id ?? ''} />