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
265 lines
9.4 KiB
TypeScript
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)
|
|
}
|