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:
parent
b80b1198fc
commit
f773c52b0a
1 changed files with 180 additions and 48 deletions
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
await participants.edit(editing.id, { 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}
|
||||
Die Startnummer gilt nur für „{event?.name}".
|
||||
</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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue