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
352 lines
14 KiB
Svelte
352 lines
14 KiB
Svelte
<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>
|