feat: Einstellungen als Reiter, Fahrerverzeichnis, datierte Events

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
This commit is contained in:
Daniel Michelberger 2026-09-02 12:56:01 +02:00
parent 1cd708e0ff
commit 7c3cf29354
17 changed files with 1568 additions and 467 deletions

View file

@ -35,7 +35,7 @@
</DropdownMenu.Item>
{/each}
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={() => goto('/dashboard/teams')}>
<DropdownMenu.Item onclick={() => goto('/dashboard/settings/team')}>
<Users class="h-4 w-4 mr-2" />
Teams verwalten
</DropdownMenu.Item>

View file

@ -2,6 +2,7 @@ import { getContext, setContext } from 'svelte'
import { api } from './pocketbase.svelte'
import { app } from './app.svelte'
import { getTeamContext } from './teams.svelte'
import { dayOf, todayISO } from '$lib/time'
import type { EventsResponse } from '$lib/types'
const KEY = Symbol('event')
@ -87,14 +88,56 @@ export class EventStore {
return this.records.find((r) => r.id === id)
}
get active() {
return this.scoped.filter((r) => r.status === 'active')
/**
* Nach Datum sortiert, das nächste zuerst. Events ohne Datum hängen
* hinten dran sie sind noch nicht terminiert.
*/
get byDate(): EventsResponse[] {
return [...this.scoped].sort((a, b) => {
const da = dayOf(a.starts)
const db = dayOf(b.starts)
if (!da && !db) return 0
if (!da) return 1
if (!db) return -1
return da < db ? -1 : da > db ? 1 : 0
})
}
get drafts() {
return this.scoped.filter((r) => r.status === 'draft')
/**
* Läuft heute. Ohne Enddatum zählt der Starttag allein genau dann ist
* ein eintägiges Event heute".
*/
get active(): EventsResponse[] {
const today = todayISO()
return this.byDate.filter((r) => {
const from = dayOf(r.starts)
if (!from) return false
const to = dayOf(r.ends) || from
return from <= today && today <= to
})
}
get finished() {
return this.scoped.filter((r) => r.status === 'finished')
/** Steht noch an. */
get upcoming(): EventsResponse[] {
const today = todayISO()
return this.byDate.filter((r) => dayOf(r.starts) > today)
}
/** Vorbei — der letzte Tag liegt hinter uns. */
get past(): EventsResponse[] {
const today = todayISO()
return this.byDate
.filter((r) => {
const from = dayOf(r.starts)
if (!from) return false
return (dayOf(r.ends) || from) < today
})
.reverse()
}
/** Noch ohne Termin. */
get undated(): EventsResponse[] {
return this.byDate.filter((r) => !dayOf(r.starts))
}
destroy() {

View file

@ -2,7 +2,7 @@ import { getContext, setContext } from 'svelte'
import { api } from './pocketbase.svelte'
import { app } from './app.svelte'
import { getTeamContext } from './teams.svelte'
import type { RidersResponse } from '$lib/types'
import type { RidersResponse, UsersResponse } from '$lib/types'
const KEY = Symbol('rider')
@ -34,6 +34,15 @@ export class RiderStore {
return this.records.filter((r) => r.team === teamId)
}
/**
* Anlegen, ändern und löschen darf nur die Teamleitung dieselbe Regel
* wie in createRule/updateRule/deleteRule der Collection. Die Prüfung
* hier blendet nur UI aus; durchgesetzt wird sie serverseitig.
*/
get canManage(): boolean {
return this.teams.isAdmin()
}
async load() {
this.loading = true
this.error = null
@ -102,6 +111,34 @@ export class RiderStore {
return this.records.find((r) => r.id === id)
}
/** Fahrer, der zu einem Login gehört — für „das bin ich"-Ansichten. */
getByUser(userId: string) {
return this.scoped.filter((r) => r.user === userId)
}
/** Bestehendes Konto zuordnen oder die Zuordnung lösen. */
async linkUser(riderId: string, userId: string | null) {
return await this.edit(riderId, { user: userId ?? '' } as Partial<RidersResponse>)
}
/**
* Legt dem Fahrer ein Login an und verknüpft es. Das Konto entsteht im
* Team-Store es ist zuerst ein Teammitglied und erst dann dieser Fahrer.
*/
async createLogin(
rider: RidersResponse,
data: { email: string; password: string; name?: string },
): Promise<UsersResponse> {
const user = await this.teams.createMember({
...data,
name: data.name?.trim() || fullName(rider),
})
await this.linkUser(rider.id, user.id)
return user
}
getByEvent(eventId: string) {
return this.scoped.filter((r) => r.event === eventId)
}

View file

@ -12,6 +12,13 @@ export class TeamStore {
loading = $state(false)
error = $state<string | null>(null)
/**
* Aufgelöste User-Datensätze, nach ID. Ein Team kennt nur die IDs seiner
* Mitglieder; die Namen holt sich die Oberfläche einzeln nach. Cache,
* damit dieselbe ID nicht auf jeder Seite erneut geladen wird.
*/
users = $state<Record<string, UsersResponse>>({})
private unsubscribe: (() => void) | null = null
get active(): TeamsResponse | null {
@ -168,6 +175,63 @@ export class TeamStore {
})
}
/**
* Einen User nachladen. Sichtbar ist ein fremder Datensatz nur über seine
* ID (users.viewRule); durchblättern lässt sich die Collection nicht.
*/
async loadUser(id: string): Promise<UsersResponse | null> {
if (this.users[id]) return this.users[id]
try {
const u = await api.collection('users').getOne(id, { requestKey: null })
this.users = { ...this.users, [id]: u }
return u
} catch {
return null
}
}
/** Alle Mitglieder eines Teams in den Cache holen. */
async loadMembers(team: TeamsResponse | null = this.active) {
if (!team) return
const ids = [...(team.users ?? []), team.owner].filter(Boolean) as string[]
await Promise.all(ids.filter((id) => !this.users[id]).map((id) => this.loadUser(id)))
}
/** Anzeigename zu einer User-ID; fällt auf eine gekürzte ID zurück. */
userLabel(id: string): string {
const u = this.users[id]
if (!u) return id.slice(0, 6) + '…'
return u.name || u.email || id.slice(0, 6) + '…'
}
/**
* Legt ein Login an und nimmt es ins Team auf. Zwei Schritte, weil die
* REST-API keine Transaktion kennt: Scheitert die Aufnahme, existiert das
* Konto bereits der Aufrufer meldet den Fehler, statt still
* weiterzumachen.
*
* Erlaubt ist das seit 1754500500_riders_user_and_admin_rules.js jedem
* eingeloggten Nutzer (users.createRule); eine öffentliche Registrierung
* gibt es weiterhin nicht.
*/
async createMember(data: { email: string; password: string; name?: string }, teamId = this.activeId) {
if (!teamId) throw new Error('Kein aktives Team')
const user = (await api.collection('users').create({
email: data.email.trim(),
emailVisibility: false,
password: data.password,
passwordConfirm: data.password,
name: data.name?.trim() || data.email.trim(),
})) as UsersResponse
this.users = { ...this.users, [user.id]: user }
await this.addMember(teamId, user.id)
return user
}
/** User per E-Mail suchen (für Einladungs-UI) */
async findUserByEmail(email: string): Promise<UsersResponse | null> {
try {

View file

@ -28,3 +28,45 @@ export function formatDateTime(iso: string | undefined): string {
if (!iso) return '—'
return new Date(iso).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
}
/** Heutiges Datum als `JJJJ-MM-TT` in lokaler Zeit (nicht UTC). */
export function todayISO(): string {
const d = new Date()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${m}-${day}`
}
/**
* Datumsanteil eines PocketBase-Werts. Die kommen als
* `2026-09-12 00:00:00.000Z` zurück; für Vergleiche und `<input type="date">`
* zählt nur der Tag.
*/
export function dayOf(value: string | undefined): string {
if (!value) return ''
return value.slice(0, 10)
}
/** Ein Tag als `TT.MM.JJJJ`. */
export function formatDay(value: string | undefined): string {
const day = dayOf(value)
if (!day) return ''
const [y, m, d] = day.split('-')
return `${d}.${m}.${y}`
}
/**
* Zeitraum eines Events. Ohne Enddatum oder bei gleichem Tag steht nur ein
* Datum da; sonst 12.09.2026 14.09.2026".
*/
export function formatDateRange(starts: string | undefined, ends: string | undefined): string {
const from = formatDay(starts)
const to = formatDay(ends)
if (!from && !to) return ''
if (!from) return to
if (!to || to === from) return from
return `${from} ${to}`
}

View file

@ -16,7 +16,7 @@
import Logo from '$lib/components/Logo.svelte'
import TeamSwitcher from '$lib/components/TeamSwitcher.svelte'
import RunningTimesToast from '$lib/components/RunningTimesToast.svelte'
import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Route, Settings } from 'lucide-svelte'
import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Route, Settings, Bike } from 'lucide-svelte'
import { toggleMode, mode } from 'mode-watcher'
import { onDestroy, onMount } from 'svelte'
@ -88,8 +88,9 @@
const nav = [
{ href: '/dashboard', icon: Home, label: 'Dashboard' },
{ href: '/dashboard/events', icon: Calendar, label: 'Events' },
{ href: '/dashboard/riders', icon: Bike, label: 'Fahrer' },
{ href: '/dashboard/trails', icon: Route, label: 'Trails' },
{ href: '/dashboard/settings/flags', icon: Settings, label: 'Einstellungen' },
{ href: '/dashboard/settings', icon: Settings, label: 'Einstellungen' },
]
</script>
@ -97,42 +98,59 @@
<div class="min-h-screen bg-background">
<header class="border-b bg-card">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<div class="flex items-center gap-2">
<Logo />
<div class="hidden md:block">
<TeamSwitcher />
</div>
<Button variant="ghost" size="icon" onclick={toggleMode} title="Design wechseln">
{#if mode.current === 'dark'}
<Sun class="h-4 w-4" />
{:else}
<Moon class="h-4 w-4" />
{/if}
</Button>
<div class="flex min-h-16 items-center justify-between gap-4 py-2">
<!-- Links: Logo und Menü. Das Logo fuellt seine Box, in
voller Groesse wuerde es die 64 px hohe Leiste
ueberragen und beschnitten wirken. -->
<div class="flex items-center gap-3 min-w-0">
<Logo class="h-11 w-11 shrink-0" />
<nav class="hidden md:flex items-center gap-1">
{#each nav as item}
{@const Icon = item.icon}
<a
href={item.href}
class="inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<Icon class="h-4 w-4" />
<span>{item.label}</span>
</a>
{/each}
</nav>
</div>
<nav class="hidden md:flex items-center gap-1">
{#each nav as item}
{@const Icon = item.icon}
<a
href={item.href}
class="inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<Icon class="h-4 w-4" />
<span>{item.label}</span>
</a>
{/each}
</nav>
<!-- Rechts eine Spalte: Name samt Design und Logout in einer
Zeile, das Team-Dropdown darunter. -->
<div class="flex items-center gap-2">
<div class="hidden md:flex items-center gap-2">
<div class="text-sm text-right">
<p class="font-medium leading-tight">{auth.user.name || auth.user.email}</p>
<div class="hidden md:flex flex-col items-end gap-1">
<div class="flex items-center gap-1">
<p class="text-sm font-medium leading-none">
{auth.user.name || auth.user.email}
</p>
<Button
variant="ghost"
size="icon"
class="size-7"
onclick={toggleMode}
title="Design wechseln"
>
{#if mode.current === 'dark'}
<Sun class="h-4 w-4" />
{:else}
<Moon class="h-4 w-4" />
{/if}
</Button>
<Button
variant="ghost"
size="icon"
class="size-7"
onclick={handleLogout}
title="Abmelden"
>
<LogOut class="h-4 w-4" />
</Button>
</div>
<Button variant="ghost" size="icon" onclick={handleLogout} title="Abmelden">
<LogOut class="h-4 w-4" />
</Button>
<TeamSwitcher />
</div>
<Button
@ -167,13 +185,25 @@
</a>
{/each}
<Separator class="my-2" />
<div class="flex items-center justify-between px-3 py-2">
<div class="text-sm">
<p class="font-medium leading-tight">{auth.user.name || auth.user.email}</p>
<div class="flex items-center justify-between gap-2 px-3 py-2">
<div class="flex flex-col items-start gap-1 min-w-0">
<p class="text-sm font-medium leading-none truncate">
{auth.user.name || auth.user.email}
</p>
<TeamSwitcher />
</div>
<div class="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" onclick={toggleMode} title="Design wechseln">
{#if mode.current === 'dark'}
<Sun class="h-4 w-4" />
{:else}
<Moon class="h-4 w-4" />
{/if}
</Button>
<Button variant="ghost" size="icon" onclick={handleLogout} title="Abmelden">
<LogOut class="h-4 w-4" />
</Button>
</div>
<Button variant="ghost" size="icon" onclick={handleLogout}>
<LogOut class="h-4 w-4" />
</Button>
</div>
</nav>
</div>

View file

@ -8,6 +8,7 @@
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { formatDateRange } from '$lib/time'
import { Calendar, Users, Clock, MapPin, Route, UsersRound } from 'lucide-svelte'
const teams = getTeamContext()
@ -16,6 +17,10 @@
const riders = getRiderContext()
const times = getTimeContext()
// „Aktiv" heißt jetzt: findet heute statt. Für die Startseite ist das
// zusammen mit dem Anstehenden interessant.
const upNext = $derived([...events.active, ...events.upcoming].slice(0, 4))
const stats = $derived({
totalEvents: events.scoped.length,
activeEvents: events.active.length,
@ -37,7 +42,7 @@
Team <span class="font-medium text-foreground">{teams.active.name}</span> · {stats.totalEvents} Events, {stats.totalRiders} Fahrer
</p>
{:else}
<p class="mt-2 text-muted-foreground">Kein Team aktiv. <a href="/dashboard/teams" class="underline">Team verwalten</a>.</p>
<p class="mt-2 text-muted-foreground">Kein Team aktiv. <a href="/dashboard/settings/team" class="underline">Team verwalten</a>.</p>
{/if}
</div>
@ -49,7 +54,7 @@
</CardHeader>
<CardContent>
<div class="text-3xl font-bold">{stats.totalEvents}</div>
<p class="text-xs text-muted-foreground mt-1">{stats.activeEvents} aktiv</p>
<p class="text-xs text-muted-foreground mt-1">{stats.activeEvents} heute</p>
</CardContent>
</Card>
@ -94,7 +99,7 @@
</a>
</Card>
<Card class="hover:border-primary transition-colors">
<a href="/dashboard/teams" class="block p-6">
<a href="/dashboard/settings/team" class="block p-6">
<UsersRound class="h-6 w-6 text-muted-foreground mb-3" />
<h3 class="font-semibold">Teams</h3>
<p class="text-sm text-muted-foreground mt-1">Mitglieder und Rechte</p>
@ -104,27 +109,31 @@
</div>
<div>
<h2 class="text-xl font-semibold mb-4">Aktive Events</h2>
{#if events.active.length === 0}
<h2 class="text-xl font-semibold mb-4">Heute und demnächst</h2>
{#if upNext.length === 0}
<Card>
<CardContent class="py-12 text-center">
<Calendar class="h-10 w-10 text-muted-foreground mx-auto mb-3" />
<p class="font-medium">Keine aktiven Events</p>
<p class="font-medium">Nichts geplant</p>
<p class="text-sm text-muted-foreground mt-1">
Aktiviere ein Event über die Eventliste, um mit der Zeitnahme zu beginnen.
Trag einem Event ein Datum ein, dann steht es hier.
</p>
<Button href="/dashboard/events" variant="outline" class="mt-4">Events ansehen</Button>
</CardContent>
</Card>
{:else}
<div class="grid gap-3 sm:grid-cols-2">
{#each events.active as event}
{#each upNext as event}
{@const eventRuns = runs.getByEvent(event.id).length}
<Card>
<CardHeader>
<div class="flex items-start justify-between gap-2">
<CardTitle>{event.name || event.description || 'Unbenannt'}</CardTitle>
<Badge>Aktiv</Badge>
{#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">

View file

@ -7,25 +7,24 @@
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog'
import * as Select from '@/components/ui/select'
import { dayOf, formatDateRange } from '$lib/time'
import { Calendar, MapPin, Plus, Pencil, Trash2 } from 'lucide-svelte'
import type { EventsResponse, EventsStatusOptions } from '$lib/types'
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: string
description: string
location: string
status: EventsStatusOptions
}>({ name: '', description: '', location: '', status: 'draft' as EventsStatusOptions })
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: '', status: 'draft' as EventsStatusOptions }
form = { name: '', description: '', location: '', starts: '', ends: '' }
multiDay = false
formError = null
showDialog = true
}
@ -35,29 +34,47 @@
name: e.name ?? '',
description: e.description ?? '',
location: e.location ?? '',
status: e.status ?? ('draft' as EventsStatusOptions),
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 {
if (editing) await events.edit(editing.id, form)
else await events.create(form)
// 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) {
alert(`Fehler: ${e.message}`)
formError = e.message ?? 'Das Event konnte nicht gespeichert werden.'
}
}
const statusLabels = { draft: 'Entwurf', active: 'Aktiv', finished: 'Abgeschlossen' }
const statusVariants = { draft: 'secondary', active: 'default', finished: 'outline' } as const
const groups = $derived([
{ title: 'Aktive Events', items: events.active, status: 'active' as const, showZero: false },
{ title: 'Entwürfe', items: events.drafts, status: 'draft' as const, showZero: false },
{ title: 'Abgeschlossen', items: events.finished, status: 'finished' as const, showZero: false },
{ title: 'Läuft gerade', items: events.active },
{ title: 'Anstehend', items: events.upcoming },
{ title: 'Ohne Termin', items: events.undated },
{ title: 'Vorbei', items: events.past },
])
</script>
@ -102,9 +119,11 @@
<CardHeader>
<div class="flex items-start justify-between gap-2">
<CardTitle class="leading-tight">{event.name || 'Unbenannt'}</CardTitle>
<Badge variant={statusVariants[event.status as keyof typeof statusVariants] ?? 'secondary'}>
{statusLabels[event.status as keyof typeof statusLabels] ?? 'Entwurf'}
</Badge>
{#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">
@ -168,19 +187,30 @@
<Input id="event-desc" bind:value={form.description} placeholder="Optionale Beschreibung" />
</div>
<div class="space-y-2">
<Label>Status</Label>
<Select.Root type="single" bind:value={form.status}>
<Select.Trigger>
{statusLabels[form.status]}
</Select.Trigger>
<Select.Content>
<Select.Item value="draft">Entwurf</Select.Item>
<Select.Item value="active">Aktiv</Select.Item>
<Select.Item value="finished">Abgeschlossen</Select.Item>
</Select.Content>
</Select.Root>
<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>

View file

@ -5,6 +5,8 @@
import { getRunContext } from '$lib/stores/runs.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.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'
@ -44,6 +46,9 @@
return `/dashboard/events/${event?.id}/runs/${runId}`
}
// Zeit-Dialog: der einzige Weg, eine Zeit zu starten oder zu stoppen.
let timeDialog = $state(false)
// Fahrer-Dialog
let riderDialog = $state(false)
let ridingEdit = $state<RidersResponse | null>(null)
@ -137,9 +142,12 @@
</a>
<div class="flex items-center gap-3">
<h1 class="text-3xl font-bold tracking-tight">{event.name || 'Unbenannt'}</h1>
<Badge variant={event.status === 'active' ? 'default' : event.status === 'finished' ? 'outline' : 'secondary'}>
{event.status === 'active' ? 'Aktiv' : event.status === 'finished' ? 'Abgeschlossen' : 'Entwurf'}
</Badge>
{#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">
@ -151,6 +159,14 @@
<p class="mt-2 text-sm text-muted-foreground">{event.description}</p>
{/if}
</div>
<Button
onclick={() => (timeDialog = true)}
disabled={eventRiders.length === 0 || eventRuns.length === 0}
>
<Timer class="h-4 w-4 mr-2" />
Zeit erfassen
</Button>
</div>
<Separator />
@ -166,10 +182,12 @@
</h2>
<p class="text-sm text-muted-foreground">Klick auf einen Fahrer zeigt seine Runs</p>
</div>
<Button onclick={openRiderCreate}>
<Plus class="h-4 w-4 mr-2" />
Fahrer hinzufügen
</Button>
{#if riders.canManage}
<Button onclick={openRiderCreate}>
<Plus class="h-4 w-4 mr-2" />
Fahrer hinzufügen
</Button>
{/if}
</div>
<Card>
@ -178,11 +196,17 @@
<div class="py-12 text-center">
<Users class="h-10 w-10 text-muted-foreground mx-auto mb-3" />
<p class="font-medium">Noch keine Fahrer</p>
<p class="text-sm text-muted-foreground mt-1 mb-4">Füge den ersten Fahrer hinzu.</p>
<Button onclick={openRiderCreate}>
<Plus class="h-4 w-4 mr-2" />
Ersten Fahrer hinzufügen
</Button>
{#if riders.canManage}
<p class="text-sm text-muted-foreground mt-1 mb-4">Füge den ersten Fahrer hinzu.</p>
<Button onclick={openRiderCreate}>
<Plus class="h-4 w-4 mr-2" />
Ersten Fahrer hinzufügen
</Button>
{:else}
<p class="text-sm text-muted-foreground mt-1">
Fahrer legt die Teamleitung an.
</p>
{/if}
</div>
{:else}
<Table.Root>
@ -215,12 +239,14 @@
{done} von {eventRuns.length} Runs
</Table.Cell>
<Table.Cell class="text-right" onclick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" onclick={() => openRiderEdit(rider)}>
<Pencil class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onclick={() => riders.remove(rider)}>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
{#if riders.canManage}
<Button variant="ghost" size="icon" onclick={() => openRiderEdit(rider)}>
<Pencil class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onclick={() => riders.remove(rider)}>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
{/if}
</Table.Cell>
</Table.Row>
{/each}
@ -370,3 +396,5 @@
</form>
</Dialog.Content>
</Dialog.Root>
<TimeDialog bind:open={timeDialog} eventId={event?.id ?? ''} />

View file

@ -12,12 +12,14 @@
import { getTimeContext } from '$lib/stores/times.svelte'
import { ticker } from '$lib/stores/ticker.svelte'
import { TIME_STATUS_LABEL, formatDuration } from '$lib/time'
import TimeDialog from '$lib/components/TimeDialog.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import * as Table from '@/components/ui/table'
import { ArrowLeft, ChevronRight, Timer, Users } from 'lucide-svelte'
const events = getEventContext()
const runs = getRunContext()
const riders = getRiderContext()
@ -59,6 +61,15 @@
})
})
// Start und Stopp laufen ausschließlich über den Zeit-Dialog.
let timeDialog = $state(false)
let dialogRun = $state<string | null>(null)
function openTimeDialog(runId: string | null = null) {
dialogRun = runId
timeDialog = true
}
const stats = $derived.by(() => {
const finished = rows.filter((r) => r.best)
const total = finished.reduce((sum, r) => sum + (r.best?.duration ?? 0), 0)
@ -88,20 +99,27 @@
</Card>
{:else if event && rider}
<div class="space-y-6">
<div>
<a
href="/dashboard/events/{event.id}"
class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2"
>
<ArrowLeft class="h-3 w-3" />
{event.name || 'Event'}
</a>
<div class="flex items-center gap-3">
<Badge variant="outline" class="text-lg px-3 py-1">{rider.number || '—'}</Badge>
<h1 class="text-3xl font-bold tracking-tight">
{fullName(rider) || 'Ohne Name'}
</h1>
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<a
href="/dashboard/events/{event.id}"
class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2"
>
<ArrowLeft class="h-3 w-3" />
{event.name || 'Event'}
</a>
<div class="flex items-center gap-3">
<Badge variant="outline" class="text-lg px-3 py-1">{rider.number || '—'}</Badge>
<h1 class="text-3xl font-bold tracking-tight">
{fullName(rider) || 'Ohne Name'}
</h1>
</div>
</div>
<Button onclick={() => openTimeDialog()} disabled={rows.length === 0}>
<Timer class="h-4 w-4 mr-2" />
Zeit erfassen
</Button>
</div>
<div class="grid gap-4 sm:grid-cols-3">
@ -215,3 +233,10 @@
</Card>
</div>
{/if}
<TimeDialog
bind:open={timeDialog}
eventId={event?.id ?? ''}
riderId={rider?.id ?? null}
runId={dialogRun}
/>

View file

@ -0,0 +1,143 @@
<script lang="ts">
/**
* Fahrerverzeichnis des Teams — für alle sichtbar, für niemanden hier
* änderbar. Anlegen, Bearbeiten und Kontoverknüpfung liegen in den
* Einstellungen und damit bei der Teamleitung; serverseitig setzen das
* die create/update/deleteRule der Collection durch.
*/
import { getTeamContext } from '$lib/stores/teams.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getEventContext } from '$lib/stores/events.svelte'
import { getTimeContext } from '$lib/stores/times.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import * as Table from '@/components/ui/table'
import { Search, Settings, UserCheck, Users } from 'lucide-svelte'
const teams = getTeamContext()
const riders = getRiderContext()
const events = getEventContext()
const times = getTimeContext()
let query = $state('')
const roster = $derived.by(() => {
const q = query.toLowerCase().trim()
if (!q) return riders.scoped
return riders.scoped.filter(
(r) =>
(r.number ?? '').toLowerCase().includes(q) ||
fullName(r).toLowerCase().includes(q),
)
})
$effect(() => {
teams.loadMembers()
})
function eventName(id: string | undefined) {
if (!id) return null
return events.getById(id)?.name ?? 'Unbekanntes Event'
}
/** Zur Fahrerseite geht es nur mit Event — Zeiten hängen daran. */
function riderPath(r: { id: string; event?: string }) {
return r.event ? `/dashboard/events/${r.event}/riders/${r.id}` : null
}
</script>
<svelte:head><title>Fahrer</title></svelte:head>
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 class="text-3xl font-bold tracking-tight">Fahrer</h1>
<p class="mt-1 text-muted-foreground">
{riders.scoped.length} im Team {teams.active?.name ?? ''}
</p>
</div>
{#if riders.canManage}
<Button variant="outline" href="/dashboard/settings/riders">
<Settings class="size-4 mr-2" />
Verwalten
</Button>
{/if}
</div>
<div class="relative max-w-sm">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input bind:value={query} placeholder="Nummer oder Name…" class="pl-9" />
</div>
{#if riders.scoped.length === 0}
<Card>
<CardContent class="py-16 text-center">
<Users class="size-10 text-muted-foreground mx-auto mb-3" />
<p class="font-medium">Noch keine Fahrer</p>
<p class="text-sm text-muted-foreground mt-1">
{#if riders.canManage}
Leg sie in den <a href="/dashboard/settings/riders" class="underline">Einstellungen</a> an.
{:else}
Die Teamleitung legt Fahrer in den Einstellungen an.
{/if}
</p>
</CardContent>
</Card>
{:else if roster.length === 0}
<Card>
<CardContent class="py-12 text-center text-muted-foreground">Keine Treffer.</CardContent>
</Card>
{:else}
<Card>
<CardContent class="p-0">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-20">#</Table.Head>
<Table.Head>Name</Table.Head>
<Table.Head>Event</Table.Head>
<Table.Head class="text-right w-24">Zeiten</Table.Head>
<Table.Head class="w-48">Konto</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each roster as r (r.id)}
{@const path = riderPath(r)}
<Table.Row>
<Table.Cell>
<Badge variant="outline">{r.number || '—'}</Badge>
</Table.Cell>
<Table.Cell class="font-medium">
{#if path}
<a href={path} class="hover:underline">{fullName(r) || '— ohne Name —'}</a>
{:else}
{fullName(r) || '— ohne Name —'}
{/if}
</Table.Cell>
<Table.Cell class="text-muted-foreground text-sm">
{eventName(r.event) ?? '—'}
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground text-sm">
{times.getByRider(r.id).length}
</Table.Cell>
<Table.Cell>
{#if r.user}
<Badge variant="secondary" class="gap-1">
<UserCheck class="size-3" />
{teams.userLabel(r.user)}
</Badge>
{:else}
<span class="text-sm text-muted-foreground"></span>
{/if}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</CardContent>
</Card>
{/if}
</div>

View file

@ -0,0 +1,57 @@
<script lang="ts">
/**
* Rahmen der Einstellungen: eine Überschrift, darunter Reiter je Bereich.
* Der Reiter „Fahrer" erscheint nur für die Teamleitung — dort wird
* angelegt und verknüpft, was alle anderen nur unter „Fahrer" im
* Hauptmenü zu sehen bekommen.
*/
import { page } from '$app/state'
import { getTeamContext } from '$lib/stores/teams.svelte'
import { Flag, User, Users, Bike } from 'lucide-svelte'
let { children } = $props()
const teams = getTeamContext()
const tabs = $derived([
{ href: '/dashboard/settings', label: 'Profil', icon: User },
{ href: '/dashboard/settings/team', label: 'Team', icon: Users },
...(teams.isAdmin()
? [{ href: '/dashboard/settings/riders', label: 'Fahrer', icon: Bike }]
: []),
{ href: '/dashboard/settings/flags', label: 'Flag-Typen', icon: Flag },
])
// Das Profil liegt auf der Wurzel; ohne den Sonderfall wäre sein Reiter
// auf jedem Unterpfad mit aktiv.
function isActive(href: string) {
return href === '/dashboard/settings'
? page.url.pathname === href
: page.url.pathname.startsWith(href)
}
</script>
<svelte:head><title>Einstellungen</title></svelte:head>
<div class="space-y-6">
<h1 class="text-3xl font-bold tracking-tight">Einstellungen</h1>
<nav class="flex flex-wrap gap-1 border-b">
{#each tabs as tab (tab.href)}
{@const Icon = tab.icon}
<a
href={tab.href}
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors"
class:border-primary={isActive(tab.href)}
class:text-foreground={isActive(tab.href)}
class:border-transparent={!isActive(tab.href)}
class:text-muted-foreground={!isActive(tab.href)}
>
<Icon class="size-4" />
{tab.label}
</a>
{/each}
</nav>
{@render children?.()}
</div>

View file

@ -0,0 +1,191 @@
<script lang="ts">
/**
* Eigenes Profil. Die users-Collection erlaubt Änderungen nur am eigenen
* Datensatz (updateRule `id = @request.auth.id`) — was hier steht, kann
* also niemand für jemand anderen ändern.
*/
import { api, auth, getFileURL } from '$lib/stores/pocketbase.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { KeyRound, User } from 'lucide-svelte'
let name = $state(auth.user?.name ?? '')
let avatarFile = $state<File | null>(null)
let savingProfile = $state(false)
let profileMessage = $state<string | null>(null)
let profileError = $state<string | null>(null)
const avatarUrl = $derived(
auth.user?.avatar ? getFileURL(auth.user as object, auth.user.avatar as string) : null,
)
async function saveProfile() {
if (!auth.user) return
savingProfile = true
profileMessage = null
profileError = null
try {
// Mit Datei als FormData, sonst als einfaches Objekt — ein leeres
// avatar-Feld im FormData würde ein vorhandenes Bild löschen.
let data: FormData | Record<string, unknown>
if (avatarFile) {
const fd = new FormData()
fd.append('name', name.trim())
fd.append('avatar', avatarFile)
data = fd
} else {
data = { name: name.trim() }
}
await api.collection('users').update(auth.user.id, data)
await auth.refresh()
avatarFile = null
profileMessage = 'Profil gespeichert.'
} catch (e: any) {
profileError = e.message ?? 'Das Profil konnte nicht gespeichert werden.'
} finally {
savingProfile = false
}
}
let oldPassword = $state('')
let newPassword = $state('')
let confirmPassword = $state('')
let savingPassword = $state(false)
let passwordMessage = $state<string | null>(null)
let passwordError = $state<string | null>(null)
async function savePassword() {
if (!auth.user) return
if (newPassword.length < 8) {
passwordError = 'Das neue Passwort braucht mindestens 8 Zeichen.'
return
}
if (newPassword !== confirmPassword) {
passwordError = 'Die Wiederholung stimmt nicht überein.'
return
}
savingPassword = true
passwordMessage = null
passwordError = null
try {
await api.collection('users').update(auth.user.id, {
oldPassword,
password: newPassword,
passwordConfirm: confirmPassword,
})
// PocketBase entwertet mit dem Passwortwechsel alle Tokens; ohne
// neue Anmeldung wäre die Sitzung ab hier tot.
await api.collection('users').authWithPassword(auth.user.email as string, newPassword)
if (auth.cookie) {
document.cookie = api.authStore.exportToCookie({ httpOnly: false })
}
oldPassword = newPassword = confirmPassword = ''
passwordMessage = 'Passwort geändert.'
} catch (e: any) {
passwordError = e.message ?? 'Das Passwort konnte nicht geändert werden.'
} finally {
savingPassword = false
}
}
</script>
<div class="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<User class="size-4 text-primary" />
Konto
</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-center gap-4">
{#if avatarUrl}
<img src={avatarUrl} alt="" class="size-16 rounded-full object-cover border" />
{:else}
<div class="size-16 rounded-full border grid place-items-center text-muted-foreground">
<User class="size-6" />
</div>
{/if}
<div class="space-y-1">
<Label for="avatar">Bild</Label>
<input
id="avatar"
type="file"
accept="image/*"
class="block w-full text-sm"
onchange={(e) => (avatarFile = e.currentTarget.files?.[0] ?? null)}
/>
</div>
</div>
<div class="space-y-2">
<Label for="profile-name">Name</Label>
<Input id="profile-name" bind:value={name} placeholder="Wie du angezeigt wirst" />
</div>
<div class="space-y-2">
<Label for="profile-mail">E-Mail</Label>
<Input id="profile-mail" value={auth.user?.email ?? ''} readonly disabled />
<p class="text-xs text-muted-foreground">
Die E-Mail-Adresse ist der Anmeldename. Ändern kann sie nur die
Team-Administration über PocketBase.
</p>
</div>
{#if profileError}
<p class="text-sm text-destructive">{profileError}</p>
{:else if profileMessage}
<p class="text-sm text-muted-foreground">{profileMessage}</p>
{/if}
<Button onclick={saveProfile} disabled={savingProfile}>
{savingProfile ? 'Wird gespeichert …' : 'Speichern'}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<KeyRound class="size-4 text-primary" />
Passwort ändern
</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="pw-old">Aktuelles Passwort</Label>
<Input id="pw-old" type="password" bind:value={oldPassword} autocomplete="current-password" />
</div>
<div class="space-y-2">
<Label for="pw-new">Neues Passwort</Label>
<Input id="pw-new" type="password" bind:value={newPassword} autocomplete="new-password" />
</div>
<div class="space-y-2">
<Label for="pw-confirm">Wiederholen</Label>
<Input id="pw-confirm" type="password" bind:value={confirmPassword} autocomplete="new-password" />
</div>
{#if passwordError}
<p class="text-sm text-destructive">{passwordError}</p>
{:else if passwordMessage}
<p class="text-sm text-muted-foreground">{passwordMessage}</p>
{/if}
<Button
onclick={savePassword}
disabled={savingPassword || !oldPassword || !newPassword}
>
{savingPassword ? 'Wird geändert …' : 'Passwort ändern'}
</Button>
</CardContent>
</Card>
</div>

View file

@ -7,6 +7,8 @@
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog'
import FlagIcon from '$lib/components/FlagIcon.svelte'
import { FLAG_ICON_SUGGESTIONS, iconExists } from '$lib/flagIcons'
import { Plus, Pencil, Trash2, Sparkles } from 'lucide-svelte'
import type { TrailFlagsResponse } from '$lib/types'
@ -104,10 +106,9 @@
}
</script>
<div class="space-y-6">
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold">Flag-Typen</h1>
<p class="text-muted-foreground text-sm">
Wofür sich Marker auf einem Trail setzen lassen.
</p>
@ -149,7 +150,7 @@
{#each flags.scoped as flag (flag.id)}
<Card>
<CardContent class="py-3 flex items-center gap-3">
<span class="size-4 rounded-full shrink-0" style:background={flag.color}></span>
<FlagIcon name={flag.icon} color={flag.color} class="size-5" />
<span class="font-medium flex-1">{flag.label}</span>
<Badge variant={flag.severity === 'kritisch' ? 'destructive' : 'secondary'}>
{flag.severity}
@ -203,10 +204,38 @@
</div>
<div class="space-y-2">
<Label for="flag-icon">Icon</Label>
<Input id="flag-icon" bind:value={form.icon} placeholder="Lucide-Name, z. B. TreePine" />
<div class="flex items-center gap-2">
<span class="grid size-9 place-items-center rounded-md border">
<FlagIcon name={form.icon} color={form.color} class="size-5" />
</span>
<Input id="flag-icon" bind:value={form.icon} placeholder="z. B. TreePine" class="flex-1" />
</div>
<p class="text-xs text-muted-foreground">
Name eines Icons von lucide.dev, in Schreibweise wie „TreePine".
Jeder Name von <a href="https://lucide.dev/icons" target="_blank" rel="noreferrer" class="underline">lucide.dev</a>
ist möglich — geschrieben wie „TreePine" oder „tree-pine".
{#if form.icon.trim() && !iconExists(form.icon)}
<span class="text-destructive">Diesen Namen gibt es dort nicht.</span>
{/if}
</p>
<p class="text-xs text-muted-foreground pt-1">Vorschläge:</p>
<div class="grid grid-cols-10 gap-1 rounded-lg border p-2">
{#each FLAG_ICON_SUGGESTIONS as name (name)}
<button
type="button"
title={name}
aria-label={name}
aria-pressed={form.icon === name}
class="grid place-items-center rounded-md p-1.5 hover:bg-accent"
class:bg-accent={form.icon === name}
class:ring-2={form.icon === name}
class:ring-primary={form.icon === name}
onclick={() => (form.icon = name)}
>
<FlagIcon {name} color={form.color} class="size-4" />
</button>
{/each}
</div>
</div>
{#if error}
<p class="text-sm text-destructive">{error}</p>

View file

@ -0,0 +1,385 @@
<script lang="ts">
/**
* Fahrerverwaltung — nur für die Teamleitung. Was alle sehen dürfen,
* steht unter „Fahrer" im Hauptmenü; angelegt, geändert und mit einem
* Login verknüpft wird ausschließlich hier.
*/
import { getTeamContext } from '$lib/stores/teams.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getEventContext } from '$lib/stores/events.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent } 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 Select from '@/components/ui/select'
import * as Table from '@/components/ui/table'
import { KeyRound, Pencil, Plus, Trash2, Unlink, UserCheck } from 'lucide-svelte'
import type { RidersResponse } from '$lib/types'
const teams = getTeamContext()
const riders = getRiderContext()
const events = getEventContext()
const canManage = $derived(riders.canManage)
const roster = $derived(riders.scoped)
$effect(() => {
teams.loadMembers()
})
function eventName(id: string | undefined) {
if (!id) return null
return events.getById(id)?.name ?? 'Unbekanntes Event'
}
// --- Fahrer anlegen und ändern ---------------------------------------
let riderDialog = $state(false)
let editing = $state<RidersResponse | null>(null)
let form = $state({ number: '', firstname: '', lastname: '', event: '' })
let riderError = $state<string | null>(null)
function openCreate() {
editing = null
form = { number: '', firstname: '', lastname: '', event: '' }
riderError = null
riderDialog = true
}
function openEdit(r: RidersResponse) {
editing = r
form = {
number: r.number ?? '',
firstname: r.firstname ?? '',
lastname: r.lastname ?? '',
event: r.event ?? '',
}
riderError = null
riderDialog = true
}
async function saveRider() {
if (!form.number.trim()) {
riderError = 'Die Startnummer ist Pflicht.'
return
}
// Startnummern sind je Event eindeutig; Fahrer ohne Event teilen sich
// den gemeinsamen Topf des Teams.
if (riders.isNumberTaken(form.number.trim(), form.event, editing?.id)) {
riderError = `Startnummer ${form.number} ist dort bereits vergeben.`
return
}
riderError = null
try {
const data = {
number: form.number.trim(),
firstname: form.firstname.trim(),
lastname: form.lastname.trim(),
event: form.event,
}
if (editing) await riders.edit(editing.id, data)
else await riders.create(data)
riderDialog = false
} catch (e: any) {
riderError = e.message ?? 'Der Fahrer konnte nicht gespeichert werden.'
}
}
// --- Login verknüpfen -------------------------------------------------
let loginDialog = $state(false)
let loginRider = $state<RidersResponse | null>(null)
let loginMode = $state<'link' | 'create'>('link')
let linkUserId = $state('')
let loginForm = $state({ email: '', password: '' })
let loginBusy = $state(false)
let loginError = $state<string | null>(null)
/** Teammitglieder, die noch an keinem anderen Fahrer hängen. */
const linkCandidates = $derived.by(() => {
const taken = new Set(
roster.filter((r) => r.user && r.id !== loginRider?.id).map((r) => r.user),
)
return (teams.active?.users ?? []).filter((id) => !taken.has(id))
})
function openLogin(r: RidersResponse) {
loginRider = r
loginMode = 'link'
linkUserId = r.user ?? ''
loginForm = { email: '', password: '' }
loginError = null
loginDialog = true
}
async function doLink() {
if (!loginRider || !linkUserId) return
loginBusy = true
loginError = null
try {
await riders.linkUser(loginRider.id, linkUserId)
loginDialog = false
} catch (e: any) {
loginError = e.message ?? 'Die Verknüpfung ist fehlgeschlagen.'
} finally {
loginBusy = false
}
}
async function doUnlink(r: RidersResponse) {
try {
await riders.linkUser(r.id, null)
} catch (e: any) {
alert(e.message ?? 'Die Verknüpfung konnte nicht gelöst werden.')
}
}
async function doCreateLogin() {
if (!loginRider) return
if (!loginForm.email.trim()) {
loginError = 'Ohne E-Mail-Adresse gibt es keinen Anmeldenamen.'
return
}
if (loginForm.password.length < 8) {
loginError = 'Das Passwort braucht mindestens 8 Zeichen.'
return
}
loginBusy = true
loginError = null
try {
await riders.createLogin(loginRider, { ...loginForm })
loginDialog = false
} catch (e: any) {
loginError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
} finally {
loginBusy = false
}
}
</script>
{#if !canManage}
<Card>
<CardContent class="py-12 text-center text-muted-foreground">
Fahrer verwaltet, wer das Team führt. Die Fahrer deines Teams siehst du
unter <a href="/dashboard/riders" class="underline">Fahrer</a>.
</CardContent>
</Card>
{:else}
<div class="space-y-4">
<div class="flex items-center justify-between gap-4">
<p class="text-sm text-muted-foreground">
Fahrer des Teams. Ein verknüpftes Konto erlaubt der Person, sich selbst
anzumelden.
</p>
<Button onclick={openCreate}>
<Plus class="size-4 mr-2" />
Fahrer anlegen
</Button>
</div>
{#if roster.length === 0}
<Card>
<CardContent class="py-12 text-center text-muted-foreground">
Noch keine Fahrer im Team.
</CardContent>
</Card>
{:else}
<Card>
<CardContent class="p-0">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-20">#</Table.Head>
<Table.Head>Name</Table.Head>
<Table.Head>Event</Table.Head>
<Table.Head>Konto</Table.Head>
<Table.Head class="text-right">Aktionen</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each roster as r (r.id)}
<Table.Row>
<Table.Cell>
<Badge variant="outline">{r.number || '—'}</Badge>
</Table.Cell>
<Table.Cell class="font-medium">
{fullName(r) || '— ohne Name —'}
</Table.Cell>
<Table.Cell class="text-muted-foreground text-sm">
{eventName(r.event) ?? '—'}
</Table.Cell>
<Table.Cell>
{#if r.user}
<span class="inline-flex items-center gap-2">
<Badge variant="secondary" class="gap-1">
<UserCheck class="size-3" />
kann sich einloggen
</Badge>
<span class="text-sm text-muted-foreground">
{teams.userLabel(r.user)}
</span>
</span>
{:else}
<span class="text-sm text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell class="text-right whitespace-nowrap">
{#if r.user}
<Button variant="ghost" size="icon" title="Verknüpfung lösen" onclick={() => doUnlink(r)}>
<Unlink class="size-4" />
</Button>
{:else}
<Button variant="ghost" size="icon" title="Konto verknüpfen" onclick={() => openLogin(r)}>
<KeyRound class="size-4" />
</Button>
{/if}
<Button variant="ghost" size="icon" title="Bearbeiten" onclick={() => openEdit(r)}>
<Pencil class="size-4" />
</Button>
<Button variant="ghost" size="icon" title="Löschen" onclick={() => riders.remove(r)}>
<Trash2 class="size-4 text-destructive" />
</Button>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</CardContent>
</Card>
{/if}
</div>
{/if}
<!-- Fahrer anlegen/bearbeiten -->
<Dialog.Root bind:open={riderDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{editing ? 'Fahrer bearbeiten' : 'Neuer Fahrer'}</Dialog.Title>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="rider-number">Startnummer</Label>
<Input id="rider-number" bind:value={form.number} placeholder="42" />
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-2">
<Label for="rider-first">Vorname</Label>
<Input id="rider-first" bind:value={form.firstname} placeholder="Max" />
</div>
<div class="space-y-2">
<Label for="rider-last">Nachname</Label>
<Input id="rider-last" bind:value={form.lastname} placeholder="Mustermann" />
</div>
</div>
<div class="space-y-2">
<Label>Event</Label>
<Select.Root type="single" bind:value={form.event}>
<Select.Trigger class="w-full">
{eventName(form.event) ?? 'Keinem Event zugeordnet'}
</Select.Trigger>
<Select.Content>
<Select.Item value="">Keinem Event zugeordnet</Select.Item>
{#each events.scoped as e (e.id)}
<Select.Item value={e.id}>{e.name || e.description}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<p class="text-xs text-muted-foreground">
Zeiten hängen am Event. Ohne Zuordnung steht der Fahrer nur im
Team-Verzeichnis.
</p>
</div>
{#if riderError}
<p class="text-sm text-destructive">{riderError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button>
<Button onclick={saveRider}>{editing ? 'Speichern' : 'Anlegen'}</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Konto verknüpfen oder anlegen -->
<Dialog.Root bind:open={loginDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Konto für {loginRider ? fullName(loginRider) : 'Fahrer'}</Dialog.Title>
<Dialog.Description>
Mit einem Konto kann sich die Person selbst anmelden.
</Dialog.Description>
</Dialog.Header>
<div class="flex gap-2">
<Button
variant={loginMode === 'link' ? 'default' : 'outline'}
size="sm"
onclick={() => (loginMode = 'link')}
>
Vorhandenes verknüpfen
</Button>
<Button
variant={loginMode === 'create' ? 'default' : 'outline'}
size="sm"
onclick={() => (loginMode = 'create')}
>
Neues anlegen
</Button>
</div>
<div class="space-y-4">
{#if loginMode === 'link'}
<div class="space-y-2">
<Label>Teammitglied</Label>
<Select.Root type="single" bind:value={linkUserId}>
<Select.Trigger class="w-full">
{linkUserId ? teams.userLabel(linkUserId) : 'Mitglied wählen…'}
</Select.Trigger>
<Select.Content>
{#each linkCandidates as id (id)}
<Select.Item value={id}>{teams.userLabel(id)}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if linkCandidates.length === 0}
<p class="text-xs text-muted-foreground">
Alle Teammitglieder sind bereits einem Fahrer zugeordnet.
</p>
{/if}
</div>
{:else}
<div class="space-y-2">
<Label for="login-mail">E-Mail</Label>
<Input id="login-mail" type="email" bind:value={loginForm.email} placeholder="max@example.com" />
</div>
<div class="space-y-2">
<Label for="login-pw">Passwort</Label>
<Input id="login-pw" type="password" bind:value={loginForm.password} placeholder="mindestens 8 Zeichen" />
<p class="text-xs text-muted-foreground">
Das Konto kommt zugleich ins Team. Gib das Passwort weiter; es lässt
sich unter „Profil" ändern.
</p>
</div>
{/if}
{#if loginError}
<p class="text-sm text-destructive">{loginError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (loginDialog = false)}>Abbrechen</Button>
{#if loginMode === 'link'}
<Button onclick={doLink} disabled={!linkUserId || loginBusy}>Verknüpfen</Button>
{:else}
<Button onclick={doCreateLogin} disabled={loginBusy}>
{loginBusy ? 'Wird angelegt …' : 'Konto anlegen'}
</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -0,0 +1,329 @@
<script lang="ts">
/**
* Team-Verwaltung. Owner und Admins ändern hier die Team-Daten und die
* Mitglieder; alle anderen sehen dieselbe Liste ohne Knöpfe.
*/
import { api } from '$lib/stores/pocketbase.svelte'
import { getTeamContext } from '$lib/stores/teams.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 { Crown, LogOut, Plus, Shield, Trash2, UserMinus, UserPlus, Users } from 'lucide-svelte'
import type { TeamsResponse } from '$lib/types'
const teams = getTeamContext()
const me = $derived(api.authStore.record?.id ?? null)
// Namen der Mitglieder nachladen — ein Team kennt nur deren IDs.
$effect(() => {
for (const t of teams.records) teams.loadMembers(t)
})
// ---- Team anlegen ----
let createDialog = $state(false)
let createName = $state('')
let createError = $state<string | null>(null)
async function doCreate() {
if (!createName.trim()) return
createError = null
try {
await teams.create(createName.trim())
createDialog = false
createName = ''
} catch (e: any) {
createError = e.message ?? 'Das Team konnte nicht angelegt werden.'
}
}
// ---- Team umbenennen ----
let renameDialog = $state(false)
let renameTeam = $state<TeamsResponse | null>(null)
let renameValue = $state('')
let renameError = $state<string | null>(null)
function openRename(t: TeamsResponse) {
renameTeam = t
renameValue = t.name
renameError = null
renameDialog = true
}
async function doRename() {
if (!renameTeam || !renameValue.trim()) return
renameError = null
try {
await teams.rename(renameTeam.id, renameValue.trim())
renameDialog = false
} catch (e: any) {
renameError = e.message ?? 'Der Name konnte nicht gespeichert werden.'
}
}
// ---- Mitglied anlegen ----
// Bestehende Konten lassen sich nicht suchen: users.listRule gibt nur den
// eigenen Datensatz frei, und das soll auch so bleiben. Ein neues Konto
// anzulegen deckt den Alltag ab — Vereinsmitglieder haben vorher keins.
let memberDialog = $state(false)
let memberTeam = $state<TeamsResponse | null>(null)
let memberForm = $state({ name: '', email: '', password: '' })
let memberBusy = $state(false)
let memberError = $state<string | null>(null)
function openMember(t: TeamsResponse) {
memberTeam = t
memberForm = { name: '', email: '', password: '' }
memberError = null
memberDialog = true
}
async function doCreateMember() {
if (!memberTeam) return
if (!memberForm.email.trim()) {
memberError = 'Ohne E-Mail-Adresse gibt es keinen Anmeldenamen.'
return
}
if (memberForm.password.length < 8) {
memberError = 'Das Passwort braucht mindestens 8 Zeichen.'
return
}
memberBusy = true
memberError = null
try {
await teams.createMember({ ...memberForm }, memberTeam.id)
memberDialog = false
} catch (e: any) {
memberError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
} finally {
memberBusy = false
}
}
// ---- Aktionen ----
async function doLeave(t: TeamsResponse) {
if (!confirm(`Team „${t.name}" wirklich verlassen?`)) return
try {
await teams.leave(t.id)
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
}
async function doRemoveMember(t: TeamsResponse, userId: string) {
if (userId === t.owner) return alert('Der Owner kann nicht entfernt werden.')
if (!confirm('Mitglied wirklich entfernen?')) return
try {
await teams.removeMember(t.id, userId)
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
}
</script>
<div class="space-y-4">
<div class="flex items-center justify-between gap-4">
<p class="text-sm text-muted-foreground">
Teams, in denen du bist. Das aktive Team bestimmt, welche Events, Fahrer und
Trails du siehst.
</p>
<Button onclick={() => (createDialog = true)}>
<Plus class="size-4 mr-2" />
Neues Team
</Button>
</div>
{#if teams.records.length === 0}
<Card>
<CardContent class="py-16 text-center">
<Users class="size-10 text-muted-foreground mx-auto mb-3" />
<p class="font-medium">Noch in keinem Team</p>
<p class="text-sm text-muted-foreground mt-1 mb-4">Erstelle dein erstes Team.</p>
<Button onclick={() => (createDialog = true)}>
<Plus class="size-4 mr-2" />
Team erstellen
</Button>
</CardContent>
</Card>
{:else}
{#each teams.records as t (t.id)}
{@const iAmOwner = t.owner === me}
{@const iAmAdmin = iAmOwner || (t.admins ?? []).includes(me ?? '')}
<Card>
<CardHeader>
<div class="flex flex-wrap items-start justify-between gap-2">
<div>
<CardTitle class="flex items-center gap-2">
{t.name}
{#if t.id === teams.activeId}
<Badge>Aktiv</Badge>
{/if}
</CardTitle>
<CardDescription>
{(t.users ?? []).length} Mitglied{(t.users ?? []).length === 1 ? '' : 'er'}
</CardDescription>
</div>
<div class="flex flex-wrap items-center gap-2">
{#if t.id !== teams.activeId}
<Button variant="outline" size="sm" onclick={() => teams.setActive(t.id)}>
Aktivieren
</Button>
{/if}
{#if iAmAdmin}
<Button variant="outline" size="sm" onclick={() => openRename(t)}>
Umbenennen
</Button>
<Button variant="outline" size="sm" onclick={() => openMember(t)}>
<UserPlus class="size-4 mr-1" />
Mitglied
</Button>
{/if}
{#if !iAmOwner}
<Button variant="ghost" size="sm" onclick={() => doLeave(t)}>
<LogOut class="size-4 mr-1" />
Verlassen
</Button>
{:else}
<Button variant="ghost" size="icon" title="Team löschen" onclick={() => teams.remove(t)}>
<Trash2 class="size-4 text-destructive" />
</Button>
{/if}
</div>
</div>
</CardHeader>
<CardContent>
<ul class="divide-y border-t">
{#each (t.users ?? []) as userId (userId)}
{@const isOwner = userId === t.owner}
{@const isAdm = (t.admins ?? []).includes(userId)}
<li class="py-3 flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<span class="font-medium truncate">{teams.userLabel(userId)}</span>
{#if isOwner}
<Badge class="gap-1">
<Crown class="size-3" />
Owner
</Badge>
{:else if isAdm}
<Badge variant="secondary" class="gap-1">
<Shield class="size-3" />
Admin
</Badge>
{/if}
{#if userId === me}
<Badge variant="outline">Du</Badge>
{/if}
</div>
{#if iAmOwner && !isOwner}
<div class="flex items-center gap-1 shrink-0">
{#if isAdm}
<Button variant="ghost" size="sm" onclick={() => teams.demoteFromAdmin(t.id, userId)}>
Admin entziehen
</Button>
{:else}
<Button variant="ghost" size="sm" onclick={() => teams.promoteToAdmin(t.id, userId)}>
Zu Admin
</Button>
{/if}
<Button variant="ghost" size="icon" title="Entfernen" onclick={() => doRemoveMember(t, userId)}>
<UserMinus class="size-4 text-destructive" />
</Button>
</div>
{/if}
</li>
{/each}
</ul>
</CardContent>
</Card>
{/each}
{/if}
</div>
<!-- Team anlegen -->
<Dialog.Root bind:open={createDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Neues Team</Dialog.Title>
<Dialog.Description>Du wirst automatisch Owner.</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="team-name">Name</Label>
<Input id="team-name" bind:value={createName} placeholder="Mein Team" />
</div>
{#if createError}
<p class="text-sm text-destructive">{createError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (createDialog = false)}>Abbrechen</Button>
<Button onclick={doCreate} disabled={!createName.trim()}>Erstellen</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Team umbenennen -->
<Dialog.Root bind:open={renameDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Team umbenennen</Dialog.Title>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="team-rename">Name</Label>
<Input id="team-rename" bind:value={renameValue} />
</div>
{#if renameError}
<p class="text-sm text-destructive">{renameError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (renameDialog = false)}>Abbrechen</Button>
<Button onclick={doRename} disabled={!renameValue.trim()}>Speichern</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Mitglied anlegen -->
<Dialog.Root bind:open={memberDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Mitglied anlegen</Dialog.Title>
<Dialog.Description>
Legt ein Konto an und nimmt es in „{memberTeam?.name}" auf.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="member-name">Name</Label>
<Input id="member-name" bind:value={memberForm.name} placeholder="Max Mustermann" />
</div>
<div class="space-y-2">
<Label for="member-mail">E-Mail</Label>
<Input id="member-mail" type="email" bind:value={memberForm.email} placeholder="max@example.com" />
</div>
<div class="space-y-2">
<Label for="member-pw">Passwort</Label>
<Input id="member-pw" type="password" bind:value={memberForm.password} placeholder="mindestens 8 Zeichen" />
<p class="text-xs text-muted-foreground">
Gib es der Person weiter; sie kann es unter „Profil" ändern.
</p>
</div>
{#if memberError}
<p class="text-sm text-destructive">{memberError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (memberDialog = false)}>Abbrechen</Button>
<Button onclick={doCreateMember} disabled={memberBusy}>
{memberBusy ? 'Wird angelegt …' : 'Anlegen'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -1,341 +0,0 @@
<script lang="ts">
import { auth, api } from '$lib/stores/pocketbase.svelte'
import { getTeamContext } from '$lib/stores/teams.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 { Crown, Plus, Trash2, UserMinus, UserPlus, Users, Shield, LogOut } from 'lucide-svelte'
import type { TeamsResponse, UsersResponse } from '$lib/types'
const teams = getTeamContext()
const me = $derived(api.authStore.record?.id ?? null)
// Member-Cache: User-Info pro ID
let userCache = $state<Record<string, UsersResponse>>({})
async function loadUser(id: string) {
if (userCache[id]) return userCache[id]
try {
const u = await api.collection('users').getOne(id, { requestKey: null })
userCache = { ...userCache, [id]: u }
return u
} catch {
return null
}
}
$effect(() => {
// Bei Team-Änderung alle Mitglieder vorladen
for (const t of teams.records) {
const ids = [...(t.users ?? []), t.owner].filter(Boolean) as string[]
for (const id of ids) {
if (!userCache[id]) loadUser(id)
}
}
})
function userLabel(id: string) {
const u = userCache[id]
if (!u) return id.slice(0, 6) + '…'
return u.name || u.email || u.id.slice(0, 6) + '…'
}
// ---- Team-Create Dialog ----
let createDialog = $state(false)
let createName = $state('')
async function doCreate() {
if (!createName.trim()) return
try {
await teams.create(createName.trim())
createDialog = false
createName = ''
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
}
// ---- Member-Invite Dialog ----
let inviteDialog = $state(false)
let inviteTargetTeam = $state<TeamsResponse | null>(null)
let inviteEmail = $state('')
let inviteSearching = $state(false)
let inviteFound = $state<UsersResponse | null>(null)
let inviteError = $state<string | null>(null)
function openInvite(t: TeamsResponse) {
inviteTargetTeam = t
inviteEmail = ''
inviteFound = null
inviteError = null
inviteDialog = true
}
async function doSearch() {
if (!inviteEmail.trim()) return
inviteSearching = true
inviteError = null
inviteFound = null
try {
const u = await teams.findUserByEmail(inviteEmail.trim())
if (u) {
inviteFound = u
userCache = { ...userCache, [u.id]: u }
} else {
inviteError = `Kein User mit "${inviteEmail}" gefunden`
}
} catch (e: any) {
inviteError = e.message
} finally {
inviteSearching = false
}
}
async function doInvite() {
if (!inviteTargetTeam || !inviteFound) return
try {
await teams.addMember(inviteTargetTeam.id, inviteFound.id)
inviteDialog = false
} catch (e: any) {
inviteError = e.message
}
}
// ---- Aktionen ----
async function doLeave(t: TeamsResponse) {
if (!confirm(`Team "${t.name}" wirklich verlassen?`)) return
try {
await teams.leave(t.id)
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
}
async function doRemoveMember(t: TeamsResponse, userId: string) {
if (userId === t.owner) return alert('Owner kann nicht entfernt werden')
if (!confirm(`Mitglied wirklich entfernen?`)) return
try {
await teams.removeMember(t.id, userId)
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
}
async function doPromote(t: TeamsResponse, userId: string) {
try {
await teams.promoteToAdmin(t.id, userId)
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
}
async function doDemote(t: TeamsResponse, userId: string) {
try {
await teams.demoteFromAdmin(t.id, userId)
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
}
function isAdmin(t: TeamsResponse, userId: string) {
return (t.admins ?? []).includes(userId)
}
</script>
<svelte:head><title>Teams 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">Teams</h1>
<p class="mt-1 text-muted-foreground">Deine Teams und Mitglieder verwalten</p>
</div>
<Button onclick={() => (createDialog = true)}>
<Plus class="h-4 w-4 mr-2" />
Neues Team
</Button>
</div>
{#if teams.records.length === 0}
<Card>
<CardContent class="py-16 text-center">
<Users class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p class="text-lg font-medium">Noch in keinem Team</p>
<p class="text-sm text-muted-foreground mt-1 mb-4">Erstelle dein erstes Team.</p>
<Button onclick={() => (createDialog = true)}>
<Plus class="h-4 w-4 mr-2" />
Team erstellen
</Button>
</CardContent>
</Card>
{:else}
<div class="space-y-4">
{#each teams.records as t (t.id)}
{@const iAmOwner = t.owner === me}
{@const iAmAdmin = iAmOwner || (t.admins ?? []).includes(me ?? '')}
<Card>
<CardHeader>
<div class="flex items-start justify-between gap-2">
<div>
<CardTitle class="flex items-center gap-2">
{t.name}
{#if t.id === teams.activeId}
<Badge variant="default">Aktiv</Badge>
{/if}
</CardTitle>
<CardDescription>
{(t.users ?? []).length} Mitglied{(t.users ?? []).length === 1 ? '' : 'er'}
</CardDescription>
</div>
<div class="flex items-center gap-2">
{#if t.id !== teams.activeId}
<Button variant="outline" size="sm" onclick={() => teams.setActive(t.id)}>
Aktivieren
</Button>
{/if}
{#if iAmAdmin}
<Button variant="outline" size="sm" onclick={() => openInvite(t)}>
<UserPlus class="h-4 w-4 mr-1" />
Mitglied
</Button>
{/if}
{#if !iAmOwner}
<Button variant="ghost" size="sm" onclick={() => doLeave(t)}>
<LogOut class="h-4 w-4 mr-1" />
Verlassen
</Button>
{/if}
{#if iAmOwner}
<Button variant="ghost" size="icon" onclick={() => teams.remove(t)}>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
{/if}
</div>
</div>
</CardHeader>
<CardContent>
<ul class="divide-y border-t">
{#each (t.users ?? []) as userId}
{@const isOwner = userId === t.owner}
{@const isAdm = isAdmin(t, userId)}
<li class="py-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium">{userLabel(userId)}</span>
{#if isOwner}
<Badge variant="default" class="gap-1">
<Crown class="h-3 w-3" />
Owner
</Badge>
{:else if isAdm}
<Badge variant="secondary" class="gap-1">
<Shield class="h-3 w-3" />
Admin
</Badge>
{/if}
{#if userId === me}
<Badge variant="outline">Du</Badge>
{/if}
</div>
{#if iAmOwner && !isOwner}
<div class="flex items-center gap-1">
{#if isAdm}
<Button variant="ghost" size="sm" onclick={() => doDemote(t, userId)}>
Admin entziehen
</Button>
{:else}
<Button variant="ghost" size="sm" onclick={() => doPromote(t, userId)}>
Zu Admin
</Button>
{/if}
<Button variant="ghost" size="icon" onclick={() => doRemoveMember(t, userId)}>
<UserMinus class="h-4 w-4 text-destructive" />
</Button>
</div>
{/if}
</li>
{/each}
</ul>
</CardContent>
</Card>
{/each}
</div>
{/if}
</div>
<!-- Create-Dialog -->
<Dialog.Root bind:open={createDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Neues Team</Dialog.Title>
<Dialog.Description>Du wirst automatisch Owner.</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault()
doCreate()
}}
class="space-y-4"
>
<div class="space-y-2">
<Label for="team-name">Name *</Label>
<Input id="team-name" bind:value={createName} placeholder="Mein Team" required />
</div>
<Dialog.Footer>
<Button type="button" variant="ghost" onclick={() => (createDialog = false)}>Abbrechen</Button>
<Button type="submit">Erstellen</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<!-- Invite-Dialog -->
<Dialog.Root bind:open={inviteDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Mitglied hinzufügen</Dialog.Title>
<Dialog.Description>
Suche einen registrierten User über seine E-Mail-Adresse.
</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault()
doSearch()
}}
class="space-y-4"
>
<div class="space-y-2">
<Label for="invite-email">E-Mail</Label>
<div class="flex gap-2">
<Input id="invite-email" type="email" bind:value={inviteEmail} placeholder="user@example.com" />
<Button type="submit" variant="outline" disabled={inviteSearching}>
{inviteSearching ? 'Suche…' : 'Suchen'}
</Button>
</div>
</div>
{#if inviteError}
<div class="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{inviteError}
</div>
{/if}
{#if inviteFound}
<div class="rounded-md border bg-accent/40 p-3">
<p class="font-medium">{inviteFound.name || inviteFound.email}</p>
{#if inviteFound.email}
<p class="text-sm text-muted-foreground">{inviteFound.email}</p>
{/if}
</div>
{/if}
<Dialog.Footer>
<Button type="button" variant="ghost" onclick={() => (inviteDialog = false)}>Abbrechen</Button>
<Button type="button" disabled={!inviteFound} onclick={doInvite}>Hinzufügen</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>