refactor: Team und Kader an einem Ort

Frueher lag beides in den Einstellungen und ein blasses Verzeichnis
zusaetzlich unter "Fahrer". Wer einen Fahrer anlegen wollte, landete
erst im Verzeichnis, dann in den Einstellungen. Jetzt gibt es einen Ort,
und die Verwaltungsknoepfe stehen an den Zeilen, zu denen sie gehoeren.

Der Einladungslink sitzt im Kopf der Seite statt in einer eigenen
Section; die ausgegebenen Links stehen mit im Dialog, weil sie sonst mit
der Section auch das Kopieren und das Zuruecknehmen verloren haetten.

Die Adminrolle wandert aus der Tabellenzeile in den Personendialog. Ein
Knopf, der ohne Rueckfrage Rechte vergibt, gehoert nicht neben die Icons
fuers Entfernen und Loeschen. Der Bearbeiten-Knopf steht dafuer an jeder
Zeile, auch an Konten ohne Fahrer — sonst waere deren Rolle nirgends
mehr erreichbar.

Die Liste "Meine Teams" entfaellt: Wechseln und Anlegen stehen im
Submenue am Menuepunkt Team. Verlassen gilt nur dem aktiven Team und
steht deshalb jetzt im Kopf.

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 22:11:13 +02:00
parent 8c11095b17
commit 45835cd45c
13 changed files with 1125 additions and 1234 deletions

View file

@ -60,7 +60,20 @@ docker compose down
Container und müssen mit `docker compose cp` ins Repo geholt werden; siehe Container und müssen mit `docker compose cp` ins Repo geholt werden; siehe
`backend/README.md`. `backend/README.md`.
- **Type-safe client**: The PocketBase client is typed using auto-generated types in `frontend/src/lib/types.d.ts` - **Type-safe client**: The PocketBase client is typed using auto-generated types in `frontend/src/lib/types.d.ts`
- **Collections**: users, teams, events, runs, riders, times - **Collections**: users, teams, team_invites, events, runs, riders, times,
trails, trail_versions, trail_flags, trail_markers, trail_comments,
event_participants, event_series
- **Regeln**: Jede Zugriffsregel, die `@request.auth.id` erwähnt, beginnt mit
`@request.auth.id != "" && (…)`. Ohne diesen Vorspann ist eine *leere*
Relation gleich dem leeren `@request.auth.id` einer anonymen Anfrage — ein
Team ohne Admins stünde damit offen im Netz. Siehe
`backend/pb_migrations/1754501700_rules_require_login.js`.
- **Hooks**: `backend/pb_hooks/` enthält serverseitiges JS für das, was keine
Collection-Regel abbilden kann — derzeit die öffentlichen Routen der
Einladungslinks. Jeder Route-Handler läuft in einer eigenen JS-Laufzeit;
gemeinsamer Code gehört in ein Modul und wird per
``require(`${__hooks}/…`)`` im Handler geholt. Details in
`backend/README.md`.
- **Authentication**: Handled through `src/lib/stores/pocketbase.svelte.ts` with the `AuthStore` class - **Authentication**: Handled through `src/lib/stores/pocketbase.svelte.ts` with the `AuthStore` class
- **File handling**: Use `getFileURL(record, file, options)` helper for PocketBase file URLs - **File handling**: Use `getFileURL(record, file, options)` helper for PocketBase file URLs
@ -136,7 +149,18 @@ tippy.js, which the action initializes, updates and destroys.
### File Structure Notes ### File Structure Notes
- Routes are in `src/routes/` following SvelteKit conventions - Routes are in `src/routes/` following SvelteKit conventions. **Routennamen
immer auf Englisch** (`/invite/[token]`, nicht `/einladung/[token]`) —
Oberflächentexte sind deutsch, Pfade nicht.
- Der Menüpunkt **Team** (`/dashboard/team`) führt Team und Kader an einer
Stelle: Kennzahlen, Teamdaten, Einladungslinks und die Fahrerliste samt der
Verwaltungsknöpfe der Teamleitung. Unter **Einstellungen** steht nur noch,
was einen selbst betrifft (Profil, Flag-Typen).
- **Ein Fahrer, ein Name, eine Richtung**: Ein Konto entsteht immer am Fahrer
(`riders.createLogin`), es gibt kein Verknüpfen bestehender Konten und kein
Lösen. Angezeigt wird ausschließlich der Fahrername — der Kontoname wäre
eine zweite Wahrheit, die niemand geradeziehen könnte, weil
`users.updateRule` nur den Kontoinhaber selbst ändern lässt.
- Reusable components in `src/lib/components/` - Reusable components in `src/lib/components/`
- Stores use `.svelte.ts` extension for Svelte 5 runes - Stores use `.svelte.ts` extension for Svelte 5 runes
- Static assets in `static/` - Static assets in `static/`

31
NAMING.md Normal file
View file

@ -0,0 +1,31 @@
Die "Wanderer"-Schiene
Shredder: Ziemlich actiongeladen, 100% MTB-Jargon und hat genau wie "Wanderer" dieses aktive, ausführende Element.
Kurbler: Die direktere, etwas rotzigere deutsche Alternative. Hat einen leichten Retro-Charme.
Dropper: Angelehnt an die Dropper-Post (absenkbare Sattelstütze) oder das "Reindroppen" in den Trail.
Fokus auf Trail & Community
TrailTribe: Betont den Vereins- und Gruppenaspekt hervorragend.
DirtBase: Perfekt für die Traildatenbank kurz, prägnant und technisch sauber.
FlowHub: Verbindet das ultimative MTB-Gefühl ("Flow") mit dem modernen Plattform-Gedanken für Eventveranstalter.
Fokus auf Zeitnahme & Action
SendSync: "Send it!" gepaart mit der schnellen Datensynchronisation einer Webapp.
PacePeak: Klingt sportlich, kompetitiv und extrem passend für Rennveranstalter und Bestzeitenjäger.
TrailTicker: Etwas verspielter, bringt aber das Thema Zeitmessung und Live-Updates sofort auf den Punkt.
Kurz & Knackig (Startup-Vibe)
Berm: Englisch für die Anliegerkurve. Kurz, international und unter Mountainbikern ein fester Begriff.
Roost: Der aufgeworfene Dreck in der Kurve. Hat Energie und ist sehr spezifisch für den Offroad-Sport.
Stollen: Ein sehr deutsches, geerdetes Wort (Stollenreifen), das MTB-Flair mit technischer Präzision verbindet.

118
TODO.md
View file

@ -1,118 +0,0 @@
# Offene Punkte
Stand: 2026-09-02, abgeglichen mit der produktiven Instanz. Die fachlichen
Entscheidungen sind gefallen und gebaut; was hier steht, passiert außerhalb des
Repos oder wartet auf den nächsten Deploy.
## Vor dem nächsten Deploy
### `PUBLIC_CARTO_API_KEY` in Coolify setzen
Die Kartenkacheln kommen von CARTO. Der Schlüssel muss den Browser erreichen,
deshalb das `PUBLIC_`-Präfix — ohne das reicht SvelteKit ihn nicht weiter.
Gelesen wird er über `$env/dynamic/public`, das heißt: Die Variable muss in der
**Laufzeitumgebung** des `node build`-Prozesses stehen, nicht nur zur Buildzeit.
In Coolify als normale Umgebungsvariable eintragen genügt. Fehlt sie, laufen die
Kacheln ohne Schlüssel weiter — die Karte bleibt also sichtbar.
### Migrationen: sechs gelaufen, fünf offen
Abgeglichen mit `api.stammtisch-hersbruck.de` (nur gelesen): Der Backend-Deploy
hat alles bis `1754500900` mitgenommen, ab `1754501000` steht es noch aus.
| Datei | Was sie tut | Stand |
|---|---|---|
| `1754500500_riders_user_and_admin_rules` | `riders.user`, Fahrerpflege nur Teamleitung, `users.viewRule`/`createRule` | gelaufen |
| `1754500600_events_dates_instead_of_status` | `events.status` raus, `starts`/`ends` rein | gelaufen |
| `1754500700_trail_status_meta` | `trails.status_changed`, `status_by` | gelaufen |
| `1754500800_trail_markers_custom` | `trail_markers.label`/`icon`, `flag` optional | gelaufen |
| `1754500900_users_superadmin` | `users.superadmin` samt Schutz vor Selbstbeförderung | gelaufen |
| `1754501000_teams_logo` | `teams.logo` | **offen** |
| `1754501100_trails_status_report` | Mitglieder dürfen den Trail-Zustand melden | **offen** |
| `1754501200_users_list_for_invites` | `users.listRule` gelockert (Kontosuche) | **offen** |
| `1754501300_event_participants` | neue Collection, **verschiebt Daten**, entfernt `riders.event`/`number` | **offen** |
| `1754501400_events_timing_participation` | `events.timing`/`participation` | **offen** |
| `1754501500_event_series` | `event_series`, `events.series` | **offen** |
Bis zum nächsten Deploy laufen deshalb ins Leere: Team-Logo, Zustandsmeldung
durch Mitglieder, Kontosuche per E-Mail, Teilnehmerlisten, die Event-Schalter
und die Serien. Die Oberfläche dafür ist da und wird beim Speichern eine
Fehlermeldung von PocketBase zeigen.
**Zwei Dinge sind beim Nachziehen nicht zurückzunehmen:**
1. `1754501300` legt für jeden Fahrer mit Event einen Teilnehmer an und entfernt
danach `riders.event` und `riders.number`. Eine Startnummer an einem Fahrer
**ohne** Event hat danach keinen Platz mehr und geht verloren — sie war
ohnehin nirgends sichtbar, weil alle Listen nach Event filterten.
2. Bestehende Events werden auf `timing = true` und `participation =
geschlossen` gesetzt. Genau so wurden sie bisher benutzt; wer einen
Stammtisch daraus machen will, schaltet die Zeitnahme im Dialog ab.
Ebenfalls bewusst so: Nach `1754501200` kann jeder Eingeloggte Konten
auflisten, damit sich bestehende Konten per E-Mail ins Team holen lassen.
E-Mail-Adressen bleiben über `emailVisibility` verborgen.
### Typen einmal neu erzeugen
`frontend/src/lib/types.d.ts` wurde bei allen Schema-Änderungen von Hand
gepflegt. Grund: `pocketbase-typegen` braucht native sqlite3-Bindings, die in
der Arbeitsumgebung nicht gebaut werden durften.
```bash
cd frontend && npm run generate-pocketbase-types
```
Die Datei sollte danach unverändert bleiben. Tut sie es nicht, gilt das Ergebnis
des Generators.
### Regeln einmal durchspielen
Die Zugriffsregeln der neuen Collections konnten nirgends laufen — weder Docker
noch eine lokale PocketBase-Instanz standen zur Verfügung. Sie sind nach dem
Muster der vorhandenen Collections geschrieben, aber ungetestet. Nach dem Deploy
einmal ausprobieren:
- Ein einfaches Teammitglied trägt sich bei einem **offenen** Event selbst ein.
- Dasselbe bei einem Event **mit Bestätigung** — es muss als „Angefragt"
landen, nicht als „Dabei".
- Bei einem **geschlossenen** Event darf es gar nicht gehen.
- Ein Mitglied meldet einen Trail-Zustand (muss gehen) und versucht, den Namen
des Trails zu ändern (darf nicht gehen).
Der Selbsteintrag setzt außerdem voraus, dass das Konto der Person mit einem
Fahrer verknüpft ist (Einstellungen → Fahrer & Konten). Ohne Verknüpfung
erscheint der Knopf gar nicht erst.
### Logo des Stammtischs zuordnen
`teams.logo` gibt es ab `1754501000`. Solange nichts hinterlegt ist, zeigt die
App weiterhin das Wappen aus `static/` — für den Stammtisch Hersbruck sieht es
also unverändert aus. Wer es als Datei am Team haben will, lädt es nach dem
Deploy unter Einstellungen → Team → Logo hoch.
## Kleinigkeiten
- Wer Regeln direkt im PocketBase-Admin ändert, hinterlässt keine Spur im Repo.
Die Migrationen bleiben die Quelle: Sie setzen dieselben Werte noch einmal und
überschreiben eine abweichende Handänderung stillschweigend.
- Die Rolle **Superadmin** ist gesetzt, gibt in der App aber noch keine
besonderen Rechte. Sie ist bewusst nur in PocketBase vergebbar: Die
`updateRule` der users-Collection schließt `superadmin` vom Selbstsetzen aus.
Sobald klar ist, was sie dürfen soll, kommen die Regeln dazu.
- Flag-Typen sind **teamspezifisch**. Ein neues Team bekommt beim Anlegen den
hartkodierten Standardsatz aus `trailFlags.svelte.ts` und kann ihn frei
ändern.
- „Jedes Teammitglied ist ein Fahrer" hält die Oberfläche ein, das Backend
erzwingt es nicht: Ein Konto kann im Team sein, ohne an einem Fahrer zu
hängen. Solche Fälle stehen unter „Fahrer & Konten" mit dem Vermerk „kein
Fahrer" und lassen sich per Knopf übernehmen.
- „Als Fahrer übernehmen" rät den Namen aus dem Kontonamen und setzt ihn als
Vornamen. „Max Mustermann" auf zwei Felder aufzuteilen wäre geraten und läge
bei Doppelnamen daneben; der Name lässt sich danach korrigieren. Wenn das zu
grob ist, müsste der Knopf stattdessen nach Vor- und Nachname fragen.
- Serien erzeugen bis zu zwölf Termine je Klick und überspringen, was schon
angelegt ist. Ein Termin, der ausfällt, wird als Event gelöscht oder
verschoben — die Serie bleibt davon unberührt.
- `NAMING.md` und `frontend/static/pedaler.svg` liegen bewusst nur lokal.

View file

@ -27,19 +27,19 @@ class AuthStore {
api.authStore.loadFromCookie(cookie) api.authStore.loadFromCookie(cookie)
} }
/**
* Anmelden. Ohne Argumente gelten die Felder des Stores, die das
* Login-Formular füllt; mit Argumenten die übergebenen die
* Einladungsseite meldet damit das eben angelegte Konto an, ohne den
* Umweg über die Formularfelder.
*/
login = async (user = auth.username, password = auth.password) => { login = async (user = auth.username, password = auth.password) => {
this.loading = true this.loading = true
this.error = '' this.error = ''
if (this.error) {
this.loading = false
return
}
try { try {
await api.collection('users').authWithPassword(auth.username, auth.password, this.params) await api.collection('users').authWithPassword(user, password, this.params)
this.user = api.authStore.record as AuthRecord | null this.user = api.authStore.record as AuthRecord | null

View file

@ -246,19 +246,6 @@ export class TeamStore {
return user return user
} }
/** User per E-Mail suchen (für Einladungs-UI) */
async findUserByEmail(email: string): Promise<UsersResponse | null> {
try {
const res = await api.collection('users').getFirstListItem(
`email = "${email.replaceAll('"', '')}"`,
{ requestKey: null },
)
return res
} catch {
return null
}
}
destroy() { destroy() {
if (this.unsubscribe) { if (this.unsubscribe) {
this.unsubscribe() this.unsubscribe()

View file

@ -42,7 +42,7 @@
Team <span class="font-medium text-foreground">{teams.active.name}</span> · {stats.totalEvents} Events, {stats.totalRiders} Fahrer Team <span class="font-medium text-foreground">{teams.active.name}</span> · {stats.totalEvents} Events, {stats.totalRiders} Fahrer
</p> </p>
{:else} {:else}
<p class="mt-2 text-muted-foreground">Kein Team aktiv. <a href="/dashboard/settings/team" class="underline">Team verwalten</a>.</p> <p class="mt-2 text-muted-foreground">Kein Team aktiv. <a href="/dashboard/team" class="underline">Team verwalten</a>.</p>
{/if} {/if}
</div> </div>

View file

@ -3,7 +3,7 @@
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { getEventContext } from '$lib/stores/events.svelte' import { getEventContext } from '$lib/stores/events.svelte'
import { getRunContext } from '$lib/stores/runs.svelte' import { getRunContext } from '$lib/stores/runs.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte' import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte' import { getTimeContext } from '$lib/stores/times.svelte'
import { getTeamContext } from '$lib/stores/teams.svelte' import { getTeamContext } from '$lib/stores/teams.svelte'
import { import {
@ -283,7 +283,7 @@
{#if canManage && available.length === 0 && riders.scoped.length === 0} {#if canManage && available.length === 0 && riders.scoped.length === 0}
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
Das Team hat noch keine Fahrer. Das Team hat noch keine Fahrer.
<a href="/dashboard/settings/riders" class="underline">Jetzt anlegen</a> <a href="/dashboard/team" class="underline">Jetzt anlegen</a>
</p> </p>
{/if} {/if}
@ -322,8 +322,8 @@
<Table.Cell class="font-medium"> <Table.Cell class="font-medium">
<!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. --> <!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. -->
<a href={riderPath(p.rider)} class="hover:underline"> <a href={riderPath(p.rider)} class="hover:underline">
{#if rider && fullName(rider)} {#if rider && riderName(rider)}
{fullName(rider)} {riderName(rider)}
{:else} {:else}
<span class="text-muted-foreground italic">— ohne Name —</span> <span class="text-muted-foreground italic">— ohne Name —</span>
{/if} {/if}
@ -507,12 +507,12 @@
<Select.Root type="single" bind:value={form.rider}> <Select.Root type="single" bind:value={form.rider}>
<Select.Trigger class="w-full"> <Select.Trigger class="w-full">
{form.rider {form.rider
? fullName(riders.getById(form.rider) ?? ({} as any)) || 'Fahrer wählen…' ? riderName(riders.getById(form.rider) ?? ({} as any)) || 'Fahrer wählen…'
: 'Fahrer wählen…'} : 'Fahrer wählen…'}
</Select.Trigger> </Select.Trigger>
<Select.Content> <Select.Content>
{#each available as r (r.id)} {#each available as r (r.id)}
<Select.Item value={r.id}>{fullName(r) || '— ohne Name —'}</Select.Item> <Select.Item value={r.id}>{riderName(r) || '— ohne Name —'}</Select.Item>
{/each} {/each}
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>

View file

@ -1,134 +0,0 @@
<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 { getTimeContext } from '$lib/stores/times.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.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 times = getTimeContext()
const participants = getEventParticipantContext()
let query = $state('')
const roster = $derived.by(() => {
const q = query.toLowerCase().trim()
if (!q) return riders.scoped
return riders.scoped.filter((r) => fullName(r).toLowerCase().includes(q))
})
$effect(() => {
teams.loadMembers()
})
/**
* Zur Fahrerseite geht es über ein Event, denn Zeiten hängen am Run und
* der am Event. Bei mehreren Teilnahmen führt der Link zur jüngsten.
*/
function riderPath(riderId: string) {
const latest = participants.byRider(riderId).at(-1)
return latest ? `/dashboard/events/${latest.event}/riders/${riderId}` : 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="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>Name</Table.Head>
<Table.Head class="w-28">Events</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.id)}
<Table.Row>
<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">
{participants.byRider(r.id).length}
</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

@ -2,27 +2,19 @@
/** /**
* Rahmen der Einstellungen: eine Überschrift, darunter Reiter je Bereich. * Rahmen der Einstellungen: eine Überschrift, darunter Reiter je Bereich.
* *
* „Team" ist die Übersicht des Teams, „Fahrer & Konten" führt die * Hier steht nur noch, was einen selbst betrifft — das eigene Profil und
* Menschen: Jedes Mitglied ist ein Fahrer, ein Konto hat aber nicht jeder. * die Flag-Typen. Team und Kader sind unter „Team" zu Hause; sie sind
* Beides an einer Stelle, weil es sonst zwei Wahrheiten über dieselbe * keine Einstellung, sondern der Gegenstand der Anwendung.
* Person gäbe. Der Reiter erscheint nur für die Teamleitung.
*/ */
import { page } from '$app/state' import { page } from '$app/state'
import { getTeamContext } from '$lib/stores/teams.svelte' import { Flag, User } from 'lucide-svelte'
import { Flag, User, Users, Bike } from 'lucide-svelte'
let { children } = $props() let { children } = $props()
const teams = getTeamContext() const tabs = [
const tabs = $derived([
{ href: '/dashboard/settings', label: 'Profil', icon: User }, { href: '/dashboard/settings', label: 'Profil', icon: User },
{ href: '/dashboard/settings/team', label: 'Team', icon: Users },
...(teams.isAdmin()
? [{ href: '/dashboard/settings/riders', label: 'Fahrer & Konten', icon: Bike }]
: []),
{ href: '/dashboard/settings/flags', label: 'Flag-Typen', icon: Flag }, { href: '/dashboard/settings/flags', label: 'Flag-Typen', icon: Flag },
]) ]
// Das Profil liegt auf der Wurzel; ohne den Sonderfall wäre sein Reiter // Das Profil liegt auf der Wurzel; ohne den Sonderfall wäre sein Reiter
// auf jedem Unterpfad mit aktiv. // auf jedem Unterpfad mit aktiv.

View file

@ -1,587 +0,0 @@
<script lang="ts">
/**
* Fahrer und Konten an einer Stelle — nur für die Teamleitung.
*
* Jedes Teammitglied ist ein Fahrer; ein Fahrer braucht aber kein Konto.
* Beides getrennt zu pflegen hieße, dieselben Menschen zweimal anzulegen
* und die Zuordnung von Hand zu halten. Die Liste zeigt deshalb den Kader
* und daneben, wer sich anmelden kann und mit welcher Rolle.
*/
import { getTeamContext } from '$lib/stores/teams.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import Avatar from '$lib/components/Avatar.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 Table from '@/components/ui/table'
import {
Crown, KeyRound, Mail, Pencil, Plus, Shield, Trash2, Unlink, UserMinus,
} from 'lucide-svelte'
import type { RidersResponse, UsersResponse } from '$lib/types'
const teams = getTeamContext()
const riders = getRiderContext()
const participants = getEventParticipantContext()
const canManage = $derived(riders.canManage)
const team = $derived(teams.active)
const iAmOwner = $derived(teams.isOwner())
$effect(() => {
teams.loadMembers()
})
type Row = {
rider?: RidersResponse
userId?: string
name: string
role: 'owner' | 'admin' | 'mitglied' | null
}
function roleOf(userId: string | undefined): Row['role'] {
if (!userId || !team) return null
if (team.owner === userId) return 'owner'
return (team.admins ?? []).includes(userId) ? 'admin' : 'mitglied'
}
/**
* Eine Zeile je Person: alle Fahrer des Kaders, dazu Konten im Team, die
* noch an keinem Fahrer hängen — sonst verschwänden sie aus der Ansicht,
* obwohl sie Mitglied sind.
*/
const rows = $derived.by((): Row[] => {
const list: Row[] = riders.scoped.map((rider) => ({
rider,
userId: rider.user || undefined,
name: fullName(rider) || '— ohne Name —',
role: roleOf(rider.user || undefined),
}))
const linked = new Set(riders.scoped.map((r) => r.user).filter(Boolean))
for (const userId of team?.users ?? []) {
if (linked.has(userId)) continue
list.push({ userId, name: teams.userLabel(userId), role: roleOf(userId) })
}
return list.sort((a, b) => a.name.localeCompare(b.name))
})
// --- Person anlegen ---------------------------------------------------
let personDialog = $state(false)
let editing = $state<RidersResponse | null>(null)
let form = $state({ firstname: '', lastname: '', withAccount: false, email: '', password: '' })
let busy = $state(false)
let error = $state<string | null>(null)
function openCreate() {
editing = null
form = { firstname: '', lastname: '', withAccount: false, email: '', password: '' }
error = null
personDialog = true
}
function openEdit(rider: RidersResponse) {
editing = rider
form = {
firstname: rider.firstname ?? '',
lastname: rider.lastname ?? '',
withAccount: false,
email: '',
password: '',
}
error = null
personDialog = true
}
async function savePerson() {
if (!form.firstname.trim() && !form.lastname.trim()) {
error = 'Bitte wenigstens einen Namen angeben.'
return
}
if (form.withAccount) {
if (!form.email.trim()) {
error = 'Für ein Konto braucht es eine E-Mail-Adresse.'
return
}
if (form.password.length < 8) {
error = 'Das Passwort braucht mindestens 8 Zeichen.'
return
}
}
busy = true
error = null
try {
const data = { firstname: form.firstname.trim(), lastname: form.lastname.trim() }
if (editing) {
await riders.edit(editing.id, data)
} else {
const rider = await riders.create(data)
if (form.withAccount) {
await riders.createLogin(rider as RidersResponse, {
email: form.email,
password: form.password,
})
}
}
personDialog = false
} catch (e: any) {
error = e.message ?? 'Die Person konnte nicht gespeichert werden.'
} finally {
busy = false
}
}
// --- Konto zu einem vorhandenen Fahrer --------------------------------
let accountDialog = $state(false)
let accountRider = $state<RidersResponse | null>(null)
let accountMode = $state<'neu' | 'vorhanden'>('neu')
let accountForm = $state({ email: '', password: '' })
let searchEmail = $state('')
let searching = $state(false)
let found = $state<UsersResponse | null>(null)
let accountBusy = $state(false)
let accountError = $state<string | null>(null)
function openAccount(rider: RidersResponse) {
accountRider = rider
accountMode = 'neu'
accountForm = { email: '', password: '' }
searchEmail = ''
found = null
accountError = null
accountDialog = true
}
async function createAccount() {
if (!accountRider) return
if (!accountForm.email.trim()) {
accountError = 'Ohne E-Mail-Adresse gibt es keinen Anmeldenamen.'
return
}
if (accountForm.password.length < 8) {
accountError = 'Das Passwort braucht mindestens 8 Zeichen.'
return
}
accountBusy = true
accountError = null
try {
await riders.createLogin(accountRider, { ...accountForm })
accountDialog = false
} catch (e: any) {
accountError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
} finally {
accountBusy = false
}
}
async function searchAccount() {
if (!searchEmail.trim()) return
searching = true
accountError = null
found = null
try {
const user = await teams.findUserByEmail(searchEmail.trim())
if (user) found = user
else accountError = `Kein Konto mit „${searchEmail}" gefunden.`
} catch (e: any) {
accountError = e.message ?? 'Die Suche ist fehlgeschlagen.'
} finally {
searching = false
}
}
async function linkFound() {
if (!accountRider || !found || !team) return
accountBusy = true
accountError = null
try {
// Erst ins Team, dann verknüpfen: Ein Konto, das nicht im Team
// ist, sähe in der Liste wie ein Mitglied aus, ohne eines zu sein.
if (!(team.users ?? []).includes(found.id)) {
await teams.addMember(team.id, found.id)
}
await riders.linkUser(accountRider.id, found.id)
accountDialog = false
} catch (e: any) {
accountError = e.message ?? 'Die Verknüpfung ist fehlgeschlagen.'
} finally {
accountBusy = false
}
}
async function unlink(rider: RidersResponse) {
if (!confirm('Verknüpfung lösen? Das Konto bleibt bestehen, der Fahrer auch.')) return
try {
await riders.linkUser(rider.id, null)
} catch (e: any) {
alert(e.message ?? 'Die Verknüpfung konnte nicht gelöst werden.')
}
}
/** Ein Konto ohne Fahrer nachträglich in den Kader holen. */
async function adoptAccount(userId: string) {
const user = teams.users[userId]
try {
const rider = await riders.create({
firstname: user?.name ?? '',
lastname: '',
})
await riders.linkUser(rider.id, userId)
} catch (e: any) {
alert(e.message ?? 'Der Fahrer konnte nicht angelegt werden.')
}
}
async function removeFromTeam(userId: string) {
if (!team) return
if (userId === team.owner) return alert('Der Owner kann nicht entfernt werden.')
if (!confirm('Konto aus dem Team entfernen? Der Fahrer bleibt im Kader.')) return
try {
await teams.removeMember(team.id, userId)
} catch (e: any) {
alert(e.message ?? 'Das Mitglied konnte nicht entfernt werden.')
}
}
async function toggleAdmin(userId: string, makeAdmin: boolean) {
if (!team) return
try {
if (makeAdmin) await teams.promoteToAdmin(team.id, userId)
else await teams.demoteFromAdmin(team.id, userId)
} catch (e: any) {
alert(e.message ?? 'Die Rolle konnte nicht geändert werden.')
}
}
</script>
{#if !canManage}
<Card>
<CardContent class="py-12 text-center text-muted-foreground">
Fahrer und Konten verwaltet, wer das Team führt. Den Kader siehst du
unter <a href="/dashboard/riders" class="underline">Fahrer</a>.
</CardContent>
</Card>
{:else}
<div class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-sm text-muted-foreground">
Jedes Teammitglied ist ein Fahrer — ein Konto braucht es dafür nicht.
Wer sich anmelden können soll, bekommt eins.
</p>
<Button onclick={openCreate}>
<Plus class="size-4 mr-2" />
Person anlegen
</Button>
</div>
{#if rows.length === 0}
<Card>
<CardContent class="py-12 text-center text-muted-foreground">
Noch niemand im Team.
</CardContent>
</Card>
{:else}
<Card>
<CardContent class="p-0">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Name</Table.Head>
<Table.Head class="w-56">Konto</Table.Head>
<Table.Head class="w-32">Rolle</Table.Head>
<Table.Head class="w-24">Events</Table.Head>
<Table.Head class="text-right">Aktionen</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each rows as row (row.rider?.id ?? row.userId)}
<Table.Row>
<Table.Cell class="font-medium">
<span class="flex items-center gap-2">
<Avatar user={row.userId ? teams.users[row.userId] : null} size={28} />
{row.name}
{#if !row.rider}
<Badge variant="outline">kein Fahrer</Badge>
{/if}
</span>
</Table.Cell>
<Table.Cell>
{#if row.userId}
<span class="text-sm text-muted-foreground truncate">
{teams.userLabel(row.userId)}
</span>
{:else}
<span class="text-sm text-muted-foreground">kein Konto</span>
{/if}
</Table.Cell>
<Table.Cell>
{#if row.role === 'owner'}
<Badge class="gap-1">
<Crown class="size-3" />
Owner
</Badge>
{:else if row.role === 'admin'}
<Badge variant="secondary" class="gap-1">
<Shield class="size-3" />
Admin
</Badge>
{:else if row.role === 'mitglied'}
<Badge variant="outline">Mitglied</Badge>
{:else}
<span class="text-sm text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell class="text-muted-foreground text-sm">
{row.rider ? participants.byRider(row.rider.id).length : '—'}
</Table.Cell>
<Table.Cell class="text-right whitespace-nowrap">
{#if iAmOwner && row.userId && row.role !== 'owner'}
<Button
variant="ghost"
size="sm"
onclick={() => toggleAdmin(row.userId!, row.role !== 'admin')}
>
{row.role === 'admin' ? 'Admin entziehen' : 'Zu Admin'}
</Button>
{/if}
{#if row.rider && !row.userId}
<Button
variant="ghost"
size="icon"
tooltip="Konto anlegen oder verknüpfen"
onclick={() => openAccount(row.rider!)}
>
<KeyRound class="size-4" />
</Button>
{:else if row.rider && row.userId}
<Button
variant="ghost"
size="icon"
tooltip="Verknüpfung lösen"
onclick={() => unlink(row.rider!)}
>
<Unlink class="size-4" />
</Button>
{:else if row.userId}
<Button
variant="ghost"
size="sm"
onclick={() => adoptAccount(row.userId!)}
>
Als Fahrer übernehmen
</Button>
{/if}
{#if row.rider}
<Button
variant="ghost"
size="icon"
tooltip="Bearbeiten"
onclick={() => openEdit(row.rider!)}
>
<Pencil class="size-4" />
</Button>
{/if}
{#if row.userId && row.role !== 'owner'}
<Button
variant="ghost"
size="icon"
tooltip="Aus dem Team entfernen"
onclick={() => removeFromTeam(row.userId!)}
>
<UserMinus class="size-4" />
</Button>
{/if}
{#if row.rider}
<Button
variant="ghost"
size="icon"
tooltip="Fahrer löschen"
onclick={() => riders.remove(row.rider!)}
>
<Trash2 class="size-4 text-destructive" />
</Button>
{/if}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</CardContent>
</Card>
{/if}
</div>
{/if}
<!-- Person anlegen oder bearbeiten -->
<Dialog.Root bind:open={personDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{editing ? 'Fahrer bearbeiten' : 'Person anlegen'}</Dialog.Title>
<Dialog.Description>
{editing
? 'Der Name gilt teamweit; Startnummern hängen am jeweiligen Event.'
: 'Kommt als Fahrer in den Kader. Ein Konto ist optional.'}
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-3">
<div class="space-y-2">
<Label for="p-first">Vorname</Label>
<Input id="p-first" bind:value={form.firstname} placeholder="Max" />
</div>
<div class="space-y-2">
<Label for="p-last">Nachname</Label>
<Input id="p-last" bind:value={form.lastname} placeholder="Mustermann" />
</div>
</div>
{#if !editing}
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
bind:checked={form.withAccount}
class="size-4 rounded border-input accent-primary"
/>
kann sich anmelden
</label>
{#if form.withAccount}
<div class="space-y-2">
<Label for="p-mail">E-Mail</Label>
<Input id="p-mail" type="email" bind:value={form.email} placeholder="max@example.com" />
</div>
<div class="space-y-2">
<Label for="p-pw">Passwort</Label>
<Input id="p-pw" type="password" bind:value={form.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}
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (personDialog = false)}>Abbrechen</Button>
<Button onclick={savePerson} disabled={busy}>
{busy ? 'Wird gespeichert …' : editing ? 'Speichern' : 'Anlegen'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Konto für einen vorhandenen Fahrer -->
<Dialog.Root bind:open={accountDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>
Konto für {accountRider ? fullName(accountRider) : '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={accountMode === 'neu' ? 'default' : 'outline'}
size="sm"
onclick={() => (accountMode = 'neu')}
>
Neues anlegen
</Button>
<Button
variant={accountMode === 'vorhanden' ? 'default' : 'outline'}
size="sm"
onclick={() => (accountMode = 'vorhanden')}
>
Vorhandenes verknüpfen
</Button>
</div>
<div class="space-y-4">
{#if accountMode === 'neu'}
<div class="space-y-2">
<Label for="a-mail">E-Mail</Label>
<Input id="a-mail" type="email" bind:value={accountForm.email} placeholder="max@example.com" />
</div>
<div class="space-y-2">
<Label for="a-pw">Passwort</Label>
<Input id="a-pw" type="password" bind:value={accountForm.password} placeholder="mindestens 8 Zeichen" />
</div>
{:else}
<div class="space-y-2">
<Label for="a-search">E-Mail des Kontos</Label>
<div class="flex gap-2">
<Input
id="a-search"
type="email"
bind:value={searchEmail}
placeholder="max@example.com"
onkeydown={(e) => e.key === 'Enter' && searchAccount()}
/>
<Button variant="outline" onclick={searchAccount} disabled={searching}>
<Mail class="size-4 mr-1" />
{searching ? 'Suche …' : 'Suchen'}
</Button>
</div>
</div>
{#if found}
<div class="flex items-center gap-3 rounded-lg border bg-accent/40 p-3">
<Avatar user={found} size={36} />
<div class="min-w-0">
<p class="font-medium truncate">{found.name || found.email}</p>
<p class="text-xs text-muted-foreground">
Wird ins Team aufgenommen und verknüpft.
</p>
</div>
</div>
{/if}
{/if}
{#if accountError}
<p class="text-sm text-destructive">{accountError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (accountDialog = false)}>Abbrechen</Button>
{#if accountMode === 'neu'}
<Button onclick={createAccount} disabled={accountBusy}>
{accountBusy ? 'Wird angelegt …' : 'Konto anlegen'}
</Button>
{:else}
<Button onclick={linkFound} disabled={!found || accountBusy}>Verknüpfen</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -1,352 +0,0 @@
<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 { getTrailFlagContext } from '$lib/stores/trailFlags.svelte'
import { getEventContext } from '$lib/stores/events.svelte'
import { getRiderContext } from '$lib/stores/riders.svelte'
import { getTrailContext } from '$lib/stores/trails.svelte'
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.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 Avatar from '$lib/components/Avatar.svelte'
import Logo from '$lib/components/Logo.svelte'
import {
Bike, Calendar, Image, LogOut, Plus, Route, Trash2, Users,
} from 'lucide-svelte'
import type { TeamsResponse, UsersResponse } from '$lib/types'
const teams = getTeamContext()
const flags = getTrailFlagContext()
const events = getEventContext()
const riders = getRiderContext()
const trails = getTrailContext()
const markers = getTrailMarkerContext()
const me = $derived(api.authStore.record?.id ?? null)
const accounts = $derived(riders.scoped.filter((r) => r.user).length)
const openMarkers = $derived(
trails.scoped.reduce((sum, t) => sum + markers.openByTrail(t.id).length, 0),
)
// 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 {
const team = await teams.create(createName.trim())
// Ein Team ohne Flag-Typen kann zwar Marker aufnehmen, aber nichts
// einordnen. Deshalb kommt der Standardsatz gleich mit — änderbar
// und, wie alle Flag-Typen, allein Sache dieses Teams.
try {
await flags.seedDefaults(team.id)
} catch (e: any) {
createError =
'Das Team wurde angelegt, die Standard-Flag-Typen aber nicht: ' +
(e.message ?? 'unbekannter Fehler') +
' Du kannst sie unter „Flag-Typen" nachholen.'
return
}
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.'
}
}
// Kennzahlen des aktiven Teams — was hier steht, gehört dem Team und
// wandert beim Wechsel mit.
const stats = $derived([
{
label: 'Events',
value: events.scoped.length,
hint: `${events.upcoming.length} anstehend`,
icon: Calendar,
},
{ label: 'Fahrer', value: riders.scoped.length, hint: `${accounts} mit Konto`, icon: Bike },
{
label: 'Trails',
value: trails.scoped.length,
hint: `${openMarkers} offene Meldungen`,
icon: Route,
},
{ label: 'Mitglieder', value: (teams.active?.users ?? []).length, icon: Users },
])
// ---- Logo ----
let logoDialog = $state(false)
let logoTeam = $state<TeamsResponse | null>(null)
let logoFile = $state<File | null>(null)
let logoBusy = $state(false)
let logoError = $state<string | null>(null)
function openLogo(t: TeamsResponse) {
logoTeam = t
logoFile = null
logoError = null
logoDialog = true
}
async function saveLogo(file: File | null) {
if (!logoTeam) return
logoBusy = true
logoError = null
try {
await teams.setLogo(logoTeam.id, file)
logoDialog = false
} catch (e: any) {
logoError = e.message ?? 'Das Logo konnte nicht gespeichert werden.'
} finally {
logoBusy = 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}`)
}
}
</script>
<div class="space-y-6">
{#if teams.active}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{#each stats as stat (stat.label)}
{@const Icon = stat.icon}
<Card>
<CardContent class="py-4">
<div class="flex items-center justify-between">
<p class="text-sm text-muted-foreground">{stat.label}</p>
<Icon class="size-4 text-muted-foreground" />
</div>
<p class="mt-1 text-2xl font-bold">{stat.value}</p>
{#if stat.hint}
<p class="text-xs text-muted-foreground">{stat.hint}</p>
{/if}
</CardContent>
</Card>
{/each}
</div>
{/if}
<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 class="flex items-center gap-3">
<Logo team={t} class="size-10 shrink-0" />
<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>
<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={() => openLogo(t)}>
<Image class="size-4 mr-1" />
Logo
</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" tooltip="Team löschen" onclick={() => teams.remove(t)}>
<Trash2 class="size-4 text-destructive" />
</Button>
{/if}
</div>
</div>
</CardHeader>
<CardContent class="text-sm text-muted-foreground">
{(t.users ?? []).length} Mitglied{(t.users ?? []).length === 1 ? '' : 'er'}
· verwaltet unter <a href="/dashboard/settings/riders" class="underline">Fahrer</a>
</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>
<!-- Logo -->
<Dialog.Root bind:open={logoDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Logo für „{logoTeam?.name}"</Dialog.Title>
<Dialog.Description>
Erscheint in der Kopfzeile und blass im Seitenhintergrund. Ohne eigenes
Logo steht dort das Wappen des Stammtischs.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="flex items-center gap-4">
<Logo team={logoTeam} class="size-20 shrink-0" />
<div class="space-y-1 min-w-0">
<Label for="team-logo">Datei</Label>
<input
id="team-logo"
type="file"
accept="image/*"
class="block w-full text-sm"
onchange={(e) => (logoFile = e.currentTarget.files?.[0] ?? null)}
/>
<p class="text-xs text-muted-foreground">PNG, SVG oder WebP, bis 5 MB.</p>
</div>
</div>
{#if logoError}
<p class="text-sm text-destructive">{logoError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (logoDialog = false)}>Abbrechen</Button>
{#if logoTeam?.logo}
<Button variant="outline" disabled={logoBusy} onclick={() => saveLogo(null)}>
Entfernen
</Button>
{/if}
<Button disabled={!logoFile || logoBusy} onclick={() => saveLogo(logoFile)}>
{logoBusy ? 'Wird gespeichert …' : 'Speichern'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -0,0 +1,953 @@
<script lang="ts">
/**
* Das Team an einer Stelle: oben, wofür das Team steht — Kennzahlen,
* Name, Logo, die eigenen Teams. Darunter der Kader.
*
* Früher lag beides in den Einstellungen und ein blasses Verzeichnis
* zusätzlich unter „Fahrer". Wer einen Fahrer anlegen wollte, landete
* erst im Verzeichnis, dann in den Einstellungen. Jetzt gibt es einen
* Ort, und die Verwaltungsknöpfe stehen an den Zeilen, zu denen sie
* gehören — sichtbar nur für die Teamleitung.
*
* Jedes Teammitglied ist ein Fahrer; ein Fahrer braucht aber kein Konto.
* Ein Konto entsteht deshalb immer am Fahrer und nie umgekehrt — es gibt
* kein Verknüpfen und kein Lösen, nur „dieser Fahrer bekommt ein Login".
*
* Und es gibt genau einen Namen: den des Fahrers. Der Kontoname taucht in
* dieser Tabelle nirgends auf. Zwei Namen für dieselbe Person liefen
* auseinander, und geradeziehen ließe sie niemand: users.updateRule
* erlaubt Änderungen nur am eigenen Konto, an fremde Kontonamen kommt
* auch die Teamleitung nicht heran. Beim Anlegen setzt riders.createLogin
* den Kontonamen einmal auf den Fahrernamen; ab da zählt der Fahrer.
*/
import { api } from '$lib/stores/pocketbase.svelte'
import { getTeamContext } from '$lib/stores/teams.svelte'
import { getTeamInviteContext } from '$lib/stores/teamInvites.svelte'
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
import { getEventContext } from '$lib/stores/events.svelte'
import { getTimeContext } from '$lib/stores/times.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { getTrailContext } from '$lib/stores/trails.svelte'
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
import Avatar from '$lib/components/Avatar.svelte'
import Logo from '$lib/components/Logo.svelte'
import CreateTeamDialog from '$lib/components/CreateTeamDialog.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader } 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 { Separator } from '@/components/ui/separator'
import * as Table from '@/components/ui/table'
import {
Bike, Calendar, Check, Copy, Crown, Image, KeyRound, Link2, LogOut, Pencil,
Plus, Route, Search, Shield, Trash2, UserCheck, UserMinus, Users,
} from 'lucide-svelte'
import type { RidersResponse, TeamInvitesResponse, TeamsResponse } from '$lib/types'
const teams = getTeamContext()
const invites = getTeamInviteContext()
const riders = getRiderContext()
const events = getEventContext()
const times = getTimeContext()
const participants = getEventParticipantContext()
const trails = getTrailContext()
const markers = getTrailMarkerContext()
const me = $derived(api.authStore.record?.id ?? null)
const team = $derived(teams.active)
const canManage = $derived(teams.isAdmin())
const iAmOwner = $derived(teams.isOwner())
const accounts = $derived(riders.scoped.filter((r) => r.user).length)
const openMarkers = $derived(
trails.scoped.reduce((sum, t) => sum + markers.openByTrail(t.id).length, 0),
)
// Namen der Mitglieder nachladen — ein Team kennt nur deren IDs.
$effect(() => {
for (const t of teams.records) teams.loadMembers(t)
})
const stats = $derived([
{
label: 'Events',
value: events.scoped.length,
hint: `${events.upcoming.length} anstehend`,
icon: Calendar,
},
{ label: 'Fahrer', value: riders.scoped.length, hint: `${accounts} mit Konto`, icon: Bike },
{
label: 'Trails',
value: trails.scoped.length,
hint: `${openMarkers} offene Meldungen`,
icon: Route,
},
{ label: 'Mitglieder', value: (team?.users ?? []).length, icon: Users },
])
// ---- Kader ----------------------------------------------------------
let query = $state('')
type Row = {
rider?: RidersResponse
userId?: string
name: string
role: 'owner' | 'admin' | 'mitglied' | null
}
function roleOf(userId: string | undefined): Row['role'] {
if (!userId || !team) return null
if (team.owner === userId) return 'owner'
return (team.admins ?? []).includes(userId) ? 'admin' : 'mitglied'
}
/**
* Eine Zeile je Person: alle Fahrer des Kaders, dazu Konten im Team, die
* an keinem Fahrer hängen — sonst verschwänden sie aus der Ansicht,
* obwohl sie Mitglied sind. Neu entstehen solche Zeilen nicht mehr:
* Sowohl der Einladungslink als auch „Konto anlegen" führen beides
* zusammen.
*/
const rows = $derived.by((): Row[] => {
const list: Row[] = riders.scoped.map((rider) => ({
rider,
userId: rider.user || undefined,
name: riderName(rider) || '— ohne Name —',
role: roleOf(rider.user || undefined),
}))
const linked = new Set(riders.scoped.map((r) => r.user).filter(Boolean))
for (const userId of team?.users ?? []) {
if (linked.has(userId)) continue
list.push({ userId, name: teams.userLabel(userId), role: roleOf(userId) })
}
return list.sort((a, b) => a.name.localeCompare(b.name))
})
const filtered = $derived.by(() => {
const q = query.toLowerCase().trim()
return q ? rows.filter((r) => r.name.toLowerCase().includes(q)) : rows
})
/**
* Zur Fahrerseite geht es über ein Event, denn Zeiten hängen am Run und
* der am Event. Bei mehreren Teilnahmen führt der Link zur jüngsten.
*/
function riderPath(riderId: string) {
const latest = participants.byRider(riderId).at(-1)
return latest ? `/dashboard/events/${latest.event}/riders/${riderId}` : null
}
// ---- Person anlegen oder bearbeiten ----------------------------------
//
// Der Dialog bearbeitet die Person, nicht nur den Fahrer: Name und Rolle
// im Team stehen beieinander. Die Rolle lag früher als „Zu Admin" in der
// Zeile — ein Knopf, der ohne Rückfrage Rechte vergab, direkt neben den
// Icons fürs Entfernen und Löschen.
//
// `editing` ist der Fahrer und darf leer bleiben: Alte Datensätze kennen
// Konten im Team, an denen kein Fahrer hängt. Ob der Dialog anlegt oder
// bearbeitet, sagt deshalb `isEdit`.
let personDialog = $state(false)
let isEdit = $state(false)
let editing = $state<RidersResponse | null>(null)
let editingUser = $state<string | null>(null)
let form = $state({ name: '', withAccount: false, email: '', password: '', admin: false })
let busy = $state(false)
let error = $state<string | null>(null)
/** Rolle der gerade bearbeiteten Person, mitlaufend mit dem Team. */
const editingRole = $derived(roleOf(editingUser ?? undefined))
/** Die Rolle vergibt allein der Owner, und nie die eigene. */
const canSetRole = $derived(iAmOwner && !!editingUser && editingRole !== 'owner')
function openCreate() {
isEdit = false
editing = null
editingUser = null
form = { name: '', withAccount: false, email: '', password: '', admin: false }
error = null
personDialog = true
}
function openEdit(row: Row) {
isEdit = true
editing = row.rider ?? null
editingUser = row.userId ?? null
form = {
name: row.rider?.name ?? '',
withAccount: false,
email: '',
password: '',
admin: row.role === 'admin',
}
error = null
personDialog = true
}
async function savePerson() {
if ((editing || !isEdit) && !form.name.trim()) {
error = 'Bitte einen Namen angeben.'
return
}
if (form.withAccount) {
if (!form.email.trim()) {
error = 'Für ein Konto braucht es eine E-Mail-Adresse.'
return
}
if (form.password.length < 8) {
error = 'Das Passwort braucht mindestens 8 Zeichen.'
return
}
}
busy = true
error = null
try {
const data = { name: form.name.trim() }
if (isEdit) {
if (editing) await riders.edit(editing.id, data)
// Rolle nur anfassen, wenn sie sich wirklich unterscheidet —
// sonst schriebe jedes Speichern die admins-Liste neu.
if (canSetRole && team && editingUser && form.admin !== (editingRole === 'admin')) {
if (form.admin) await teams.promoteToAdmin(team.id, editingUser)
else await teams.demoteFromAdmin(team.id, editingUser)
}
} else {
const rider = await riders.create(data)
if (form.withAccount) {
await riders.createLogin(rider as RidersResponse, {
email: form.email,
password: form.password,
})
}
}
personDialog = false
} catch (e: any) {
error = e.message ?? 'Die Person konnte nicht gespeichert werden.'
} finally {
busy = false
}
}
// ---- Konto zu einem vorhandenen Fahrer -------------------------------
//
// Nur diese eine Richtung: aus einem Fahrer ohne Konto einen mit Konto
// machen. Ein bestehendes Konto an einen Fahrer zu hängen oder die
// Verbindung wieder zu lösen gibt es bewusst nicht — wer sich anmelden
// können soll, bekommt hier ein Konto oder kommt über den Einladungslink.
let accountDialog = $state(false)
let accountRider = $state<RidersResponse | null>(null)
let accountForm = $state({ email: '', password: '' })
let accountBusy = $state(false)
let accountError = $state<string | null>(null)
function openAccount(rider: RidersResponse) {
accountRider = rider
accountForm = { email: '', password: '' }
accountError = null
accountDialog = true
}
async function createAccount() {
if (!accountRider) return
if (!accountForm.email.trim()) {
accountError = 'Ohne E-Mail-Adresse gibt es keinen Anmeldenamen.'
return
}
if (accountForm.password.length < 8) {
accountError = 'Das Passwort braucht mindestens 8 Zeichen.'
return
}
accountBusy = true
accountError = null
try {
await riders.createLogin(accountRider, { ...accountForm })
accountDialog = false
} catch (e: any) {
accountError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
} finally {
accountBusy = false
}
}
async function removeFromTeam(userId: string) {
if (!team) return
if (userId === team.owner) return alert('Der Owner kann nicht entfernt werden.')
if (!confirm('Konto aus dem Team entfernen? Der Fahrer bleibt im Kader.')) return
try {
await teams.removeMember(team.id, userId)
} catch (e: any) {
alert(e.message ?? 'Das Mitglied konnte nicht entfernt werden.')
}
}
// ---- Einladungslinks -------------------------------------------------
let inviteDialog = $state(false)
let inviteForm = $state({ days: 14, maxUses: 0 })
let inviteBusy = $state(false)
let inviteError = $state<string | null>(null)
let copied = $state<string | null>(null)
async function createInvite() {
inviteBusy = true
inviteError = null
try {
const invite = await invites.create({ days: inviteForm.days, maxUses: inviteForm.maxUses })
// Gleich in der Zwischenablage: Wer einen Link erzeugt, will ihn
// verschicken, nicht bewundern.
await copyLink(invite)
} catch (e: any) {
inviteError = e.message ?? 'Der Link konnte nicht erzeugt werden.'
} finally {
inviteBusy = false
}
}
async function copyLink(invite: TeamInvitesResponse) {
try {
await navigator.clipboard.writeText(invites.url(invite))
copied = invite.id
setTimeout(() => (copied = copied === invite.id ? null : copied), 2000)
} catch {
// Ohne Zwischenablage-Recht bleibt der Link im Feld daneben
// stehen und lässt sich von Hand markieren.
}
}
async function removeInvite(invite: TeamInvitesResponse) {
try {
await invites.remove(invite.id)
} catch (e: any) {
alert(e.message ?? 'Der Link konnte nicht zurückgezogen werden.')
}
}
function inviteHint(invite: TeamInvitesResponse) {
const parts: string[] = []
if (!invites.isValid(invite)) parts.push('abgelaufen')
else if (invite.expires) {
parts.push(`gültig bis ${new Date(invite.expires).toLocaleDateString('de-DE')}`)
} else parts.push('ohne Ablauf')
const max = invite.max_uses ?? 0
parts.push(max > 0 ? `${invite.uses ?? 0} von ${max} genutzt` : `${invite.uses ?? 0}× genutzt`)
return parts.join(' · ')
}
// ---- Team anlegen ----------------------------------------------------
// Dialog und Anlegelogik stecken in CreateTeamDialog — derselbe Weg wie
// über den Teamwechsler im Kopf.
let createDialog = $state(false)
// ---- 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.'
}
}
// ---- Logo ------------------------------------------------------------
let logoDialog = $state(false)
let logoTeam = $state<TeamsResponse | null>(null)
let logoFile = $state<File | null>(null)
let logoBusy = $state(false)
let logoError = $state<string | null>(null)
function openLogo(t: TeamsResponse) {
logoTeam = t
logoFile = null
logoError = null
logoDialog = true
}
async function saveLogo(file: File | null) {
if (!logoTeam) return
logoBusy = true
logoError = null
try {
await teams.setLogo(logoTeam.id, file)
logoDialog = false
} catch (e: any) {
logoError = e.message ?? 'Das Logo konnte nicht gespeichert werden.'
} finally {
logoBusy = false
}
}
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}`)
}
}
</script>
<svelte:head><title>Team</title></svelte:head>
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<!--
Kein Logo neben der Ueberschrift: Auf dieser Seite ist immer das
aktive Team gemeint, und dessen Logo steht wenige Pixel darueber
schon in der Kopfleiste. Zweimal dasselbe Bild uebereinander sagt
nicht mehr als einmal.
-->
<div>
<h1 class="text-3xl font-bold tracking-tight">{team?.name ?? 'Team'}</h1>
<p class="mt-1 text-muted-foreground">
{(team?.users ?? []).length} Mitglied{(team?.users ?? []).length === 1 ? '' : 'er'}
· {riders.scoped.length} im Kader
</p>
</div>
<!--
Alles hier gilt dem aktiven Team — auf dieser Seite ist kein
anderes gemeint. „Verlassen" stand frueher in der Liste „Meine
Teams"; die ist entfallen, weil Wechseln und Anlegen im Submenue
am Menuepunkt Team stehen. Verlassen kann man nur dieses eine,
also gehoert es hierher.
-->
{#if team}
<div class="flex flex-wrap items-center gap-2">
{#if canManage}
<Button variant="outline" size="sm" onclick={() => openRename(team)}>
<Pencil class="size-4 mr-1" />
Umbenennen
</Button>
<Button variant="outline" size="sm" onclick={() => openLogo(team)}>
<Image class="size-4 mr-1" />
Logo
</Button>
<Button variant="outline" size="sm" onclick={() => (inviteDialog = true)}>
<Link2 class="size-4 mr-1" />
Einladungslink
</Button>
{/if}
{#if iAmOwner}
<Button variant="ghost" size="icon" tooltip="Team löschen" onclick={() => teams.remove(team)}>
<Trash2 class="size-4 text-destructive" />
</Button>
{:else}
<Button variant="ghost" size="sm" onclick={() => doLeave(team)}>
<LogOut class="size-4 mr-1" />
Verlassen
</Button>
{/if}
</div>
{/if}
</div>
{#if !team}
<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}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{#each stats as stat (stat.label)}
{@const Icon = stat.icon}
<Card>
<CardContent>
<div class="flex items-center justify-between">
<p class="text-sm text-muted-foreground">{stat.label}</p>
<Icon class="size-4 text-muted-foreground" />
</div>
<p class="mt-1 text-2xl font-bold">{stat.value}</p>
{#if stat.hint}
<p class="text-xs text-muted-foreground">{stat.hint}</p>
{/if}
</CardContent>
</Card>
{/each}
</div>
<!-- Kader -->
<Card>
<CardHeader>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="relative w-full 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="Name…" class="pl-9" />
</div>
{#if canManage}
<Button onclick={openCreate}>
<Plus class="size-4 mr-2" />
Person anlegen
</Button>
{/if}
</div>
</CardHeader>
<CardContent class="p-0">
{#if rows.length === 0}
<div class="py-16 text-center">
<Users class="size-10 text-muted-foreground mx-auto mb-3" />
<p class="font-medium">Noch niemand im Team</p>
<p class="text-sm text-muted-foreground mt-1">
{#if canManage}
Leg die erste Person an oder verschick einen Einladungslink.
{:else}
Die Teamleitung legt den Kader an.
{/if}
</p>
</div>
{:else if filtered.length === 0}
<div class="py-12 text-center text-muted-foreground">Keine Treffer.</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Name</Table.Head>
<Table.Head class="w-48">Konto</Table.Head>
<Table.Head class="w-32">Rolle</Table.Head>
<Table.Head class="w-24">Events</Table.Head>
<Table.Head class="w-24 text-right">Zeiten</Table.Head>
{#if canManage}
<Table.Head class="text-right">Aktionen</Table.Head>
{/if}
</Table.Row>
</Table.Header>
<Table.Body>
{#each filtered as row (row.rider?.id ?? row.userId)}
{@const path = row.rider ? riderPath(row.rider.id) : null}
<Table.Row>
<Table.Cell class="font-medium">
<span class="flex items-center gap-2">
<Avatar user={row.userId ? teams.users[row.userId] : null} size={28} />
{#if path}
<a href={path} class="hover:underline">{row.name}</a>
{:else}
{row.name}
{/if}
{#if !row.rider}
<Badge variant="outline">kein Fahrer</Badge>
{/if}
</span>
</Table.Cell>
<!--
Hier steht bewusst KEIN zweiter Name.
Der Name der Person steht am Fahrer,
der Kontoname wäre eine zweite
Wahrheit, die auseinanderläuft — und
die niemand geradeziehen könnte:
users.updateRule lässt nur den
Kontoinhaber selbst ändern, kein Admin
kommt an fremde Kontonamen heran. Das
Konto ist deshalb nur ein Ja oder
Nein: kann sich anmelden.
-->
<Table.Cell>
{#if row.userId}
<Badge variant="secondary" class="gap-1">
<UserCheck class="size-3" />
kann sich anmelden
</Badge>
{:else}
<span class="text-sm text-muted-foreground">kein Konto</span>
{/if}
</Table.Cell>
<Table.Cell>
{#if row.role === 'owner'}
<Badge class="gap-1">
<Crown class="size-3" />
Owner
</Badge>
{:else if row.role === 'admin'}
<Badge variant="secondary" class="gap-1">
<Shield class="size-3" />
Admin
</Badge>
{:else if row.role === 'mitglied'}
<Badge variant="outline">Mitglied</Badge>
{:else}
<span class="text-sm text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell class="text-sm text-muted-foreground">
{row.rider ? participants.byRider(row.rider.id).length : '—'}
</Table.Cell>
<Table.Cell class="text-right text-sm text-muted-foreground">
{row.rider ? times.getByRider(row.rider.id).length : '—'}
</Table.Cell>
{#if canManage}
<Table.Cell class="text-right whitespace-nowrap">
{#if row.rider && !row.userId}
<Button
variant="ghost"
size="icon"
tooltip="Konto anlegen"
onclick={() => openAccount(row.rider!)}
>
<KeyRound class="size-4" />
</Button>
{/if}
<Button
variant="ghost"
size="icon"
tooltip="Bearbeiten"
onclick={() => openEdit(row)}
>
<Pencil class="size-4" />
</Button>
{#if row.userId && row.role !== 'owner'}
<Button
variant="ghost"
size="icon"
tooltip="Aus dem Team entfernen"
onclick={() => removeFromTeam(row.userId!)}
>
<UserMinus class="size-4" />
</Button>
{/if}
{#if row.rider}
<Button
variant="ghost"
size="icon"
tooltip="Fahrer löschen"
onclick={() => riders.remove(row.rider!)}
>
<Trash2 class="size-4 text-destructive" />
</Button>
{/if}
</Table.Cell>
{/if}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</CardContent>
</Card>
{/if}
</div>
<!-- Person anlegen oder bearbeiten -->
<Dialog.Root bind:open={personDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{isEdit ? 'Person bearbeiten' : 'Person anlegen'}</Dialog.Title>
<Dialog.Description>
{isEdit
? 'Der Name gilt teamweit; Startnummern hängen am jeweiligen Event.'
: 'Kommt als Fahrer in den Kader. Ein Konto ist optional.'}
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
{#if editing || !isEdit}
<div class="space-y-2">
<Label for="p-name">Name</Label>
<Input id="p-name" bind:value={form.name} placeholder="Max Mustermann" />
</div>
{/if}
<!--
Rolle im Team. Nur der Owner sieht sie, und nie an sich selbst
— den Owner gibt es genau einmal, er steht am Team und nicht
in der Adminliste.
-->
{#if canSetRole}
<Separator />
<div class="space-y-2">
<Label>Rolle im Team</Label>
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
bind:checked={form.admin}
class="size-4 rounded border-input accent-primary"
/>
Admin
</label>
<p class="text-xs text-muted-foreground">
Admins verwalten Kader, Einladungslinks und Teamdaten. Löschen und
Rollen bleiben beim Owner.
</p>
</div>
{:else if isEdit && editingRole === 'owner'}
<Separator />
<p class="text-sm text-muted-foreground">
Owner des Teams — diese Rolle lässt sich hier nicht ändern.
</p>
{/if}
{#if !isEdit}
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
bind:checked={form.withAccount}
class="size-4 rounded border-input accent-primary"
/>
kann sich anmelden
</label>
{#if form.withAccount}
<div class="space-y-2">
<Label for="p-mail">E-Mail</Label>
<Input id="p-mail" type="email" bind:value={form.email} placeholder="max@example.com" />
</div>
<div class="space-y-2">
<Label for="p-pw">Passwort</Label>
<Input id="p-pw" type="password" bind:value={form.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 „Einstellungen" ändern.
</p>
</div>
{/if}
{/if}
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (personDialog = false)}>Abbrechen</Button>
<Button onclick={savePerson} disabled={busy}>
{busy ? 'Wird gespeichert …' : editing ? 'Speichern' : 'Anlegen'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Konto für einen vorhandenen Fahrer -->
<Dialog.Root bind:open={accountDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>
Konto für {accountRider ? riderName(accountRider) : 'Fahrer'}
</Dialog.Title>
<Dialog.Description>
Mit einem Konto kann sich die Person selbst anmelden. Gib das Passwort
weiter; ändern lässt es sich unter „Einstellungen".
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="a-mail">E-Mail</Label>
<Input id="a-mail" type="email" bind:value={accountForm.email} placeholder="max@example.com" />
</div>
<div class="space-y-2">
<Label for="a-pw">Passwort</Label>
<Input id="a-pw" type="password" bind:value={accountForm.password} placeholder="mindestens 8 Zeichen" />
</div>
{#if accountError}
<p class="text-sm text-destructive">{accountError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (accountDialog = false)}>Abbrechen</Button>
<Button onclick={createAccount} disabled={accountBusy}>
{accountBusy ? 'Wird angelegt …' : 'Konto anlegen'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Einladungslinks: erzeugen und verwalten an einer Stelle. Die ausgegebenen
Links stehen mit im Dialog, weil sie sonst mit der eigenen Section auch das
Kopieren und das Zurückziehen verloren hätten. -->
<Dialog.Root bind:open={inviteDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Einladungslinks</Dialog.Title>
<Dialog.Description>
Wer den Link öffnet, legt sich selbst ein Konto an und steht danach im
Team {team?.name ?? ''} und im Kader. Gib ihn nur weiter, wem du das
zutraust — der Link ist das Passwort.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
{#if invites.scoped.length > 0}
<div class="space-y-2">
{#each invites.scoped as invite (invite.id)}
<div
class="flex flex-wrap items-center gap-2 rounded-lg border p-2"
class:opacity-60={!invites.isValid(invite)}
>
<input
readonly
value={invites.url(invite)}
class="min-w-0 flex-1 bg-transparent px-1 font-mono text-xs outline-none"
onfocus={(e) => e.currentTarget.select()}
/>
<span class="text-xs text-muted-foreground whitespace-nowrap">
{inviteHint(invite)}
</span>
<Button
variant="ghost"
size="icon"
class="size-8"
tooltip="Link kopieren"
onclick={() => copyLink(invite)}
>
{#if copied === invite.id}
<Check class="size-4 text-primary" />
{:else}
<Copy class="size-4" />
{/if}
</Button>
<Button
variant="ghost"
size="icon"
class="size-8"
tooltip="Link zurückziehen"
onclick={() => removeInvite(invite)}
>
<Trash2 class="size-4 text-destructive" />
</Button>
</div>
{/each}
</div>
<Separator />
{/if}
<div class="space-y-2">
<Label for="i-days">Gültig für (Tage)</Label>
<Input id="i-days" type="number" min="0" bind:value={inviteForm.days} />
<p class="text-xs text-muted-foreground">0 = läuft nicht ab.</p>
</div>
<div class="space-y-2">
<Label for="i-max">Höchstens einlösbar</Label>
<Input id="i-max" type="number" min="0" bind:value={inviteForm.maxUses} />
<p class="text-xs text-muted-foreground">0 = beliebig oft.</p>
</div>
{#if inviteError}
<p class="text-sm text-destructive">{inviteError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (inviteDialog = false)}>Schließen</Button>
<Button onclick={createInvite} disabled={inviteBusy}>
{inviteBusy ? 'Wird erzeugt …' : 'Erzeugen und kopieren'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Team anlegen -->
<CreateTeamDialog bind:open={createDialog} />
<!-- 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>
<!-- Logo -->
<Dialog.Root bind:open={logoDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Logo für „{logoTeam?.name}"</Dialog.Title>
<Dialog.Description>
Erscheint in der Kopfzeile und blass im Seitenhintergrund. Ohne eigenes
Logo steht dort das Wappen des Stammtischs.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="flex items-center gap-4">
<Logo team={logoTeam} class="size-20 shrink-0" />
<div class="space-y-1 min-w-0">
<Label for="team-logo">Datei</Label>
<input
id="team-logo"
type="file"
accept="image/*"
class="block w-full text-sm"
onchange={(e) => (logoFile = e.currentTarget.files?.[0] ?? null)}
/>
<p class="text-xs text-muted-foreground">PNG, SVG oder WebP, bis 5 MB.</p>
</div>
</div>
{#if logoError}
<p class="text-sm text-destructive">{logoError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (logoDialog = false)}>Abbrechen</Button>
{#if logoTeam?.logo}
<Button variant="outline" disabled={logoBusy} onclick={() => saveLogo(null)}>
Entfernen
</Button>
{/if}
<Button disabled={!logoFile || logoBusy} onclick={() => saveLogo(logoFile)}>
{logoBusy ? 'Wird gespeichert …' : 'Speichern'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 55 KiB