diff --git a/frontend/src/lib/components/TeamSwitcher.svelte b/frontend/src/lib/components/TeamSwitcher.svelte index bbff610..ff4227b 100644 --- a/frontend/src/lib/components/TeamSwitcher.svelte +++ b/frontend/src/lib/components/TeamSwitcher.svelte @@ -35,7 +35,7 @@ {/each} - goto('/dashboard/teams')}> + goto('/dashboard/settings/team')}> Teams verwalten diff --git a/frontend/src/lib/stores/events.svelte.ts b/frontend/src/lib/stores/events.svelte.ts index 9896c70..554c340 100644 --- a/frontend/src/lib/stores/events.svelte.ts +++ b/frontend/src/lib/stores/events.svelte.ts @@ -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() { diff --git a/frontend/src/lib/stores/riders.svelte.ts b/frontend/src/lib/stores/riders.svelte.ts index d0fd349..fed8b33 100644 --- a/frontend/src/lib/stores/riders.svelte.ts +++ b/frontend/src/lib/stores/riders.svelte.ts @@ -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) + } + + /** + * 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 { + 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) } diff --git a/frontend/src/lib/stores/teams.svelte.ts b/frontend/src/lib/stores/teams.svelte.ts index 9968197..4bb9790 100644 --- a/frontend/src/lib/stores/teams.svelte.ts +++ b/frontend/src/lib/stores/teams.svelte.ts @@ -12,6 +12,13 @@ export class TeamStore { loading = $state(false) error = $state(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>({}) + 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 { + 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 { try { diff --git a/frontend/src/lib/time.ts b/frontend/src/lib/time.ts index bbf8f05..eacd705 100644 --- a/frontend/src/lib/time.ts +++ b/frontend/src/lib/time.ts @@ -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 `` + * 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}` +} diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 924fd2c..41162cb 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -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' }, ] @@ -97,42 +98,59 @@
-
-
- - - +
+ +
+ + +
- - +
-
diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index c22eefd..079862d 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -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 {teams.active.name} · {stats.totalEvents} Events, {stats.totalRiders} Fahrer

{:else} -

Kein Team aktiv. Team verwalten.

+

Kein Team aktiv. Team verwalten.

{/if}
@@ -49,7 +54,7 @@
{stats.totalEvents}
-

{stats.activeEvents} aktiv

+

{stats.activeEvents} heute

@@ -94,7 +99,7 @@ - +

Teams

Mitglieder und Rechte

@@ -104,27 +109,31 @@
-

Aktive Events

- {#if events.active.length === 0} +

Heute und demnächst

+ {#if upNext.length === 0} -

Keine aktiven Events

+

Nichts geplant

- Aktiviere ein Event über die Eventliste, um mit der Zeitnahme zu beginnen. + Trag einem Event ein Datum ein, dann steht es hier.

{:else}
- {#each events.active as event} + {#each upNext as event} {@const eventRuns = runs.getByEvent(event.id).length}
{event.name || event.description || 'Unbenannt'} - Aktiv + {#if event.starts} + + {formatDateRange(event.starts, event.ends)} + + {/if}
{#if event.location} diff --git a/frontend/src/routes/dashboard/events/+page.svelte b/frontend/src/routes/dashboard/events/+page.svelte index 391e38b..3e87e46 100644 --- a/frontend/src/routes/dashboard/events/+page.svelte +++ b/frontend/src/routes/dashboard/events/+page.svelte @@ -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(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(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 }, ]) @@ -102,9 +119,11 @@
{event.name || 'Unbenannt'} - - {statusLabels[event.status as keyof typeof statusLabels] ?? 'Entwurf'} - + {#if event.starts} + + {formatDateRange(event.starts, event.ends)} + + {/if}
{#if event.location} @@ -168,19 +187,30 @@
- - - - {statusLabels[form.status]} - - - Entwurf - Aktiv - Abgeschlossen - - + +
+ + + {#if multiDay} +
+ + +
+ {/if} + + {#if formError} +

{formError}

+ {/if} + diff --git a/frontend/src/routes/dashboard/events/[id]/+page.svelte b/frontend/src/routes/dashboard/events/[id]/+page.svelte index bea292a..aa1200c 100644 --- a/frontend/src/routes/dashboard/events/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/events/[id]/+page.svelte @@ -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(null) @@ -137,9 +142,12 @@

{event.name || 'Unbenannt'}

- - {event.status === 'active' ? 'Aktiv' : event.status === 'finished' ? 'Abgeschlossen' : 'Entwurf'} - + {#if event.starts} + + + {formatDateRange(event.starts, event.ends)} + + {/if}
{#if event.location}

@@ -151,6 +159,14 @@

{event.description}

{/if}
+ +
@@ -166,10 +182,12 @@

Klick auf einen Fahrer zeigt seine Runs

- + {#if riders.canManage} + + {/if}
@@ -178,11 +196,17 @@

Noch keine Fahrer

-

Füge den ersten Fahrer hinzu.

- + {#if riders.canManage} +

Füge den ersten Fahrer hinzu.

+ + {:else} +

+ Fahrer legt die Teamleitung an. +

+ {/if}
{:else} @@ -215,12 +239,14 @@ {done} von {eventRuns.length} Runs e.stopPropagation()}> - - + {#if riders.canManage} + + + {/if} {/each} @@ -370,3 +396,5 @@ + + diff --git a/frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte b/frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte index a3871da..954d934 100644 --- a/frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte +++ b/frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte @@ -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(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,20 +99,27 @@
{:else if event && rider}
-
- - - {event.name || 'Event'} - -
- {rider.number || '—'} -

- {fullName(rider) || 'Ohne Name'} -

+
+
+ + + {event.name || 'Event'} + +
+ {rider.number || '—'} +

+ {fullName(rider) || 'Ohne Name'} +

+
+ +
@@ -215,3 +233,10 @@
{/if} + + diff --git a/frontend/src/routes/dashboard/riders/+page.svelte b/frontend/src/routes/dashboard/riders/+page.svelte new file mode 100644 index 0000000..bdee0c5 --- /dev/null +++ b/frontend/src/routes/dashboard/riders/+page.svelte @@ -0,0 +1,143 @@ + + +Fahrer + +
+
+
+

Fahrer

+

+ {riders.scoped.length} im Team {teams.active?.name ?? ''} +

+
+ {#if riders.canManage} + + {/if} +
+ +
+ + +
+ + {#if riders.scoped.length === 0} + + + +

Noch keine Fahrer

+

+ {#if riders.canManage} + Leg sie in den Einstellungen an. + {:else} + Die Teamleitung legt Fahrer in den Einstellungen an. + {/if} +

+
+
+ {:else if roster.length === 0} + + Keine Treffer. + + {:else} + + + + + + # + Name + Event + Zeiten + Konto + + + + {#each roster as r (r.id)} + {@const path = riderPath(r)} + + + {r.number || '—'} + + + {#if path} + {fullName(r) || '— ohne Name —'} + {:else} + {fullName(r) || '— ohne Name —'} + {/if} + + + {eventName(r.event) ?? '—'} + + + {times.getByRider(r.id).length} + + + {#if r.user} + + + {teams.userLabel(r.user)} + + {:else} + + {/if} + + + {/each} + + + + + {/if} +
diff --git a/frontend/src/routes/dashboard/settings/+layout.svelte b/frontend/src/routes/dashboard/settings/+layout.svelte new file mode 100644 index 0000000..612abb8 --- /dev/null +++ b/frontend/src/routes/dashboard/settings/+layout.svelte @@ -0,0 +1,57 @@ + + +Einstellungen + +
+

Einstellungen

+ + + + {@render children?.()} +
diff --git a/frontend/src/routes/dashboard/settings/+page.svelte b/frontend/src/routes/dashboard/settings/+page.svelte new file mode 100644 index 0000000..26bb038 --- /dev/null +++ b/frontend/src/routes/dashboard/settings/+page.svelte @@ -0,0 +1,191 @@ + + +
+ + + + + Konto + + + +
+ {#if avatarUrl} + + {:else} +
+ +
+ {/if} +
+ + (avatarFile = e.currentTarget.files?.[0] ?? null)} + /> +
+
+ +
+ + +
+ +
+ + +

+ Die E-Mail-Adresse ist der Anmeldename. Ändern kann sie nur die + Team-Administration über PocketBase. +

+
+ + {#if profileError} +

{profileError}

+ {:else if profileMessage} +

{profileMessage}

+ {/if} + + +
+
+ + + + + + Passwort ändern + + + +
+ + +
+
+ + +
+
+ + +
+ + {#if passwordError} +

{passwordError}

+ {:else if passwordMessage} +

{passwordMessage}

+ {/if} + + +
+
+
diff --git a/frontend/src/routes/dashboard/settings/flags/+page.svelte b/frontend/src/routes/dashboard/settings/flags/+page.svelte index 5fb1727..3b607de 100644 --- a/frontend/src/routes/dashboard/settings/flags/+page.svelte +++ b/frontend/src/routes/dashboard/settings/flags/+page.svelte @@ -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 @@ } -
+
-

Flag-Typen

Wofür sich Marker auf einem Trail setzen lassen.

@@ -149,7 +150,7 @@ {#each flags.scoped as flag (flag.id)} - + {flag.label} {flag.severity} @@ -203,10 +204,38 @@
- +
+ + + + +

- Name eines Icons von lucide.dev, in Schreibweise wie „TreePine". + Jeder Name von lucide.dev + ist möglich — geschrieben wie „TreePine" oder „tree-pine". + {#if form.icon.trim() && !iconExists(form.icon)} + Diesen Namen gibt es dort nicht. + {/if}

+ +

Vorschläge:

+
+ {#each FLAG_ICON_SUGGESTIONS as name (name)} + + {/each} +
{#if error}

{error}

diff --git a/frontend/src/routes/dashboard/settings/riders/+page.svelte b/frontend/src/routes/dashboard/settings/riders/+page.svelte new file mode 100644 index 0000000..cdd020d --- /dev/null +++ b/frontend/src/routes/dashboard/settings/riders/+page.svelte @@ -0,0 +1,385 @@ + + +{#if !canManage} + + + Fahrer verwaltet, wer das Team führt. Die Fahrer deines Teams siehst du + unter Fahrer. + + +{:else} +
+
+

+ Fahrer des Teams. Ein verknüpftes Konto erlaubt der Person, sich selbst + anzumelden. +

+ +
+ + {#if roster.length === 0} + + + Noch keine Fahrer im Team. + + + {:else} + + + + + + # + Name + Event + Konto + Aktionen + + + + {#each roster as r (r.id)} + + + {r.number || '—'} + + + {fullName(r) || '— ohne Name —'} + + + {eventName(r.event) ?? '—'} + + + {#if r.user} + + + + kann sich einloggen + + + {teams.userLabel(r.user)} + + + {:else} + + {/if} + + + {#if r.user} + + {:else} + + {/if} + + + + + {/each} + + + + + {/if} +
+{/if} + + + + + + {editing ? 'Fahrer bearbeiten' : 'Neuer Fahrer'} + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + + + {eventName(form.event) ?? 'Keinem Event zugeordnet'} + + + Keinem Event zugeordnet + {#each events.scoped as e (e.id)} + {e.name || e.description} + {/each} + + +

+ Zeiten hängen am Event. Ohne Zuordnung steht der Fahrer nur im + Team-Verzeichnis. +

+
+ {#if riderError} +

{riderError}

+ {/if} +
+ + + + +
+
+ + + + + + Konto für {loginRider ? fullName(loginRider) : 'Fahrer'} + + Mit einem Konto kann sich die Person selbst anmelden. + + + +
+ + +
+ +
+ {#if loginMode === 'link'} +
+ + + + {linkUserId ? teams.userLabel(linkUserId) : 'Mitglied wählen…'} + + + {#each linkCandidates as id (id)} + {teams.userLabel(id)} + {/each} + + + {#if linkCandidates.length === 0} +

+ Alle Teammitglieder sind bereits einem Fahrer zugeordnet. +

+ {/if} +
+ {:else} +
+ + +
+
+ + +

+ Das Konto kommt zugleich ins Team. Gib das Passwort weiter; es lässt + sich unter „Profil" ändern. +

+
+ {/if} + + {#if loginError} +

{loginError}

+ {/if} +
+ + + + {#if loginMode === 'link'} + + {:else} + + {/if} + +
+
diff --git a/frontend/src/routes/dashboard/settings/team/+page.svelte b/frontend/src/routes/dashboard/settings/team/+page.svelte new file mode 100644 index 0000000..cbb24a9 --- /dev/null +++ b/frontend/src/routes/dashboard/settings/team/+page.svelte @@ -0,0 +1,329 @@ + + +
+
+

+ Teams, in denen du bist. Das aktive Team bestimmt, welche Events, Fahrer und + Trails du siehst. +

+ +
+ + {#if teams.records.length === 0} + + + +

Noch in keinem Team

+

Erstelle dein erstes Team.

+ +
+
+ {:else} + {#each teams.records as t (t.id)} + {@const iAmOwner = t.owner === me} + {@const iAmAdmin = iAmOwner || (t.admins ?? []).includes(me ?? '')} + + +
+
+ + {t.name} + {#if t.id === teams.activeId} + Aktiv + {/if} + + + {(t.users ?? []).length} Mitglied{(t.users ?? []).length === 1 ? '' : 'er'} + +
+
+ {#if t.id !== teams.activeId} + + {/if} + {#if iAmAdmin} + + + {/if} + {#if !iAmOwner} + + {:else} + + {/if} +
+
+
+ +
    + {#each (t.users ?? []) as userId (userId)} + {@const isOwner = userId === t.owner} + {@const isAdm = (t.admins ?? []).includes(userId)} +
  • +
    + {teams.userLabel(userId)} + {#if isOwner} + + + Owner + + {:else if isAdm} + + + Admin + + {/if} + {#if userId === me} + Du + {/if} +
    + {#if iAmOwner && !isOwner} +
    + {#if isAdm} + + {:else} + + {/if} + +
    + {/if} +
  • + {/each} +
+
+
+ {/each} + {/if} +
+ + + + + + Neues Team + Du wirst automatisch Owner. + +
+
+ + +
+ {#if createError} +

{createError}

+ {/if} +
+ + + + +
+
+ + + + + + Team umbenennen + +
+
+ + +
+ {#if renameError} +

{renameError}

+ {/if} +
+ + + + +
+
+ + + + + + Mitglied anlegen + + Legt ein Konto an und nimmt es in „{memberTeam?.name}" auf. + + +
+
+ + +
+
+ + +
+
+ + +

+ Gib es der Person weiter; sie kann es unter „Profil" ändern. +

+
+ {#if memberError} +

{memberError}

+ {/if} +
+ + + + +
+
diff --git a/frontend/src/routes/dashboard/teams/+page.svelte b/frontend/src/routes/dashboard/teams/+page.svelte deleted file mode 100644 index 26a90bc..0000000 --- a/frontend/src/routes/dashboard/teams/+page.svelte +++ /dev/null @@ -1,341 +0,0 @@ - - -Teams – Zeitnahme - -
-
-
-

Teams

-

Deine Teams und Mitglieder verwalten

-
- -
- - {#if teams.records.length === 0} - - - -

Noch in keinem Team

-

Erstelle dein erstes Team.

- -
-
- {:else} -
- {#each teams.records as t (t.id)} - {@const iAmOwner = t.owner === me} - {@const iAmAdmin = iAmOwner || (t.admins ?? []).includes(me ?? '')} - - -
-
- - {t.name} - {#if t.id === teams.activeId} - Aktiv - {/if} - - - {(t.users ?? []).length} Mitglied{(t.users ?? []).length === 1 ? '' : 'er'} - -
-
- {#if t.id !== teams.activeId} - - {/if} - {#if iAmAdmin} - - {/if} - {#if !iAmOwner} - - {/if} - {#if iAmOwner} - - {/if} -
-
-
- -
    - {#each (t.users ?? []) as userId} - {@const isOwner = userId === t.owner} - {@const isAdm = isAdmin(t, userId)} -
  • -
    - {userLabel(userId)} - {#if isOwner} - - - Owner - - {:else if isAdm} - - - Admin - - {/if} - {#if userId === me} - Du - {/if} -
    - {#if iAmOwner && !isOwner} -
    - {#if isAdm} - - {:else} - - {/if} - -
    - {/if} -
  • - {/each} -
-
-
- {/each} -
- {/if} -
- - - - - - Neues Team - Du wirst automatisch Owner. - -
{ - e.preventDefault() - doCreate() - }} - class="space-y-4" - > -
- - -
- - - - -
-
-
- - - - - - Mitglied hinzufügen - - Suche einen registrierten User über seine E-Mail-Adresse. - - -
{ - e.preventDefault() - doSearch() - }} - class="space-y-4" - > -
- -
- - -
-
- - {#if inviteError} -
- {inviteError} -
- {/if} - - {#if inviteFound} -
-

{inviteFound.name || inviteFound.email}

- {#if inviteFound.email} -

{inviteFound.email}

- {/if} -
- {/if} - - - - - -
-
-