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
This commit is contained in:
Daniel Michelberger 2026-09-07 23:57:45 +02:00
parent b80b1198fc
commit f773c52b0a

View file

@ -13,7 +13,6 @@
} from '$lib/stores/eventParticipants.svelte'
import { auth } from '$lib/stores/pocketbase.svelte'
import TimeDialog from '$lib/components/TimeDialog.svelte'
import * as Select from '@/components/ui/select'
import { formatDateRange } from '$lib/time'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@ -34,6 +33,7 @@
Users,
UserPlus,
Check,
Search,
X,
} from 'lucide-svelte'
import type { EventParticipantsResponse, StagesResponse } from '$lib/types'
@ -81,48 +81,110 @@
// --- Teilnehmer -------------------------------------------------------
let riderDialog = $state(false)
let editing = $state<EventParticipantsResponse | null>(null)
let form = $state({ rider: '', number: '' })
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() {
editing = null
form = { rider: available[0]?.id ?? '', number: '' }
formError = null
riderDialog = true
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 = { rider: p.rider, number: p.number ?? '' }
form = { number: p.number ?? '' }
formError = null
riderDialog = true
}
async function saveParticipant() {
if (!event) return
if (!event || !editing) return
if (!form.rider) {
formError = 'Bitte einen Fahrer wählen.'
return
}
if (participants.isNumberTaken(event.id, form.number, editing?.id)) {
if (participants.isNumberTaken(event.id, form.number, editing.id)) {
formError = `Startnummer ${form.number} ist in diesem Event bereits vergeben.`
return
}
formError = null
try {
if (editing) {
await participants.edit(editing.id, { number: form.number.trim() })
} else {
await participants.add({
event: event.id,
rider: form.rider,
number: form.number.trim(),
})
}
riderDialog = false
} catch (e: any) {
formError = e.message ?? 'Der Teilnehmer konnte nicht gespeichert werden.'
@ -489,36 +551,13 @@
<Dialog.Root bind:open={riderDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{editing ? 'Startnummer ändern' : 'Teilnehmer hinzufügen'}</Dialog.Title>
<Dialog.Title>Startnummer ändern</Dialog.Title>
<Dialog.Description>
{#if editing}
Die Startnummer gilt nur für „{event?.name}".
{:else}
Aus dem Kader des Teams. Neue Fahrer legt die Teamleitung in den
Einstellungen an.
{/if}
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
{#if !editing}
<div class="space-y-2">
<Label>Fahrer</Label>
<Select.Root type="single" bind:value={form.rider}>
<Select.Trigger class="w-full">
{form.rider
? riderName(riders.getById(form.rider) ?? ({} as any)) || 'Fahrer wählen…'
: 'Fahrer wählen…'}
</Select.Trigger>
<Select.Content>
{#each available as r (r.id)}
<Select.Item value={r.id}>{riderName(r) || '— ohne Name —'}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
<div class="space-y-2">
<Label for="p-number">Startnummer</Label>
<Input id="p-number" bind:value={form.number} placeholder="optional" />
@ -534,7 +573,100 @@
<Dialog.Footer>
<Button variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button>
<Button onclick={saveParticipant}>{editing ? 'Speichern' : 'Hinzufügen'}</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>