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"> <script lang="ts">
/** /**
* Rahmen der Einstellungen: eine Überschrift, darunter Reiter je Bereich. * 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 * „Team" ist die Übersicht des Teams, „Fahrer & Konten" führt die
* Hauptmenü zu sehen bekommen. * 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 { page } from '$app/state'
import { getTeamContext } from '$lib/stores/teams.svelte' import { getTeamContext } from '$lib/stores/teams.svelte'
@ -17,7 +19,7 @@
{ href: '/dashboard/settings', label: 'Profil', icon: User }, { href: '/dashboard/settings', label: 'Profil', icon: User },
{ href: '/dashboard/settings/team', label: 'Team', icon: Users }, { href: '/dashboard/settings/team', label: 'Team', icon: Users },
...(teams.isAdmin() ...(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 }, { href: '/dashboard/settings/flags', label: 'Flag-Typen', icon: Flag },
]) ])

View file

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

View file

@ -6,6 +6,10 @@
import { api } from '$lib/stores/pocketbase.svelte' import { api } from '$lib/stores/pocketbase.svelte'
import { getTeamContext } from '$lib/stores/teams.svelte' import { getTeamContext } from '$lib/stores/teams.svelte'
import { getTrailFlagContext } from '$lib/stores/trailFlags.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 { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@ -14,13 +18,24 @@
import * as Dialog from '@/components/ui/dialog' import * as Dialog from '@/components/ui/dialog'
import Avatar from '$lib/components/Avatar.svelte' import Avatar from '$lib/components/Avatar.svelte'
import Logo from '$lib/components/Logo.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' import type { TeamsResponse, UsersResponse } from '$lib/types'
const teams = getTeamContext() const teams = getTeamContext()
const flags = getTrailFlagContext() const flags = getTrailFlagContext()
const events = getEventContext()
const riders = getRiderContext()
const trails = getTrailContext()
const markers = getTrailMarkerContext()
const me = $derived(api.authStore.record?.id ?? null) 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. // Namen der Mitglieder nachladen — ein Team kennt nur deren IDs.
$effect(() => { $effect(() => {
for (const t of teams.records) teams.loadMembers(t) 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 ---- // ---- Logo ----
let logoDialog = $state(false) let logoDialog = $state(false)
let logoTeam = $state<TeamsResponse | null>(null) 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 ---- // ---- Aktionen ----
async function doLeave(t: TeamsResponse) { async function doLeave(t: TeamsResponse) {
if (!confirm(`Team „${t.name}" wirklich verlassen?`)) return 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> </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"> <div class="flex items-center justify-between gap-4">
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
Teams, in denen du bist. Das aktive Team bestimmt, welche Events, Fahrer und Teams, in denen du bist. Das aktive Team bestimmt, welche Events, Fahrer und
@ -277,10 +237,6 @@
<Image class="size-4 mr-1" /> <Image class="size-4 mr-1" />
Logo Logo
</Button> </Button>
<Button variant="outline" size="sm" onclick={() => openMember(t)}>
<UserPlus class="size-4 mr-1" />
Mitglied
</Button>
{/if} {/if}
{#if !iAmOwner} {#if !iAmOwner}
<Button variant="ghost" size="sm" onclick={() => doLeave(t)}> <Button variant="ghost" size="sm" onclick={() => doLeave(t)}>
@ -295,49 +251,9 @@
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent class="text-sm text-muted-foreground">
<ul class="divide-y border-t"> {(t.users ?? []).length} Mitglied{(t.users ?? []).length === 1 ? '' : 'er'}
{#each (t.users ?? []) as userId (userId)} · verwaltet unter <a href="/dashboard/settings/riders" class="underline">Fahrer</a>
{@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> </CardContent>
</Card> </Card>
{/each} {/each}
@ -389,96 +305,6 @@
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </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 --> <!-- Logo -->
<Dialog.Root bind:open={logoDialog}> <Dialog.Root bind:open={logoDialog}>
<Dialog.Content> <Dialog.Content>