stammtisch-hersbruck/frontend/src/lib/stores/riders.svelte.ts
Daniel Michelberger 7c3cf29354 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
2026-09-02 12:56:01 +02:00

181 lines
5.8 KiB
TypeScript

import { getContext, setContext } from 'svelte'
import { api } from './pocketbase.svelte'
import { app } from './app.svelte'
import { getTeamContext } from './teams.svelte'
import type { RidersResponse, UsersResponse } from '$lib/types'
const KEY = Symbol('rider')
function sortByNumber(records: RidersResponse[]) {
return [...records].sort((a, b) => {
const na = parseInt(a.number ?? '0', 10)
const nb = parseInt(b.number ?? '0', 10)
return na - nb
})
}
export function fullName(r: RidersResponse) {
const parts = [r.firstname, r.lastname].filter(Boolean)
return parts.join(' ').trim() || r.name || ''
}
export class RiderStore {
records = $state<RidersResponse[]>([])
loading = $state(false)
error = $state<string | null>(null)
searchQuery = $state('')
private unsubscribe: (() => void) | null = null
private teams = getTeamContext()
get scoped(): RidersResponse[] {
const teamId = this.teams.activeId
if (!teamId) return []
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
try {
const raw = await api.collection('riders').getFullList({ requestKey: null })
this.records = sortByNumber(raw)
} catch (e: any) {
this.error = e.message ?? 'Fehler beim Laden der Fahrer'
console.error(e)
} finally {
this.loading = false
}
}
subscribe() {
if (this.unsubscribe) return
api.collection('riders').subscribe('*', (e) => {
const idx = this.records.findIndex((r) => r.id === e.record.id)
if (e.action === 'create' && idx === -1) {
this.records = sortByNumber([...this.records, e.record])
} else if (e.action === 'update' && idx !== -1) {
this.records[idx] = e.record
this.records = sortByNumber(this.records)
} else if (e.action === 'delete' && idx !== -1) {
this.records = this.records.filter((r) => r.id !== e.record.id)
}
}).then((unsub) => {
this.unsubscribe = unsub
}).catch((e) => console.warn('riders subscribe failed:', e))
}
async create(data: Partial<RidersResponse>) {
const teamId = this.teams.activeId
if (!teamId) throw new Error('Kein aktives Team')
return await api.collection('riders').create({ ...data, team: teamId })
}
async edit(id: string, data: Partial<RidersResponse>) {
return await api.collection('riders').update(id, data)
}
remove(record: RidersResponse) {
return new Promise<void>((resolve, reject) => {
app.confirm.request({
title: 'Fahrer löschen?',
text: `Möchten Sie „#${record.number} ${fullName(record)}" wirklich löschen? Alle zugehörigen Zeiten werden ebenfalls gelöscht.`,
yes: async () => {
try {
await api.collection('riders').delete(record.id)
app.confirm.close()
resolve()
} catch (e) {
app.confirm.close()
reject(e)
}
},
no: () => {
app.confirm.close()
resolve()
},
})
})
}
getById(id: string) {
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)
}
isNumberTaken(number: string, eventId: string, excludeId?: string) {
return this.scoped.some(
(r) => r.number === number && r.event === eventId && r.id !== excludeId,
)
}
get filtered() {
const q = this.searchQuery.toLowerCase().trim()
if (!q) return this.scoped
return this.scoped.filter((r) => {
return (
(r.number ?? '').toLowerCase().includes(q) ||
(r.firstname ?? '').toLowerCase().includes(q) ||
(r.lastname ?? '').toLowerCase().includes(q) ||
(r.name ?? '').toLowerCase().includes(q)
)
})
}
destroy() {
if (this.unsubscribe) {
this.unsubscribe()
this.unsubscribe = null
}
}
}
export function setRiderContext() {
const store = new RiderStore()
setContext(KEY, store)
return store
}
export function getRiderContext(): RiderStore {
return getContext<RiderStore>(KEY)
}