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
587 lines
24 KiB
Svelte
587 lines
24 KiB
Svelte
<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>
|