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:
parent
8c11095b17
commit
45835cd45c
13 changed files with 1125 additions and 1234 deletions
28
CLAUDE.md
28
CLAUDE.md
|
|
@ -60,7 +60,20 @@ docker compose down
|
|||
Container und müssen mit `docker compose cp` ins Repo geholt werden; siehe
|
||||
`backend/README.md`.
|
||||
- **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
|
||||
- **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
|
||||
|
||||
- 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/`
|
||||
- Stores use `.svelte.ts` extension for Svelte 5 runes
|
||||
- Static assets in `static/`
|
||||
|
|
|
|||
31
NAMING.md
Normal file
31
NAMING.md
Normal 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
118
TODO.md
|
|
@ -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.
|
||||
|
|
@ -27,19 +27,19 @@ class AuthStore {
|
|||
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) => {
|
||||
|
||||
this.loading = true
|
||||
this.error = ''
|
||||
|
||||
|
||||
if (this.error) {
|
||||
this.loading = false
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -246,19 +246,6 @@ export class TeamStore {
|
|||
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() {
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe()
|
||||
|
|
|
|||
|
|
@ -42,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/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}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { goto } from '$app/navigation'
|
||||
import { getEventContext } from '$lib/stores/events.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 { getTeamContext } from '$lib/stores/teams.svelte'
|
||||
import {
|
||||
|
|
@ -283,7 +283,7 @@
|
|||
{#if canManage && available.length === 0 && riders.scoped.length === 0}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
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>
|
||||
{/if}
|
||||
|
||||
|
|
@ -322,8 +322,8 @@
|
|||
<Table.Cell class="font-medium">
|
||||
<!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. -->
|
||||
<a href={riderPath(p.rider)} class="hover:underline">
|
||||
{#if rider && fullName(rider)}
|
||||
{fullName(rider)}
|
||||
{#if rider && riderName(rider)}
|
||||
{riderName(rider)}
|
||||
{:else}
|
||||
<span class="text-muted-foreground italic">— ohne Name —</span>
|
||||
{/if}
|
||||
|
|
@ -507,12 +507,12 @@
|
|||
<Select.Root type="single" bind:value={form.rider}>
|
||||
<Select.Trigger class="w-full">
|
||||
{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…'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#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}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -2,27 +2,19 @@
|
|||
/**
|
||||
* Rahmen der Einstellungen: eine Überschrift, darunter Reiter je Bereich.
|
||||
*
|
||||
* „Team" ist die Übersicht des Teams, „Fahrer & Konten" führt die
|
||||
* Menschen: Jedes Mitglied ist ein Fahrer, ein Konto hat aber nicht jeder.
|
||||
* Beides an einer Stelle, weil es sonst zwei Wahrheiten über dieselbe
|
||||
* Person gäbe. Der Reiter erscheint nur für die Teamleitung.
|
||||
* Hier steht nur noch, was einen selbst betrifft — das eigene Profil und
|
||||
* die Flag-Typen. Team und Kader sind unter „Team" zu Hause; sie sind
|
||||
* keine Einstellung, sondern der Gegenstand der Anwendung.
|
||||
*/
|
||||
import { page } from '$app/state'
|
||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||
import { Flag, User, Users, Bike } from 'lucide-svelte'
|
||||
import { Flag, User } from 'lucide-svelte'
|
||||
|
||||
let { children } = $props()
|
||||
|
||||
const teams = getTeamContext()
|
||||
|
||||
const tabs = $derived([
|
||||
const tabs = [
|
||||
{ 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 },
|
||||
])
|
||||
]
|
||||
|
||||
// Das Profil liegt auf der Wurzel; ohne den Sonderfall wäre sein Reiter
|
||||
// auf jedem Unterpfad mit aktiv.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
953
frontend/src/routes/dashboard/team/+page.svelte
Normal file
953
frontend/src/routes/dashboard/team/+page.svelte
Normal 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>
|
||||
95
frontend/static/pedaler.svg
Normal file
95
frontend/static/pedaler.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 55 KiB |
Loading…
Reference in a new issue