refactor: Fahrer und Konten in einem Reiter, Team wird zur Uebersicht

Dieselben Menschen wurden an zwei Stellen gepflegt: Mitglieder unter Team,
Fahrer unter Fahrer - und die Zuordnung dazwischen von Hand. Jedes
Teammitglied ist aber ein Fahrer; nur hat nicht jeder Fahrer ein Konto.

"Fahrer & Konten" fuehrt beides in einer Liste: Kader, daneben wer sich
anmelden kann und mit welcher Rolle. Angelegt wird eine Person als Fahrer,
das Konto ist eine Checkbox im selben Dialog - mit E-Mail, sobald sie
gesetzt ist. Rollen, Entfernen aus dem Team und die Kontosuche wandern
ebenfalls hierher.

Konten im Team ohne Fahrer bekommen eine eigene Zeile samt "Als Fahrer
uebernehmen". Sie verschwaenden sonst aus der Ansicht, obwohl sie Mitglied
sind.

"Team" bleibt als Uebersicht: Kennzahlen des aktiven Teams, Name, Logo,
Wechseln, Verlassen, Loeschen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
This commit is contained in:
Daniel Michelberger 2026-09-02 16:18:47 +02:00
parent b1594d99c4
commit e3adddafda
3 changed files with 493 additions and 423 deletions

View file

@ -1,9 +1,11 @@
<script lang="ts">
/**
* Rahmen der Einstellungen: eine Überschrift, darunter Reiter je Bereich.
* Der Reiter „Fahrer" erscheint nur für die Teamleitung — dort wird
* angelegt und verknüpft, was alle anderen nur unter „Fahrer" im
* Hauptmenü zu sehen bekommen.
*
* „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.
*/
import { page } from '$app/state'
import { getTeamContext } from '$lib/stores/teams.svelte'
@ -17,7 +19,7 @@
{ href: '/dashboard/settings', label: 'Profil', icon: User },
{ href: '/dashboard/settings/team', label: 'Team', icon: Users },
...(teams.isAdmin()
? [{ href: '/dashboard/settings/riders', label: 'Fahrer', icon: Bike }]
? [{ href: '/dashboard/settings/riders', label: 'Fahrer & Konten', icon: Bike }]
: []),
{ href: '/dashboard/settings/flags', label: 'Flag-Typen', icon: Flag },
])

View file

@ -1,147 +1,269 @@
<script lang="ts">
/**
* Fahrerverwaltung — nur für die Teamleitung. Was alle sehen dürfen,
* steht unter „Fahrer" im Hauptmenü; angelegt, geändert und mit einem
* Login verknüpft wird ausschließlich hier.
* 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 Select from '@/components/ui/select'
import * as Table from '@/components/ui/table'
import { KeyRound, Pencil, Plus, Trash2, Unlink, UserCheck } from 'lucide-svelte'
import type { RidersResponse } from '$lib/types'
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 roster = $derived(riders.scoped)
const team = $derived(teams.active)
const iAmOwner = $derived(teams.isOwner())
$effect(() => {
teams.loadMembers()
})
// --- Fahrer anlegen und ändern ---------------------------------------
let riderDialog = $state(false)
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: '' })
let riderError = $state<string | 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: '' }
riderError = null
riderDialog = true
form = { firstname: '', lastname: '', withAccount: false, email: '', password: '' }
error = null
personDialog = true
}
function openEdit(r: RidersResponse) {
editing = r
function openEdit(rider: RidersResponse) {
editing = rider
form = {
firstname: r.firstname ?? '',
lastname: r.lastname ?? '',
firstname: rider.firstname ?? '',
lastname: rider.lastname ?? '',
withAccount: false,
email: '',
password: '',
}
riderError = null
riderDialog = true
error = null
personDialog = true
}
async function saveRider() {
async function savePerson() {
if (!form.firstname.trim() && !form.lastname.trim()) {
riderError = 'Bitte wenigstens einen Namen angeben.'
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
}
riderError = null
accountBusy = true
accountError = null
try {
const data = {
firstname: form.firstname.trim(),
lastname: form.lastname.trim(),
}
if (editing) await riders.edit(editing.id, data)
else await riders.create(data)
riderDialog = false
await riders.createLogin(accountRider, { ...accountForm })
accountDialog = false
} catch (e: any) {
riderError = e.message ?? 'Der Fahrer konnte nicht gespeichert werden.'
}
}
// --- Login verknüpfen -------------------------------------------------
let loginDialog = $state(false)
let loginRider = $state<RidersResponse | null>(null)
let loginMode = $state<'link' | 'create'>('link')
let linkUserId = $state('')
let loginForm = $state({ email: '', password: '' })
let loginBusy = $state(false)
let loginError = $state<string | null>(null)
/** Teammitglieder, die noch an keinem anderen Fahrer hängen. */
const linkCandidates = $derived.by(() => {
const taken = new Set(
roster.filter((r) => r.user && r.id !== loginRider?.id).map((r) => r.user),
)
return (teams.active?.users ?? []).filter((id) => !taken.has(id))
})
function openLogin(r: RidersResponse) {
loginRider = r
loginMode = 'link'
linkUserId = r.user ?? ''
loginForm = { email: '', password: '' }
loginError = null
loginDialog = true
}
async function doLink() {
if (!loginRider || !linkUserId) return
loginBusy = true
loginError = null
try {
await riders.linkUser(loginRider.id, linkUserId)
loginDialog = false
} catch (e: any) {
loginError = e.message ?? 'Die Verknüpfung ist fehlgeschlagen.'
accountError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
} finally {
loginBusy = false
accountBusy = false
}
}
async function doUnlink(r: RidersResponse) {
async function searchAccount() {
if (!searchEmail.trim()) return
searching = true
accountError = null
found = null
try {
await riders.linkUser(r.id, null)
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.')
}
}
async function doCreateLogin() {
if (!loginRider) return
/** Ein Konto ohne Fahrer nachträglich in den Kader holen. */
async function adoptAccount(userId: string) {
const user = teams.users[userId]
if (!loginForm.email.trim()) {
loginError = 'Ohne E-Mail-Adresse gibt es keinen Anmeldenamen.'
return
}
if (loginForm.password.length < 8) {
loginError = 'Das Passwort braucht mindestens 8 Zeichen.'
return
}
loginBusy = true
loginError = null
try {
await riders.createLogin(loginRider, { ...loginForm })
loginDialog = false
const rider = await riders.create({
firstname: user?.name ?? '',
lastname: '',
})
await riders.linkUser(rider.id, userId)
} catch (e: any) {
loginError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
} finally {
loginBusy = false
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>
@ -149,28 +271,27 @@
{#if !canManage}
<Card>
<CardContent class="py-12 text-center text-muted-foreground">
Fahrer verwaltet, wer das Team führt. Die Fahrer deines Teams siehst du
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 items-center justify-between gap-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-sm text-muted-foreground">
Der Kader des Teams. Wer bei einem Event dabei ist und mit welcher
Startnummer, steht am Event selbst. Ein verknüpftes Konto erlaubt der
Person, sich anzumelden.
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" />
Fahrer anlegen
Person anlegen
</Button>
</div>
{#if roster.length === 0}
{#if rows.length === 0}
<Card>
<CardContent class="py-12 text-center text-muted-foreground">
Noch keine Fahrer im Team.
Noch niemand im Team.
</CardContent>
</Card>
{:else}
@ -180,51 +301,128 @@
<Table.Header>
<Table.Row>
<Table.Head>Name</Table.Head>
<Table.Head class="w-32">Teilnahmen</Table.Head>
<Table.Head>Konto</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 roster as r (r.id)}
{#each rows as row (row.rider?.id ?? row.userId)}
<Table.Row>
<Table.Cell class="font-medium">
{fullName(r) || '— ohne Name —'}
</Table.Cell>
<Table.Cell class="text-muted-foreground text-sm">
{participants.byRider(r.id).length}
<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 r.user}
<span class="inline-flex items-center gap-2">
<Badge variant="secondary" class="gap-1">
<UserCheck class="size-3" />
kann sich einloggen
</Badge>
<span class="text-sm text-muted-foreground">
{teams.userLabel(r.user)}
</span>
{#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 r.user}
<Button variant="ghost" size="icon" tooltip="Verknüpfung lösen" onclick={() => doUnlink(r)}>
<Unlink class="size-4" />
</Button>
{:else}
<Button variant="ghost" size="icon" tooltip="Konto verknüpfen" onclick={() => openLogin(r)}>
<KeyRound class="size-4" />
{#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}
<Button variant="ghost" size="icon" tooltip="Bearbeiten" onclick={() => openEdit(r)}>
<Pencil class="size-4" />
</Button>
<Button variant="ghost" size="icon" tooltip="Löschen" onclick={() => riders.remove(r)}>
<Trash2 class="size-4 text-destructive" />
</Button>
</Table.Cell>
</Table.Row>
{/each}
@ -236,39 +434,77 @@
</div>
{/if}
<!-- Fahrer anlegen/bearbeiten -->
<Dialog.Root bind:open={riderDialog}>
<!-- Person anlegen oder bearbeiten -->
<Dialog.Root bind:open={personDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{editing ? 'Fahrer bearbeiten' : 'Neuer Fahrer'}</Dialog.Title>
<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="rider-first">Vorname</Label>
<Input id="rider-first" bind:value={form.firstname} placeholder="Max" />
<Label for="p-first">Vorname</Label>
<Input id="p-first" bind:value={form.firstname} placeholder="Max" />
</div>
<div class="space-y-2">
<Label for="rider-last">Nachname</Label>
<Input id="rider-last" bind:value={form.lastname} placeholder="Mustermann" />
<Label for="p-last">Nachname</Label>
<Input id="p-last" bind:value={form.lastname} placeholder="Mustermann" />
</div>
</div>
{#if riderError}
<p class="text-sm text-destructive">{riderError}</p>
{#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={() => (riderDialog = false)}>Abbrechen</Button>
<Button onclick={saveRider}>{editing ? 'Speichern' : 'Anlegen'}</Button>
<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 verknüpfen oder anlegen -->
<Dialog.Root bind:open={loginDialog}>
<!-- Konto für einen vorhandenen Fahrer -->
<Dialog.Root bind:open={accountDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Konto für {loginRider ? fullName(loginRider) : 'Fahrer'}</Dialog.Title>
<Dialog.Title>
Konto für {accountRider ? fullName(accountRider) : 'Fahrer'}
</Dialog.Title>
<Dialog.Description>
Mit einem Konto kann sich die Person selbst anmelden.
</Dialog.Description>
@ -276,69 +512,75 @@
<div class="flex gap-2">
<Button
variant={loginMode === 'link' ? 'default' : 'outline'}
variant={accountMode === 'neu' ? 'default' : 'outline'}
size="sm"
onclick={() => (loginMode = 'link')}
>
Vorhandenes verknüpfen
</Button>
<Button
variant={loginMode === 'create' ? 'default' : 'outline'}
size="sm"
onclick={() => (loginMode = 'create')}
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 loginMode === 'link'}
{#if accountMode === 'neu'}
<div class="space-y-2">
<Label>Teammitglied</Label>
<Select.Root type="single" bind:value={linkUserId}>
<Select.Trigger class="w-full">
{linkUserId ? teams.userLabel(linkUserId) : 'Mitglied wählen…'}
</Select.Trigger>
<Select.Content>
{#each linkCandidates as id (id)}
<Select.Item value={id}>{teams.userLabel(id)}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if linkCandidates.length === 0}
<p class="text-xs text-muted-foreground">
Alle Teammitglieder sind bereits einem Fahrer zugeordnet.
</p>
{/if}
<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="login-mail">E-Mail</Label>
<Input id="login-mail" type="email" bind:value={loginForm.email} placeholder="max@example.com" />
</div>
<div class="space-y-2">
<Label for="login-pw">Passwort</Label>
<Input id="login-pw" type="password" bind:value={loginForm.password} placeholder="mindestens 8 Zeichen" />
<p class="text-xs text-muted-foreground">
Das Konto kommt zugleich ins Team. Gib das Passwort weiter; es lässt
sich unter „Profil" ändern.
</p>
<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 loginError}
<p class="text-sm text-destructive">{loginError}</p>
{#if accountError}
<p class="text-sm text-destructive">{accountError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (loginDialog = false)}>Abbrechen</Button>
{#if loginMode === 'link'}
<Button onclick={doLink} disabled={!linkUserId || loginBusy}>Verknüpfen</Button>
{:else}
<Button onclick={doCreateLogin} disabled={loginBusy}>
{loginBusy ? 'Wird angelegt …' : 'Konto anlegen'}
<Button 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>

View file

@ -6,6 +6,10 @@
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'
@ -14,13 +18,24 @@
import * as Dialog from '@/components/ui/dialog'
import Avatar from '$lib/components/Avatar.svelte'
import Logo from '$lib/components/Logo.svelte'
import { Crown, Image, LogOut, Plus, Shield, Trash2, UserMinus, UserPlus, Users } from 'lucide-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)
@ -83,6 +98,25 @@
}
}
// 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)
@ -112,91 +146,6 @@
}
}
// ---- Mitglied hinzufügen ----
// Zwei Wege: ein bestehendes Konto über seine E-Mail suchen (seit
// 1754501200 wieder möglich) oder ein neues anlegen.
let memberMode = $state<'suchen' | 'anlegen'>('suchen')
let searchEmail = $state('')
let searching = $state(false)
let found = $state<UsersResponse | null>(null)
let memberDialog = $state(false)
let memberTeam = $state<TeamsResponse | null>(null)
let memberForm = $state({ name: '', email: '', password: '' })
let memberBusy = $state(false)
let memberError = $state<string | null>(null)
function openMember(t: TeamsResponse) {
memberTeam = t
memberMode = 'suchen'
searchEmail = ''
found = null
memberForm = { name: '', email: '', password: '' }
memberError = null
memberDialog = true
}
async function searchMember() {
if (!searchEmail.trim()) return
searching = true
memberError = null
found = null
try {
const user = await teams.findUserByEmail(searchEmail.trim())
if (user) found = user
else memberError = `Kein Konto mit „${searchEmail}" gefunden.`
} catch (e: any) {
memberError = e.message ?? 'Die Suche ist fehlgeschlagen.'
} finally {
searching = false
}
}
async function addFound() {
if (!memberTeam || !found) return
if ((memberTeam.users ?? []).includes(found.id)) {
memberError = 'Diese Person ist bereits im Team.'
return
}
memberBusy = true
memberError = null
try {
await teams.addMember(memberTeam.id, found.id)
memberDialog = false
} catch (e: any) {
memberError = e.message ?? 'Das Konto konnte nicht hinzugefügt werden.'
} finally {
memberBusy = false
}
}
async function doCreateMember() {
if (!memberTeam) return
if (!memberForm.email.trim()) {
memberError = 'Ohne E-Mail-Adresse gibt es keinen Anmeldenamen.'
return
}
if (memberForm.password.length < 8) {
memberError = 'Das Passwort braucht mindestens 8 Zeichen.'
return
}
memberBusy = true
memberError = null
try {
await teams.createMember({ ...memberForm }, memberTeam.id)
memberDialog = false
} catch (e: any) {
memberError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
} finally {
memberBusy = false
}
}
// ---- Aktionen ----
async function doLeave(t: TeamsResponse) {
if (!confirm(`Team „${t.name}" wirklich verlassen?`)) return
@ -207,18 +156,29 @@
}
}
async function doRemoveMember(t: TeamsResponse, userId: string) {
if (userId === t.owner) return alert('Der Owner kann nicht entfernt werden.')
if (!confirm('Mitglied wirklich entfernen?')) return
try {
await teams.removeMember(t.id, userId)
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
}
</script>
<div class="space-y-4">
<div class="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
@ -277,10 +237,6 @@
<Image class="size-4 mr-1" />
Logo
</Button>
<Button variant="outline" size="sm" onclick={() => openMember(t)}>
<UserPlus class="size-4 mr-1" />
Mitglied
</Button>
{/if}
{#if !iAmOwner}
<Button variant="ghost" size="sm" onclick={() => doLeave(t)}>
@ -295,49 +251,9 @@
</div>
</div>
</CardHeader>
<CardContent>
<ul class="divide-y border-t">
{#each (t.users ?? []) as userId (userId)}
{@const isOwner = userId === t.owner}
{@const isAdm = (t.admins ?? []).includes(userId)}
<li class="py-3 flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<Avatar user={teams.users[userId]} size={28} />
<span class="font-medium truncate">{teams.userLabel(userId)}</span>
{#if isOwner}
<Badge class="gap-1">
<Crown class="size-3" />
Owner
</Badge>
{:else if isAdm}
<Badge variant="secondary" class="gap-1">
<Shield class="size-3" />
Admin
</Badge>
{/if}
{#if userId === me}
<Badge variant="outline">Du</Badge>
{/if}
</div>
{#if iAmOwner && !isOwner}
<div class="flex items-center gap-1 shrink-0">
{#if isAdm}
<Button variant="ghost" size="sm" onclick={() => teams.demoteFromAdmin(t.id, userId)}>
Admin entziehen
</Button>
{:else}
<Button variant="ghost" size="sm" onclick={() => teams.promoteToAdmin(t.id, userId)}>
Zu Admin
</Button>
{/if}
<Button variant="ghost" size="icon" tooltip="Entfernen" onclick={() => doRemoveMember(t, userId)}>
<UserMinus class="size-4 text-destructive" />
</Button>
</div>
{/if}
</li>
{/each}
</ul>
<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}
@ -389,96 +305,6 @@
</Dialog.Content>
</Dialog.Root>
<!-- Mitglied hinzufügen -->
<Dialog.Root bind:open={memberDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Mitglied hinzufügen</Dialog.Title>
<Dialog.Description>
Für „{memberTeam?.name}" — entweder ein bestehendes Konto oder ein neues.
</Dialog.Description>
</Dialog.Header>
<div class="flex gap-2">
<Button
variant={memberMode === 'suchen' ? 'default' : 'outline'}
size="sm"
onclick={() => (memberMode = 'suchen')}
>
Vorhandenes Konto
</Button>
<Button
variant={memberMode === 'anlegen' ? 'default' : 'outline'}
size="sm"
onclick={() => (memberMode = 'anlegen')}
>
Neues anlegen
</Button>
</div>
<div class="space-y-4">
{#if memberMode === 'suchen'}
<div class="space-y-2">
<Label for="search-mail">E-Mail</Label>
<div class="flex gap-2">
<Input
id="search-mail"
type="email"
bind:value={searchEmail}
placeholder="max@example.com"
onkeydown={(e) => e.key === 'Enter' && searchMember()}
/>
<Button variant="outline" onclick={searchMember} disabled={searching}>
{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">Konto gefunden</p>
</div>
</div>
{/if}
{:else}
<div class="space-y-2">
<Label for="member-name">Name</Label>
<Input id="member-name" bind:value={memberForm.name} placeholder="Max Mustermann" />
</div>
<div class="space-y-2">
<Label for="member-mail">E-Mail</Label>
<Input id="member-mail" type="email" bind:value={memberForm.email} placeholder="max@example.com" />
</div>
<div class="space-y-2">
<Label for="member-pw">Passwort</Label>
<Input id="member-pw" type="password" bind:value={memberForm.password} placeholder="mindestens 8 Zeichen" />
<p class="text-xs text-muted-foreground">
Gib es der Person weiter; sie kann es unter „Profil" ändern.
</p>
</div>
{/if}
{#if memberError}
<p class="text-sm text-destructive">{memberError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (memberDialog = false)}>Abbrechen</Button>
{#if memberMode === 'suchen'}
<Button onclick={addFound} disabled={!found || memberBusy}>Hinzufügen</Button>
{:else}
<Button onclick={doCreateMember} disabled={memberBusy}>
{memberBusy ? 'Wird angelegt …' : 'Anlegen'}
</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Logo -->
<Dialog.Root bind:open={logoDialog}>
<Dialog.Content>