feat: Einstellungen als Reiter, Fahrerverzeichnis, datierte Events
Die Einstellungen bekommen Reiter: Profil, Team, Fahrer (nur Teamleitung) und Flag-Typen. /dashboard/teams wandert dorthin. Neu ist die Hauptroute Fahrer - das Verzeichnis des Teams, fuer alle sichtbar und fuer niemanden dort aenderbar. Angelegt, geaendert und mit einem Konto verknuepft wird in den Einstellungen; serverseitig setzen das die neuen Regeln der Collection durch. Ein Fahrer bekommt sein Login entweder aus den vorhandenen Teammitgliedern oder direkt neu angelegt. Die Einladung per E-Mail-Suche entfaellt: Sie konnte nie funktionieren, weil users.listRule nur den eigenen Datensatz freigibt - daher auch die abgeschnittenen IDs in der alten Mitgliederliste. Events zeigen statt eines Status ihr Datum, mit Checkbox fuer mehrtaegig. Gespeichert wird ein Enddatum nur, wenn es sich vom Beginn unterscheidet - es ist damit zugleich das Kennzeichen fuer mehrtaegig statt einer zweiten Wahrheit ueber dieselbe Sache. Listen gruppieren nach laufend, anstehend, ohne Termin und vorbei. Im Kopf teilen sich Name, Design-Umschalter und Logout eine Zeile, das Team-Dropdown sitzt darunter; das Logo passt jetzt in die Leiste, statt beschnitten zu wirken. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
This commit is contained in:
parent
1cd708e0ff
commit
7c3cf29354
17 changed files with 1568 additions and 467 deletions
|
|
@ -35,7 +35,7 @@
|
|||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={() => goto('/dashboard/teams')}>
|
||||
<DropdownMenu.Item onclick={() => goto('/dashboard/settings/team')}>
|
||||
<Users class="h-4 w-4 mr-2" />
|
||||
Teams verwalten
|
||||
</DropdownMenu.Item>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { getContext, setContext } from 'svelte'
|
|||
import { api } from './pocketbase.svelte'
|
||||
import { app } from './app.svelte'
|
||||
import { getTeamContext } from './teams.svelte'
|
||||
import { dayOf, todayISO } from '$lib/time'
|
||||
import type { EventsResponse } from '$lib/types'
|
||||
|
||||
const KEY = Symbol('event')
|
||||
|
|
@ -87,14 +88,56 @@ export class EventStore {
|
|||
return this.records.find((r) => r.id === id)
|
||||
}
|
||||
|
||||
get active() {
|
||||
return this.scoped.filter((r) => r.status === 'active')
|
||||
/**
|
||||
* Nach Datum sortiert, das nächste zuerst. Events ohne Datum hängen
|
||||
* hinten dran — sie sind noch nicht terminiert.
|
||||
*/
|
||||
get byDate(): EventsResponse[] {
|
||||
return [...this.scoped].sort((a, b) => {
|
||||
const da = dayOf(a.starts)
|
||||
const db = dayOf(b.starts)
|
||||
if (!da && !db) return 0
|
||||
if (!da) return 1
|
||||
if (!db) return -1
|
||||
return da < db ? -1 : da > db ? 1 : 0
|
||||
})
|
||||
}
|
||||
get drafts() {
|
||||
return this.scoped.filter((r) => r.status === 'draft')
|
||||
|
||||
/**
|
||||
* Läuft heute. Ohne Enddatum zählt der Starttag allein — genau dann ist
|
||||
* ein eintägiges Event „heute".
|
||||
*/
|
||||
get active(): EventsResponse[] {
|
||||
const today = todayISO()
|
||||
return this.byDate.filter((r) => {
|
||||
const from = dayOf(r.starts)
|
||||
if (!from) return false
|
||||
const to = dayOf(r.ends) || from
|
||||
return from <= today && today <= to
|
||||
})
|
||||
}
|
||||
get finished() {
|
||||
return this.scoped.filter((r) => r.status === 'finished')
|
||||
|
||||
/** Steht noch an. */
|
||||
get upcoming(): EventsResponse[] {
|
||||
const today = todayISO()
|
||||
return this.byDate.filter((r) => dayOf(r.starts) > today)
|
||||
}
|
||||
|
||||
/** Vorbei — der letzte Tag liegt hinter uns. */
|
||||
get past(): EventsResponse[] {
|
||||
const today = todayISO()
|
||||
return this.byDate
|
||||
.filter((r) => {
|
||||
const from = dayOf(r.starts)
|
||||
if (!from) return false
|
||||
return (dayOf(r.ends) || from) < today
|
||||
})
|
||||
.reverse()
|
||||
}
|
||||
|
||||
/** Noch ohne Termin. */
|
||||
get undated(): EventsResponse[] {
|
||||
return this.byDate.filter((r) => !dayOf(r.starts))
|
||||
}
|
||||
|
||||
destroy() {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { getContext, setContext } from 'svelte'
|
|||
import { api } from './pocketbase.svelte'
|
||||
import { app } from './app.svelte'
|
||||
import { getTeamContext } from './teams.svelte'
|
||||
import type { RidersResponse } from '$lib/types'
|
||||
import type { RidersResponse, UsersResponse } from '$lib/types'
|
||||
|
||||
const KEY = Symbol('rider')
|
||||
|
||||
|
|
@ -34,6 +34,15 @@ export class RiderStore {
|
|||
return this.records.filter((r) => r.team === teamId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Anlegen, ändern und löschen darf nur die Teamleitung — dieselbe Regel
|
||||
* wie in createRule/updateRule/deleteRule der Collection. Die Prüfung
|
||||
* hier blendet nur UI aus; durchgesetzt wird sie serverseitig.
|
||||
*/
|
||||
get canManage(): boolean {
|
||||
return this.teams.isAdmin()
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
|
@ -102,6 +111,34 @@ export class RiderStore {
|
|||
return this.records.find((r) => r.id === id)
|
||||
}
|
||||
|
||||
/** Fahrer, der zu einem Login gehört — für „das bin ich"-Ansichten. */
|
||||
getByUser(userId: string) {
|
||||
return this.scoped.filter((r) => r.user === userId)
|
||||
}
|
||||
|
||||
/** Bestehendes Konto zuordnen oder die Zuordnung lösen. */
|
||||
async linkUser(riderId: string, userId: string | null) {
|
||||
return await this.edit(riderId, { user: userId ?? '' } as Partial<RidersResponse>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt dem Fahrer ein Login an und verknüpft es. Das Konto entsteht im
|
||||
* Team-Store — es ist zuerst ein Teammitglied und erst dann dieser Fahrer.
|
||||
*/
|
||||
async createLogin(
|
||||
rider: RidersResponse,
|
||||
data: { email: string; password: string; name?: string },
|
||||
): Promise<UsersResponse> {
|
||||
const user = await this.teams.createMember({
|
||||
...data,
|
||||
name: data.name?.trim() || fullName(rider),
|
||||
})
|
||||
|
||||
await this.linkUser(rider.id, user.id)
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
getByEvent(eventId: string) {
|
||||
return this.scoped.filter((r) => r.event === eventId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,13 @@ export class TeamStore {
|
|||
loading = $state(false)
|
||||
error = $state<string | null>(null)
|
||||
|
||||
/**
|
||||
* Aufgelöste User-Datensätze, nach ID. Ein Team kennt nur die IDs seiner
|
||||
* Mitglieder; die Namen holt sich die Oberfläche einzeln nach. Cache,
|
||||
* damit dieselbe ID nicht auf jeder Seite erneut geladen wird.
|
||||
*/
|
||||
users = $state<Record<string, UsersResponse>>({})
|
||||
|
||||
private unsubscribe: (() => void) | null = null
|
||||
|
||||
get active(): TeamsResponse | null {
|
||||
|
|
@ -168,6 +175,63 @@ export class TeamStore {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Einen User nachladen. Sichtbar ist ein fremder Datensatz nur über seine
|
||||
* ID (users.viewRule); durchblättern lässt sich die Collection nicht.
|
||||
*/
|
||||
async loadUser(id: string): Promise<UsersResponse | null> {
|
||||
if (this.users[id]) return this.users[id]
|
||||
|
||||
try {
|
||||
const u = await api.collection('users').getOne(id, { requestKey: null })
|
||||
this.users = { ...this.users, [id]: u }
|
||||
return u
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Alle Mitglieder eines Teams in den Cache holen. */
|
||||
async loadMembers(team: TeamsResponse | null = this.active) {
|
||||
if (!team) return
|
||||
const ids = [...(team.users ?? []), team.owner].filter(Boolean) as string[]
|
||||
await Promise.all(ids.filter((id) => !this.users[id]).map((id) => this.loadUser(id)))
|
||||
}
|
||||
|
||||
/** Anzeigename zu einer User-ID; fällt auf eine gekürzte ID zurück. */
|
||||
userLabel(id: string): string {
|
||||
const u = this.users[id]
|
||||
if (!u) return id.slice(0, 6) + '…'
|
||||
return u.name || u.email || id.slice(0, 6) + '…'
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt ein Login an und nimmt es ins Team auf. Zwei Schritte, weil die
|
||||
* REST-API keine Transaktion kennt: Scheitert die Aufnahme, existiert das
|
||||
* Konto bereits — der Aufrufer meldet den Fehler, statt still
|
||||
* weiterzumachen.
|
||||
*
|
||||
* Erlaubt ist das seit 1754500500_riders_user_and_admin_rules.js jedem
|
||||
* eingeloggten Nutzer (users.createRule); eine öffentliche Registrierung
|
||||
* gibt es weiterhin nicht.
|
||||
*/
|
||||
async createMember(data: { email: string; password: string; name?: string }, teamId = this.activeId) {
|
||||
if (!teamId) throw new Error('Kein aktives Team')
|
||||
|
||||
const user = (await api.collection('users').create({
|
||||
email: data.email.trim(),
|
||||
emailVisibility: false,
|
||||
password: data.password,
|
||||
passwordConfirm: data.password,
|
||||
name: data.name?.trim() || data.email.trim(),
|
||||
})) as UsersResponse
|
||||
|
||||
this.users = { ...this.users, [user.id]: user }
|
||||
await this.addMember(teamId, user.id)
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
/** User per E-Mail suchen (für Einladungs-UI) */
|
||||
async findUserByEmail(email: string): Promise<UsersResponse | null> {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -28,3 +28,45 @@ export function formatDateTime(iso: string | undefined): string {
|
|||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
}
|
||||
|
||||
/** Heutiges Datum als `JJJJ-MM-TT` in lokaler Zeit (nicht UTC). */
|
||||
export function todayISO(): string {
|
||||
const d = new Date()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${m}-${day}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Datumsanteil eines PocketBase-Werts. Die kommen als
|
||||
* `2026-09-12 00:00:00.000Z` zurück; für Vergleiche und `<input type="date">`
|
||||
* zählt nur der Tag.
|
||||
*/
|
||||
export function dayOf(value: string | undefined): string {
|
||||
if (!value) return ''
|
||||
return value.slice(0, 10)
|
||||
}
|
||||
|
||||
/** Ein Tag als `TT.MM.JJJJ`. */
|
||||
export function formatDay(value: string | undefined): string {
|
||||
const day = dayOf(value)
|
||||
if (!day) return ''
|
||||
|
||||
const [y, m, d] = day.split('-')
|
||||
return `${d}.${m}.${y}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeitraum eines Events. Ohne Enddatum oder bei gleichem Tag steht nur ein
|
||||
* Datum da; sonst „12.09.2026 – 14.09.2026".
|
||||
*/
|
||||
export function formatDateRange(starts: string | undefined, ends: string | undefined): string {
|
||||
const from = formatDay(starts)
|
||||
const to = formatDay(ends)
|
||||
|
||||
if (!from && !to) return ''
|
||||
if (!from) return to
|
||||
if (!to || to === from) return from
|
||||
|
||||
return `${from} – ${to}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
import Logo from '$lib/components/Logo.svelte'
|
||||
import TeamSwitcher from '$lib/components/TeamSwitcher.svelte'
|
||||
import RunningTimesToast from '$lib/components/RunningTimesToast.svelte'
|
||||
import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Route, Settings } from 'lucide-svelte'
|
||||
import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Route, Settings, Bike } from 'lucide-svelte'
|
||||
import { toggleMode, mode } from 'mode-watcher'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
|
||||
|
|
@ -88,8 +88,9 @@
|
|||
const nav = [
|
||||
{ href: '/dashboard', icon: Home, label: 'Dashboard' },
|
||||
{ href: '/dashboard/events', icon: Calendar, label: 'Events' },
|
||||
{ href: '/dashboard/riders', icon: Bike, label: 'Fahrer' },
|
||||
{ href: '/dashboard/trails', icon: Route, label: 'Trails' },
|
||||
{ href: '/dashboard/settings/flags', icon: Settings, label: 'Einstellungen' },
|
||||
{ href: '/dashboard/settings', icon: Settings, label: 'Einstellungen' },
|
||||
]
|
||||
</script>
|
||||
|
||||
|
|
@ -97,20 +98,12 @@
|
|||
<div class="min-h-screen bg-background">
|
||||
<header class="border-b bg-card">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-16 items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Logo />
|
||||
<div class="hidden md:block">
|
||||
<TeamSwitcher />
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onclick={toggleMode} title="Design wechseln">
|
||||
{#if mode.current === 'dark'}
|
||||
<Sun class="h-4 w-4" />
|
||||
{:else}
|
||||
<Moon class="h-4 w-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex min-h-16 items-center justify-between gap-4 py-2">
|
||||
<!-- Links: Logo und Menü. Das Logo fuellt seine Box, in
|
||||
voller Groesse wuerde es die 64 px hohe Leiste
|
||||
ueberragen und beschnitten wirken. -->
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Logo class="h-11 w-11 shrink-0" />
|
||||
|
||||
<nav class="hidden md:flex items-center gap-1">
|
||||
{#each nav as item}
|
||||
|
|
@ -124,16 +117,41 @@
|
|||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<div class="text-sm text-right">
|
||||
<p class="font-medium leading-tight">{auth.user.name || auth.user.email}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onclick={handleLogout} title="Abmelden">
|
||||
|
||||
<!-- Rechts eine Spalte: Name samt Design und Logout in einer
|
||||
Zeile, das Team-Dropdown darunter. -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="hidden md:flex flex-col items-end gap-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<p class="text-sm font-medium leading-none">
|
||||
{auth.user.name || auth.user.email}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-7"
|
||||
onclick={toggleMode}
|
||||
title="Design wechseln"
|
||||
>
|
||||
{#if mode.current === 'dark'}
|
||||
<Sun class="h-4 w-4" />
|
||||
{:else}
|
||||
<Moon class="h-4 w-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-7"
|
||||
onclick={handleLogout}
|
||||
title="Abmelden"
|
||||
>
|
||||
<LogOut class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<TeamSwitcher />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -167,14 +185,26 @@
|
|||
</a>
|
||||
{/each}
|
||||
<Separator class="my-2" />
|
||||
<div class="flex items-center justify-between px-3 py-2">
|
||||
<div class="text-sm">
|
||||
<p class="font-medium leading-tight">{auth.user.name || auth.user.email}</p>
|
||||
<div class="flex items-center justify-between gap-2 px-3 py-2">
|
||||
<div class="flex flex-col items-start gap-1 min-w-0">
|
||||
<p class="text-sm font-medium leading-none truncate">
|
||||
{auth.user.name || auth.user.email}
|
||||
</p>
|
||||
<TeamSwitcher />
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onclick={handleLogout}>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button variant="ghost" size="icon" onclick={toggleMode} title="Design wechseln">
|
||||
{#if mode.current === 'dark'}
|
||||
<Sun class="h-4 w-4" />
|
||||
{:else}
|
||||
<Moon class="h-4 w-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onclick={handleLogout} title="Abmelden">
|
||||
<LogOut class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { formatDateRange } from '$lib/time'
|
||||
import { Calendar, Users, Clock, MapPin, Route, UsersRound } from 'lucide-svelte'
|
||||
|
||||
const teams = getTeamContext()
|
||||
|
|
@ -16,6 +17,10 @@
|
|||
const riders = getRiderContext()
|
||||
const times = getTimeContext()
|
||||
|
||||
// „Aktiv" heißt jetzt: findet heute statt. Für die Startseite ist das
|
||||
// zusammen mit dem Anstehenden interessant.
|
||||
const upNext = $derived([...events.active, ...events.upcoming].slice(0, 4))
|
||||
|
||||
const stats = $derived({
|
||||
totalEvents: events.scoped.length,
|
||||
activeEvents: events.active.length,
|
||||
|
|
@ -37,7 +42,7 @@
|
|||
Team <span class="font-medium text-foreground">{teams.active.name}</span> · {stats.totalEvents} Events, {stats.totalRiders} Fahrer
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-muted-foreground">Kein Team aktiv. <a href="/dashboard/teams" class="underline">Team verwalten</a>.</p>
|
||||
<p class="mt-2 text-muted-foreground">Kein Team aktiv. <a href="/dashboard/settings/team" class="underline">Team verwalten</a>.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
@ -49,7 +54,7 @@
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-3xl font-bold">{stats.totalEvents}</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">{stats.activeEvents} aktiv</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">{stats.activeEvents} heute</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
@ -94,7 +99,7 @@
|
|||
</a>
|
||||
</Card>
|
||||
<Card class="hover:border-primary transition-colors">
|
||||
<a href="/dashboard/teams" class="block p-6">
|
||||
<a href="/dashboard/settings/team" class="block p-6">
|
||||
<UsersRound class="h-6 w-6 text-muted-foreground mb-3" />
|
||||
<h3 class="font-semibold">Teams</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">Mitglieder und Rechte</p>
|
||||
|
|
@ -104,27 +109,31 @@
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold mb-4">Aktive Events</h2>
|
||||
{#if events.active.length === 0}
|
||||
<h2 class="text-xl font-semibold mb-4">Heute und demnächst</h2>
|
||||
{#if upNext.length === 0}
|
||||
<Card>
|
||||
<CardContent class="py-12 text-center">
|
||||
<Calendar class="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
||||
<p class="font-medium">Keine aktiven Events</p>
|
||||
<p class="font-medium">Nichts geplant</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Aktiviere ein Event über die Eventliste, um mit der Zeitnahme zu beginnen.
|
||||
Trag einem Event ein Datum ein, dann steht es hier.
|
||||
</p>
|
||||
<Button href="/dashboard/events" variant="outline" class="mt-4">Events ansehen</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
{#each events.active as event}
|
||||
{#each upNext as event}
|
||||
{@const eventRuns = runs.getByEvent(event.id).length}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<CardTitle>{event.name || event.description || 'Unbenannt'}</CardTitle>
|
||||
<Badge>Aktiv</Badge>
|
||||
{#if event.starts}
|
||||
<Badge variant="secondary" class="shrink-0">
|
||||
{formatDateRange(event.starts, event.ends)}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{#if event.location}
|
||||
<CardDescription class="flex items-center gap-1">
|
||||
|
|
|
|||
|
|
@ -7,25 +7,24 @@
|
|||
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 { dayOf, formatDateRange } from '$lib/time'
|
||||
import { Calendar, MapPin, Plus, Pencil, Trash2 } from 'lucide-svelte'
|
||||
import type { EventsResponse, EventsStatusOptions } from '$lib/types'
|
||||
import type { EventsResponse } from '$lib/types'
|
||||
|
||||
const events = getEventContext()
|
||||
const runs = getRunContext()
|
||||
|
||||
let showDialog = $state(false)
|
||||
let editing = $state<EventsResponse | null>(null)
|
||||
let form = $state<{
|
||||
name: string
|
||||
description: string
|
||||
location: string
|
||||
status: EventsStatusOptions
|
||||
}>({ name: '', description: '', location: '', status: 'draft' as EventsStatusOptions })
|
||||
let form = $state({ name: '', description: '', location: '', starts: '', ends: '' })
|
||||
let multiDay = $state(false)
|
||||
let formError = $state<string | null>(null)
|
||||
|
||||
function openCreate() {
|
||||
editing = null
|
||||
form = { name: '', description: '', location: '', status: 'draft' as EventsStatusOptions }
|
||||
form = { name: '', description: '', location: '', starts: '', ends: '' }
|
||||
multiDay = false
|
||||
formError = null
|
||||
showDialog = true
|
||||
}
|
||||
|
||||
|
|
@ -35,29 +34,47 @@
|
|||
name: e.name ?? '',
|
||||
description: e.description ?? '',
|
||||
location: e.location ?? '',
|
||||
status: e.status ?? ('draft' as EventsStatusOptions),
|
||||
starts: dayOf(e.starts),
|
||||
ends: dayOf(e.ends),
|
||||
}
|
||||
// Ein gespeichertes Enddatum ist das Kennzeichen für mehrtägig; ein
|
||||
// eigenes Feld dafür wäre eine zweite Wahrheit über dieselbe Sache.
|
||||
multiDay = !!dayOf(e.ends)
|
||||
formError = null
|
||||
showDialog = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.name.trim()) return
|
||||
|
||||
if (multiDay && !form.starts) {
|
||||
formError = 'Ohne Beginn ergibt ein Ende keinen Sinn.'
|
||||
return
|
||||
}
|
||||
if (multiDay && form.ends && form.ends < form.starts) {
|
||||
formError = 'Das Ende liegt vor dem Beginn.'
|
||||
return
|
||||
}
|
||||
|
||||
formError = null
|
||||
try {
|
||||
if (editing) await events.edit(editing.id, form)
|
||||
else await events.create(form)
|
||||
// Ein eintägiges Event hat kein Ende — sonst stünde überall
|
||||
// dasselbe Datum zweimal.
|
||||
const ends = multiDay && form.ends !== form.starts ? form.ends : ''
|
||||
const data = { ...form, ends }
|
||||
if (editing) await events.edit(editing.id, data)
|
||||
else await events.create(data)
|
||||
showDialog = false
|
||||
} catch (e: any) {
|
||||
alert(`Fehler: ${e.message}`)
|
||||
formError = e.message ?? 'Das Event konnte nicht gespeichert werden.'
|
||||
}
|
||||
}
|
||||
|
||||
const statusLabels = { draft: 'Entwurf', active: 'Aktiv', finished: 'Abgeschlossen' }
|
||||
const statusVariants = { draft: 'secondary', active: 'default', finished: 'outline' } as const
|
||||
|
||||
const groups = $derived([
|
||||
{ title: 'Aktive Events', items: events.active, status: 'active' as const, showZero: false },
|
||||
{ title: 'Entwürfe', items: events.drafts, status: 'draft' as const, showZero: false },
|
||||
{ title: 'Abgeschlossen', items: events.finished, status: 'finished' as const, showZero: false },
|
||||
{ title: 'Läuft gerade', items: events.active },
|
||||
{ title: 'Anstehend', items: events.upcoming },
|
||||
{ title: 'Ohne Termin', items: events.undated },
|
||||
{ title: 'Vorbei', items: events.past },
|
||||
])
|
||||
</script>
|
||||
|
||||
|
|
@ -102,9 +119,11 @@
|
|||
<CardHeader>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<CardTitle class="leading-tight">{event.name || 'Unbenannt'}</CardTitle>
|
||||
<Badge variant={statusVariants[event.status as keyof typeof statusVariants] ?? 'secondary'}>
|
||||
{statusLabels[event.status as keyof typeof statusLabels] ?? 'Entwurf'}
|
||||
{#if event.starts}
|
||||
<Badge variant="secondary" class="shrink-0">
|
||||
{formatDateRange(event.starts, event.ends)}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{#if event.location}
|
||||
<CardDescription class="flex items-center gap-1">
|
||||
|
|
@ -168,19 +187,30 @@
|
|||
<Input id="event-desc" bind:value={form.description} placeholder="Optionale Beschreibung" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Status</Label>
|
||||
<Select.Root type="single" bind:value={form.status}>
|
||||
<Select.Trigger>
|
||||
{statusLabels[form.status]}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="draft">Entwurf</Select.Item>
|
||||
<Select.Item value="active">Aktiv</Select.Item>
|
||||
<Select.Item value="finished">Abgeschlossen</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Label for="event-starts">Datum</Label>
|
||||
<Input id="event-starts" type="date" bind:value={form.starts} />
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={multiDay}
|
||||
class="size-4 rounded border-input accent-primary"
|
||||
/>
|
||||
mehrtägig
|
||||
</label>
|
||||
|
||||
{#if multiDay}
|
||||
<div class="space-y-2">
|
||||
<Label for="event-ends">Bis</Label>
|
||||
<Input id="event-ends" type="date" bind:value={form.ends} min={form.starts} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if formError}
|
||||
<p class="text-sm text-destructive">{formError}</p>
|
||||
{/if}
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="ghost" onclick={() => (showDialog = false)}>Abbrechen</Button>
|
||||
<Button type="submit">{editing ? 'Speichern' : 'Erstellen'}</Button>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||
import TimeDialog from '$lib/components/TimeDialog.svelte'
|
||||
import { formatDateRange } from '$lib/time'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
|
@ -44,6 +46,9 @@
|
|||
return `/dashboard/events/${event?.id}/runs/${runId}`
|
||||
}
|
||||
|
||||
// Zeit-Dialog: der einzige Weg, eine Zeit zu starten oder zu stoppen.
|
||||
let timeDialog = $state(false)
|
||||
|
||||
// Fahrer-Dialog
|
||||
let riderDialog = $state(false)
|
||||
let ridingEdit = $state<RidersResponse | null>(null)
|
||||
|
|
@ -137,9 +142,12 @@
|
|||
</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-3xl font-bold tracking-tight">{event.name || 'Unbenannt'}</h1>
|
||||
<Badge variant={event.status === 'active' ? 'default' : event.status === 'finished' ? 'outline' : 'secondary'}>
|
||||
{event.status === 'active' ? 'Aktiv' : event.status === 'finished' ? 'Abgeschlossen' : 'Entwurf'}
|
||||
{#if event.starts}
|
||||
<Badge variant="secondary" class="gap-1">
|
||||
<Calendar class="h-3 w-3" />
|
||||
{formatDateRange(event.starts, event.ends)}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{#if event.location}
|
||||
<p class="mt-1 text-muted-foreground flex items-center gap-1">
|
||||
|
|
@ -151,6 +159,14 @@
|
|||
<p class="mt-2 text-sm text-muted-foreground">{event.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onclick={() => (timeDialog = true)}
|
||||
disabled={eventRiders.length === 0 || eventRuns.length === 0}
|
||||
>
|
||||
<Timer class="h-4 w-4 mr-2" />
|
||||
Zeit erfassen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
|
@ -166,10 +182,12 @@
|
|||
</h2>
|
||||
<p class="text-sm text-muted-foreground">Klick auf einen Fahrer zeigt seine Runs</p>
|
||||
</div>
|
||||
{#if riders.canManage}
|
||||
<Button onclick={openRiderCreate}>
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Fahrer hinzufügen
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
|
|
@ -178,11 +196,17 @@
|
|||
<div class="py-12 text-center">
|
||||
<Users class="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
||||
<p class="font-medium">Noch keine Fahrer</p>
|
||||
{#if riders.canManage}
|
||||
<p class="text-sm text-muted-foreground mt-1 mb-4">Füge den ersten Fahrer hinzu.</p>
|
||||
<Button onclick={openRiderCreate}>
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Ersten Fahrer hinzufügen
|
||||
</Button>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Fahrer legt die Teamleitung an.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<Table.Root>
|
||||
|
|
@ -215,12 +239,14 @@
|
|||
{done} von {eventRuns.length} Runs
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right" onclick={(e) => e.stopPropagation()}>
|
||||
{#if riders.canManage}
|
||||
<Button variant="ghost" size="icon" onclick={() => openRiderEdit(rider)}>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onclick={() => riders.remove(rider)}>
|
||||
<Trash2 class="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
|
|
@ -370,3 +396,5 @@
|
|||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<TimeDialog bind:open={timeDialog} eventId={event?.id ?? ''} />
|
||||
|
|
|
|||
|
|
@ -12,12 +12,14 @@
|
|||
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||
import { ticker } from '$lib/stores/ticker.svelte'
|
||||
import { TIME_STATUS_LABEL, formatDuration } from '$lib/time'
|
||||
import TimeDialog from '$lib/components/TimeDialog.svelte'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import * as Table from '@/components/ui/table'
|
||||
import { ArrowLeft, ChevronRight, Timer, Users } from 'lucide-svelte'
|
||||
|
||||
|
||||
const events = getEventContext()
|
||||
const runs = getRunContext()
|
||||
const riders = getRiderContext()
|
||||
|
|
@ -59,6 +61,15 @@
|
|||
})
|
||||
})
|
||||
|
||||
// Start und Stopp laufen ausschließlich über den Zeit-Dialog.
|
||||
let timeDialog = $state(false)
|
||||
let dialogRun = $state<string | null>(null)
|
||||
|
||||
function openTimeDialog(runId: string | null = null) {
|
||||
dialogRun = runId
|
||||
timeDialog = true
|
||||
}
|
||||
|
||||
const stats = $derived.by(() => {
|
||||
const finished = rows.filter((r) => r.best)
|
||||
const total = finished.reduce((sum, r) => sum + (r.best?.duration ?? 0), 0)
|
||||
|
|
@ -88,6 +99,7 @@
|
|||
</Card>
|
||||
{:else if event && rider}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<a
|
||||
href="/dashboard/events/{event.id}"
|
||||
|
|
@ -104,6 +116,12 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Button onclick={() => openTimeDialog()} disabled={rows.length === 0}>
|
||||
<Timer class="h-4 w-4 mr-2" />
|
||||
Zeit erfassen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader class="pb-2">
|
||||
|
|
@ -215,3 +233,10 @@
|
|||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<TimeDialog
|
||||
bind:open={timeDialog}
|
||||
eventId={event?.id ?? ''}
|
||||
riderId={rider?.id ?? null}
|
||||
runId={dialogRun}
|
||||
/>
|
||||
|
|
|
|||
143
frontend/src/routes/dashboard/riders/+page.svelte
Normal file
143
frontend/src/routes/dashboard/riders/+page.svelte
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Fahrerverzeichnis des Teams — für alle sichtbar, für niemanden hier
|
||||
* änderbar. Anlegen, Bearbeiten und Kontoverknüpfung liegen in den
|
||||
* Einstellungen und damit bei der Teamleitung; serverseitig setzen das
|
||||
* die create/update/deleteRule der Collection durch.
|
||||
*/
|
||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getEventContext } from '$lib/stores/events.svelte'
|
||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import * as Table from '@/components/ui/table'
|
||||
import { Search, Settings, UserCheck, Users } from 'lucide-svelte'
|
||||
|
||||
const teams = getTeamContext()
|
||||
const riders = getRiderContext()
|
||||
const events = getEventContext()
|
||||
const times = getTimeContext()
|
||||
|
||||
let query = $state('')
|
||||
|
||||
const roster = $derived.by(() => {
|
||||
const q = query.toLowerCase().trim()
|
||||
if (!q) return riders.scoped
|
||||
|
||||
return riders.scoped.filter(
|
||||
(r) =>
|
||||
(r.number ?? '').toLowerCase().includes(q) ||
|
||||
fullName(r).toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
teams.loadMembers()
|
||||
})
|
||||
|
||||
function eventName(id: string | undefined) {
|
||||
if (!id) return null
|
||||
return events.getById(id)?.name ?? 'Unbekanntes Event'
|
||||
}
|
||||
|
||||
/** Zur Fahrerseite geht es nur mit Event — Zeiten hängen daran. */
|
||||
function riderPath(r: { id: string; event?: string }) {
|
||||
return r.event ? `/dashboard/events/${r.event}/riders/${r.id}` : null
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Fahrer</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Fahrer</h1>
|
||||
<p class="mt-1 text-muted-foreground">
|
||||
{riders.scoped.length} im Team {teams.active?.name ?? ''}
|
||||
</p>
|
||||
</div>
|
||||
{#if riders.canManage}
|
||||
<Button variant="outline" href="/dashboard/settings/riders">
|
||||
<Settings class="size-4 mr-2" />
|
||||
Verwalten
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="relative max-w-sm">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input bind:value={query} placeholder="Nummer oder Name…" class="pl-9" />
|
||||
</div>
|
||||
|
||||
{#if riders.scoped.length === 0}
|
||||
<Card>
|
||||
<CardContent class="py-16 text-center">
|
||||
<Users class="size-10 text-muted-foreground mx-auto mb-3" />
|
||||
<p class="font-medium">Noch keine Fahrer</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
{#if riders.canManage}
|
||||
Leg sie in den <a href="/dashboard/settings/riders" class="underline">Einstellungen</a> an.
|
||||
{:else}
|
||||
Die Teamleitung legt Fahrer in den Einstellungen an.
|
||||
{/if}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else if roster.length === 0}
|
||||
<Card>
|
||||
<CardContent class="py-12 text-center text-muted-foreground">Keine Treffer.</CardContent>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card>
|
||||
<CardContent class="p-0">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-20">#</Table.Head>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head>Event</Table.Head>
|
||||
<Table.Head class="text-right w-24">Zeiten</Table.Head>
|
||||
<Table.Head class="w-48">Konto</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each roster as r (r.id)}
|
||||
{@const path = riderPath(r)}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<Badge variant="outline">{r.number || '—'}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-medium">
|
||||
{#if path}
|
||||
<a href={path} class="hover:underline">{fullName(r) || '— ohne Name —'}</a>
|
||||
{:else}
|
||||
{fullName(r) || '— ohne Name —'}
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground text-sm">
|
||||
{eventName(r.event) ?? '—'}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right text-muted-foreground text-sm">
|
||||
{times.getByRider(r.id).length}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if r.user}
|
||||
<Badge variant="secondary" class="gap-1">
|
||||
<UserCheck class="size-3" />
|
||||
{teams.userLabel(r.user)}
|
||||
</Badge>
|
||||
{:else}
|
||||
<span class="text-sm text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
57
frontend/src/routes/dashboard/settings/+layout.svelte
Normal file
57
frontend/src/routes/dashboard/settings/+layout.svelte
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<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.
|
||||
*/
|
||||
import { page } from '$app/state'
|
||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||
import { Flag, User, Users, Bike } from 'lucide-svelte'
|
||||
|
||||
let { children } = $props()
|
||||
|
||||
const teams = getTeamContext()
|
||||
|
||||
const tabs = $derived([
|
||||
{ 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/flags', label: 'Flag-Typen', icon: Flag },
|
||||
])
|
||||
|
||||
// Das Profil liegt auf der Wurzel; ohne den Sonderfall wäre sein Reiter
|
||||
// auf jedem Unterpfad mit aktiv.
|
||||
function isActive(href: string) {
|
||||
return href === '/dashboard/settings'
|
||||
? page.url.pathname === href
|
||||
: page.url.pathname.startsWith(href)
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Einstellungen</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-3xl font-bold tracking-tight">Einstellungen</h1>
|
||||
|
||||
<nav class="flex flex-wrap gap-1 border-b">
|
||||
{#each tabs as tab (tab.href)}
|
||||
{@const Icon = tab.icon}
|
||||
<a
|
||||
href={tab.href}
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors"
|
||||
class:border-primary={isActive(tab.href)}
|
||||
class:text-foreground={isActive(tab.href)}
|
||||
class:border-transparent={!isActive(tab.href)}
|
||||
class:text-muted-foreground={!isActive(tab.href)}
|
||||
>
|
||||
<Icon class="size-4" />
|
||||
{tab.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
{@render children?.()}
|
||||
</div>
|
||||
191
frontend/src/routes/dashboard/settings/+page.svelte
Normal file
191
frontend/src/routes/dashboard/settings/+page.svelte
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Eigenes Profil. Die users-Collection erlaubt Änderungen nur am eigenen
|
||||
* Datensatz (updateRule `id = @request.auth.id`) — was hier steht, kann
|
||||
* also niemand für jemand anderen ändern.
|
||||
*/
|
||||
import { api, auth, getFileURL } from '$lib/stores/pocketbase.svelte'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { KeyRound, User } from 'lucide-svelte'
|
||||
|
||||
let name = $state(auth.user?.name ?? '')
|
||||
let avatarFile = $state<File | null>(null)
|
||||
let savingProfile = $state(false)
|
||||
let profileMessage = $state<string | null>(null)
|
||||
let profileError = $state<string | null>(null)
|
||||
|
||||
const avatarUrl = $derived(
|
||||
auth.user?.avatar ? getFileURL(auth.user as object, auth.user.avatar as string) : null,
|
||||
)
|
||||
|
||||
async function saveProfile() {
|
||||
if (!auth.user) return
|
||||
|
||||
savingProfile = true
|
||||
profileMessage = null
|
||||
profileError = null
|
||||
try {
|
||||
// Mit Datei als FormData, sonst als einfaches Objekt — ein leeres
|
||||
// avatar-Feld im FormData würde ein vorhandenes Bild löschen.
|
||||
let data: FormData | Record<string, unknown>
|
||||
if (avatarFile) {
|
||||
const fd = new FormData()
|
||||
fd.append('name', name.trim())
|
||||
fd.append('avatar', avatarFile)
|
||||
data = fd
|
||||
} else {
|
||||
data = { name: name.trim() }
|
||||
}
|
||||
|
||||
await api.collection('users').update(auth.user.id, data)
|
||||
await auth.refresh()
|
||||
|
||||
avatarFile = null
|
||||
profileMessage = 'Profil gespeichert.'
|
||||
} catch (e: any) {
|
||||
profileError = e.message ?? 'Das Profil konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
savingProfile = false
|
||||
}
|
||||
}
|
||||
|
||||
let oldPassword = $state('')
|
||||
let newPassword = $state('')
|
||||
let confirmPassword = $state('')
|
||||
let savingPassword = $state(false)
|
||||
let passwordMessage = $state<string | null>(null)
|
||||
let passwordError = $state<string | null>(null)
|
||||
|
||||
async function savePassword() {
|
||||
if (!auth.user) return
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
passwordError = 'Das neue Passwort braucht mindestens 8 Zeichen.'
|
||||
return
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
passwordError = 'Die Wiederholung stimmt nicht überein.'
|
||||
return
|
||||
}
|
||||
|
||||
savingPassword = true
|
||||
passwordMessage = null
|
||||
passwordError = null
|
||||
try {
|
||||
await api.collection('users').update(auth.user.id, {
|
||||
oldPassword,
|
||||
password: newPassword,
|
||||
passwordConfirm: confirmPassword,
|
||||
})
|
||||
|
||||
// PocketBase entwertet mit dem Passwortwechsel alle Tokens; ohne
|
||||
// neue Anmeldung wäre die Sitzung ab hier tot.
|
||||
await api.collection('users').authWithPassword(auth.user.email as string, newPassword)
|
||||
if (auth.cookie) {
|
||||
document.cookie = api.authStore.exportToCookie({ httpOnly: false })
|
||||
}
|
||||
|
||||
oldPassword = newPassword = confirmPassword = ''
|
||||
passwordMessage = 'Passwort geändert.'
|
||||
} catch (e: any) {
|
||||
passwordError = e.message ?? 'Das Passwort konnte nicht geändert werden.'
|
||||
} finally {
|
||||
savingPassword = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<User class="size-4 text-primary" />
|
||||
Konto
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
{#if avatarUrl}
|
||||
<img src={avatarUrl} alt="" class="size-16 rounded-full object-cover border" />
|
||||
{:else}
|
||||
<div class="size-16 rounded-full border grid place-items-center text-muted-foreground">
|
||||
<User class="size-6" />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="space-y-1">
|
||||
<Label for="avatar">Bild</Label>
|
||||
<input
|
||||
id="avatar"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="block w-full text-sm"
|
||||
onchange={(e) => (avatarFile = e.currentTarget.files?.[0] ?? null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="profile-name">Name</Label>
|
||||
<Input id="profile-name" bind:value={name} placeholder="Wie du angezeigt wirst" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="profile-mail">E-Mail</Label>
|
||||
<Input id="profile-mail" value={auth.user?.email ?? ''} readonly disabled />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Die E-Mail-Adresse ist der Anmeldename. Ändern kann sie nur die
|
||||
Team-Administration über PocketBase.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if profileError}
|
||||
<p class="text-sm text-destructive">{profileError}</p>
|
||||
{:else if profileMessage}
|
||||
<p class="text-sm text-muted-foreground">{profileMessage}</p>
|
||||
{/if}
|
||||
|
||||
<Button onclick={saveProfile} disabled={savingProfile}>
|
||||
{savingProfile ? 'Wird gespeichert …' : 'Speichern'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<KeyRound class="size-4 text-primary" />
|
||||
Passwort ändern
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="pw-old">Aktuelles Passwort</Label>
|
||||
<Input id="pw-old" type="password" bind:value={oldPassword} autocomplete="current-password" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="pw-new">Neues Passwort</Label>
|
||||
<Input id="pw-new" type="password" bind:value={newPassword} autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="pw-confirm">Wiederholen</Label>
|
||||
<Input id="pw-confirm" type="password" bind:value={confirmPassword} autocomplete="new-password" />
|
||||
</div>
|
||||
|
||||
{#if passwordError}
|
||||
<p class="text-sm text-destructive">{passwordError}</p>
|
||||
{:else if passwordMessage}
|
||||
<p class="text-sm text-muted-foreground">{passwordMessage}</p>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
onclick={savePassword}
|
||||
disabled={savingPassword || !oldPassword || !newPassword}
|
||||
>
|
||||
{savingPassword ? 'Wird geändert …' : 'Passwort ändern'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
@ -7,6 +7,8 @@
|
|||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import * as Dialog from '@/components/ui/dialog'
|
||||
import FlagIcon from '$lib/components/FlagIcon.svelte'
|
||||
import { FLAG_ICON_SUGGESTIONS, iconExists } from '$lib/flagIcons'
|
||||
import { Plus, Pencil, Trash2, Sparkles } from 'lucide-svelte'
|
||||
import type { TrailFlagsResponse } from '$lib/types'
|
||||
|
||||
|
|
@ -104,10 +106,9 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Flag-Typen</h1>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Wofür sich Marker auf einem Trail setzen lassen.
|
||||
</p>
|
||||
|
|
@ -149,7 +150,7 @@
|
|||
{#each flags.scoped as flag (flag.id)}
|
||||
<Card>
|
||||
<CardContent class="py-3 flex items-center gap-3">
|
||||
<span class="size-4 rounded-full shrink-0" style:background={flag.color}></span>
|
||||
<FlagIcon name={flag.icon} color={flag.color} class="size-5" />
|
||||
<span class="font-medium flex-1">{flag.label}</span>
|
||||
<Badge variant={flag.severity === 'kritisch' ? 'destructive' : 'secondary'}>
|
||||
{flag.severity}
|
||||
|
|
@ -203,10 +204,38 @@
|
|||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="flag-icon">Icon</Label>
|
||||
<Input id="flag-icon" bind:value={form.icon} placeholder="Lucide-Name, z. B. TreePine" />
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="grid size-9 place-items-center rounded-md border">
|
||||
<FlagIcon name={form.icon} color={form.color} class="size-5" />
|
||||
</span>
|
||||
<Input id="flag-icon" bind:value={form.icon} placeholder="z. B. TreePine" class="flex-1" />
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Name eines Icons von lucide.dev, in Schreibweise wie „TreePine".
|
||||
Jeder Name von <a href="https://lucide.dev/icons" target="_blank" rel="noreferrer" class="underline">lucide.dev</a>
|
||||
ist möglich — geschrieben wie „TreePine" oder „tree-pine".
|
||||
{#if form.icon.trim() && !iconExists(form.icon)}
|
||||
<span class="text-destructive">Diesen Namen gibt es dort nicht.</span>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<p class="text-xs text-muted-foreground pt-1">Vorschläge:</p>
|
||||
<div class="grid grid-cols-10 gap-1 rounded-lg border p-2">
|
||||
{#each FLAG_ICON_SUGGESTIONS as name (name)}
|
||||
<button
|
||||
type="button"
|
||||
title={name}
|
||||
aria-label={name}
|
||||
aria-pressed={form.icon === name}
|
||||
class="grid place-items-center rounded-md p-1.5 hover:bg-accent"
|
||||
class:bg-accent={form.icon === name}
|
||||
class:ring-2={form.icon === name}
|
||||
class:ring-primary={form.icon === name}
|
||||
onclick={() => (form.icon = name)}
|
||||
>
|
||||
<FlagIcon {name} color={form.color} class="size-4" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if error}
|
||||
<p class="text-sm text-destructive">{error}</p>
|
||||
|
|
|
|||
385
frontend/src/routes/dashboard/settings/riders/+page.svelte
Normal file
385
frontend/src/routes/dashboard/settings/riders/+page.svelte
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
<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.
|
||||
*/
|
||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getEventContext } from '$lib/stores/events.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'
|
||||
|
||||
const teams = getTeamContext()
|
||||
const riders = getRiderContext()
|
||||
const events = getEventContext()
|
||||
|
||||
const canManage = $derived(riders.canManage)
|
||||
const roster = $derived(riders.scoped)
|
||||
|
||||
$effect(() => {
|
||||
teams.loadMembers()
|
||||
})
|
||||
|
||||
function eventName(id: string | undefined) {
|
||||
if (!id) return null
|
||||
return events.getById(id)?.name ?? 'Unbekanntes Event'
|
||||
}
|
||||
|
||||
// --- Fahrer anlegen und ändern ---------------------------------------
|
||||
let riderDialog = $state(false)
|
||||
let editing = $state<RidersResponse | null>(null)
|
||||
let form = $state({ number: '', firstname: '', lastname: '', event: '' })
|
||||
let riderError = $state<string | null>(null)
|
||||
|
||||
function openCreate() {
|
||||
editing = null
|
||||
form = { number: '', firstname: '', lastname: '', event: '' }
|
||||
riderError = null
|
||||
riderDialog = true
|
||||
}
|
||||
|
||||
function openEdit(r: RidersResponse) {
|
||||
editing = r
|
||||
form = {
|
||||
number: r.number ?? '',
|
||||
firstname: r.firstname ?? '',
|
||||
lastname: r.lastname ?? '',
|
||||
event: r.event ?? '',
|
||||
}
|
||||
riderError = null
|
||||
riderDialog = true
|
||||
}
|
||||
|
||||
async function saveRider() {
|
||||
if (!form.number.trim()) {
|
||||
riderError = 'Die Startnummer ist Pflicht.'
|
||||
return
|
||||
}
|
||||
// Startnummern sind je Event eindeutig; Fahrer ohne Event teilen sich
|
||||
// den gemeinsamen Topf des Teams.
|
||||
if (riders.isNumberTaken(form.number.trim(), form.event, editing?.id)) {
|
||||
riderError = `Startnummer ${form.number} ist dort bereits vergeben.`
|
||||
return
|
||||
}
|
||||
|
||||
riderError = null
|
||||
try {
|
||||
const data = {
|
||||
number: form.number.trim(),
|
||||
firstname: form.firstname.trim(),
|
||||
lastname: form.lastname.trim(),
|
||||
event: form.event,
|
||||
}
|
||||
if (editing) await riders.edit(editing.id, data)
|
||||
else await riders.create(data)
|
||||
riderDialog = 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.'
|
||||
} finally {
|
||||
loginBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doUnlink(r: RidersResponse) {
|
||||
try {
|
||||
await riders.linkUser(r.id, null)
|
||||
} catch (e: any) {
|
||||
alert(e.message ?? 'Die Verknüpfung konnte nicht gelöst werden.')
|
||||
}
|
||||
}
|
||||
|
||||
async function doCreateLogin() {
|
||||
if (!loginRider) return
|
||||
|
||||
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
|
||||
} catch (e: any) {
|
||||
loginError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
|
||||
} finally {
|
||||
loginBusy = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#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
|
||||
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">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Fahrer des Teams. Ein verknüpftes Konto erlaubt der Person, sich selbst
|
||||
anzumelden.
|
||||
</p>
|
||||
<Button onclick={openCreate}>
|
||||
<Plus class="size-4 mr-2" />
|
||||
Fahrer anlegen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if roster.length === 0}
|
||||
<Card>
|
||||
<CardContent class="py-12 text-center text-muted-foreground">
|
||||
Noch keine Fahrer im Team.
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card>
|
||||
<CardContent class="p-0">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-20">#</Table.Head>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head>Event</Table.Head>
|
||||
<Table.Head>Konto</Table.Head>
|
||||
<Table.Head class="text-right">Aktionen</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each roster as r (r.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<Badge variant="outline">{r.number || '—'}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-medium">
|
||||
{fullName(r) || '— ohne Name —'}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground text-sm">
|
||||
{eventName(r.event) ?? '—'}
|
||||
</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>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-sm text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right whitespace-nowrap">
|
||||
{#if r.user}
|
||||
<Button variant="ghost" size="icon" title="Verknüpfung lösen" onclick={() => doUnlink(r)}>
|
||||
<Unlink class="size-4" />
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="ghost" size="icon" title="Konto verknüpfen" onclick={() => openLogin(r)}>
|
||||
<KeyRound class="size-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="ghost" size="icon" title="Bearbeiten" onclick={() => openEdit(r)}>
|
||||
<Pencil class="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" title="Löschen" onclick={() => riders.remove(r)}>
|
||||
<Trash2 class="size-4 text-destructive" />
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Fahrer anlegen/bearbeiten -->
|
||||
<Dialog.Root bind:open={riderDialog}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{editing ? 'Fahrer bearbeiten' : 'Neuer Fahrer'}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="rider-number">Startnummer</Label>
|
||||
<Input id="rider-number" bind:value={form.number} placeholder="42" />
|
||||
</div>
|
||||
<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" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="rider-last">Nachname</Label>
|
||||
<Input id="rider-last" bind:value={form.lastname} placeholder="Mustermann" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Event</Label>
|
||||
<Select.Root type="single" bind:value={form.event}>
|
||||
<Select.Trigger class="w-full">
|
||||
{eventName(form.event) ?? 'Keinem Event zugeordnet'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="">Keinem Event zugeordnet</Select.Item>
|
||||
{#each events.scoped as e (e.id)}
|
||||
<Select.Item value={e.id}>{e.name || e.description}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Zeiten hängen am Event. Ohne Zuordnung steht der Fahrer nur im
|
||||
Team-Verzeichnis.
|
||||
</p>
|
||||
</div>
|
||||
{#if riderError}
|
||||
<p class="text-sm text-destructive">{riderError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button>
|
||||
<Button onclick={saveRider}>{editing ? 'Speichern' : 'Anlegen'}</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Konto verknüpfen oder anlegen -->
|
||||
<Dialog.Root bind:open={loginDialog}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Konto für {loginRider ? fullName(loginRider) : '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={loginMode === 'link' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => (loginMode = 'link')}
|
||||
>
|
||||
Vorhandenes verknüpfen
|
||||
</Button>
|
||||
<Button
|
||||
variant={loginMode === 'create' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => (loginMode = 'create')}
|
||||
>
|
||||
Neues anlegen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#if loginMode === 'link'}
|
||||
<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}
|
||||
</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>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loginError}
|
||||
<p class="text-sm text-destructive">{loginError}</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>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
329
frontend/src/routes/dashboard/settings/team/+page.svelte
Normal file
329
frontend/src/routes/dashboard/settings/team/+page.svelte
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
<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 { 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 { Crown, LogOut, Plus, Shield, Trash2, UserMinus, UserPlus, Users } from 'lucide-svelte'
|
||||
import type { TeamsResponse } from '$lib/types'
|
||||
|
||||
const teams = getTeamContext()
|
||||
const me = $derived(api.authStore.record?.id ?? null)
|
||||
|
||||
// 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 {
|
||||
await teams.create(createName.trim())
|
||||
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.'
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Mitglied anlegen ----
|
||||
// Bestehende Konten lassen sich nicht suchen: users.listRule gibt nur den
|
||||
// eigenen Datensatz frei, und das soll auch so bleiben. Ein neues Konto
|
||||
// anzulegen deckt den Alltag ab — Vereinsmitglieder haben vorher keins.
|
||||
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
|
||||
memberForm = { name: '', email: '', password: '' }
|
||||
memberError = null
|
||||
memberDialog = true
|
||||
}
|
||||
|
||||
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
|
||||
try {
|
||||
await teams.leave(t.id)
|
||||
} catch (e: any) {
|
||||
alert(`Fehler: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
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="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>
|
||||
<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 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={() => openMember(t)}>
|
||||
<UserPlus class="size-4 mr-1" />
|
||||
Mitglied
|
||||
</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" title="Team löschen" onclick={() => teams.remove(t)}>
|
||||
<Trash2 class="size-4 text-destructive" />
|
||||
</Button>
|
||||
{/if}
|
||||
</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">
|
||||
<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" title="Entfernen" onclick={() => doRemoveMember(t, userId)}>
|
||||
<UserMinus class="size-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</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>
|
||||
|
||||
<!-- Mitglied anlegen -->
|
||||
<Dialog.Root bind:open={memberDialog}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Mitglied anlegen</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Legt ein Konto an und nimmt es in „{memberTeam?.name}" auf.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="space-y-4">
|
||||
<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 memberError}
|
||||
<p class="text-sm text-destructive">{memberError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button variant="ghost" onclick={() => (memberDialog = false)}>Abbrechen</Button>
|
||||
<Button onclick={doCreateMember} disabled={memberBusy}>
|
||||
{memberBusy ? 'Wird angelegt …' : 'Anlegen'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
|
@ -1,341 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { auth, api } from '$lib/stores/pocketbase.svelte'
|
||||
import { getTeamContext } from '$lib/stores/teams.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 { Crown, Plus, Trash2, UserMinus, UserPlus, Users, Shield, LogOut } from 'lucide-svelte'
|
||||
import type { TeamsResponse, UsersResponse } from '$lib/types'
|
||||
|
||||
const teams = getTeamContext()
|
||||
const me = $derived(api.authStore.record?.id ?? null)
|
||||
|
||||
// Member-Cache: User-Info pro ID
|
||||
let userCache = $state<Record<string, UsersResponse>>({})
|
||||
|
||||
async function loadUser(id: string) {
|
||||
if (userCache[id]) return userCache[id]
|
||||
try {
|
||||
const u = await api.collection('users').getOne(id, { requestKey: null })
|
||||
userCache = { ...userCache, [id]: u }
|
||||
return u
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Bei Team-Änderung alle Mitglieder vorladen
|
||||
for (const t of teams.records) {
|
||||
const ids = [...(t.users ?? []), t.owner].filter(Boolean) as string[]
|
||||
for (const id of ids) {
|
||||
if (!userCache[id]) loadUser(id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function userLabel(id: string) {
|
||||
const u = userCache[id]
|
||||
if (!u) return id.slice(0, 6) + '…'
|
||||
return u.name || u.email || u.id.slice(0, 6) + '…'
|
||||
}
|
||||
|
||||
// ---- Team-Create Dialog ----
|
||||
let createDialog = $state(false)
|
||||
let createName = $state('')
|
||||
|
||||
async function doCreate() {
|
||||
if (!createName.trim()) return
|
||||
try {
|
||||
await teams.create(createName.trim())
|
||||
createDialog = false
|
||||
createName = ''
|
||||
} catch (e: any) {
|
||||
alert(`Fehler: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Member-Invite Dialog ----
|
||||
let inviteDialog = $state(false)
|
||||
let inviteTargetTeam = $state<TeamsResponse | null>(null)
|
||||
let inviteEmail = $state('')
|
||||
let inviteSearching = $state(false)
|
||||
let inviteFound = $state<UsersResponse | null>(null)
|
||||
let inviteError = $state<string | null>(null)
|
||||
|
||||
function openInvite(t: TeamsResponse) {
|
||||
inviteTargetTeam = t
|
||||
inviteEmail = ''
|
||||
inviteFound = null
|
||||
inviteError = null
|
||||
inviteDialog = true
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
if (!inviteEmail.trim()) return
|
||||
inviteSearching = true
|
||||
inviteError = null
|
||||
inviteFound = null
|
||||
try {
|
||||
const u = await teams.findUserByEmail(inviteEmail.trim())
|
||||
if (u) {
|
||||
inviteFound = u
|
||||
userCache = { ...userCache, [u.id]: u }
|
||||
} else {
|
||||
inviteError = `Kein User mit "${inviteEmail}" gefunden`
|
||||
}
|
||||
} catch (e: any) {
|
||||
inviteError = e.message
|
||||
} finally {
|
||||
inviteSearching = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doInvite() {
|
||||
if (!inviteTargetTeam || !inviteFound) return
|
||||
try {
|
||||
await teams.addMember(inviteTargetTeam.id, inviteFound.id)
|
||||
inviteDialog = false
|
||||
} catch (e: any) {
|
||||
inviteError = e.message
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function doRemoveMember(t: TeamsResponse, userId: string) {
|
||||
if (userId === t.owner) return alert('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}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function doPromote(t: TeamsResponse, userId: string) {
|
||||
try {
|
||||
await teams.promoteToAdmin(t.id, userId)
|
||||
} catch (e: any) {
|
||||
alert(`Fehler: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function doDemote(t: TeamsResponse, userId: string) {
|
||||
try {
|
||||
await teams.demoteFromAdmin(t.id, userId)
|
||||
} catch (e: any) {
|
||||
alert(`Fehler: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function isAdmin(t: TeamsResponse, userId: string) {
|
||||
return (t.admins ?? []).includes(userId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Teams – Zeitnahme</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Teams</h1>
|
||||
<p class="mt-1 text-muted-foreground">Deine Teams und Mitglieder verwalten</p>
|
||||
</div>
|
||||
<Button onclick={() => (createDialog = true)}>
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Neues Team
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if teams.records.length === 0}
|
||||
<Card>
|
||||
<CardContent class="py-16 text-center">
|
||||
<Users class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p class="text-lg 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="h-4 w-4 mr-2" />
|
||||
Team erstellen
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each teams.records as t (t.id)}
|
||||
{@const iAmOwner = t.owner === me}
|
||||
{@const iAmAdmin = iAmOwner || (t.admins ?? []).includes(me ?? '')}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
{t.name}
|
||||
{#if t.id === teams.activeId}
|
||||
<Badge variant="default">Aktiv</Badge>
|
||||
{/if}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{(t.users ?? []).length} Mitglied{(t.users ?? []).length === 1 ? '' : 'er'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div class="flex 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={() => openInvite(t)}>
|
||||
<UserPlus class="h-4 w-4 mr-1" />
|
||||
Mitglied
|
||||
</Button>
|
||||
{/if}
|
||||
{#if !iAmOwner}
|
||||
<Button variant="ghost" size="sm" onclick={() => doLeave(t)}>
|
||||
<LogOut class="h-4 w-4 mr-1" />
|
||||
Verlassen
|
||||
</Button>
|
||||
{/if}
|
||||
{#if iAmOwner}
|
||||
<Button variant="ghost" size="icon" onclick={() => teams.remove(t)}>
|
||||
<Trash2 class="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul class="divide-y border-t">
|
||||
{#each (t.users ?? []) as userId}
|
||||
{@const isOwner = userId === t.owner}
|
||||
{@const isAdm = isAdmin(t, userId)}
|
||||
<li class="py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{userLabel(userId)}</span>
|
||||
{#if isOwner}
|
||||
<Badge variant="default" class="gap-1">
|
||||
<Crown class="h-3 w-3" />
|
||||
Owner
|
||||
</Badge>
|
||||
{:else if isAdm}
|
||||
<Badge variant="secondary" class="gap-1">
|
||||
<Shield class="h-3 w-3" />
|
||||
Admin
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if userId === me}
|
||||
<Badge variant="outline">Du</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{#if iAmOwner && !isOwner}
|
||||
<div class="flex items-center gap-1">
|
||||
{#if isAdm}
|
||||
<Button variant="ghost" size="sm" onclick={() => doDemote(t, userId)}>
|
||||
Admin entziehen
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="ghost" size="sm" onclick={() => doPromote(t, userId)}>
|
||||
Zu Admin
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="ghost" size="icon" onclick={() => doRemoveMember(t, userId)}>
|
||||
<UserMinus class="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Create-Dialog -->
|
||||
<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>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault()
|
||||
doCreate()
|
||||
}}
|
||||
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" required />
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="ghost" onclick={() => (createDialog = false)}>Abbrechen</Button>
|
||||
<Button type="submit">Erstellen</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Invite-Dialog -->
|
||||
<Dialog.Root bind:open={inviteDialog}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Mitglied hinzufügen</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Suche einen registrierten User über seine E-Mail-Adresse.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault()
|
||||
doSearch()
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<Label for="invite-email">E-Mail</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input id="invite-email" type="email" bind:value={inviteEmail} placeholder="user@example.com" />
|
||||
<Button type="submit" variant="outline" disabled={inviteSearching}>
|
||||
{inviteSearching ? 'Suche…' : 'Suchen'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if inviteError}
|
||||
<div class="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{inviteError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if inviteFound}
|
||||
<div class="rounded-md border bg-accent/40 p-3">
|
||||
<p class="font-medium">{inviteFound.name || inviteFound.email}</p>
|
||||
{#if inviteFound.email}
|
||||
<p class="text-sm text-muted-foreground">{inviteFound.email}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="ghost" onclick={() => (inviteDialog = false)}>Abbrechen</Button>
|
||||
<Button type="button" disabled={!inviteFound} onclick={doInvite}>Hinzufügen</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
Loading…
Reference in a new issue