stammtisch-hersbruck/frontend/src/lib/stores/teams.svelte.ts
Daniel Michelberger 45835cd45c refactor: Team und Kader an einem Ort
Frueher lag beides in den Einstellungen und ein blasses Verzeichnis
zusaetzlich unter "Fahrer". Wer einen Fahrer anlegen wollte, landete
erst im Verzeichnis, dann in den Einstellungen. Jetzt gibt es einen Ort,
und die Verwaltungsknoepfe stehen an den Zeilen, zu denen sie gehoeren.

Der Einladungslink sitzt im Kopf der Seite statt in einer eigenen
Section; die ausgegebenen Links stehen mit im Dialog, weil sie sonst mit
der Section auch das Kopieren und das Zuruecknehmen verloren haetten.

Die Adminrolle wandert aus der Tabellenzeile in den Personendialog. Ein
Knopf, der ohne Rueckfrage Rechte vergibt, gehoert nicht neben die Icons
fuers Entfernen und Loeschen. Der Bearbeiten-Knopf steht dafuer an jeder
Zeile, auch an Konten ohne Fahrer — sonst waere deren Rolle nirgends
mehr erreichbar.

Die Liste "Meine Teams" entfaellt: Wechseln und Anlegen stehen im
Submenue am Menuepunkt Team. Verlassen gilt nur dem aktiven Team und
steht deshalb jetzt im Kopf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:11:13 +02:00

265 lines
9.4 KiB
TypeScript

import { getContext, setContext } from 'svelte'
import { api, auth } from './pocketbase.svelte'
import { app } from './app.svelte'
import type { TeamsResponse, UsersResponse } from '$lib/types'
const KEY = Symbol('team')
const LS_KEY = 'activeTeamId'
export class TeamStore {
records = $state<TeamsResponse[]>([])
activeId = $state<string | null>(null)
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 {
return this.records.find((t) => t.id === this.activeId) ?? null
}
/** ID des aktuell eingeloggten Users */
private get me(): string | null {
return api.authStore.record?.id ?? null
}
isOwner(team: TeamsResponse | null = this.active): boolean {
if (!team || !this.me) return false
return team.owner === this.me
}
isAdmin(team: TeamsResponse | null = this.active): boolean {
if (!team || !this.me) return false
if (this.isOwner(team)) return true
return (team.admins ?? []).includes(this.me)
}
isMember(team: TeamsResponse | null = this.active): boolean {
if (!team || !this.me) return false
return (team.users ?? []).includes(this.me)
}
async load() {
this.loading = true
this.error = null
try {
this.records = await api.collection('teams').getFullList({
sort: '+name',
requestKey: null,
})
// Aktives Team aus localStorage wiederherstellen, sonst erstes nehmen
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(LS_KEY) : null
if (stored && this.records.some((t) => t.id === stored)) {
this.activeId = stored
} else if (this.records.length > 0) {
this.activeId = this.records[0].id
} else {
this.activeId = null
}
} catch (e: any) {
this.error = e.message ?? 'Fehler beim Laden der Teams'
console.error(e)
} finally {
this.loading = false
}
}
subscribe() {
if (this.unsubscribe) return
api.collection('teams').subscribe('*', (e) => {
const idx = this.records.findIndex((r) => r.id === e.record.id)
if (e.action === 'create' && idx === -1) this.records = [...this.records, e.record].sort((a, b) => a.name.localeCompare(b.name))
else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record
else if (e.action === 'delete' && idx !== -1) {
this.records = this.records.filter((r) => r.id !== e.record.id)
if (this.activeId === e.record.id) {
this.activeId = this.records[0]?.id ?? null
}
}
}).then((unsub) => {
this.unsubscribe = unsub
}).catch((e) => console.warn('teams subscribe failed:', e))
}
setActive(id: string) {
if (!this.records.some((t) => t.id === id)) return
this.activeId = id
if (typeof localStorage !== 'undefined') localStorage.setItem(LS_KEY, id)
}
async create(name: string) {
if (!this.me) throw new Error('Nicht eingeloggt')
const team = await api.collection('teams').create({
name,
owner: this.me,
users: [this.me],
admins: [],
})
// sofort als aktiv setzen
setTimeout(() => this.setActive(team.id), 0)
return team
}
async rename(id: string, name: string) {
return await api.collection('teams').update(id, { name })
}
/**
* Logo setzen oder entfernen. Mit Datei als FormData — ein leeres Feld im
* FormData würde ein vorhandenes Bild löschen, deshalb der zweite Weg.
*/
async setLogo(id: string, file: File | null) {
if (!file) {
return await api.collection('teams').update(id, { logo: null })
}
const data = new FormData()
data.append('logo', file)
return await api.collection('teams').update(id, data)
}
/** Mitglied hinzufügen (Owner/Admin only) */
async addMember(teamId: string, userId: string) {
const team = this.records.find((t) => t.id === teamId)
if (!team) throw new Error('Team nicht gefunden')
const users = [...(team.users ?? [])]
if (!users.includes(userId)) users.push(userId)
return await api.collection('teams').update(teamId, { users })
}
/** Mitglied entfernen */
async removeMember(teamId: string, userId: string) {
const team = this.records.find((t) => t.id === teamId)
if (!team) throw new Error('Team nicht gefunden')
const users = (team.users ?? []).filter((u) => u !== userId)
const admins = (team.admins ?? []).filter((u) => u !== userId)
return await api.collection('teams').update(teamId, { users, admins })
}
async promoteToAdmin(teamId: string, userId: string) {
const team = this.records.find((t) => t.id === teamId)
if (!team) throw new Error('Team nicht gefunden')
const admins = [...(team.admins ?? [])]
if (!admins.includes(userId)) admins.push(userId)
return await api.collection('teams').update(teamId, { admins })
}
async demoteFromAdmin(teamId: string, userId: string) {
const team = this.records.find((t) => t.id === teamId)
if (!team) throw new Error('Team nicht gefunden')
const admins = (team.admins ?? []).filter((u) => u !== userId)
return await api.collection('teams').update(teamId, { admins })
}
/** Verlassen — entfernt sich selbst aus users + admins */
async leave(teamId: string) {
if (!this.me) return
return await this.removeMember(teamId, this.me)
}
/** Team löschen (nur Owner) */
remove(team: TeamsResponse) {
return new Promise<void>((resolve, reject) => {
app.confirm.request({
title: 'Team löschen?',
text: `Möchtest du das Team „${team.name}" wirklich löschen? Achtung: zugehörige Events, Fahrer und Zeiten werden NICHT gelöscht, sind aber für niemanden mehr sichtbar.`,
yes: async () => {
try {
await api.collection('teams').delete(team.id)
app.confirm.close()
resolve()
} catch (e) {
app.confirm.close()
reject(e)
}
},
no: () => {
app.confirm.close()
resolve()
},
})
})
}
/**
* 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
}
destroy() {
if (this.unsubscribe) {
this.unsubscribe()
this.unsubscribe = null
}
}
}
export function setTeamContext() {
const store = new TeamStore()
setContext(KEY, store)
return store
}
export function getTeamContext(): TeamStore {
return getContext<TeamStore>(KEY)
}