Die Einstellungen bekommen Reiter: Profil, Team, Fahrer (nur Teamleitung) und Flag-Typen. /dashboard/teams wandert dorthin. Neu ist die Hauptroute Fahrer - das Verzeichnis des Teams, fuer alle sichtbar und fuer niemanden dort aenderbar. Angelegt, geaendert und mit einem Konto verknuepft wird in den Einstellungen; serverseitig setzen das die neuen Regeln der Collection durch. Ein Fahrer bekommt sein Login entweder aus den vorhandenen Teammitgliedern oder direkt neu angelegt. Die Einladung per E-Mail-Suche entfaellt: Sie konnte nie funktionieren, weil users.listRule nur den eigenen Datensatz freigibt - daher auch die abgeschnittenen IDs in der alten Mitgliederliste. Events zeigen statt eines Status ihr Datum, mit Checkbox fuer mehrtaegig. Gespeichert wird ein Enddatum nur, wenn es sich vom Beginn unterscheidet - es ist damit zugleich das Kennzeichen fuer mehrtaegig statt einer zweiten Wahrheit ueber dieselbe Sache. Listen gruppieren nach laufend, anstehend, ohne Termin und vorbei. Im Kopf teilen sich Name, Design-Umschalter und Logout eine Zeile, das Team-Dropdown sitzt darunter; das Logo passt jetzt in die Leiste, statt beschnitten zu wirken. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
220 lines
9.5 KiB
Svelte
220 lines
9.5 KiB
Svelte
<script lang="ts">
|
||
import { getEventContext } from '$lib/stores/events.svelte'
|
||
import { getRunContext } from '$lib/stores/runs.svelte'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Card, CardContent, CardDescription, 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 { dayOf, formatDateRange } from '$lib/time'
|
||
import { Calendar, MapPin, Plus, Pencil, Trash2 } from 'lucide-svelte'
|
||
import type { EventsResponse } from '$lib/types'
|
||
|
||
const events = getEventContext()
|
||
const runs = getRunContext()
|
||
|
||
let showDialog = $state(false)
|
||
let editing = $state<EventsResponse | null>(null)
|
||
let form = $state({ name: '', description: '', location: '', starts: '', ends: '' })
|
||
let multiDay = $state(false)
|
||
let formError = $state<string | null>(null)
|
||
|
||
function openCreate() {
|
||
editing = null
|
||
form = { name: '', description: '', location: '', starts: '', ends: '' }
|
||
multiDay = false
|
||
formError = null
|
||
showDialog = true
|
||
}
|
||
|
||
function openEdit(e: EventsResponse) {
|
||
editing = e
|
||
form = {
|
||
name: e.name ?? '',
|
||
description: e.description ?? '',
|
||
location: e.location ?? '',
|
||
starts: dayOf(e.starts),
|
||
ends: dayOf(e.ends),
|
||
}
|
||
// Ein gespeichertes Enddatum ist das Kennzeichen für mehrtägig; ein
|
||
// eigenes Feld dafür wäre eine zweite Wahrheit über dieselbe Sache.
|
||
multiDay = !!dayOf(e.ends)
|
||
formError = null
|
||
showDialog = true
|
||
}
|
||
|
||
async function save() {
|
||
if (!form.name.trim()) return
|
||
|
||
if (multiDay && !form.starts) {
|
||
formError = 'Ohne Beginn ergibt ein Ende keinen Sinn.'
|
||
return
|
||
}
|
||
if (multiDay && form.ends && form.ends < form.starts) {
|
||
formError = 'Das Ende liegt vor dem Beginn.'
|
||
return
|
||
}
|
||
|
||
formError = null
|
||
try {
|
||
// Ein eintägiges Event hat kein Ende — sonst stünde überall
|
||
// dasselbe Datum zweimal.
|
||
const ends = multiDay && form.ends !== form.starts ? form.ends : ''
|
||
const data = { ...form, ends }
|
||
if (editing) await events.edit(editing.id, data)
|
||
else await events.create(data)
|
||
showDialog = false
|
||
} catch (e: any) {
|
||
formError = e.message ?? 'Das Event konnte nicht gespeichert werden.'
|
||
}
|
||
}
|
||
|
||
const groups = $derived([
|
||
{ title: 'Läuft gerade', items: events.active },
|
||
{ title: 'Anstehend', items: events.upcoming },
|
||
{ title: 'Ohne Termin', items: events.undated },
|
||
{ title: 'Vorbei', items: events.past },
|
||
])
|
||
</script>
|
||
|
||
<svelte:head><title>Events – Zeitnahme</title></svelte:head>
|
||
|
||
<div class="space-y-6">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<h1 class="text-3xl font-bold tracking-tight">Events</h1>
|
||
<p class="mt-1 text-muted-foreground">Veranstaltungen und Rennen verwalten</p>
|
||
</div>
|
||
<Button onclick={openCreate}>
|
||
<Plus class="h-4 w-4 mr-2" />
|
||
Neues Event
|
||
</Button>
|
||
</div>
|
||
|
||
{#if events.loading}
|
||
<div class="text-center py-12 text-muted-foreground">Lade Events…</div>
|
||
{:else if events.scoped.length === 0}
|
||
<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">Keine Events vorhanden</p>
|
||
<p class="text-sm text-muted-foreground mt-1 mb-4">Erstelle dein erstes Event, um zu beginnen.</p>
|
||
<Button onclick={openCreate}>
|
||
<Plus class="h-4 w-4 mr-2" />
|
||
Erstes Event erstellen
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
{:else}
|
||
{#each groups as group}
|
||
{#if group.items.length > 0}
|
||
<section class="space-y-3">
|
||
<h2 class="text-lg font-semibold">{group.title} <span class="text-muted-foreground font-normal">({group.items.length})</span></h2>
|
||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||
{#each group.items as event}
|
||
{@const runCount = runs.getByEvent(event.id).length}
|
||
<Card class="hover:border-primary transition-colors">
|
||
<a href="/dashboard/events/{event.id}" class="block">
|
||
<CardHeader>
|
||
<div class="flex items-start justify-between gap-2">
|
||
<CardTitle class="leading-tight">{event.name || 'Unbenannt'}</CardTitle>
|
||
{#if event.starts}
|
||
<Badge variant="secondary" class="shrink-0">
|
||
{formatDateRange(event.starts, event.ends)}
|
||
</Badge>
|
||
{/if}
|
||
</div>
|
||
{#if event.location}
|
||
<CardDescription class="flex items-center gap-1">
|
||
<MapPin class="h-3 w-3" />
|
||
{event.location}
|
||
</CardDescription>
|
||
{/if}
|
||
</CardHeader>
|
||
<CardContent class="space-y-3">
|
||
{#if event.description && event.description !== event.name}
|
||
<p class="text-sm text-muted-foreground line-clamp-2">{event.description}</p>
|
||
{/if}
|
||
<div class="flex items-center justify-between text-sm">
|
||
<span class="text-muted-foreground">{runCount} Run{runCount === 1 ? '' : 's'}</span>
|
||
</div>
|
||
</CardContent>
|
||
</a>
|
||
<div class="px-6 pb-4 flex items-center justify-end gap-1 border-t pt-3">
|
||
<Button variant="ghost" size="icon" onclick={() => openEdit(event)} title="Bearbeiten">
|
||
<Pencil class="h-4 w-4" />
|
||
</Button>
|
||
<Button variant="ghost" size="icon" onclick={() => events.remove(event)} title="Löschen">
|
||
<Trash2 class="h-4 w-4 text-destructive" />
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
{/each}
|
||
</div>
|
||
</section>
|
||
{/if}
|
||
{/each}
|
||
{/if}
|
||
</div>
|
||
|
||
<Dialog.Root bind:open={showDialog}>
|
||
<Dialog.Content>
|
||
<Dialog.Header>
|
||
<Dialog.Title>{editing ? 'Event bearbeiten' : 'Neues Event'}</Dialog.Title>
|
||
<Dialog.Description>
|
||
Trage die Stammdaten ein. Mehr Details lassen sich später ergänzen.
|
||
</Dialog.Description>
|
||
</Dialog.Header>
|
||
|
||
<form
|
||
onsubmit={(e) => {
|
||
e.preventDefault()
|
||
save()
|
||
}}
|
||
class="space-y-4"
|
||
>
|
||
<div class="space-y-2">
|
||
<Label for="event-name">Name *</Label>
|
||
<Input id="event-name" bind:value={form.name} placeholder="Biketoberfest 2026" required />
|
||
</div>
|
||
<div class="space-y-2">
|
||
<Label for="event-location">Ort</Label>
|
||
<Input id="event-location" bind:value={form.location} placeholder="Hersbruck" />
|
||
</div>
|
||
<div class="space-y-2">
|
||
<Label for="event-desc">Beschreibung</Label>
|
||
<Input id="event-desc" bind:value={form.description} placeholder="Optionale Beschreibung" />
|
||
</div>
|
||
<div class="space-y-2">
|
||
<Label for="event-starts">Datum</Label>
|
||
<Input id="event-starts" type="date" bind:value={form.starts} />
|
||
</div>
|
||
|
||
<label class="flex items-center gap-2 text-sm">
|
||
<input
|
||
type="checkbox"
|
||
bind:checked={multiDay}
|
||
class="size-4 rounded border-input accent-primary"
|
||
/>
|
||
mehrtägig
|
||
</label>
|
||
|
||
{#if multiDay}
|
||
<div class="space-y-2">
|
||
<Label for="event-ends">Bis</Label>
|
||
<Input id="event-ends" type="date" bind:value={form.ends} min={form.starts} />
|
||
</div>
|
||
{/if}
|
||
|
||
{#if formError}
|
||
<p class="text-sm text-destructive">{formError}</p>
|
||
{/if}
|
||
|
||
<Dialog.Footer>
|
||
<Button type="button" variant="ghost" onclick={() => (showDialog = false)}>Abbrechen</Button>
|
||
<Button type="submit">{editing ? 'Speichern' : 'Erstellen'}</Button>
|
||
</Dialog.Footer>
|
||
</form>
|
||
</Dialog.Content>
|
||
</Dialog.Root>
|