Compare commits
No commits in common. "e7892be60b9e7f51e5bbcc1c2c1325a12966f2b8" and "27d34ad0d3bfa574b467ddbfa7945ccc677ad922" have entirely different histories.
e7892be60b
...
27d34ad0d3
36 changed files with 2397 additions and 4629 deletions
|
|
@ -115,12 +115,6 @@ The app uses Svelte 5 runes for state management with a custom store pattern:
|
||||||
|
|
||||||
**Hotkeys**: Use `app.hotkey(event, condition, callback)` which auto-ignores input fields and contenteditable elements.
|
**Hotkeys**: Use `app.hotkey(event, condition, callback)` which auto-ignores input fields and contenteditable elements.
|
||||||
|
|
||||||
**Tooltips**: Never use the `title` attribute for tooltips — appwide. `<Button>`
|
|
||||||
takes a `tooltip="…"` prop (it also becomes the `aria-label`, so icon-only
|
|
||||||
buttons keep an accessible name); every other element uses the action:
|
|
||||||
`use:tooltip={{ content: '…' }}` from `app.svelte.ts`. Both render through
|
|
||||||
tippy.js, which the action initializes, updates and destroys.
|
|
||||||
|
|
||||||
### Project Configuration
|
### Project Configuration
|
||||||
|
|
||||||
- **Repo-Struktur**: Monorepo mit `frontend/` (SvelteKit) und `backend/`
|
- **Repo-Struktur**: Monorepo mit `frontend/` (SvelteKit) und `backend/`
|
||||||
|
|
|
||||||
150
TODO.md
150
TODO.md
|
|
@ -1,8 +1,40 @@
|
||||||
# Offene Punkte
|
# Offene Punkte
|
||||||
|
|
||||||
Stand: 2026-09-02, abgeglichen mit der produktiven Instanz. Die fachlichen
|
Stand: 2026-09-02. Was hier steht, ist bewusst nicht erledigt — entweder weil
|
||||||
Entscheidungen sind gefallen und gebaut; was hier steht, passiert außerhalb des
|
es eine Entscheidung braucht oder weil es außerhalb des Repos passiert.
|
||||||
Repos oder wartet auf den nächsten Deploy.
|
|
||||||
|
## Entscheidungen
|
||||||
|
|
||||||
|
### Trail-Status: dürfen Mitglieder melden?
|
||||||
|
|
||||||
|
Anzeige und Herkunft (`status_changed`, `status_by`) sind fertig. Ändern darf
|
||||||
|
den Status weiterhin nur, wer Pate oder Team-Admin ist — `trails.updateRule`
|
||||||
|
lässt nichts anderes zu.
|
||||||
|
|
||||||
|
Sollen alle Teammitglieder melden dürfen, braucht es eine Regel, die genau ein
|
||||||
|
Feld freigibt: `team.users.id ?= @request.auth.id` kombiniert mit
|
||||||
|
`@request.body.<feld>:isset = false` für **jedes** andere Feld. Das
|
||||||
|
funktioniert, wird aber lang und bricht still, sobald ein Feld dazukommt.
|
||||||
|
Deshalb erst nach ausdrücklicher Entscheidung.
|
||||||
|
|
||||||
|
### Fahrer: Kader oder Event-Teilnehmer?
|
||||||
|
|
||||||
|
`riders.event` bindet einen Fahrer an genau ein Event. Ein in den Einstellungen
|
||||||
|
angelegter Fahrer ohne Event steht deshalb nur im Verzeichnis und taucht in
|
||||||
|
keiner Zeitnahme auf; der Dialog hat dafür ein optionales Event-Feld.
|
||||||
|
|
||||||
|
Ein echter Team-Kader, aus dem man Fahrer in Events übernimmt, wäre eine
|
||||||
|
n:m-Beziehung — also eine Schema-Änderung, keine Oberflächenfrage.
|
||||||
|
|
||||||
|
### Mitglied per E-Mail einladen
|
||||||
|
|
||||||
|
Die alte Einladung über eine E-Mail-Suche ist entfallen und durch „Mitglied
|
||||||
|
anlegen" ersetzt. Sie konnte nie funktionieren: `users.listRule` gibt nur den
|
||||||
|
eigenen Datensatz frei, die Suche lief immer ins Leere.
|
||||||
|
|
||||||
|
Ein **bestehendes** fremdes Konto in ein Team zu holen braucht eine offenere
|
||||||
|
`listRule` (z. B. `@request.auth.id != ""`). Das erlaubt allerdings, alle
|
||||||
|
Konten der Instanz aufzulisten — deshalb bewusst nicht ohne Rücksprache.
|
||||||
|
|
||||||
## Vor dem nächsten Deploy
|
## Vor dem nächsten Deploy
|
||||||
|
|
||||||
|
|
@ -16,49 +48,35 @@ Gelesen wird er über `$env/dynamic/public`, das heißt: Die Variable muss in de
|
||||||
In Coolify als normale Umgebungsvariable eintragen genügt. Fehlt sie, laufen die
|
In Coolify als normale Umgebungsvariable eintragen genügt. Fehlt sie, laufen die
|
||||||
Kacheln ohne Schlüssel weiter — die Karte bleibt also sichtbar.
|
Kacheln ohne Schlüssel weiter — die Karte bleibt also sichtbar.
|
||||||
|
|
||||||
### Migrationen: sechs gelaufen, fünf offen
|
### Migrationen greifen beim nächsten Start
|
||||||
|
|
||||||
Abgeglichen mit `api.stammtisch-hersbruck.de` (nur gelesen): Der Backend-Deploy
|
Fünf neue Migrationen liegen in `backend/pb_migrations/`:
|
||||||
hat alles bis `1754500900` mitgenommen, ab `1754501000` steht es noch aus.
|
|
||||||
|
|
||||||
| Datei | Was sie tut | Stand |
|
- `1754500500_riders_user_and_admin_rules.js`
|
||||||
|---|---|---|
|
- `1754500600_events_dates_instead_of_status.js`
|
||||||
| `1754500500_riders_user_and_admin_rules` | `riders.user`, Fahrerpflege nur Teamleitung, `users.viewRule`/`createRule` | gelaufen |
|
- `1754500700_trail_status_meta.js`
|
||||||
| `1754500600_events_dates_instead_of_status` | `events.status` raus, `starts`/`ends` rein | gelaufen |
|
- `1754500800_trail_markers_custom.js`
|
||||||
| `1754500700_trail_status_meta` | `trails.status_changed`, `status_by` | gelaufen |
|
- `1754500900_users_superadmin.js`
|
||||||
| `1754500800_trail_markers_custom` | `trail_markers.label`/`icon`, `flag` optional | gelaufen |
|
|
||||||
| `1754500900_users_superadmin` | `users.superadmin` samt Schutz vor Selbstbeförderung | gelaufen |
|
|
||||||
| `1754501000_teams_logo` | `teams.logo` | **offen** |
|
|
||||||
| `1754501100_trails_status_report` | Mitglieder dürfen den Trail-Zustand melden | **offen** |
|
|
||||||
| `1754501200_users_list_for_invites` | `users.listRule` gelockert (Kontosuche) | **offen** |
|
|
||||||
| `1754501300_event_participants` | neue Collection, **verschiebt Daten**, entfernt `riders.event`/`number` | **offen** |
|
|
||||||
| `1754501400_events_timing_participation` | `events.timing`/`participation` | **offen** |
|
|
||||||
| `1754501500_event_series` | `event_series`, `events.series` | **offen** |
|
|
||||||
|
|
||||||
Bis zum nächsten Deploy laufen deshalb ins Leere: Team-Logo, Zustandsmeldung
|
Solange sie nicht gelaufen sind, scheitert alles, was auf den neuen Regeln
|
||||||
durch Mitglieder, Kontosuche per E-Mail, Teilnehmerlisten, die Event-Schalter
|
aufbaut. Am deutlichsten beim Anlegen eines Mitglieds oder eines Fahrer-Logins:
|
||||||
und die Serien. Die Oberfläche dafür ist da und wird beim Speichern eine
|
**„Only superusers can perform this action"** — genau das sagt die noch
|
||||||
Fehlermeldung von PocketBase zeigen.
|
unveraenderte `users.createRule` (null = nur Superuser).
|
||||||
|
|
||||||
**Zwei Dinge sind beim Nachziehen nicht zurückzunehmen:**
|
**Achtung:** Die mittlere entfernt `events.status` **samt seiner Werte**. Das
|
||||||
|
ist so gewollt (Events werden datiert statt bewertet), aber es ist ein
|
||||||
|
Datenverlust und nicht zurückzunehmen.
|
||||||
|
|
||||||
1. `1754501300` legt für jeden Fahrer mit Event einen Teilnehmer an und entfernt
|
Ebenfalls neu: Fahrer anlegen, ändern und löschen darf ab dann nur noch die
|
||||||
danach `riders.event` und `riders.number`. Eine Startnummer an einem Fahrer
|
Teamleitung. Mitglieder ohne Admin-Rolle verlieren diese Möglichkeit auch auf
|
||||||
**ohne** Event hat danach keinen Platz mehr und geht verloren — sie war
|
der Event-Seite.
|
||||||
ohnehin nirgends sichtbar, weil alle Listen nach Event filterten.
|
|
||||||
2. Bestehende Events werden auf `timing = true` und `participation =
|
|
||||||
geschlossen` gesetzt. Genau so wurden sie bisher benutzt; wer einen
|
|
||||||
Stammtisch daraus machen will, schaltet die Zeitnahme im Dialog ab.
|
|
||||||
|
|
||||||
Ebenfalls bewusst so: Nach `1754501200` kann jeder Eingeloggte Konten
|
|
||||||
auflisten, damit sich bestehende Konten per E-Mail ins Team holen lassen.
|
|
||||||
E-Mail-Adressen bleiben über `emailVisibility` verborgen.
|
|
||||||
|
|
||||||
### Typen einmal neu erzeugen
|
### Typen einmal neu erzeugen
|
||||||
|
|
||||||
`frontend/src/lib/types.d.ts` wurde bei allen Schema-Änderungen von Hand
|
`frontend/src/lib/types.d.ts` wurde bei den letzten Schema-Änderungen von Hand
|
||||||
gepflegt. Grund: `pocketbase-typegen` braucht native sqlite3-Bindings, die in
|
ergänzt (`riders.user`, `trails.status_by`/`status_changed`,
|
||||||
der Arbeitsumgebung nicht gebaut werden durften.
|
`events.starts`/`ends` statt `status`). Grund: `pocketbase-typegen` braucht
|
||||||
|
native sqlite3-Bindings, die in der Arbeitsumgebung nicht gebaut werden durften.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend && npm run generate-pocketbase-types
|
cd frontend && npm run generate-pocketbase-types
|
||||||
|
|
@ -67,52 +85,16 @@ cd frontend && npm run generate-pocketbase-types
|
||||||
Die Datei sollte danach unverändert bleiben. Tut sie es nicht, gilt das Ergebnis
|
Die Datei sollte danach unverändert bleiben. Tut sie es nicht, gilt das Ergebnis
|
||||||
des Generators.
|
des Generators.
|
||||||
|
|
||||||
### Regeln einmal durchspielen
|
|
||||||
|
|
||||||
Die Zugriffsregeln der neuen Collections konnten nirgends laufen — weder Docker
|
|
||||||
noch eine lokale PocketBase-Instanz standen zur Verfügung. Sie sind nach dem
|
|
||||||
Muster der vorhandenen Collections geschrieben, aber ungetestet. Nach dem Deploy
|
|
||||||
einmal ausprobieren:
|
|
||||||
|
|
||||||
- Ein einfaches Teammitglied trägt sich bei einem **offenen** Event selbst ein.
|
|
||||||
- Dasselbe bei einem Event **mit Bestätigung** — es muss als „Angefragt"
|
|
||||||
landen, nicht als „Dabei".
|
|
||||||
- Bei einem **geschlossenen** Event darf es gar nicht gehen.
|
|
||||||
- Ein Mitglied meldet einen Trail-Zustand (muss gehen) und versucht, den Namen
|
|
||||||
des Trails zu ändern (darf nicht gehen).
|
|
||||||
|
|
||||||
Der Selbsteintrag setzt außerdem voraus, dass das Konto der Person mit einem
|
|
||||||
Fahrer verknüpft ist (Einstellungen → Fahrer & Konten). Ohne Verknüpfung
|
|
||||||
erscheint der Knopf gar nicht erst.
|
|
||||||
|
|
||||||
### Logo des Stammtischs zuordnen
|
|
||||||
|
|
||||||
`teams.logo` gibt es ab `1754501000`. Solange nichts hinterlegt ist, zeigt die
|
|
||||||
App weiterhin das Wappen aus `static/` — für den Stammtisch Hersbruck sieht es
|
|
||||||
also unverändert aus. Wer es als Datei am Team haben will, lädt es nach dem
|
|
||||||
Deploy unter Einstellungen → Team → Logo hoch.
|
|
||||||
|
|
||||||
## Kleinigkeiten
|
## Kleinigkeiten
|
||||||
|
|
||||||
- Wer Regeln direkt im PocketBase-Admin ändert, hinterlässt keine Spur im Repo.
|
- Der Melder eines Trail-Markers („wer") zeigt bis zum Deploy „Unbekannt". Die
|
||||||
Die Migrationen bleiben die Quelle: Sie setzen dieselben Werte noch einmal und
|
gelockerte `users.viewRule` kommt mit `1754500500`.
|
||||||
überschreiben eine abweichende Handänderung stillschweigend.
|
- Die Rolle **Superadmin** existiert seit `1754500900` und ist für
|
||||||
- Die Rolle **Superadmin** ist gesetzt, gibt in der App aber noch keine
|
stammtisch@dne.name gesetzt, gibt in der App aber noch keine besonderen
|
||||||
besonderen Rechte. Sie ist bewusst nur in PocketBase vergebbar: Die
|
Rechte. Sie ist bewusst nur in PocketBase vergebbar: Die `updateRule` der
|
||||||
`updateRule` der users-Collection schließt `superadmin` vom Selbstsetzen aus.
|
users-Collection schließt `superadmin` vom Selbstsetzen aus. Sobald klar ist,
|
||||||
Sobald klar ist, was sie dürfen soll, kommen die Regeln dazu.
|
was sie dürfen soll, kommen die Regeln dazu.
|
||||||
- Flag-Typen sind **teamspezifisch**. Ein neues Team bekommt beim Anlegen den
|
- Flag-Typen bleiben **teamspezifisch**. Ein neues Team bekommt beim Anlegen
|
||||||
hartkodierten Standardsatz aus `trailFlags.svelte.ts` und kann ihn frei
|
den hartkodierten Standardsatz aus `trailFlags.svelte.ts`, den es danach frei
|
||||||
ändern.
|
ändern kann. Eine appweite Verwaltung gibt es damit bewusst nicht.
|
||||||
- „Jedes Teammitglied ist ein Fahrer" hält die Oberfläche ein, das Backend
|
|
||||||
erzwingt es nicht: Ein Konto kann im Team sein, ohne an einem Fahrer zu
|
|
||||||
hängen. Solche Fälle stehen unter „Fahrer & Konten" mit dem Vermerk „kein
|
|
||||||
Fahrer" und lassen sich per Knopf übernehmen.
|
|
||||||
- „Als Fahrer übernehmen" rät den Namen aus dem Kontonamen und setzt ihn als
|
|
||||||
Vornamen. „Max Mustermann" auf zwei Felder aufzuteilen wäre geraten und läge
|
|
||||||
bei Doppelnamen daneben; der Name lässt sich danach korrigieren. Wenn das zu
|
|
||||||
grob ist, müsste der Knopf stattdessen nach Vor- und Nachname fragen.
|
|
||||||
- Serien erzeugen bis zu zwölf Termine je Klick und überspringen, was schon
|
|
||||||
angelegt ist. Ein Termin, der ausfällt, wird als Event gelöscht oder
|
|
||||||
verschoben — die Serie bleibt davon unberührt.
|
|
||||||
- `NAMING.md` und `frontend/static/pedaler.svg` liegen bewusst nur lokal.
|
- `NAMING.md` und `frontend/static/pedaler.svg` liegen bewusst nur lokal.
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,38 +0,0 @@
|
||||||
/// <reference path="../pb_data/types.d.ts" />
|
|
||||||
|
|
||||||
// Jedes Team bringt sein eigenes Logo mit.
|
|
||||||
//
|
|
||||||
// Bisher steckte das Wappen fest in der Anwendung (static/schildl.svg und
|
|
||||||
// static/radl.svg). Für den Stammtisch Hersbruck stimmt das, für jedes
|
|
||||||
// weitere Team wäre es fremdes Vereinswappen. Ohne hinterlegte Datei bleibt
|
|
||||||
// die bisherige Darstellung als Rückfalloption bestehen.
|
|
||||||
//
|
|
||||||
// 5 MB, weil die vorhandenen Wappen als Inkscape-SVG je rund 2,5 MB wiegen.
|
|
||||||
|
|
||||||
migrate((app) => {
|
|
||||||
const teams = app.findCollectionByNameOrId('teams')
|
|
||||||
|
|
||||||
if (!teams.fields.getByName('logo')) {
|
|
||||||
teams.fields.add(new Field({
|
|
||||||
id: 'file_team_logo',
|
|
||||||
name: 'logo',
|
|
||||||
type: 'file',
|
|
||||||
maxSelect: 1,
|
|
||||||
maxSize: 5242880,
|
|
||||||
mimeTypes: [
|
|
||||||
'image/jpeg',
|
|
||||||
'image/png',
|
|
||||||
'image/svg+xml',
|
|
||||||
'image/gif',
|
|
||||||
'image/webp',
|
|
||||||
],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
app.save(teams)
|
|
||||||
}, (app) => {
|
|
||||||
const teams = app.findCollectionByNameOrId('teams')
|
|
||||||
|
|
||||||
teams.fields.removeByName('logo')
|
|
||||||
app.save(teams)
|
|
||||||
})
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
/// <reference path="../pb_data/types.d.ts" />
|
|
||||||
|
|
||||||
// Jedes Teammitglied darf den Zustand eines Trails melden.
|
|
||||||
//
|
|
||||||
// Bisher durften das nur Paten und Team-Admins. Genau die sind aber selten
|
|
||||||
// die, die vor dem umgestürzten Baum stehen. Gemeldet werden darf deshalb von
|
|
||||||
// allen — geändert werden darf dabei aber ausschließlich der Zustand.
|
|
||||||
//
|
|
||||||
// PocketBase kennt keine Rechte je Feld, also wird jedes andere Feld
|
|
||||||
// ausdrücklich ausgeschlossen. Das ist lang und muss mitwachsen, wenn ein
|
|
||||||
// Feld dazukommt; die Alternative wäre, Mitgliedern das ganze Objekt
|
|
||||||
// freizugeben. `status_by = @request.auth.id` verhindert außerdem, eine
|
|
||||||
// Meldung unter fremdem Namen abzusetzen.
|
|
||||||
|
|
||||||
const STEWARD_OR_ADMIN =
|
|
||||||
'stewards.id ?= @request.auth.id' +
|
|
||||||
' || team.owner.id ?= @request.auth.id' +
|
|
||||||
' || team.admins.id ?= @request.auth.id'
|
|
||||||
|
|
||||||
const MEMBER_REPORTS_STATUS =
|
|
||||||
'team.users.id ?= @request.auth.id' +
|
|
||||||
' && @request.body.team:isset = false' +
|
|
||||||
' && @request.body.name:isset = false' +
|
|
||||||
' && @request.body.description:isset = false' +
|
|
||||||
' && @request.body.stewards:isset = false' +
|
|
||||||
' && @request.body.event:isset = false' +
|
|
||||||
' && @request.body.current:isset = false' +
|
|
||||||
' && @request.body.created_by:isset = false' +
|
|
||||||
' && @request.body.status_by = @request.auth.id'
|
|
||||||
|
|
||||||
migrate((app) => {
|
|
||||||
const trails = app.findCollectionByNameOrId('trails')
|
|
||||||
trails.updateRule = `${STEWARD_OR_ADMIN} || (${MEMBER_REPORTS_STATUS})`
|
|
||||||
app.save(trails)
|
|
||||||
}, (app) => {
|
|
||||||
const trails = app.findCollectionByNameOrId('trails')
|
|
||||||
trails.updateRule = STEWARD_OR_ADMIN
|
|
||||||
app.save(trails)
|
|
||||||
})
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
/// <reference path="../pb_data/types.d.ts" />
|
|
||||||
|
|
||||||
// Konten lassen sich wieder über ihre E-Mail-Adresse finden.
|
|
||||||
//
|
|
||||||
// Die Einladung eines bestehenden Kontos in ein Team braucht genau das: eine
|
|
||||||
// Suche. Mit `id = @request.auth.id` fand die Suche immer nur einen selbst,
|
|
||||||
// weshalb die Funktion faktisch nie funktioniert hat.
|
|
||||||
//
|
|
||||||
// Damit kann jeder Eingeloggte die Konten der Instanz auflisten. Für einen
|
|
||||||
// Verein mit eingeladenen Mitgliedern ist das vertretbar; E-Mail-Adressen
|
|
||||||
// bleiben über emailVisibility weiterhin verborgen, und anlegen kann Konten
|
|
||||||
// ohnehin nur, wer eingeloggt ist.
|
|
||||||
|
|
||||||
migrate((app) => {
|
|
||||||
const users = app.findCollectionByNameOrId('users')
|
|
||||||
users.listRule = '@request.auth.id != ""'
|
|
||||||
app.save(users)
|
|
||||||
}, (app) => {
|
|
||||||
const users = app.findCollectionByNameOrId('users')
|
|
||||||
users.listRule = 'id = @request.auth.id'
|
|
||||||
app.save(users)
|
|
||||||
})
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
/// <reference path="../pb_data/types.d.ts" />
|
|
||||||
|
|
||||||
// Teilnehmer eines Events — und damit die Auflösung der Frage, ob Fahrer zum
|
|
||||||
// Team oder zum Event gehören: zum Team.
|
|
||||||
//
|
|
||||||
// `riders` ist ab hier der Kader des Teams. Wer bei einem Event dabei ist,
|
|
||||||
// steht in `event_participants`; dort hängt auch die Startnummer, denn die
|
|
||||||
// gilt je Event und nicht je Person. Ein Fahrer kann so über Jahre an vielen
|
|
||||||
// Events teilnehmen, ohne dass er mehrfach angelegt werden muss.
|
|
||||||
//
|
|
||||||
// Die vorhandenen Daten wandern mit: Jeder Fahrer mit Event wird zu einem
|
|
||||||
// Teilnehmer samt seiner bisherigen Nummer. Danach entfallen `riders.event`
|
|
||||||
// und `riders.number`.
|
|
||||||
//
|
|
||||||
// ACHTUNG: Eine Nummer an einem Fahrer OHNE Event hat danach keinen Platz
|
|
||||||
// mehr und geht verloren. Sie war ohnehin nirgends sichtbar, weil alle
|
|
||||||
// Listen nach Event gefiltert haben.
|
|
||||||
|
|
||||||
migrate((app) => {
|
|
||||||
const events = app.findCollectionByNameOrId('events')
|
|
||||||
const riders = app.findCollectionByNameOrId('riders')
|
|
||||||
const teams = app.findCollectionByNameOrId('teams')
|
|
||||||
|
|
||||||
// Wer das Event angelegt hat, darf seine Teilnehmer verwalten — ebenso
|
|
||||||
// die Teamleitung. Das Feld kommt in derselben Migration dazu, damit die
|
|
||||||
// Regeln unten darauf zeigen können.
|
|
||||||
if (!events.fields.getByName('created_by')) {
|
|
||||||
events.fields.add(new Field({
|
|
||||||
id: 'relation_event_created_by',
|
|
||||||
name: 'created_by',
|
|
||||||
type: 'relation',
|
|
||||||
collectionId: app.findCollectionByNameOrId('users').id,
|
|
||||||
cascadeDelete: false,
|
|
||||||
maxSelect: 1,
|
|
||||||
minSelect: 0,
|
|
||||||
required: false,
|
|
||||||
}))
|
|
||||||
app.save(events)
|
|
||||||
}
|
|
||||||
|
|
||||||
let participants
|
|
||||||
try {
|
|
||||||
participants = app.findCollectionByNameOrId('event_participants')
|
|
||||||
} catch {
|
|
||||||
// Verwaltet wird die Teilnehmerliste vom Ersteller des Events und von
|
|
||||||
// der Teamleitung. Wer sich selbst einträgt, hängt am
|
|
||||||
// Teilnahmemodus — das regelt 1754501400, sobald es das Feld gibt.
|
|
||||||
const manager =
|
|
||||||
'event.created_by.id ?= @request.auth.id' +
|
|
||||||
' || event.team.owner.id ?= @request.auth.id' +
|
|
||||||
' || event.team.admins.id ?= @request.auth.id'
|
|
||||||
|
|
||||||
participants = new Collection({
|
|
||||||
type: 'base',
|
|
||||||
name: 'event_participants',
|
|
||||||
listRule: 'event.team.users.id ?= @request.auth.id',
|
|
||||||
viewRule: 'event.team.users.id ?= @request.auth.id',
|
|
||||||
createRule: manager,
|
|
||||||
updateRule: manager,
|
|
||||||
deleteRule: manager,
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'event',
|
|
||||||
type: 'relation',
|
|
||||||
required: true,
|
|
||||||
maxSelect: 1,
|
|
||||||
collectionId: events.id,
|
|
||||||
cascadeDelete: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'rider',
|
|
||||||
type: 'relation',
|
|
||||||
required: true,
|
|
||||||
maxSelect: 1,
|
|
||||||
collectionId: riders.id,
|
|
||||||
cascadeDelete: true,
|
|
||||||
},
|
|
||||||
{ name: 'number', type: 'text', max: 20 },
|
|
||||||
{
|
|
||||||
name: 'status',
|
|
||||||
type: 'select',
|
|
||||||
maxSelect: 1,
|
|
||||||
values: ['angefragt', 'zugesagt', 'abgesagt'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'created_by',
|
|
||||||
type: 'relation',
|
|
||||||
maxSelect: 1,
|
|
||||||
collectionId: app.findCollectionByNameOrId('users').id,
|
|
||||||
cascadeDelete: false,
|
|
||||||
},
|
|
||||||
{ name: 'created', type: 'autodate', onCreate: true, onUpdate: false },
|
|
||||||
{ name: 'updated', type: 'autodate', onCreate: true, onUpdate: true },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
app.save(participants)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Bestand übernehmen ---------------------------------------------
|
|
||||||
const existing = app.findRecordsByFilter('riders', 'event != ""', '', 0, 0)
|
|
||||||
for (const rider of existing) {
|
|
||||||
const record = new Record(participants)
|
|
||||||
record.set('event', rider.get('event'))
|
|
||||||
record.set('rider', rider.id)
|
|
||||||
record.set('number', rider.get('number'))
|
|
||||||
record.set('status', 'zugesagt')
|
|
||||||
app.save(record)
|
|
||||||
}
|
|
||||||
|
|
||||||
riders.fields.removeByName('event')
|
|
||||||
riders.fields.removeByName('number')
|
|
||||||
app.save(riders)
|
|
||||||
}, (app) => {
|
|
||||||
const riders = app.findCollectionByNameOrId('riders')
|
|
||||||
const events = app.findCollectionByNameOrId('events')
|
|
||||||
|
|
||||||
riders.fields.add(new Field({
|
|
||||||
name: 'event',
|
|
||||||
type: 'relation',
|
|
||||||
collectionId: events.id,
|
|
||||||
cascadeDelete: false,
|
|
||||||
maxSelect: 1,
|
|
||||||
}))
|
|
||||||
riders.fields.add(new Field({ name: 'number', type: 'text' }))
|
|
||||||
app.save(riders)
|
|
||||||
|
|
||||||
// Zurück wandert nur das Offensichtliche: das erste Event je Fahrer.
|
|
||||||
const participants = app.findRecordsByFilter('event_participants', 'id != ""', '', 0, 0)
|
|
||||||
for (const p of participants) {
|
|
||||||
try {
|
|
||||||
const rider = app.findRecordById('riders', p.get('rider'))
|
|
||||||
if (!rider.get('event')) {
|
|
||||||
rider.set('event', p.get('event'))
|
|
||||||
rider.set('number', p.get('number'))
|
|
||||||
app.save(rider)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fahrer inzwischen gelöscht — nichts zu tun.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app.delete(app.findCollectionByNameOrId('event_participants'))
|
|
||||||
|
|
||||||
events.fields.removeByName('created_by')
|
|
||||||
app.save(events)
|
|
||||||
})
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
/// <reference path="../pb_data/types.d.ts" />
|
|
||||||
|
|
||||||
// Events werden zu Ausfahrten, Rennen, Stammtischen — je nach Schaltern.
|
|
||||||
//
|
|
||||||
// `timing` blendet Runs und Zeitnahme ein. Der wöchentliche Stammtisch
|
|
||||||
// braucht das nicht, das Rennen schon; ein zweites Konzept neben Events wäre
|
|
||||||
// dafür zu viel gewesen.
|
|
||||||
//
|
|
||||||
// `participation` legt fest, wie man teilnimmt:
|
|
||||||
// offen — jedes Teammitglied trägt sich selbst ein
|
|
||||||
// bestaetigung — Anmeldung möglich, Ersteller oder Team-Admin bestätigt
|
|
||||||
// geschlossen — nur Ersteller oder Team-Admin trägt ein
|
|
||||||
//
|
|
||||||
// Bestehende Events bekommen `timing = true` und `geschlossen`: Genau so
|
|
||||||
// wurden sie bisher benutzt.
|
|
||||||
|
|
||||||
const MANAGER =
|
|
||||||
'event.created_by.id ?= @request.auth.id' +
|
|
||||||
' || event.team.owner.id ?= @request.auth.id' +
|
|
||||||
' || event.team.admins.id ?= @request.auth.id'
|
|
||||||
|
|
||||||
// Sich selbst eintragen: nur den eigenen Datensatz, nur mit dem Status, den
|
|
||||||
// der Modus vorsieht. Ohne die Statusprüfung könnte man sich im
|
|
||||||
// Bestätigungsmodus gleich selbst bestätigen.
|
|
||||||
//
|
|
||||||
// Geprüft wird auf den Feldern des entstehenden Datensatzes statt über
|
|
||||||
// @request.body: Eine Relation aus dem Request-Body heraus weiterzuverfolgen
|
|
||||||
// (@request.body.rider.user) ist nicht verlässlich.
|
|
||||||
const SELF_SIGNUP =
|
|
||||||
'event.team.users.id ?= @request.auth.id' +
|
|
||||||
' && rider.user.id ?= @request.auth.id' +
|
|
||||||
' && (' +
|
|
||||||
'(event.participation = "offen" && status = "zugesagt")' +
|
|
||||||
' || (event.participation = "bestaetigung" && status = "angefragt")' +
|
|
||||||
')'
|
|
||||||
|
|
||||||
// Die eigene Teilnahme darf man ändern — aber nicht in eine Bestätigung.
|
|
||||||
const SELF_UPDATE =
|
|
||||||
'rider.user.id ?= @request.auth.id' +
|
|
||||||
' && @request.body.rider:isset = false' +
|
|
||||||
' && @request.body.event:isset = false' +
|
|
||||||
' && @request.body.number:isset = false' +
|
|
||||||
' && @request.body.status != "zugesagt"'
|
|
||||||
|
|
||||||
migrate((app) => {
|
|
||||||
const events = app.findCollectionByNameOrId('events')
|
|
||||||
|
|
||||||
if (!events.fields.getByName('timing')) {
|
|
||||||
events.fields.add(new Field({
|
|
||||||
id: 'bool_event_timing',
|
|
||||||
name: 'timing',
|
|
||||||
type: 'bool',
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!events.fields.getByName('participation')) {
|
|
||||||
events.fields.add(new Field({
|
|
||||||
id: 'select_event_participation',
|
|
||||||
name: 'participation',
|
|
||||||
type: 'select',
|
|
||||||
maxSelect: 1,
|
|
||||||
values: ['offen', 'bestaetigung', 'geschlossen'],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
app.save(events)
|
|
||||||
|
|
||||||
for (const event of app.findRecordsByFilter('events', 'id != ""', '', 0, 0)) {
|
|
||||||
event.set('timing', true)
|
|
||||||
event.set('participation', 'geschlossen')
|
|
||||||
app.save(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
const participants = app.findCollectionByNameOrId('event_participants')
|
|
||||||
participants.createRule = `${MANAGER} || (${SELF_SIGNUP})`
|
|
||||||
participants.updateRule = `${MANAGER} || (${SELF_UPDATE})`
|
|
||||||
// Absagen heißt Status ändern, nicht löschen — sonst verschwindet die
|
|
||||||
// Historie einer Anmeldung.
|
|
||||||
participants.deleteRule = MANAGER
|
|
||||||
app.save(participants)
|
|
||||||
}, (app) => {
|
|
||||||
const events = app.findCollectionByNameOrId('events')
|
|
||||||
|
|
||||||
events.fields.removeByName('timing')
|
|
||||||
events.fields.removeByName('participation')
|
|
||||||
app.save(events)
|
|
||||||
|
|
||||||
const participants = app.findCollectionByNameOrId('event_participants')
|
|
||||||
participants.createRule = MANAGER
|
|
||||||
participants.updateRule = MANAGER
|
|
||||||
participants.deleteRule = MANAGER
|
|
||||||
app.save(participants)
|
|
||||||
})
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
/// <reference path="../pb_data/types.d.ts" />
|
|
||||||
|
|
||||||
// Wiederkehrende Termine — vom wöchentlichen Stammtisch bis zur monatlichen
|
|
||||||
// Feierabendrunde.
|
|
||||||
//
|
|
||||||
// Eine Serie beschreibt die Regel und erzeugt daraus echte Einzeltermine;
|
|
||||||
// sie ist keine Wiederholungsregel, aus der Termine nur gerechnet werden.
|
|
||||||
// Der Unterschied zählt, sobald jemand fragt „wer kommt am 12.?" oder
|
|
||||||
// „diese Woche fällt aus": Beides braucht einen Datensatz je Termin, den man
|
|
||||||
// absagen, verschieben und bespielen kann.
|
|
||||||
//
|
|
||||||
// Die Serie trägt dafür die Vorlage (Ort, Beschreibung, Schalter), aus der
|
|
||||||
// neue Termine entstehen. Ändert sich die Vorlage, gilt das für künftige
|
|
||||||
// Termine — bereits erzeugte bleiben, wie sie sind.
|
|
||||||
|
|
||||||
migrate((app) => {
|
|
||||||
const teams = app.findCollectionByNameOrId('teams')
|
|
||||||
const users = app.findCollectionByNameOrId('users')
|
|
||||||
const events = app.findCollectionByNameOrId('events')
|
|
||||||
|
|
||||||
let series
|
|
||||||
try {
|
|
||||||
series = app.findCollectionByNameOrId('event_series')
|
|
||||||
} catch {
|
|
||||||
const admin =
|
|
||||||
'team.owner.id ?= @request.auth.id' +
|
|
||||||
' || team.admins.id ?= @request.auth.id' +
|
|
||||||
' || created_by.id ?= @request.auth.id'
|
|
||||||
|
|
||||||
series = new Collection({
|
|
||||||
type: 'base',
|
|
||||||
name: 'event_series',
|
|
||||||
listRule: 'team.users.id ?= @request.auth.id',
|
|
||||||
viewRule: 'team.users.id ?= @request.auth.id',
|
|
||||||
createRule: 'team.users.id ?= @request.auth.id',
|
|
||||||
updateRule: admin,
|
|
||||||
deleteRule: admin,
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'team',
|
|
||||||
type: 'relation',
|
|
||||||
required: true,
|
|
||||||
maxSelect: 1,
|
|
||||||
collectionId: teams.id,
|
|
||||||
cascadeDelete: true,
|
|
||||||
},
|
|
||||||
{ name: 'name', type: 'text', required: true, max: 200 },
|
|
||||||
{ name: 'location', type: 'text', max: 200 },
|
|
||||||
{ name: 'description', type: 'text', max: 2000 },
|
|
||||||
{
|
|
||||||
name: 'rule',
|
|
||||||
type: 'select',
|
|
||||||
maxSelect: 1,
|
|
||||||
values: ['woechentlich', 'zweiwoechentlich', 'monatlich'],
|
|
||||||
},
|
|
||||||
{ name: 'starts', type: 'date' },
|
|
||||||
{ name: 'until', type: 'date' },
|
|
||||||
{ name: 'timing', type: 'bool' },
|
|
||||||
{
|
|
||||||
name: 'participation',
|
|
||||||
type: 'select',
|
|
||||||
maxSelect: 1,
|
|
||||||
values: ['offen', 'bestaetigung', 'geschlossen'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'created_by',
|
|
||||||
type: 'relation',
|
|
||||||
maxSelect: 1,
|
|
||||||
collectionId: users.id,
|
|
||||||
cascadeDelete: false,
|
|
||||||
},
|
|
||||||
{ name: 'created', type: 'autodate', onCreate: true, onUpdate: false },
|
|
||||||
{ name: 'updated', type: 'autodate', onCreate: true, onUpdate: true },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
app.save(series)
|
|
||||||
}
|
|
||||||
|
|
||||||
// cascadeDelete bleibt aus: Wer eine Serie beendet, will selten alle
|
|
||||||
// gelaufenen Termine samt Zeiten und Teilnehmern mitlöschen.
|
|
||||||
if (!events.fields.getByName('series')) {
|
|
||||||
events.fields.add(new Field({
|
|
||||||
id: 'relation_event_series',
|
|
||||||
name: 'series',
|
|
||||||
type: 'relation',
|
|
||||||
collectionId: series.id,
|
|
||||||
cascadeDelete: false,
|
|
||||||
maxSelect: 1,
|
|
||||||
minSelect: 0,
|
|
||||||
required: false,
|
|
||||||
}))
|
|
||||||
app.save(events)
|
|
||||||
}
|
|
||||||
}, (app) => {
|
|
||||||
const events = app.findCollectionByNameOrId('events')
|
|
||||||
events.fields.removeByName('series')
|
|
||||||
app.save(events)
|
|
||||||
|
|
||||||
app.delete(app.findCollectionByNameOrId('event_series'))
|
|
||||||
})
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
* beide dieselbe Auswahl zeigen.
|
* beide dieselbe Auswahl zeigen.
|
||||||
*/
|
*/
|
||||||
import FlagIcon from './FlagIcon.svelte'
|
import FlagIcon from './FlagIcon.svelte'
|
||||||
import { tooltip } from '$lib/stores/app.svelte'
|
|
||||||
import { FLAG_ICON_SUGGESTIONS, iconExists } from '$lib/flagIcons'
|
import { FLAG_ICON_SUGGESTIONS, iconExists } from '$lib/flagIcons'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
|
@ -49,8 +48,8 @@
|
||||||
{#each FLAG_ICON_SUGGESTIONS as name (name)}
|
{#each FLAG_ICON_SUGGESTIONS as name (name)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
title={name}
|
||||||
aria-label={name}
|
aria-label={name}
|
||||||
use:tooltip={{ content: name }}
|
|
||||||
aria-pressed={value === name}
|
aria-pressed={value === name}
|
||||||
class="grid place-items-center rounded-md p-1.5 hover:bg-accent"
|
class="grid place-items-center rounded-md p-1.5 hover:bg-accent"
|
||||||
class:bg-accent={value === name}
|
class:bg-accent={value === name}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
// Groesse ueber eine Klasse steuerbar, damit dasselbe Logo im Header
|
||||||
* Logo eines Teams. Ist eins hinterlegt, steht es allein; sonst bleibt das
|
// (klein) und auf der Login-Karte (gross) verwendet werden kann.
|
||||||
* Wappen des Stammtischs als Rückfalloption — zwei übereinanderliegende
|
let { class: className = 'w-24 h-24' }: { class?: string } = $props()
|
||||||
* SVGs, weil das Radl über dem Schildl leicht übersteht.
|
|
||||||
*
|
|
||||||
* Groesse ueber eine Klasse steuerbar, damit dasselbe Logo im Header
|
|
||||||
* (klein) und auf der Login-Karte (gross) verwendet werden kann.
|
|
||||||
*/
|
|
||||||
import { getFileURL } from '$lib/stores/pocketbase.svelte'
|
|
||||||
import type { TeamsResponse } from '$lib/types'
|
|
||||||
|
|
||||||
let {
|
|
||||||
class: className = 'w-24 h-24',
|
|
||||||
team = null,
|
|
||||||
}: { class?: string; team?: TeamsResponse | null } = $props()
|
|
||||||
|
|
||||||
const url = $derived(team?.logo ? getFileURL(team, team.logo) : null)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative {className}" id="logo">
|
<div class="relative {className}" id="logo">
|
||||||
{#if url}
|
<img alt="" class="absolute inset-0 h-full w-full object-contain" src="/schildl.svg">
|
||||||
<img alt="" class="absolute inset-0 h-full w-full object-contain" src={url} />
|
<img alt="" class="absolute inset-0 h-full w-full scale-110 object-contain" src="/radl.svg">
|
||||||
{:else}
|
|
||||||
<img alt="" class="absolute inset-0 h-full w-full object-contain" src="/schildl.svg">
|
|
||||||
<img alt="" class="absolute inset-0 h-full w-full scale-110 object-contain" src="/radl.svg">
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
|
||||||
import { ticker } from '$lib/stores/ticker.svelte'
|
import { ticker } from '$lib/stores/ticker.svelte'
|
||||||
import TimeDialog from './TimeDialog.svelte'
|
import TimeDialog from './TimeDialog.svelte'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
@ -23,7 +22,6 @@
|
||||||
const times = getTimeContext()
|
const times = getTimeContext()
|
||||||
const riders = getRiderContext()
|
const riders = getRiderContext()
|
||||||
const runs = getRunContext()
|
const runs = getRunContext()
|
||||||
const participants = getEventParticipantContext()
|
|
||||||
const clock = ticker()
|
const clock = ticker()
|
||||||
|
|
||||||
const running = $derived(times.allRunning(clock.now))
|
const running = $derived(times.allRunning(clock.now))
|
||||||
|
|
@ -51,14 +49,10 @@
|
||||||
// Nutzer. Fällt die Anzahl wieder auf eine, ist der Zustand egal.
|
// Nutzer. Fällt die Anzahl wieder auf eine, ist der Zustand egal.
|
||||||
const open = $derived(single || expanded)
|
const open = $derived(single || expanded)
|
||||||
|
|
||||||
/** Die Startnummer gilt je Event, deshalb der Umweg über den Run. */
|
function riderLabel(riderId: string | undefined) {
|
||||||
function riderLabel(riderId: string | undefined, runId: string | undefined) {
|
const r = riderId ? riders.getById(riderId) : undefined
|
||||||
const eventId = runId ? runs.getById(runId)?.event : undefined
|
if (!r) return 'Unbekannter Fahrer'
|
||||||
if (!eventId) {
|
return `#${r.number || '—'} ${fullName(r)}`.trim()
|
||||||
const r = riderId ? riders.getById(riderId) : undefined
|
|
||||||
return r ? fullName(r) : 'Unbekannter Fahrer'
|
|
||||||
}
|
|
||||||
return participants.riderLabel(eventId, riderId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function runPath(runId: string | undefined) {
|
function runPath(runId: string | undefined) {
|
||||||
|
|
@ -98,7 +92,7 @@
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="ml-auto size-7 shrink-0"
|
class="ml-auto size-7 shrink-0"
|
||||||
tooltip={expanded ? 'Einklappen' : 'Alle anzeigen'}
|
title={expanded ? 'Einklappen' : 'Alle anzeigen'}
|
||||||
onclick={() => (expanded = !expanded)}
|
onclick={() => (expanded = !expanded)}
|
||||||
>
|
>
|
||||||
{#if expanded}
|
{#if expanded}
|
||||||
|
|
@ -116,7 +110,7 @@
|
||||||
{@const path = runPath(t.run)}
|
{@const path = runPath(t.run)}
|
||||||
<li class="flex items-center gap-2 px-3 py-2">
|
<li class="flex items-center gap-2 px-3 py-2">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-sm font-medium truncate">{riderLabel(t.rider, t.run)}</p>
|
<p class="text-sm font-medium truncate">{riderLabel(t.rider)}</p>
|
||||||
<p class="text-xs text-muted-foreground truncate">
|
<p class="text-xs text-muted-foreground truncate">
|
||||||
{#if path}
|
{#if path}
|
||||||
<a href={path} class="hover:underline">{runLabel(t.run)}</a>
|
<a href={path} class="hover:underline">{runLabel(t.run)}</a>
|
||||||
|
|
@ -134,7 +128,7 @@
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="shrink-0"
|
class="shrink-0"
|
||||||
tooltip="Zeit erfassen"
|
title="Zeit erfassen"
|
||||||
onclick={() => openTimeDialog(t.rider, t.run)}
|
onclick={() => openTimeDialog(t.rider, t.run)}
|
||||||
>
|
>
|
||||||
<Timer class="size-4" />
|
<Timer class="size-4" />
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@
|
||||||
import { getTimeContext, type TimeStatus } from '$lib/stores/times.svelte'
|
import { getTimeContext, type TimeStatus } from '$lib/stores/times.svelte'
|
||||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
|
||||||
import { ticker } from '$lib/stores/ticker.svelte'
|
import { ticker } from '$lib/stores/ticker.svelte'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
|
@ -34,7 +33,6 @@
|
||||||
const times = getTimeContext()
|
const times = getTimeContext()
|
||||||
const riders = getRiderContext()
|
const riders = getRiderContext()
|
||||||
const runs = getRunContext()
|
const runs = getRunContext()
|
||||||
const participants = getEventParticipantContext()
|
|
||||||
const clock = ticker()
|
const clock = ticker()
|
||||||
|
|
||||||
let selectedRider = $state('')
|
let selectedRider = $state('')
|
||||||
|
|
@ -54,19 +52,16 @@
|
||||||
confirmDnf = false
|
confirmDnf = false
|
||||||
})
|
})
|
||||||
|
|
||||||
// Zur Auswahl steht, wer beim Event zugesagt hat — nicht der ganze Kader.
|
const eventRiders = $derived(eventId ? riders.getByEvent(eventId) : [])
|
||||||
const eventRiders = $derived(
|
|
||||||
eventId
|
|
||||||
? participants
|
|
||||||
.confirmedByEvent(eventId)
|
|
||||||
.map((p) => riders.getById(p.rider))
|
|
||||||
.filter((r) => !!r)
|
|
||||||
: [],
|
|
||||||
)
|
|
||||||
const eventRuns = $derived(eventId ? runs.getByEvent(eventId) : [])
|
const eventRuns = $derived(eventId ? runs.getByEvent(eventId) : [])
|
||||||
|
|
||||||
const riderLabel = $derived(
|
const riderLabel = $derived(
|
||||||
selectedRider ? participants.riderLabel(eventId, selectedRider) : 'Fahrer wählen…',
|
selectedRider
|
||||||
|
? (() => {
|
||||||
|
const r = riders.getById(selectedRider)
|
||||||
|
return r ? `#${r.number || '—'} ${fullName(r)}`.trim() : 'Fahrer wählen…'
|
||||||
|
})()
|
||||||
|
: 'Fahrer wählen…',
|
||||||
)
|
)
|
||||||
const runLabel = $derived(
|
const runLabel = $derived(
|
||||||
selectedRun ? runs.getById(selectedRun)?.description ?? 'Run wählen…' : 'Run wählen…',
|
selectedRun ? runs.getById(selectedRun)?.description ?? 'Run wählen…' : 'Run wählen…',
|
||||||
|
|
@ -140,7 +135,7 @@
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
{#each eventRiders as r (r.id)}
|
{#each eventRiders as r (r.id)}
|
||||||
<Select.Item value={r.id}>
|
<Select.Item value={r.id}>
|
||||||
{participants.riderLabel(eventId, r.id)}
|
#{r.number || '—'} {fullName(r)}
|
||||||
</Select.Item>
|
</Select.Item>
|
||||||
{/each}
|
{/each}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
|
|
|
||||||
|
|
@ -38,18 +38,10 @@
|
||||||
WithElementRef<HTMLAnchorAttributes> & {
|
WithElementRef<HTMLAnchorAttributes> & {
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
/**
|
|
||||||
* Hinweistext als Tooltip. Ersetzt `title` appweit und dient
|
|
||||||
* zugleich als Beschriftung, wenn der Knopf nur ein Icon zeigt —
|
|
||||||
* ohne das bliebe er für Screenreader namenlos.
|
|
||||||
*/
|
|
||||||
tooltip?: string;
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tooltip as tooltipAction } from "$lib/stores/app.svelte";
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
class: className,
|
class: className,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
|
|
@ -58,7 +50,6 @@
|
||||||
href = undefined,
|
href = undefined,
|
||||||
type = "button",
|
type = "button",
|
||||||
disabled,
|
disabled,
|
||||||
tooltip,
|
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: ButtonProps = $props();
|
}: ButtonProps = $props();
|
||||||
|
|
@ -73,9 +64,7 @@
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
role={disabled ? "link" : undefined}
|
role={disabled ? "link" : undefined}
|
||||||
tabindex={disabled ? -1 : undefined}
|
tabindex={disabled ? -1 : undefined}
|
||||||
aria-label={tooltip}
|
|
||||||
{...restProps}
|
{...restProps}
|
||||||
use:tooltipAction={{ content: tooltip }}
|
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -86,9 +75,7 @@
|
||||||
class={cn(buttonVariants({ variant, size }), className)}
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
{type}
|
{type}
|
||||||
{disabled}
|
{disabled}
|
||||||
aria-label={tooltip}
|
|
||||||
{...restProps}
|
{...restProps}
|
||||||
use:tooltipAction={{ content: tooltip }}
|
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -160,46 +160,20 @@ export const utils = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Tooltip für ein Element. Appweit die einzige Art, einen Hinweis an einen
|
|
||||||
* Knopf zu hängen — `title` überlassen wir dem Browser nicht mehr: Es
|
|
||||||
* erscheint erst nach einer Sekunde, sieht auf jedem System anders aus und
|
|
||||||
* lässt sich auf Touch-Geräten gar nicht aufrufen.
|
|
||||||
*
|
|
||||||
* Die Action beherrscht Aktualisieren und Aufräumen: Ein Text, der sich
|
|
||||||
* ändert (»Erledigt« / »Wieder öffnen«), muss auch im Tooltip wechseln, und
|
|
||||||
* eine Instanz, die ein entferntes Element überlebt, hinge als leere Blase
|
|
||||||
* im Dokument.
|
|
||||||
*/
|
|
||||||
export const tooltip = (
|
export const tooltip = (
|
||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
props: { active?: boolean; content?: string; showOnCreate?: boolean } = {},
|
props: { active?: boolean; content?: string; showOnCreate?: boolean },
|
||||||
) => {
|
) => {
|
||||||
const options = (p: typeof props) => ({
|
let defaultProps = {
|
||||||
|
active: true,
|
||||||
animation: 'shift-toward-subtle',
|
animation: 'shift-toward-subtle',
|
||||||
allowHTML: false,
|
allowHTML: true,
|
||||||
content: p.content ?? '',
|
|
||||||
showOnCreate: p.showOnCreate,
|
|
||||||
})
|
|
||||||
|
|
||||||
const wanted = (p: typeof props) => p.active !== false && !!p.content?.trim()
|
|
||||||
|
|
||||||
let instance = wanted(props) ? tippy(element, options(props)) : null
|
|
||||||
|
|
||||||
return {
|
|
||||||
update(next: typeof props) {
|
|
||||||
if (!wanted(next)) {
|
|
||||||
instance?.destroy()
|
|
||||||
instance = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (instance) instance.setProps(options(next))
|
|
||||||
else instance = tippy(element, options(next))
|
|
||||||
},
|
|
||||||
destroy() {
|
|
||||||
instance?.destroy()
|
|
||||||
instance = null
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
props = {...defaultProps, ...props}
|
||||||
|
if (props.showOnCreate) {
|
||||||
|
props = {
|
||||||
|
...props,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (props.active) tippy(element, props)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,184 +0,0 @@
|
||||||
import { getContext, setContext } from 'svelte'
|
|
||||||
import { api, auth } from './pocketbase.svelte'
|
|
||||||
import { getEventContext } from './events.svelte'
|
|
||||||
import { getRiderContext } from './riders.svelte'
|
|
||||||
import type { EventParticipantsResponse, RidersResponse } from '$lib/types'
|
|
||||||
|
|
||||||
const KEY = Symbol('eventParticipant')
|
|
||||||
|
|
||||||
export type ParticipantStatus = 'angefragt' | 'zugesagt' | 'abgesagt'
|
|
||||||
|
|
||||||
export const PARTICIPANT_STATUS_LABEL: Record<string, string> = {
|
|
||||||
angefragt: 'Angefragt',
|
|
||||||
zugesagt: 'Dabei',
|
|
||||||
abgesagt: 'Abgesagt',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Teilnehmer eines Events. Fahrer gehören dem Team, nicht dem Event — wer bei
|
|
||||||
* einem Termin dabei ist, steht hier. Die Startnummer hängt ebenfalls hier,
|
|
||||||
* denn sie gilt je Event: Derselbe Fahrer kann in zwei Rennen zwei Nummern
|
|
||||||
* haben, und beim Stammtisch gar keine.
|
|
||||||
*/
|
|
||||||
export class EventParticipantStore {
|
|
||||||
records = $state<EventParticipantsResponse[]>([])
|
|
||||||
loading = $state(false)
|
|
||||||
error = $state<string | null>(null)
|
|
||||||
|
|
||||||
private unsubscribe: (() => void) | null = null
|
|
||||||
private events = getEventContext()
|
|
||||||
private riders = getRiderContext()
|
|
||||||
|
|
||||||
/** Nur Teilnehmer von Events des aktiven Teams. */
|
|
||||||
get scoped(): EventParticipantsResponse[] {
|
|
||||||
const eventIds = new Set(this.events.scoped.map((e) => e.id))
|
|
||||||
return this.records.filter((r) => eventIds.has(r.event))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Teilnehmer eines Events, nach Startnummer und dann nach Name. */
|
|
||||||
byEvent(eventId: string): EventParticipantsResponse[] {
|
|
||||||
return this.records
|
|
||||||
.filter((r) => r.event === eventId)
|
|
||||||
.sort((a, b) => {
|
|
||||||
const na = parseInt(a.number ?? '', 10)
|
|
||||||
const nb = parseInt(b.number ?? '', 10)
|
|
||||||
if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb
|
|
||||||
if (Number.isFinite(na)) return -1
|
|
||||||
if (Number.isFinite(nb)) return 1
|
|
||||||
return this.riderName(a.rider).localeCompare(this.riderName(b.rider))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Nur die Zugesagten — wer wirklich am Start steht. */
|
|
||||||
confirmedByEvent(eventId: string): EventParticipantsResponse[] {
|
|
||||||
return this.byEvent(eventId).filter((r) => r.status === 'zugesagt')
|
|
||||||
}
|
|
||||||
|
|
||||||
byRider(riderId: string): EventParticipantsResponse[] {
|
|
||||||
return this.scoped.filter((r) => r.rider === riderId)
|
|
||||||
}
|
|
||||||
|
|
||||||
get(eventId: string, riderId: string): EventParticipantsResponse | undefined {
|
|
||||||
return this.records.find((r) => r.event === eventId && r.rider === riderId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Startnummer eines Fahrers bei einem Event, falls vergeben. */
|
|
||||||
numberOf(eventId: string, riderId: string | undefined): string {
|
|
||||||
if (!riderId) return ''
|
|
||||||
return this.get(eventId, riderId)?.number ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Startnummern sind je Event eindeutig — zwei gleiche Nummern in einem
|
|
||||||
* Rennen wären an der Strecke nicht auseinanderzuhalten.
|
|
||||||
*/
|
|
||||||
isNumberTaken(eventId: string, number: string, excludeId?: string): boolean {
|
|
||||||
const wanted = number.trim()
|
|
||||||
if (!wanted) return false
|
|
||||||
|
|
||||||
return this.byEvent(eventId).some(
|
|
||||||
(r) => (r.number ?? '').trim() === wanted && r.id !== excludeId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Teilnahme des eingeloggten Nutzers, sofern sein Konto an einem Fahrer hängt. */
|
|
||||||
own(eventId: string): EventParticipantsResponse | undefined {
|
|
||||||
const uid = auth.user?.id
|
|
||||||
if (!uid) return undefined
|
|
||||||
|
|
||||||
const mine = new Set(this.riders.getByUser(uid).map((r) => r.id))
|
|
||||||
return this.byEvent(eventId).find((p) => mine.has(p.rider))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** „#12 Max Mustermann" — ohne Nummer nur der Name. */
|
|
||||||
riderLabel(eventId: string, riderId: string | undefined): string {
|
|
||||||
if (!riderId) return 'Unbekannter Fahrer'
|
|
||||||
|
|
||||||
const name = this.riderName(riderId) || 'Unbekannter Fahrer'
|
|
||||||
const number = this.numberOf(eventId, riderId)
|
|
||||||
|
|
||||||
return number ? `#${number} ${name}` : name
|
|
||||||
}
|
|
||||||
|
|
||||||
private riderName(riderId: string): string {
|
|
||||||
const r = this.riders.getById(riderId)
|
|
||||||
if (!r) return ''
|
|
||||||
return [r.firstname, r.lastname].filter(Boolean).join(' ') || r.name || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
async load() {
|
|
||||||
this.loading = true
|
|
||||||
this.error = null
|
|
||||||
try {
|
|
||||||
this.records = await api.collection('event_participants').getFullList({
|
|
||||||
sort: '+created',
|
|
||||||
requestKey: null,
|
|
||||||
})
|
|
||||||
} catch (e: any) {
|
|
||||||
this.error = e.message ?? 'Fehler beim Laden der Teilnehmer'
|
|
||||||
console.error(e)
|
|
||||||
} finally {
|
|
||||||
this.loading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribe() {
|
|
||||||
if (this.unsubscribe) return
|
|
||||||
api.collection('event_participants').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]
|
|
||||||
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)
|
|
||||||
}).then((unsub) => {
|
|
||||||
this.unsubscribe = unsub
|
|
||||||
}).catch((e) => console.warn('event_participants subscribe failed:', e))
|
|
||||||
}
|
|
||||||
|
|
||||||
async add(data: {
|
|
||||||
event: string
|
|
||||||
rider: string
|
|
||||||
number?: string
|
|
||||||
status?: ParticipantStatus
|
|
||||||
}) {
|
|
||||||
return await api.collection('event_participants').create({
|
|
||||||
status: 'zugesagt' as ParticipantStatus,
|
|
||||||
...data,
|
|
||||||
created_by: auth.user?.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async edit(id: string, data: Partial<EventParticipantsResponse>) {
|
|
||||||
return await api.collection('event_participants').update(id, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
async setStatus(id: string, status: ParticipantStatus) {
|
|
||||||
return await this.edit(id, { status } as Partial<EventParticipantsResponse>)
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(id: string) {
|
|
||||||
await api.collection('event_participants').delete(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fahrer des Teams, die bei diesem Event noch fehlen. */
|
|
||||||
available(eventId: string): RidersResponse[] {
|
|
||||||
const taken = new Set(this.byEvent(eventId).map((r) => r.rider))
|
|
||||||
return this.riders.scoped.filter((r) => !taken.has(r.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
if (this.unsubscribe) {
|
|
||||||
this.unsubscribe()
|
|
||||||
this.unsubscribe = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setEventParticipantContext() {
|
|
||||||
const store = new EventParticipantStore()
|
|
||||||
setContext(KEY, store)
|
|
||||||
return store
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getEventParticipantContext(): EventParticipantStore {
|
|
||||||
return getContext<EventParticipantStore>(KEY)
|
|
||||||
}
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
import { getContext, setContext } from 'svelte'
|
|
||||||
import { api, auth } from './pocketbase.svelte'
|
|
||||||
import { getTeamContext } from './teams.svelte'
|
|
||||||
import { getEventContext } from './events.svelte'
|
|
||||||
import { dayOf, todayISO } from '$lib/time'
|
|
||||||
import type { EventSeriesResponse } from '$lib/types'
|
|
||||||
|
|
||||||
const KEY = Symbol('eventSeries')
|
|
||||||
|
|
||||||
export type SeriesRule = 'woechentlich' | 'zweiwoechentlich' | 'monatlich'
|
|
||||||
|
|
||||||
export const SERIES_RULE_LABEL: Record<string, string> = {
|
|
||||||
woechentlich: 'wöchentlich',
|
|
||||||
zweiwoechentlich: 'alle zwei Wochen',
|
|
||||||
monatlich: 'monatlich',
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wie viele Termine ein Erzeugen-Lauf höchstens anlegt. */
|
|
||||||
const BATCH = 12
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Nächste Termine einer Regel ab einem Startdatum.
|
|
||||||
*
|
|
||||||
* Gerechnet wird auf Kalendertagen, nicht auf Millisekunden: Über Sommer- und
|
|
||||||
* Winterzeit hinweg verschiebt sich eine Woche sonst um eine Stunde und ein
|
|
||||||
* Termin kippt auf den Vortag.
|
|
||||||
*/
|
|
||||||
export function occurrences(
|
|
||||||
startDay: string,
|
|
||||||
rule: SeriesRule,
|
|
||||||
count: number,
|
|
||||||
untilDay?: string,
|
|
||||||
): string[] {
|
|
||||||
if (!startDay) return []
|
|
||||||
|
|
||||||
const [y, m, d] = startDay.split('-').map(Number)
|
|
||||||
const out: string[] = []
|
|
||||||
let cursor = new Date(Date.UTC(y, m - 1, d))
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const iso = cursor.toISOString().slice(0, 10)
|
|
||||||
if (untilDay && iso > untilDay) break
|
|
||||||
out.push(iso)
|
|
||||||
|
|
||||||
if (rule === 'monatlich') {
|
|
||||||
cursor = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, cursor.getUTCDate()))
|
|
||||||
} else {
|
|
||||||
const step = rule === 'zweiwoechentlich' ? 14 : 7
|
|
||||||
cursor = new Date(cursor.getTime() + step * 24 * 60 * 60 * 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
export class EventSeriesStore {
|
|
||||||
records = $state<EventSeriesResponse[]>([])
|
|
||||||
loading = $state(false)
|
|
||||||
error = $state<string | null>(null)
|
|
||||||
|
|
||||||
private unsubscribe: (() => void) | null = null
|
|
||||||
private teams = getTeamContext()
|
|
||||||
private events = getEventContext()
|
|
||||||
|
|
||||||
get scoped(): EventSeriesResponse[] {
|
|
||||||
const teamId = this.teams.activeId
|
|
||||||
if (!teamId) return []
|
|
||||||
return this.records.filter((r) => r.team === teamId)
|
|
||||||
}
|
|
||||||
|
|
||||||
getById(id: string) {
|
|
||||||
return this.records.find((r) => r.id === id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Termine, die aus dieser Serie entstanden sind. */
|
|
||||||
eventsOf(seriesId: string) {
|
|
||||||
return this.events.scoped.filter((e) => e.series === seriesId)
|
|
||||||
}
|
|
||||||
|
|
||||||
async load() {
|
|
||||||
this.loading = true
|
|
||||||
this.error = null
|
|
||||||
try {
|
|
||||||
this.records = await api.collection('event_series').getFullList({
|
|
||||||
sort: '+name',
|
|
||||||
requestKey: null,
|
|
||||||
})
|
|
||||||
} catch (e: any) {
|
|
||||||
this.error = e.message ?? 'Fehler beim Laden der Serien'
|
|
||||||
console.error(e)
|
|
||||||
} finally {
|
|
||||||
this.loading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribe() {
|
|
||||||
if (this.unsubscribe) return
|
|
||||||
api.collection('event_series').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]
|
|
||||||
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)
|
|
||||||
}).then((unsub) => {
|
|
||||||
this.unsubscribe = unsub
|
|
||||||
}).catch((e) => console.warn('event_series subscribe failed:', e))
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(data: Partial<EventSeriesResponse>) {
|
|
||||||
const teamId = this.teams.activeId
|
|
||||||
if (!teamId) throw new Error('Kein aktives Team')
|
|
||||||
|
|
||||||
return await api.collection('event_series').create({
|
|
||||||
...data,
|
|
||||||
team: teamId,
|
|
||||||
created_by: auth.user?.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async edit(id: string, data: Partial<EventSeriesResponse>) {
|
|
||||||
return await api.collection('event_series').update(id, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(id: string) {
|
|
||||||
await api.collection('event_series').delete(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legt die nächsten fälligen Termine an. Vorhandene bleiben unberührt —
|
|
||||||
* erzeugt wird nur, was fehlt, damit ein zweiter Klick nichts verdoppelt.
|
|
||||||
* Zurück kommt die Zahl der neuen Termine.
|
|
||||||
*/
|
|
||||||
async generate(seriesId: string): Promise<number> {
|
|
||||||
const series = this.getById(seriesId)
|
|
||||||
if (!series) throw new Error('Serie nicht gefunden')
|
|
||||||
if (!series.starts) throw new Error('Die Serie hat kein Startdatum.')
|
|
||||||
|
|
||||||
const existing = new Set(this.eventsOf(seriesId).map((e) => dayOf(e.starts)))
|
|
||||||
const today = todayISO()
|
|
||||||
|
|
||||||
// Vergangene Termine nachträglich anzulegen hilft niemandem.
|
|
||||||
const dates = occurrences(
|
|
||||||
dayOf(series.starts),
|
|
||||||
(series.rule || 'woechentlich') as SeriesRule,
|
|
||||||
BATCH * 4,
|
|
||||||
dayOf(series.until) || undefined,
|
|
||||||
).filter((d) => d >= today && !existing.has(d))
|
|
||||||
|
|
||||||
let created = 0
|
|
||||||
for (const day of dates.slice(0, BATCH)) {
|
|
||||||
await api.collection('events').create({
|
|
||||||
name: series.name,
|
|
||||||
description: series.description,
|
|
||||||
location: series.location,
|
|
||||||
starts: day,
|
|
||||||
timing: !!series.timing,
|
|
||||||
participation: series.participation || 'offen',
|
|
||||||
series: series.id,
|
|
||||||
team: series.team,
|
|
||||||
created_by: auth.user?.id,
|
|
||||||
})
|
|
||||||
created++
|
|
||||||
}
|
|
||||||
|
|
||||||
return created
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
if (this.unsubscribe) {
|
|
||||||
this.unsubscribe()
|
|
||||||
this.unsubscribe = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setEventSeriesContext() {
|
|
||||||
const store = new EventSeriesStore()
|
|
||||||
setContext(KEY, store)
|
|
||||||
return store
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getEventSeriesContext(): EventSeriesStore {
|
|
||||||
return getContext<EventSeriesStore>(KEY)
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { occurrences } from './eventSeries.svelte'
|
|
||||||
|
|
||||||
describe('occurrences', () => {
|
|
||||||
it('zählt wöchentlich weiter', () => {
|
|
||||||
expect(occurrences('2026-09-02', 'woechentlich', 3)).toEqual([
|
|
||||||
'2026-09-02',
|
|
||||||
'2026-09-09',
|
|
||||||
'2026-09-16',
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('zählt zweiwöchentlich weiter', () => {
|
|
||||||
expect(occurrences('2026-09-02', 'zweiwoechentlich', 3)).toEqual([
|
|
||||||
'2026-09-02',
|
|
||||||
'2026-09-16',
|
|
||||||
'2026-09-30',
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('zählt monatlich weiter', () => {
|
|
||||||
expect(occurrences('2026-01-15', 'monatlich', 3)).toEqual([
|
|
||||||
'2026-01-15',
|
|
||||||
'2026-02-15',
|
|
||||||
'2026-03-15',
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('bleibt über die Zeitumstellung hinweg auf demselben Wochentag', () => {
|
|
||||||
// Ende Oktober 2026 wird in Europa die Uhr zurückgestellt; über
|
|
||||||
// Millisekunden gerechnet kippte der Termin sonst auf den Vortag.
|
|
||||||
const days = occurrences('2026-10-21', 'woechentlich', 3)
|
|
||||||
expect(days).toEqual(['2026-10-21', '2026-10-28', '2026-11-04'])
|
|
||||||
|
|
||||||
const weekdays = days.map((d) => new Date(`${d}T12:00:00Z`).getUTCDay())
|
|
||||||
expect(new Set(weekdays).size).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hört beim Enddatum auf', () => {
|
|
||||||
expect(occurrences('2026-09-02', 'woechentlich', 10, '2026-09-20')).toEqual([
|
|
||||||
'2026-09-02',
|
|
||||||
'2026-09-09',
|
|
||||||
'2026-09-16',
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('liefert nichts ohne Startdatum', () => {
|
|
||||||
expect(occurrences('', 'woechentlich', 5)).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -7,13 +7,6 @@ import type { EventsResponse } from '$lib/types'
|
||||||
|
|
||||||
const KEY = Symbol('event')
|
const KEY = Symbol('event')
|
||||||
|
|
||||||
/** Wie man zu einem Event dazukommt. */
|
|
||||||
export const PARTICIPATION_LABEL: Record<string, string> = {
|
|
||||||
offen: 'Offen',
|
|
||||||
bestaetigung: 'Mit Bestätigung',
|
|
||||||
geschlossen: 'Geschlossen',
|
|
||||||
}
|
|
||||||
|
|
||||||
export class EventStore {
|
export class EventStore {
|
||||||
records = $state<EventsResponse[]>([])
|
records = $state<EventsResponse[]>([])
|
||||||
loading = $state(false)
|
loading = $state(false)
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@ import type { RidersResponse, UsersResponse } from '$lib/types'
|
||||||
|
|
||||||
const KEY = Symbol('rider')
|
const KEY = Symbol('rider')
|
||||||
|
|
||||||
/**
|
function sortByNumber(records: RidersResponse[]) {
|
||||||
* Der Kader wird nach Namen sortiert. Nach Startnummern ginge es nicht mehr:
|
return [...records].sort((a, b) => {
|
||||||
* Die gelten seit 1754501300 je Event und stehen am Teilnehmer.
|
const na = parseInt(a.number ?? '0', 10)
|
||||||
*/
|
const nb = parseInt(b.number ?? '0', 10)
|
||||||
function sortByName(records: RidersResponse[]) {
|
return na - nb
|
||||||
return [...records].sort((a, b) => fullName(a).localeCompare(fullName(b)))
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fullName(r: RidersResponse) {
|
export function fullName(r: RidersResponse) {
|
||||||
|
|
@ -48,7 +48,7 @@ export class RiderStore {
|
||||||
this.error = null
|
this.error = null
|
||||||
try {
|
try {
|
||||||
const raw = await api.collection('riders').getFullList({ requestKey: null })
|
const raw = await api.collection('riders').getFullList({ requestKey: null })
|
||||||
this.records = sortByName(raw)
|
this.records = sortByNumber(raw)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.error = e.message ?? 'Fehler beim Laden der Fahrer'
|
this.error = e.message ?? 'Fehler beim Laden der Fahrer'
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
|
@ -62,10 +62,10 @@ export class RiderStore {
|
||||||
api.collection('riders').subscribe('*', (e) => {
|
api.collection('riders').subscribe('*', (e) => {
|
||||||
const idx = this.records.findIndex((r) => r.id === e.record.id)
|
const idx = this.records.findIndex((r) => r.id === e.record.id)
|
||||||
if (e.action === 'create' && idx === -1) {
|
if (e.action === 'create' && idx === -1) {
|
||||||
this.records = sortByName([...this.records, e.record])
|
this.records = sortByNumber([...this.records, e.record])
|
||||||
} else if (e.action === 'update' && idx !== -1) {
|
} else if (e.action === 'update' && idx !== -1) {
|
||||||
this.records[idx] = e.record
|
this.records[idx] = e.record
|
||||||
this.records = sortByName(this.records)
|
this.records = sortByNumber(this.records)
|
||||||
} else if (e.action === 'delete' && idx !== -1) {
|
} else if (e.action === 'delete' && idx !== -1) {
|
||||||
this.records = this.records.filter((r) => r.id !== e.record.id)
|
this.records = this.records.filter((r) => r.id !== e.record.id)
|
||||||
}
|
}
|
||||||
|
|
@ -88,7 +88,7 @@ export class RiderStore {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
app.confirm.request({
|
app.confirm.request({
|
||||||
title: 'Fahrer löschen?',
|
title: 'Fahrer löschen?',
|
||||||
text: `Möchten Sie „${fullName(record)}" wirklich löschen? Alle Teilnahmen und Zeiten dieses Fahrers werden ebenfalls gelöscht.`,
|
text: `Möchten Sie „#${record.number} ${fullName(record)}" wirklich löschen? Alle zugehörigen Zeiten werden ebenfalls gelöscht.`,
|
||||||
yes: async () => {
|
yes: async () => {
|
||||||
try {
|
try {
|
||||||
await api.collection('riders').delete(record.id)
|
await api.collection('riders').delete(record.id)
|
||||||
|
|
@ -139,11 +139,22 @@ export class RiderStore {
|
||||||
return user
|
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() {
|
get filtered() {
|
||||||
const q = this.searchQuery.toLowerCase().trim()
|
const q = this.searchQuery.toLowerCase().trim()
|
||||||
if (!q) return this.scoped
|
if (!q) return this.scoped
|
||||||
return this.scoped.filter((r) => {
|
return this.scoped.filter((r) => {
|
||||||
return (
|
return (
|
||||||
|
(r.number ?? '').toLowerCase().includes(q) ||
|
||||||
(r.firstname ?? '').toLowerCase().includes(q) ||
|
(r.firstname ?? '').toLowerCase().includes(q) ||
|
||||||
(r.lastname ?? '').toLowerCase().includes(q) ||
|
(r.lastname ?? '').toLowerCase().includes(q) ||
|
||||||
(r.name ?? '').toLowerCase().includes(q)
|
(r.name ?? '').toLowerCase().includes(q)
|
||||||
|
|
|
||||||
|
|
@ -112,20 +112,6 @@ export class TeamStore {
|
||||||
return await api.collection('teams').update(id, { name })
|
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) */
|
/** Mitglied hinzufügen (Owner/Admin only) */
|
||||||
async addMember(teamId: string, userId: string) {
|
async addMember(teamId: string, userId: string) {
|
||||||
const team = this.records.find((t) => t.id === teamId)
|
const team = this.records.find((t) => t.id === teamId)
|
||||||
|
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
import { getContext, setContext } from 'svelte'
|
|
||||||
import { api, auth } from './pocketbase.svelte'
|
|
||||||
import { getTeamContext } from './teams.svelte'
|
|
||||||
import { isRecent } from '$lib/time'
|
|
||||||
import type { TrailCommentsResponse, UsersResponse } from '$lib/types'
|
|
||||||
|
|
||||||
const KEY = Symbol('trailComment')
|
|
||||||
|
|
||||||
/** Kommentar samt Verfasser, sofern dessen Datensatz sichtbar ist. */
|
|
||||||
export type TrailComment = Omit<TrailCommentsResponse, 'expand'> & {
|
|
||||||
expand?: { created_by?: UsersResponse }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Kommentare lagen bisher direkt in der Trail-Seite — als es sie nur dort
|
|
||||||
* gab, war das die einfachere Lösung. Die Trail-Liste zeigt jetzt ebenfalls
|
|
||||||
* ihre Anzahl, und zwei Ladewege auf dieselben Daten wären einer zu viel.
|
|
||||||
*/
|
|
||||||
export class TrailCommentStore {
|
|
||||||
records = $state<TrailComment[]>([])
|
|
||||||
loading = $state(false)
|
|
||||||
error = $state<string | null>(null)
|
|
||||||
|
|
||||||
private unsubscribe: (() => void) | null = null
|
|
||||||
private teams = getTeamContext()
|
|
||||||
|
|
||||||
get scoped(): TrailComment[] {
|
|
||||||
const teamId = this.teams.activeId
|
|
||||||
if (!teamId) return []
|
|
||||||
return this.records.filter((r) => r.team === teamId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Kommentare eines Trails, jüngste zuerst */
|
|
||||||
byTrail(trailId: string): TrailComment[] {
|
|
||||||
return this.scoped
|
|
||||||
.filter((r) => r.trail === trailId)
|
|
||||||
.sort((a, b) => (a.created < b.created ? 1 : -1))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Gab es zuletzt Bewegung? Für die Hervorhebung in der Liste. */
|
|
||||||
hasRecent(trailId: string, days?: number): boolean {
|
|
||||||
return this.byTrail(trailId).some((r) => isRecent(r.created, days))
|
|
||||||
}
|
|
||||||
|
|
||||||
async load() {
|
|
||||||
this.loading = true
|
|
||||||
this.error = null
|
|
||||||
try {
|
|
||||||
this.records = (await api.collection('trail_comments').getFullList({
|
|
||||||
sort: '-created',
|
|
||||||
expand: 'created_by',
|
|
||||||
requestKey: null,
|
|
||||||
})) as TrailComment[]
|
|
||||||
} catch (e: any) {
|
|
||||||
this.error = e.message ?? 'Fehler beim Laden der Kommentare'
|
|
||||||
console.error(e)
|
|
||||||
} finally {
|
|
||||||
this.loading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribe() {
|
|
||||||
if (this.unsubscribe) return
|
|
||||||
api.collection('trail_comments').subscribe('*', (e) => {
|
|
||||||
const record = e.record as TrailComment
|
|
||||||
const idx = this.records.findIndex((r) => r.id === record.id)
|
|
||||||
if (e.action === 'create' && idx === -1) this.records = [record, ...this.records]
|
|
||||||
else if (e.action === 'update' && idx !== -1) this.records[idx] = record
|
|
||||||
else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== record.id)
|
|
||||||
}, { expand: 'created_by' }).then((unsub) => {
|
|
||||||
this.unsubscribe = unsub
|
|
||||||
}).catch((e) => console.warn('trail_comments subscribe failed:', e))
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(trailId: string, teamId: string, text: string) {
|
|
||||||
return await api.collection('trail_comments').create({
|
|
||||||
trail: trailId,
|
|
||||||
team: teamId,
|
|
||||||
text: text.trim(),
|
|
||||||
created_by: auth.user?.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(id: string) {
|
|
||||||
await api.collection('trail_comments').delete(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
if (this.unsubscribe) {
|
|
||||||
this.unsubscribe()
|
|
||||||
this.unsubscribe = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setTrailCommentContext() {
|
|
||||||
const store = new TrailCommentStore()
|
|
||||||
setContext(KEY, store)
|
|
||||||
return store
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTrailCommentContext(): TrailCommentStore {
|
|
||||||
return getContext<TrailCommentStore>(KEY)
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { getContext, setContext } from 'svelte'
|
import { getContext, setContext } from 'svelte'
|
||||||
import { api, auth } from './pocketbase.svelte'
|
import { api, auth } from './pocketbase.svelte'
|
||||||
import { getTeamContext } from './teams.svelte'
|
import { getTeamContext } from './teams.svelte'
|
||||||
import { isRecent } from '$lib/time'
|
|
||||||
import type { TrailMarkersResponse, UsersResponse } from '$lib/types'
|
import type { TrailMarkersResponse, UsersResponse } from '$lib/types'
|
||||||
|
|
||||||
const KEY = Symbol('trailMarker')
|
const KEY = Symbol('trailMarker')
|
||||||
|
|
@ -29,11 +28,6 @@ export class TrailMarkerStore {
|
||||||
.sort((a, b) => (a.created < b.created ? 1 : -1))
|
.sort((a, b) => (a.created < b.created ? 1 : -1))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gab es zuletzt Bewegung? Für die Hervorhebung in der Liste. */
|
|
||||||
hasRecent(trailId: string, days?: number): boolean {
|
|
||||||
return this.byTrail(trailId).some((r) => isRecent(r.created, days))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Nur die offenen — was auf der Karte auffallen soll */
|
/** Nur die offenen — was auf der Karte auffallen soll */
|
||||||
openByTrail(trailId: string): TrailMarker[] {
|
openByTrail(trailId: string): TrailMarker[] {
|
||||||
return this.byTrail(trailId).filter((r) => !r.resolved)
|
return this.byTrail(trailId).filter((r) => !r.resolved)
|
||||||
|
|
|
||||||
|
|
@ -70,19 +70,3 @@ export function formatDateRange(starts: string | undefined, ends: string | undef
|
||||||
|
|
||||||
return `${from} – ${to}`
|
return `${from} – ${to}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Standardfenster für „neu" in Übersichten. Zwei Wochen, weil ein Trail
|
|
||||||
* selten öfter befahren wird — kürzer wäre die Hervorhebung meist leer.
|
|
||||||
*/
|
|
||||||
export const RECENT_DAYS = 14
|
|
||||||
|
|
||||||
/** Liegt der Zeitpunkt innerhalb der letzten `days` Tage? */
|
|
||||||
export function isRecent(value: string | undefined, days = RECENT_DAYS): boolean {
|
|
||||||
if (!value) return false
|
|
||||||
|
|
||||||
const then = new Date(value).getTime()
|
|
||||||
if (!Number.isFinite(then)) return false
|
|
||||||
|
|
||||||
return Date.now() - then <= days * 24 * 60 * 60 * 1000
|
|
||||||
}
|
|
||||||
|
|
|
||||||
65
frontend/src/lib/types.d.ts
vendored
65
frontend/src/lib/types.d.ts
vendored
|
|
@ -6,8 +6,6 @@ import type PocketBase from 'pocketbase'
|
||||||
import type { RecordService } from 'pocketbase'
|
import type { RecordService } from 'pocketbase'
|
||||||
|
|
||||||
export enum Collections {
|
export enum Collections {
|
||||||
EventParticipants = "event_participants",
|
|
||||||
EventSeries = "event_series",
|
|
||||||
Events = "events",
|
Events = "events",
|
||||||
Riders = "riders",
|
Riders = "riders",
|
||||||
Runs = "runs",
|
Runs = "runs",
|
||||||
|
|
@ -48,78 +46,28 @@ export type AuthSystemFields<T = unknown> = {
|
||||||
|
|
||||||
// Record types for each collection
|
// Record types for each collection
|
||||||
|
|
||||||
export enum EventParticipantsStatusOptions {
|
|
||||||
"angefragt" = "angefragt",
|
|
||||||
"zugesagt" = "zugesagt",
|
|
||||||
"abgesagt" = "abgesagt",
|
|
||||||
}
|
|
||||||
export type EventParticipantsRecord = {
|
|
||||||
created?: IsoDateString
|
|
||||||
created_by?: RecordIdString
|
|
||||||
event: RecordIdString
|
|
||||||
id: string
|
|
||||||
number?: string
|
|
||||||
rider: RecordIdString
|
|
||||||
status?: EventParticipantsStatusOptions
|
|
||||||
updated?: IsoDateString
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum EventSeriesRuleOptions {
|
|
||||||
"woechentlich" = "woechentlich",
|
|
||||||
"zweiwoechentlich" = "zweiwoechentlich",
|
|
||||||
"monatlich" = "monatlich",
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum EventSeriesParticipationOptions {
|
|
||||||
"offen" = "offen",
|
|
||||||
"bestaetigung" = "bestaetigung",
|
|
||||||
"geschlossen" = "geschlossen",
|
|
||||||
}
|
|
||||||
export type EventSeriesRecord = {
|
|
||||||
created?: IsoDateString
|
|
||||||
created_by?: RecordIdString
|
|
||||||
description?: string
|
|
||||||
id: string
|
|
||||||
location?: string
|
|
||||||
name: string
|
|
||||||
participation?: EventSeriesParticipationOptions
|
|
||||||
rule?: EventSeriesRuleOptions
|
|
||||||
starts?: IsoDateString
|
|
||||||
team: RecordIdString
|
|
||||||
timing?: boolean
|
|
||||||
until?: IsoDateString
|
|
||||||
updated?: IsoDateString
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum EventsParticipationOptions {
|
|
||||||
"offen" = "offen",
|
|
||||||
"bestaetigung" = "bestaetigung",
|
|
||||||
"geschlossen" = "geschlossen",
|
|
||||||
}
|
|
||||||
export type EventsRecord = {
|
export type EventsRecord = {
|
||||||
created?: IsoDateString
|
created?: IsoDateString
|
||||||
created_by?: RecordIdString
|
|
||||||
description?: string
|
description?: string
|
||||||
ends?: IsoDateString
|
ends?: IsoDateString
|
||||||
gps?: string
|
gps?: string
|
||||||
id: string
|
id: string
|
||||||
location?: string
|
location?: string
|
||||||
name?: string
|
name?: string
|
||||||
participation?: EventsParticipationOptions
|
|
||||||
series?: RecordIdString
|
|
||||||
starts?: IsoDateString
|
starts?: IsoDateString
|
||||||
team?: RecordIdString
|
team?: RecordIdString
|
||||||
timing?: boolean
|
|
||||||
updated?: IsoDateString
|
updated?: IsoDateString
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RidersRecord = {
|
export type RidersRecord = {
|
||||||
avatar?: string
|
avatar?: string
|
||||||
created?: IsoDateString
|
created?: IsoDateString
|
||||||
|
event?: RecordIdString
|
||||||
firstname?: string
|
firstname?: string
|
||||||
id: string
|
id: string
|
||||||
lastname?: string
|
lastname?: string
|
||||||
name?: string
|
name?: string
|
||||||
|
number?: string
|
||||||
team?: RecordIdString
|
team?: RecordIdString
|
||||||
updated?: IsoDateString
|
updated?: IsoDateString
|
||||||
user?: RecordIdString
|
user?: RecordIdString
|
||||||
|
|
@ -138,7 +86,6 @@ export type TeamsRecord = {
|
||||||
admins?: RecordIdString[]
|
admins?: RecordIdString[]
|
||||||
created?: IsoDateString
|
created?: IsoDateString
|
||||||
id: string
|
id: string
|
||||||
logo?: string
|
|
||||||
name: string
|
name: string
|
||||||
owner: RecordIdString
|
owner: RecordIdString
|
||||||
updated?: IsoDateString
|
updated?: IsoDateString
|
||||||
|
|
@ -263,8 +210,6 @@ export type UsersRecord = {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response types include system fields and match responses from the PocketBase API
|
// Response types include system fields and match responses from the PocketBase API
|
||||||
export type EventParticipantsResponse<Texpand = unknown> = Required<EventParticipantsRecord> & BaseSystemFields<Texpand>
|
|
||||||
export type EventSeriesResponse<Texpand = unknown> = Required<EventSeriesRecord> & BaseSystemFields<Texpand>
|
|
||||||
export type EventsResponse<Texpand = unknown> = Required<EventsRecord> & BaseSystemFields<Texpand>
|
export type EventsResponse<Texpand = unknown> = Required<EventsRecord> & BaseSystemFields<Texpand>
|
||||||
export type RidersResponse<Texpand = unknown> = Required<RidersRecord> & BaseSystemFields<Texpand>
|
export type RidersResponse<Texpand = unknown> = Required<RidersRecord> & BaseSystemFields<Texpand>
|
||||||
export type RunsResponse<Texpand = unknown> = Required<RunsRecord> & BaseSystemFields<Texpand>
|
export type RunsResponse<Texpand = unknown> = Required<RunsRecord> & BaseSystemFields<Texpand>
|
||||||
|
|
@ -280,8 +225,6 @@ export type UsersResponse<Texpand = unknown> = Required<UsersRecord> & AuthSyste
|
||||||
// Types containing all Records and Responses, useful for creating typing helper functions
|
// Types containing all Records and Responses, useful for creating typing helper functions
|
||||||
|
|
||||||
export type CollectionRecords = {
|
export type CollectionRecords = {
|
||||||
event_participants: EventParticipantsRecord
|
|
||||||
event_series: EventSeriesRecord
|
|
||||||
events: EventsRecord
|
events: EventsRecord
|
||||||
riders: RidersRecord
|
riders: RidersRecord
|
||||||
runs: RunsRecord
|
runs: RunsRecord
|
||||||
|
|
@ -296,8 +239,6 @@ export type CollectionRecords = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CollectionResponses = {
|
export type CollectionResponses = {
|
||||||
event_participants: EventParticipantsResponse
|
|
||||||
event_series: EventSeriesResponse
|
|
||||||
events: EventsResponse
|
events: EventsResponse
|
||||||
riders: RidersResponse
|
riders: RidersResponse
|
||||||
runs: RunsResponse
|
runs: RunsResponse
|
||||||
|
|
@ -315,8 +256,6 @@ export type CollectionResponses = {
|
||||||
// https://github.com/pocketbase/js-sdk#specify-typescript-definitions
|
// https://github.com/pocketbase/js-sdk#specify-typescript-definitions
|
||||||
|
|
||||||
export type TypedPocketBase = PocketBase & {
|
export type TypedPocketBase = PocketBase & {
|
||||||
collection(idOrName: 'event_participants'): RecordService<EventParticipantsResponse>
|
|
||||||
collection(idOrName: 'event_series'): RecordService<EventSeriesResponse>
|
|
||||||
collection(idOrName: 'events'): RecordService<EventsResponse>
|
collection(idOrName: 'events'): RecordService<EventsResponse>
|
||||||
collection(idOrName: 'riders'): RecordService<RidersResponse>
|
collection(idOrName: 'riders'): RecordService<RidersResponse>
|
||||||
collection(idOrName: 'runs'): RecordService<RunsResponse>
|
collection(idOrName: 'runs'): RecordService<RunsResponse>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,16 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { auth } from '$lib/stores/pocketbase.svelte'
|
import { auth } from '$lib/stores/pocketbase.svelte'
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import { app, tooltip } from '$lib/stores/app.svelte'
|
import { app } from '$lib/stores/app.svelte'
|
||||||
import { setTeamContext } from '$lib/stores/teams.svelte'
|
import { setTeamContext } from '$lib/stores/teams.svelte'
|
||||||
import { setEventContext } from '$lib/stores/events.svelte'
|
import { setEventContext } from '$lib/stores/events.svelte'
|
||||||
import { setRunContext } from '$lib/stores/runs.svelte'
|
import { setRunContext } from '$lib/stores/runs.svelte'
|
||||||
import { setRiderContext } from '$lib/stores/riders.svelte'
|
import { setRiderContext } from '$lib/stores/riders.svelte'
|
||||||
import { setTimeContext } from '$lib/stores/times.svelte'
|
import { setTimeContext } from '$lib/stores/times.svelte'
|
||||||
import { setEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
|
||||||
import { setEventSeriesContext } from '$lib/stores/eventSeries.svelte'
|
|
||||||
import { setTrailContext } from '$lib/stores/trails.svelte'
|
import { setTrailContext } from '$lib/stores/trails.svelte'
|
||||||
import { setTrailVersionContext } from '$lib/stores/trailVersions.svelte'
|
import { setTrailVersionContext } from '$lib/stores/trailVersions.svelte'
|
||||||
import { setTrailFlagContext } from '$lib/stores/trailFlags.svelte'
|
import { setTrailFlagContext } from '$lib/stores/trailFlags.svelte'
|
||||||
import { setTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
|
import { setTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
|
||||||
import { setTrailCommentContext } from '$lib/stores/trailComments.svelte'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import Logo from '$lib/components/Logo.svelte'
|
import Logo from '$lib/components/Logo.svelte'
|
||||||
|
|
@ -33,14 +30,10 @@
|
||||||
const runs = setRunContext()
|
const runs = setRunContext()
|
||||||
const riders = setRiderContext()
|
const riders = setRiderContext()
|
||||||
const times = setTimeContext()
|
const times = setTimeContext()
|
||||||
// Nach events und riders: Der Store greift im Constructor auf beide zu.
|
|
||||||
const participants = setEventParticipantContext()
|
|
||||||
const series = setEventSeriesContext()
|
|
||||||
const trails = setTrailContext()
|
const trails = setTrailContext()
|
||||||
const trailVersions = setTrailVersionContext()
|
const trailVersions = setTrailVersionContext()
|
||||||
const trailFlags = setTrailFlagContext()
|
const trailFlags = setTrailFlagContext()
|
||||||
const trailMarkers = setTrailMarkerContext()
|
const trailMarkers = setTrailMarkerContext()
|
||||||
const trailComments = setTrailCommentContext()
|
|
||||||
|
|
||||||
let isMobileMenuOpen = $state(false)
|
let isMobileMenuOpen = $state(false)
|
||||||
|
|
||||||
|
|
@ -53,21 +46,17 @@
|
||||||
await teams.load()
|
await teams.load()
|
||||||
teams.subscribe()
|
teams.subscribe()
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
events.load(), runs.load(), riders.load(), times.load(), participants.load(), series.load(),
|
events.load(), runs.load(), riders.load(), times.load(),
|
||||||
trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(),
|
trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(),
|
||||||
trailComments.load(),
|
|
||||||
])
|
])
|
||||||
events.subscribe()
|
events.subscribe()
|
||||||
runs.subscribe()
|
runs.subscribe()
|
||||||
riders.subscribe()
|
riders.subscribe()
|
||||||
times.subscribe()
|
times.subscribe()
|
||||||
participants.subscribe()
|
|
||||||
series.subscribe()
|
|
||||||
trails.subscribe()
|
trails.subscribe()
|
||||||
trailVersions.subscribe()
|
trailVersions.subscribe()
|
||||||
trailFlags.subscribe()
|
trailFlags.subscribe()
|
||||||
trailMarkers.subscribe()
|
trailMarkers.subscribe()
|
||||||
trailComments.subscribe()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
|
@ -76,13 +65,10 @@
|
||||||
runs.destroy()
|
runs.destroy()
|
||||||
riders.destroy()
|
riders.destroy()
|
||||||
times.destroy()
|
times.destroy()
|
||||||
participants.destroy()
|
|
||||||
series.destroy()
|
|
||||||
trails.destroy()
|
trails.destroy()
|
||||||
trailVersions.destroy()
|
trailVersions.destroy()
|
||||||
trailFlags.destroy()
|
trailFlags.destroy()
|
||||||
trailMarkers.destroy()
|
trailMarkers.destroy()
|
||||||
trailComments.destroy()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
|
|
@ -110,28 +96,15 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if auth.user}
|
{#if auth.user}
|
||||||
<div class="relative min-h-screen bg-background">
|
<div class="min-h-screen bg-background">
|
||||||
<!--
|
<header class="border-b bg-card">
|
||||||
Das Logo als Wasserzeichen: fest in der Ecke, gedreht und sehr
|
|
||||||
blass. aria-hidden und pointer-events-none, damit es weder
|
|
||||||
vorgelesen wird noch Klicks abfängt. Im Dunkelmodus etwas
|
|
||||||
kräftiger, sonst verschwindet es im dunklen Grund.
|
|
||||||
-->
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
class="pointer-events-none fixed -bottom-24 -right-24 z-0 select-none opacity-[0.04] dark:opacity-[0.07]"
|
|
||||||
>
|
|
||||||
<Logo team={teams.active} class="size-[32rem] max-w-[70vw] rotate-12" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<header class="relative z-10 border-b bg-card">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex min-h-16 items-center justify-between gap-4 py-2">
|
<div class="flex min-h-16 items-center justify-between gap-4 py-2">
|
||||||
<!-- Links: Logo und Menü. Das Logo fuellt seine Box, in
|
<!-- Links: Logo und Menü. Das Logo fuellt seine Box, in
|
||||||
voller Groesse wuerde es die 64 px hohe Leiste
|
voller Groesse wuerde es die 64 px hohe Leiste
|
||||||
ueberragen und beschnitten wirken. -->
|
ueberragen und beschnitten wirken. -->
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<Logo team={teams.active} class="h-11 w-11 shrink-0" />
|
<Logo class="h-11 w-11 shrink-0" />
|
||||||
|
|
||||||
<nav class="hidden md:flex items-center gap-1">
|
<nav class="hidden md:flex items-center gap-1">
|
||||||
{#each nav as item}
|
{#each nav as item}
|
||||||
|
|
@ -155,8 +128,8 @@
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<a
|
<a
|
||||||
href="/dashboard/settings"
|
href="/dashboard/settings"
|
||||||
|
title="Profil"
|
||||||
class="flex min-w-0 items-center gap-2"
|
class="flex min-w-0 items-center gap-2"
|
||||||
use:tooltip={{ content: 'Profil' }}
|
|
||||||
>
|
>
|
||||||
<Avatar user={auth.user} size={24} />
|
<Avatar user={auth.user} size={24} />
|
||||||
<span class="truncate text-sm font-medium leading-none">
|
<span class="truncate text-sm font-medium leading-none">
|
||||||
|
|
@ -170,7 +143,7 @@
|
||||||
size="icon"
|
size="icon"
|
||||||
class="size-7"
|
class="size-7"
|
||||||
onclick={toggleMode}
|
onclick={toggleMode}
|
||||||
tooltip="Design wechseln"
|
title="Design wechseln"
|
||||||
>
|
>
|
||||||
{#if mode.current === 'dark'}
|
{#if mode.current === 'dark'}
|
||||||
<Sun class="h-4 w-4" />
|
<Sun class="h-4 w-4" />
|
||||||
|
|
@ -183,7 +156,7 @@
|
||||||
size="icon"
|
size="icon"
|
||||||
class="size-7"
|
class="size-7"
|
||||||
onclick={handleLogout}
|
onclick={handleLogout}
|
||||||
tooltip="Abmelden"
|
title="Abmelden"
|
||||||
>
|
>
|
||||||
<LogOut class="h-4 w-4" />
|
<LogOut class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -200,7 +173,7 @@
|
||||||
size="icon"
|
size="icon"
|
||||||
class="md:hidden"
|
class="md:hidden"
|
||||||
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
|
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
|
||||||
tooltip="Menü"
|
title="Menü"
|
||||||
>
|
>
|
||||||
{#if isMobileMenuOpen}
|
{#if isMobileMenuOpen}
|
||||||
<X class="h-4 w-4" />
|
<X class="h-4 w-4" />
|
||||||
|
|
@ -238,14 +211,14 @@
|
||||||
<TeamSwitcher />
|
<TeamSwitcher />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
<Button variant="ghost" size="icon" onclick={toggleMode} tooltip="Design wechseln">
|
<Button variant="ghost" size="icon" onclick={toggleMode} title="Design wechseln">
|
||||||
{#if mode.current === 'dark'}
|
{#if mode.current === 'dark'}
|
||||||
<Sun class="h-4 w-4" />
|
<Sun class="h-4 w-4" />
|
||||||
{:else}
|
{:else}
|
||||||
<Moon class="h-4 w-4" />
|
<Moon class="h-4 w-4" />
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" onclick={handleLogout} tooltip="Abmelden">
|
<Button variant="ghost" size="icon" onclick={handleLogout} title="Abmelden">
|
||||||
<LogOut class="h-4 w-4" />
|
<LogOut class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -255,7 +228,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,28 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getEventContext } from '$lib/stores/events.svelte'
|
import { getEventContext } from '$lib/stores/events.svelte'
|
||||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
|
||||||
import {
|
|
||||||
getEventSeriesContext,
|
|
||||||
SERIES_RULE_LABEL,
|
|
||||||
occurrences,
|
|
||||||
type SeriesRule,
|
|
||||||
} from '$lib/stores/eventSeries.svelte'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import * as Dialog from '@/components/ui/dialog'
|
import * as Dialog from '@/components/ui/dialog'
|
||||||
import { dayOf, formatDateRange, formatDay } from '$lib/time'
|
import { dayOf, formatDateRange } from '$lib/time'
|
||||||
import { auth } from '$lib/stores/pocketbase.svelte'
|
import { Calendar, MapPin, Plus, Pencil, Trash2 } from 'lucide-svelte'
|
||||||
import { PARTICIPATION_LABEL } from '$lib/stores/events.svelte'
|
|
||||||
import * as Select from '@/components/ui/select'
|
|
||||||
import { Calendar, MapPin, Plus, Pencil, Trash2, Timer, Users } from 'lucide-svelte'
|
|
||||||
import type { EventsResponse } from '$lib/types'
|
import type { EventsResponse } from '$lib/types'
|
||||||
|
|
||||||
const events = getEventContext()
|
const events = getEventContext()
|
||||||
const runs = getRunContext()
|
const runs = getRunContext()
|
||||||
const participants = getEventParticipantContext()
|
|
||||||
const series = getEventSeriesContext()
|
|
||||||
|
|
||||||
let showDialog = $state(false)
|
let showDialog = $state(false)
|
||||||
let editing = $state<EventsResponse | null>(null)
|
let editing = $state<EventsResponse | null>(null)
|
||||||
let form = $state({
|
let form = $state({ name: '', description: '', location: '', starts: '', ends: '' })
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
location: '',
|
|
||||||
starts: '',
|
|
||||||
ends: '',
|
|
||||||
timing: false,
|
|
||||||
participation: 'offen',
|
|
||||||
})
|
|
||||||
let multiDay = $state(false)
|
let multiDay = $state(false)
|
||||||
let formError = $state<string | null>(null)
|
let formError = $state<string | null>(null)
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editing = null
|
editing = null
|
||||||
form = {
|
form = { name: '', description: '', location: '', starts: '', ends: '' }
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
location: '',
|
|
||||||
starts: '',
|
|
||||||
ends: '',
|
|
||||||
timing: false,
|
|
||||||
participation: 'offen',
|
|
||||||
}
|
|
||||||
multiDay = false
|
multiDay = false
|
||||||
formError = null
|
formError = null
|
||||||
showDialog = true
|
showDialog = true
|
||||||
|
|
@ -64,8 +36,6 @@
|
||||||
location: e.location ?? '',
|
location: e.location ?? '',
|
||||||
starts: dayOf(e.starts),
|
starts: dayOf(e.starts),
|
||||||
ends: dayOf(e.ends),
|
ends: dayOf(e.ends),
|
||||||
timing: !!e.timing,
|
|
||||||
participation: e.participation || 'offen',
|
|
||||||
}
|
}
|
||||||
// Ein gespeichertes Enddatum ist das Kennzeichen für mehrtägig; ein
|
// Ein gespeichertes Enddatum ist das Kennzeichen für mehrtägig; ein
|
||||||
// eigenes Feld dafür wäre eine zweite Wahrheit über dieselbe Sache.
|
// eigenes Feld dafür wäre eine zweite Wahrheit über dieselbe Sache.
|
||||||
|
|
@ -92,109 +62,14 @@
|
||||||
// dasselbe Datum zweimal.
|
// dasselbe Datum zweimal.
|
||||||
const ends = multiDay && form.ends !== form.starts ? form.ends : ''
|
const ends = multiDay && form.ends !== form.starts ? form.ends : ''
|
||||||
const data = { ...form, ends }
|
const data = { ...form, ends }
|
||||||
if (editing) await events.edit(editing.id, data as any)
|
if (editing) await events.edit(editing.id, data)
|
||||||
else await events.create({ ...data, created_by: auth.user?.id } as any)
|
else await events.create(data)
|
||||||
showDialog = false
|
showDialog = false
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
formError = e.message ?? 'Das Event konnte nicht gespeichert werden.'
|
formError = e.message ?? 'Das Event konnte nicht gespeichert werden.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Serien -----------------------------------------------------------
|
|
||||||
let seriesDialog = $state(false)
|
|
||||||
let seriesEditing = $state<string | null>(null)
|
|
||||||
let seriesForm = $state({
|
|
||||||
name: '',
|
|
||||||
location: '',
|
|
||||||
description: '',
|
|
||||||
rule: 'woechentlich' as SeriesRule,
|
|
||||||
starts: '',
|
|
||||||
until: '',
|
|
||||||
timing: false,
|
|
||||||
participation: 'offen',
|
|
||||||
})
|
|
||||||
let seriesError = $state<string | null>(null)
|
|
||||||
let seriesBusy = $state(false)
|
|
||||||
|
|
||||||
function openSeriesCreate() {
|
|
||||||
seriesEditing = null
|
|
||||||
seriesForm = {
|
|
||||||
name: '',
|
|
||||||
location: '',
|
|
||||||
description: '',
|
|
||||||
rule: 'woechentlich',
|
|
||||||
starts: '',
|
|
||||||
until: '',
|
|
||||||
timing: false,
|
|
||||||
participation: 'offen',
|
|
||||||
}
|
|
||||||
seriesError = null
|
|
||||||
seriesDialog = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSeriesEdit(id: string) {
|
|
||||||
const s = series.getById(id)
|
|
||||||
if (!s) return
|
|
||||||
|
|
||||||
seriesEditing = id
|
|
||||||
seriesForm = {
|
|
||||||
name: s.name ?? '',
|
|
||||||
location: s.location ?? '',
|
|
||||||
description: s.description ?? '',
|
|
||||||
rule: (s.rule || 'woechentlich') as SeriesRule,
|
|
||||||
starts: dayOf(s.starts),
|
|
||||||
until: dayOf(s.until),
|
|
||||||
timing: !!s.timing,
|
|
||||||
participation: s.participation || 'offen',
|
|
||||||
}
|
|
||||||
seriesError = null
|
|
||||||
seriesDialog = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSeries() {
|
|
||||||
if (!seriesForm.name.trim()) {
|
|
||||||
seriesError = 'Die Serie braucht einen Namen.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!seriesForm.starts) {
|
|
||||||
seriesError = 'Ohne erstes Datum gibt es nichts zu wiederholen.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
seriesBusy = true
|
|
||||||
seriesError = null
|
|
||||||
try {
|
|
||||||
if (seriesEditing) await series.edit(seriesEditing, { ...seriesForm } as any)
|
|
||||||
else await series.create({ ...seriesForm } as any)
|
|
||||||
seriesDialog = false
|
|
||||||
} catch (e: any) {
|
|
||||||
seriesError = e.message ?? 'Die Serie konnte nicht gespeichert werden.'
|
|
||||||
} finally {
|
|
||||||
seriesBusy = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generate(id: string) {
|
|
||||||
try {
|
|
||||||
const n = await series.generate(id)
|
|
||||||
if (n === 0) alert('Alle fälligen Termine sind bereits angelegt.')
|
|
||||||
} catch (e: any) {
|
|
||||||
alert(e.message ?? 'Die Termine konnten nicht angelegt werden.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeSeries(id: string) {
|
|
||||||
const s = series.getById(id)
|
|
||||||
if (!s) return
|
|
||||||
if (!confirm(`Serie „${s.name}" löschen? Bereits angelegte Termine bleiben bestehen.`)) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await series.remove(id)
|
|
||||||
} catch (e: any) {
|
|
||||||
alert(e.message ?? 'Die Serie konnte nicht gelöscht werden.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const groups = $derived([
|
const groups = $derived([
|
||||||
{ title: 'Läuft gerade', items: events.active },
|
{ title: 'Läuft gerade', items: events.active },
|
||||||
{ title: 'Anstehend', items: events.upcoming },
|
{ title: 'Anstehend', items: events.upcoming },
|
||||||
|
|
@ -217,69 +92,6 @@
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Serien -->
|
|
||||||
<section class="space-y-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-lg font-semibold">Serien</h2>
|
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
Wiederkehrende Termine — jeder Termin entsteht als eigenes Event und
|
|
||||||
lässt sich einzeln absagen oder verschieben.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" onclick={openSeriesCreate}>
|
|
||||||
<Plus class="h-4 w-4 mr-2" />
|
|
||||||
Serie
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if series.scoped.length > 0}
|
|
||||||
<div class="grid gap-3 md:grid-cols-2">
|
|
||||||
{#each series.scoped as s (s.id)}
|
|
||||||
{@const angelegt = series.eventsOf(s.id).length}
|
|
||||||
{@const naechste = occurrences(
|
|
||||||
dayOf(s.starts),
|
|
||||||
(s.rule || 'woechentlich') as SeriesRule,
|
|
||||||
3,
|
|
||||||
dayOf(s.until) || undefined,
|
|
||||||
)}
|
|
||||||
<Card>
|
|
||||||
<CardContent class="py-4 space-y-2">
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="font-medium truncate">{s.name}</p>
|
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
{SERIES_RULE_LABEL[s.rule] ?? ''} ab {formatDay(s.starts)}
|
|
||||||
{#if s.until}bis {formatDay(s.until)}{/if}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex shrink-0 items-center gap-1">
|
|
||||||
<Button variant="ghost" size="icon" tooltip="Bearbeiten" onclick={() => openSeriesEdit(s.id)}>
|
|
||||||
<Pencil class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" tooltip="Serie löschen" onclick={() => removeSeries(s.id)}>
|
|
||||||
<Trash2 class="h-4 w-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
{angelegt} Termin{angelegt === 1 ? '' : 'e'} angelegt
|
|
||||||
{#if naechste.length > 0}
|
|
||||||
· nächste: {naechste.map(formatDay).join(', ')}
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button variant="outline" size="sm" onclick={() => generate(s.id)}>
|
|
||||||
Termine anlegen
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{#if events.loading}
|
{#if events.loading}
|
||||||
<div class="text-center py-12 text-muted-foreground">Lade Events…</div>
|
<div class="text-center py-12 text-muted-foreground">Lade Events…</div>
|
||||||
{:else if events.scoped.length === 0}
|
{:else if events.scoped.length === 0}
|
||||||
|
|
@ -324,28 +136,16 @@
|
||||||
{#if event.description && event.description !== event.name}
|
{#if event.description && event.description !== event.name}
|
||||||
<p class="text-sm text-muted-foreground line-clamp-2">{event.description}</p>
|
<p class="text-sm text-muted-foreground line-clamp-2">{event.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
<div class="flex items-center justify-between text-sm">
|
||||||
{#if event.timing}
|
<span class="text-muted-foreground">{runCount} Run{runCount === 1 ? '' : 's'}</span>
|
||||||
<span class="flex items-center gap-1">
|
|
||||||
<Timer class="size-3.5" />
|
|
||||||
{runCount} Run{runCount === 1 ? '' : 's'}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
<span class="flex items-center gap-1">
|
|
||||||
<Users class="size-3.5" />
|
|
||||||
{participants.byEvent(event.id).length}
|
|
||||||
</span>
|
|
||||||
<span class="ml-auto">
|
|
||||||
{PARTICIPATION_LABEL[event.participation] ?? ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</a>
|
</a>
|
||||||
<div class="px-6 pb-4 flex items-center justify-end gap-1 border-t pt-3">
|
<div class="px-6 pb-4 flex items-center justify-end gap-1 border-t pt-3">
|
||||||
<Button variant="ghost" size="icon" onclick={() => openEdit(event)} tooltip="Bearbeiten">
|
<Button variant="ghost" size="icon" onclick={() => openEdit(event)} title="Bearbeiten">
|
||||||
<Pencil class="h-4 w-4" />
|
<Pencil class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" onclick={() => events.remove(event)} tooltip="Löschen">
|
<Button variant="ghost" size="icon" onclick={() => events.remove(event)} title="Löschen">
|
||||||
<Trash2 class="h-4 w-4 text-destructive" />
|
<Trash2 class="h-4 w-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -407,37 +207,6 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<label class="flex items-center gap-2 text-sm">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
bind:checked={form.timing}
|
|
||||||
class="size-4 rounded border-input accent-primary"
|
|
||||||
/>
|
|
||||||
mit Zeitnahme
|
|
||||||
</label>
|
|
||||||
<p class="text-xs text-muted-foreground -mt-2">
|
|
||||||
Blendet Runs und Zeitnahme ein. Für Stammtisch oder Feierabendrunde
|
|
||||||
unnötig.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>Teilnahme</Label>
|
|
||||||
<Select.Root type="single" bind:value={form.participation}>
|
|
||||||
<Select.Trigger class="w-full">
|
|
||||||
{PARTICIPATION_LABEL[form.participation] ?? 'Offen'}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
<Select.Item value="offen">Offen — jeder trägt sich selbst ein</Select.Item>
|
|
||||||
<Select.Item value="bestaetigung">
|
|
||||||
Mit Bestätigung — Anmeldung, du bestätigst
|
|
||||||
</Select.Item>
|
|
||||||
<Select.Item value="geschlossen">
|
|
||||||
Geschlossen — du trägst Teilnehmer ein
|
|
||||||
</Select.Item>
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if formError}
|
{#if formError}
|
||||||
<p class="text-sm text-destructive">{formError}</p>
|
<p class="text-sm text-destructive">{formError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -449,86 +218,3 @@
|
||||||
</form>
|
</form>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|
||||||
<!-- Serie -->
|
|
||||||
<Dialog.Root bind:open={seriesDialog}>
|
|
||||||
<Dialog.Content>
|
|
||||||
<Dialog.Header>
|
|
||||||
<Dialog.Title>{seriesEditing ? 'Serie bearbeiten' : 'Neue Serie'}</Dialog.Title>
|
|
||||||
<Dialog.Description>
|
|
||||||
Die Serie ist die Vorlage. Termine entstehen daraus als eigene Events —
|
|
||||||
Änderungen hier gelten für künftige, nicht für bereits angelegte.
|
|
||||||
</Dialog.Description>
|
|
||||||
</Dialog.Header>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="series-name">Name</Label>
|
|
||||||
<Input id="series-name" bind:value={seriesForm.name} placeholder="Stammtisch" />
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="series-location">Ort</Label>
|
|
||||||
<Input id="series-location" bind:value={seriesForm.location} placeholder="Hersbruck" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>Rhythmus</Label>
|
|
||||||
<Select.Root type="single" bind:value={seriesForm.rule}>
|
|
||||||
<Select.Trigger class="w-full">
|
|
||||||
{SERIES_RULE_LABEL[seriesForm.rule] ?? 'wöchentlich'}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
<Select.Item value="woechentlich">wöchentlich</Select.Item>
|
|
||||||
<Select.Item value="zweiwoechentlich">alle zwei Wochen</Select.Item>
|
|
||||||
<Select.Item value="monatlich">monatlich</Select.Item>
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="series-starts">Erster Termin</Label>
|
|
||||||
<Input id="series-starts" type="date" bind:value={seriesForm.starts} />
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="series-until">Bis (optional)</Label>
|
|
||||||
<Input id="series-until" type="date" bind:value={seriesForm.until} min={seriesForm.starts} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="flex items-center gap-2 text-sm">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
bind:checked={seriesForm.timing}
|
|
||||||
class="size-4 rounded border-input accent-primary"
|
|
||||||
/>
|
|
||||||
mit Zeitnahme
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>Teilnahme</Label>
|
|
||||||
<Select.Root type="single" bind:value={seriesForm.participation}>
|
|
||||||
<Select.Trigger class="w-full">
|
|
||||||
{PARTICIPATION_LABEL[seriesForm.participation] ?? 'Offen'}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
<Select.Item value="offen">Offen</Select.Item>
|
|
||||||
<Select.Item value="bestaetigung">Mit Bestätigung</Select.Item>
|
|
||||||
<Select.Item value="geschlossen">Geschlossen</Select.Item>
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if seriesError}
|
|
||||||
<p class="text-sm text-destructive">{seriesError}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog.Footer>
|
|
||||||
<Button variant="ghost" onclick={() => (seriesDialog = false)}>Abbrechen</Button>
|
|
||||||
<Button onclick={saveSeries} disabled={seriesBusy}>
|
|
||||||
{seriesBusy ? 'Wird gespeichert …' : 'Speichern'}
|
|
||||||
</Button>
|
|
||||||
</Dialog.Footer>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Root>
|
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,7 @@
|
||||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
|
||||||
import {
|
|
||||||
getEventParticipantContext,
|
|
||||||
PARTICIPANT_STATUS_LABEL,
|
|
||||||
type ParticipantStatus,
|
|
||||||
} from '$lib/stores/eventParticipants.svelte'
|
|
||||||
import { auth } from '$lib/stores/pocketbase.svelte'
|
|
||||||
import TimeDialog from '$lib/components/TimeDialog.svelte'
|
import TimeDialog from '$lib/components/TimeDialog.svelte'
|
||||||
import * as Select from '@/components/ui/select'
|
|
||||||
import { formatDateRange } from '$lib/time'
|
import { formatDateRange } from '$lib/time'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
|
@ -32,39 +24,18 @@
|
||||||
Timer,
|
Timer,
|
||||||
Trash2,
|
Trash2,
|
||||||
Users,
|
Users,
|
||||||
UserPlus,
|
|
||||||
Check,
|
|
||||||
X,
|
|
||||||
} from 'lucide-svelte'
|
} from 'lucide-svelte'
|
||||||
import type { EventParticipantsResponse, RunsResponse } from '$lib/types'
|
import type { RidersResponse, RunsResponse } from '$lib/types'
|
||||||
|
|
||||||
const events = getEventContext()
|
const events = getEventContext()
|
||||||
const runs = getRunContext()
|
const runs = getRunContext()
|
||||||
const riders = getRiderContext()
|
const riders = getRiderContext()
|
||||||
const times = getTimeContext()
|
const times = getTimeContext()
|
||||||
const teams = getTeamContext()
|
|
||||||
const participants = getEventParticipantContext()
|
|
||||||
|
|
||||||
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
|
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
|
||||||
const eventParticipants = $derived(event ? participants.byEvent(event.id) : [])
|
const eventRiders = $derived(event ? riders.getByEvent(event.id) : [])
|
||||||
const eventRuns = $derived(event ? runs.getByEvent(event.id) : [])
|
const eventRuns = $derived(event ? runs.getByEvent(event.id) : [])
|
||||||
|
|
||||||
/**
|
|
||||||
* Teilnehmer verwaltet, wer das Event angelegt hat, und die Teamleitung —
|
|
||||||
* dieselbe Regel wie in der Collection.
|
|
||||||
*/
|
|
||||||
const canManage = $derived(
|
|
||||||
!!event && (event.created_by === auth.user?.id || teams.isAdmin()),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Eigene Teilnahme, sofern das Konto an einem Fahrer hängt. */
|
|
||||||
const ownEntry = $derived(event ? participants.own(event.id) : undefined)
|
|
||||||
const ownRider = $derived(riders.getByUser(auth.user?.id ?? '')[0])
|
|
||||||
|
|
||||||
const canSignUp = $derived(
|
|
||||||
!!event && !!ownRider && !ownEntry && event.participation !== 'geschlossen',
|
|
||||||
)
|
|
||||||
|
|
||||||
// Zeitnahme und Ergebnisse hängen jetzt am jeweiligen Run bzw. Fahrer.
|
// Zeitnahme und Ergebnisse hängen jetzt am jeweiligen Run bzw. Fahrer.
|
||||||
// Die Übersicht zeigt deshalb pro Zeile, was dort schon passiert ist.
|
// Die Übersicht zeigt deshalb pro Zeile, was dort schon passiert ist.
|
||||||
function riderPath(riderId: string) {
|
function riderPath(riderId: string) {
|
||||||
|
|
@ -78,87 +49,42 @@
|
||||||
// Zeit-Dialog: der einzige Weg, eine Zeit zu starten oder zu stoppen.
|
// Zeit-Dialog: der einzige Weg, eine Zeit zu starten oder zu stoppen.
|
||||||
let timeDialog = $state(false)
|
let timeDialog = $state(false)
|
||||||
|
|
||||||
// --- Teilnehmer -------------------------------------------------------
|
// Fahrer-Dialog
|
||||||
let riderDialog = $state(false)
|
let riderDialog = $state(false)
|
||||||
let editing = $state<EventParticipantsResponse | null>(null)
|
let ridingEdit = $state<RidersResponse | null>(null)
|
||||||
let form = $state({ rider: '', number: '' })
|
let riderForm = $state({ number: '', firstname: '', lastname: '' })
|
||||||
let formError = $state<string | null>(null)
|
|
||||||
|
|
||||||
const available = $derived(event ? participants.available(event.id) : [])
|
function openRiderCreate() {
|
||||||
|
ridingEdit = null
|
||||||
function openAdd() {
|
riderForm = { number: '', firstname: '', lastname: '' }
|
||||||
editing = null
|
|
||||||
form = { rider: available[0]?.id ?? '', number: '' }
|
|
||||||
formError = null
|
|
||||||
riderDialog = true
|
riderDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(p: EventParticipantsResponse) {
|
function openRiderEdit(r: RidersResponse) {
|
||||||
editing = p
|
ridingEdit = r
|
||||||
form = { rider: p.rider, number: p.number ?? '' }
|
riderForm = {
|
||||||
formError = null
|
number: r.number ?? '',
|
||||||
|
firstname: r.firstname ?? '',
|
||||||
|
lastname: r.lastname ?? '',
|
||||||
|
}
|
||||||
riderDialog = true
|
riderDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveParticipant() {
|
async function saveRider() {
|
||||||
if (!event) return
|
if (!event) return
|
||||||
|
if (!riderForm.number.trim()) return alert('Startnummer ist Pflicht')
|
||||||
if (!form.rider) {
|
if (riders.isNumberTaken(riderForm.number, event.id, ridingEdit?.id)) {
|
||||||
formError = 'Bitte einen Fahrer wählen.'
|
return alert(`Startnummer ${riderForm.number} ist in diesem Event bereits vergeben`)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (participants.isNumberTaken(event.id, form.number, editing?.id)) {
|
|
||||||
formError = `Startnummer ${form.number} ist in diesem Event bereits vergeben.`
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
formError = null
|
|
||||||
try {
|
try {
|
||||||
if (editing) {
|
if (ridingEdit) {
|
||||||
await participants.edit(editing.id, { number: form.number.trim() })
|
await riders.edit(ridingEdit.id, riderForm)
|
||||||
} else {
|
} else {
|
||||||
await participants.add({
|
await riders.create({ ...riderForm, event: event.id })
|
||||||
event: event.id,
|
|
||||||
rider: form.rider,
|
|
||||||
number: form.number.trim(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
riderDialog = false
|
riderDialog = false
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
formError = e.message ?? 'Der Teilnehmer konnte nicht gespeichert werden.'
|
alert(`Fehler: ${e.message}`)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setStatus(p: EventParticipantsResponse, status: ParticipantStatus) {
|
|
||||||
try {
|
|
||||||
await participants.setStatus(p.id, status)
|
|
||||||
} catch (e: any) {
|
|
||||||
alert(e.message ?? 'Der Status konnte nicht geändert werden.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Selbst eintragen — im Bestätigungsmodus zunächst nur als Anfrage. */
|
|
||||||
async function signUp() {
|
|
||||||
if (!event || !ownRider) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await participants.add({
|
|
||||||
event: event.id,
|
|
||||||
rider: ownRider.id,
|
|
||||||
status: event.participation === 'bestaetigung' ? 'angefragt' : 'zugesagt',
|
|
||||||
})
|
|
||||||
} catch (e: any) {
|
|
||||||
alert(e.message ?? 'Die Anmeldung ist fehlgeschlagen.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeParticipant(p: EventParticipantsResponse) {
|
|
||||||
if (!confirm('Teilnehmer wirklich entfernen? Seine Zeiten bleiben erhalten.')) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await participants.remove(p.id)
|
|
||||||
} catch (e: any) {
|
|
||||||
alert(e.message ?? 'Der Teilnehmer konnte nicht entfernt werden.')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,7 +162,7 @@
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onclick={() => (timeDialog = true)}
|
onclick={() => (timeDialog = true)}
|
||||||
disabled={eventParticipants.length === 0 || eventRuns.length === 0}
|
disabled={eventRiders.length === 0 || eventRuns.length === 0}
|
||||||
>
|
>
|
||||||
<Timer class="h-4 w-4 mr-2" />
|
<Timer class="h-4 w-4 mr-2" />
|
||||||
Zeit erfassen
|
Zeit erfassen
|
||||||
|
|
@ -245,58 +171,42 @@
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<!-- Teilnehmer -->
|
<!-- Fahrer -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold flex items-center gap-2">
|
<h2 class="text-xl font-semibold flex items-center gap-2">
|
||||||
<Users class="h-5 w-5" />
|
<Users class="h-5 w-5" />
|
||||||
Teilnehmer
|
Fahrer
|
||||||
<span class="text-muted-foreground font-normal text-base">({eventParticipants.length})</span>
|
<span class="text-muted-foreground font-normal text-base">({eventRiders.length})</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">Klick auf einen Fahrer zeigt seine Runs</p>
|
||||||
{#if event.participation === 'offen'}
|
|
||||||
Jedes Teammitglied kann sich selbst eintragen.
|
|
||||||
{:else if event.participation === 'bestaetigung'}
|
|
||||||
Anmeldungen werden vom Ersteller oder der Teamleitung bestätigt.
|
|
||||||
{:else}
|
|
||||||
Teilnehmer trägt der Ersteller oder die Teamleitung ein.
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
{#if canSignUp}
|
|
||||||
<Button variant="outline" onclick={signUp}>
|
|
||||||
<UserPlus class="h-4 w-4 mr-2" />
|
|
||||||
{event.participation === 'bestaetigung' ? 'Anfragen' : 'Ich bin dabei'}
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
{#if canManage}
|
|
||||||
<Button onclick={openAdd} disabled={available.length === 0}>
|
|
||||||
<Plus class="h-4 w-4 mr-2" />
|
|
||||||
Teilnehmer
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
{#if riders.canManage}
|
||||||
|
<Button onclick={openRiderCreate}>
|
||||||
|
<Plus class="h-4 w-4 mr-2" />
|
||||||
|
Fahrer hinzufügen
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if canManage && available.length === 0 && riders.scoped.length === 0}
|
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
Das Team hat noch keine Fahrer.
|
|
||||||
<a href="/dashboard/settings/riders" class="underline">Jetzt anlegen</a>
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent class="p-0">
|
<CardContent class="p-0">
|
||||||
{#if eventParticipants.length === 0}
|
{#if eventRiders.length === 0}
|
||||||
<div class="py-12 text-center">
|
<div class="py-12 text-center">
|
||||||
<Users class="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
<Users class="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
||||||
<p class="font-medium">Noch niemand dabei</p>
|
<p class="font-medium">Noch keine Fahrer</p>
|
||||||
<p class="text-sm text-muted-foreground mt-1">
|
{#if riders.canManage}
|
||||||
Fahrer gehören dem Team; hier steht, wer bei diesem Event
|
<p class="text-sm text-muted-foreground mt-1 mb-4">Füge den ersten Fahrer hinzu.</p>
|
||||||
mitfährt.
|
<Button onclick={openRiderCreate}>
|
||||||
</p>
|
<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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
|
|
@ -304,102 +214,38 @@
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Head class="w-20">#</Table.Head>
|
<Table.Head class="w-20">#</Table.Head>
|
||||||
<Table.Head>Name</Table.Head>
|
<Table.Head>Name</Table.Head>
|
||||||
<Table.Head class="w-32">Status</Table.Head>
|
<Table.Head class="w-40">Zeiten</Table.Head>
|
||||||
{#if event.timing}
|
|
||||||
<Table.Head class="w-40">Zeiten</Table.Head>
|
|
||||||
{/if}
|
|
||||||
<Table.Head class="text-right">Aktionen</Table.Head>
|
<Table.Head class="text-right">Aktionen</Table.Head>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each eventParticipants as p (p.id)}
|
{#each eventRiders as rider (rider.id)}
|
||||||
{@const rider = riders.getById(p.rider)}
|
{@const done = times.getByRider(rider.id).filter((t) => t.status === 'finished').length}
|
||||||
{@const done = times.getByRider(p.rider).filter((t) => t.status === 'finished').length}
|
<Table.Row class="cursor-pointer" onclick={() => goto(riderPath(rider.id))}>
|
||||||
<Table.Row class="cursor-pointer" onclick={() => goto(riderPath(p.rider))}>
|
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Badge variant="outline">{p.number || '—'}</Badge>
|
<Badge variant="outline">{rider.number || '—'}</Badge>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="font-medium">
|
<Table.Cell class="font-medium">
|
||||||
<!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. -->
|
<!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. -->
|
||||||
<a href={riderPath(p.rider)} class="hover:underline">
|
<a href={riderPath(rider.id)} class="hover:underline">
|
||||||
{#if rider && fullName(rider)}
|
{#if fullName(rider)}
|
||||||
{fullName(rider)}
|
{fullName(rider)}
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-muted-foreground italic">— ohne Name —</span>
|
<span class="text-muted-foreground italic">— ohne Name —</span>
|
||||||
{/if}
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell class="text-muted-foreground text-sm">
|
||||||
<Badge
|
{done} von {eventRuns.length} Runs
|
||||||
variant={p.status === 'zugesagt'
|
|
||||||
? 'default'
|
|
||||||
: p.status === 'angefragt'
|
|
||||||
? 'secondary'
|
|
||||||
: 'outline'}
|
|
||||||
>
|
|
||||||
{PARTICIPANT_STATUS_LABEL[p.status] ?? p.status}
|
|
||||||
</Badge>
|
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
{#if event.timing}
|
<Table.Cell class="text-right" onclick={(e) => e.stopPropagation()}>
|
||||||
<Table.Cell class="text-muted-foreground text-sm">
|
{#if riders.canManage}
|
||||||
{done} von {eventRuns.length} Runs
|
<Button variant="ghost" size="icon" onclick={() => openRiderEdit(rider)}>
|
||||||
</Table.Cell>
|
|
||||||
{/if}
|
|
||||||
<Table.Cell class="text-right whitespace-nowrap" onclick={(e) => e.stopPropagation()}>
|
|
||||||
{#if canManage}
|
|
||||||
{#if p.status === 'angefragt'}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
tooltip="Bestätigen"
|
|
||||||
onclick={() => setStatus(p, 'zugesagt')}
|
|
||||||
>
|
|
||||||
<Check class="h-4 w-4 text-primary" />
|
|
||||||
</Button>
|
|
||||||
{:else if p.status === 'zugesagt'}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
tooltip="Als abgesagt markieren"
|
|
||||||
onclick={() => setStatus(p, 'abgesagt')}
|
|
||||||
>
|
|
||||||
<X class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
{:else}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
tooltip="Wieder zusagen"
|
|
||||||
onclick={() => setStatus(p, 'zugesagt')}
|
|
||||||
>
|
|
||||||
<Check class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
tooltip="Startnummer ändern"
|
|
||||||
onclick={() => openEdit(p)}
|
|
||||||
>
|
|
||||||
<Pencil class="h-4 w-4" />
|
<Pencil class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="ghost" size="icon" onclick={() => riders.remove(rider)}>
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
tooltip="Entfernen"
|
|
||||||
onclick={() => removeParticipant(p)}
|
|
||||||
>
|
|
||||||
<Trash2 class="h-4 w-4 text-destructive" />
|
<Trash2 class="h-4 w-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
{:else if p.id === ownEntry?.id}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onclick={() =>
|
|
||||||
setStatus(p, p.status === 'abgesagt' ? 'zugesagt' : 'abgesagt')}
|
|
||||||
>
|
|
||||||
{p.status === 'abgesagt' ? 'Doch dabei' : 'Absagen'}
|
|
||||||
</Button>
|
|
||||||
{/if}
|
{/if}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
|
|
@ -411,8 +257,9 @@
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Runs — nur bei Events mit Zeitnahme -->
|
<Separator />
|
||||||
{#if event.timing}
|
|
||||||
|
<!-- Runs -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -481,61 +328,44 @@
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Teilnehmer -->
|
<!-- Fahrer-Dialog -->
|
||||||
<Dialog.Root bind:open={riderDialog}>
|
<Dialog.Root bind:open={riderDialog}>
|
||||||
<Dialog.Content>
|
<Dialog.Content>
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>{editing ? 'Startnummer ändern' : 'Teilnehmer hinzufügen'}</Dialog.Title>
|
<Dialog.Title>{ridingEdit ? 'Fahrer bearbeiten' : 'Neuer Fahrer'}</Dialog.Title>
|
||||||
<Dialog.Description>
|
<Dialog.Description>
|
||||||
{#if editing}
|
Fahrer für „{event?.name}"
|
||||||
Die Startnummer gilt nur für „{event?.name}".
|
|
||||||
{:else}
|
|
||||||
Aus dem Kader des Teams. Neue Fahrer legt die Teamleitung in den
|
|
||||||
Einstellungen an.
|
|
||||||
{/if}
|
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
<form
|
||||||
<div class="space-y-4">
|
onsubmit={(e) => {
|
||||||
{#if !editing}
|
e.preventDefault()
|
||||||
<div class="space-y-2">
|
saveRider()
|
||||||
<Label>Fahrer</Label>
|
}}
|
||||||
<Select.Root type="single" bind:value={form.rider}>
|
class="space-y-4"
|
||||||
<Select.Trigger class="w-full">
|
>
|
||||||
{form.rider
|
|
||||||
? fullName(riders.getById(form.rider) ?? ({} as any)) || 'Fahrer wählen…'
|
|
||||||
: 'Fahrer wählen…'}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each available as r (r.id)}
|
|
||||||
<Select.Item value={r.id}>{fullName(r) || '— ohne Name —'}</Select.Item>
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="p-number">Startnummer</Label>
|
<Label for="r-number">Startnummer *</Label>
|
||||||
<Input id="p-number" bind:value={form.number} placeholder="optional" />
|
<Input id="r-number" bind:value={riderForm.number} placeholder="42" required />
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
Gilt je Event. Ohne Zeitnahme braucht es sie meist gar nicht.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
{#if formError}
|
<div class="space-y-2">
|
||||||
<p class="text-sm text-destructive">{formError}</p>
|
<Label for="r-first">Vorname</Label>
|
||||||
{/if}
|
<Input id="r-first" bind:value={riderForm.firstname} placeholder="Max" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
<Dialog.Footer>
|
<Label for="r-last">Nachname</Label>
|
||||||
<Button variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button>
|
<Input id="r-last" bind:value={riderForm.lastname} placeholder="Mustermann" />
|
||||||
<Button onclick={saveParticipant}>{editing ? 'Speichern' : 'Hinzufügen'}</Button>
|
</div>
|
||||||
</Dialog.Footer>
|
</div>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button type="button" variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button>
|
||||||
|
<Button type="submit">{ridingEdit ? 'Speichern' : 'Hinzufügen'}</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</form>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
|
||||||
import { ticker } from '$lib/stores/ticker.svelte'
|
import { ticker } from '$lib/stores/ticker.svelte'
|
||||||
import { TIME_STATUS_LABEL, formatDuration } from '$lib/time'
|
import { TIME_STATUS_LABEL, formatDuration } from '$lib/time'
|
||||||
import TimeDialog from '$lib/components/TimeDialog.svelte'
|
import TimeDialog from '$lib/components/TimeDialog.svelte'
|
||||||
|
|
@ -25,16 +24,13 @@
|
||||||
const runs = getRunContext()
|
const runs = getRunContext()
|
||||||
const riders = getRiderContext()
|
const riders = getRiderContext()
|
||||||
const times = getTimeContext()
|
const times = getTimeContext()
|
||||||
const participants = getEventParticipantContext()
|
|
||||||
|
|
||||||
const clock = ticker()
|
const clock = ticker()
|
||||||
|
|
||||||
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
|
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
|
||||||
const rider = $derived(page.params.riderId ? riders.getById(page.params.riderId) : undefined)
|
const rider = $derived(page.params.riderId ? riders.getById(page.params.riderId) : undefined)
|
||||||
|
|
||||||
// Gültig ist die Adresse, wenn der Fahrer bei diesem Event dabei ist.
|
const valid = $derived(!!event && !!rider && rider.event === event.id)
|
||||||
const entry = $derived(event && rider ? participants.get(event.id, rider.id) : undefined)
|
|
||||||
const valid = $derived(!!event && !!rider && !!entry)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Eine Zeile je Run des Events — auch für Runs ohne Zeit, damit sichtbar
|
* Eine Zeile je Run des Events — auch für Runs ohne Zeit, damit sichtbar
|
||||||
|
|
@ -113,9 +109,7 @@
|
||||||
{event.name || 'Event'}
|
{event.name || 'Event'}
|
||||||
</a>
|
</a>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
{#if entry?.number}
|
<Badge variant="outline" class="text-lg px-3 py-1">{rider.number || '—'}</Badge>
|
||||||
<Badge variant="outline" class="text-lg px-3 py-1">{entry.number}</Badge>
|
|
||||||
{/if}
|
|
||||||
<h1 class="text-3xl font-bold tracking-tight">
|
<h1 class="text-3xl font-bold tracking-tight">
|
||||||
{fullName(rider) || 'Ohne Name'}
|
{fullName(rider) || 'Ohne Name'}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@
|
||||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
|
||||||
import { ticker } from '$lib/stores/ticker.svelte'
|
import { ticker } from '$lib/stores/ticker.svelte'
|
||||||
import { TIME_STATUS_LABEL } from '$lib/time'
|
import { TIME_STATUS_LABEL } from '$lib/time'
|
||||||
import TimeDialog from '$lib/components/TimeDialog.svelte'
|
import TimeDialog from '$lib/components/TimeDialog.svelte'
|
||||||
|
|
@ -25,7 +24,6 @@
|
||||||
const runs = getRunContext()
|
const runs = getRunContext()
|
||||||
const riders = getRiderContext()
|
const riders = getRiderContext()
|
||||||
const times = getTimeContext()
|
const times = getTimeContext()
|
||||||
const participants = getEventParticipantContext()
|
|
||||||
|
|
||||||
const clock = ticker()
|
const clock = ticker()
|
||||||
|
|
||||||
|
|
@ -36,8 +34,7 @@
|
||||||
// sie von Hand zusammengebaut oder veraltet.
|
// sie von Hand zusammengebaut oder veraltet.
|
||||||
const valid = $derived(!!event && !!run && run.event === event.id)
|
const valid = $derived(!!event && !!run && run.event === event.id)
|
||||||
|
|
||||||
// Am Start steht, wer beim Event zugesagt hat.
|
const eventRiders = $derived(event ? riders.getByEvent(event.id) : [])
|
||||||
const starters = $derived(event ? participants.confirmedByEvent(event.id) : [])
|
|
||||||
const running = $derived(valid && run ? times.runningByRun(run.id, clock.now) : [])
|
const running = $derived(valid && run ? times.runningByRun(run.id, clock.now) : [])
|
||||||
const leaderboard = $derived(valid && run ? times.getLeaderboard(run.id) : [])
|
const leaderboard = $derived(valid && run ? times.getLeaderboard(run.id) : [])
|
||||||
const unranked = $derived(valid && run ? times.unrankedByRun(run.id) : [])
|
const unranked = $derived(valid && run ? times.unrankedByRun(run.id) : [])
|
||||||
|
|
@ -74,7 +71,7 @@
|
||||||
const r = rider(t.rider)
|
const r = rider(t.rider)
|
||||||
rows.push([
|
rows.push([
|
||||||
String(i + 1),
|
String(i + 1),
|
||||||
event ? participants.numberOf(event.id, t.rider) : '',
|
r?.number ?? '',
|
||||||
r ? fullName(r) : '',
|
r ? fullName(r) : '',
|
||||||
t.formattedTime ?? '',
|
t.formattedTime ?? '',
|
||||||
String(t.correction ?? 0),
|
String(t.correction ?? 0),
|
||||||
|
|
@ -122,7 +119,7 @@
|
||||||
</a>
|
</a>
|
||||||
<h1 class="text-3xl font-bold tracking-tight">{run.description || 'Run'}</h1>
|
<h1 class="text-3xl font-bold tracking-tight">{run.description || 'Run'}</h1>
|
||||||
<p class="mt-1 text-muted-foreground">
|
<p class="mt-1 text-muted-foreground">
|
||||||
{running.length} laufend · {leaderboard.length} gewertet · {starters.length} am Start
|
{running.length} laufend · {leaderboard.length} gewertet · {eventRiders.length} Fahrer im Event
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -133,18 +130,18 @@
|
||||||
CSV
|
CSV
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
<Button onclick={() => (timeDialog = true)} disabled={starters.length === 0}>
|
<Button onclick={() => (timeDialog = true)} disabled={eventRiders.length === 0}>
|
||||||
<Timer class="h-4 w-4 mr-2" />
|
<Timer class="h-4 w-4 mr-2" />
|
||||||
Zeit erfassen
|
Zeit erfassen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if starters.length === 0}
|
{#if eventRiders.length === 0}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent class="py-6 text-sm text-muted-foreground">
|
<CardContent class="py-6 text-sm text-muted-foreground">
|
||||||
Für dieses Event hat noch niemand zugesagt.
|
Für dieses Event gibt es noch keine Fahrer.
|
||||||
<a href="/dashboard/events/{event.id}" class="underline">Teilnehmer eintragen</a>
|
<a href="/dashboard/events/{event.id}" class="underline">Jetzt anlegen</a>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -189,9 +186,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">{r?.number ?? '—'}</Badge>
|
||||||
{participants.numberOf(event.id, t.rider) || '—'}
|
|
||||||
</Badge>
|
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="font-medium">
|
<Table.Cell class="font-medium">
|
||||||
{#if r}
|
{#if r}
|
||||||
|
|
@ -228,9 +223,7 @@
|
||||||
{@const r = rider(t.rider)}
|
{@const r = rider(t.rider)}
|
||||||
<li class="px-6 py-3 flex items-center justify-between gap-4">
|
<li class="px-6 py-3 flex items-center justify-between gap-4">
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">{r?.number ?? '—'}</Badge>
|
||||||
{participants.numberOf(event.id, t.rider) || '—'}
|
|
||||||
</Badge>
|
|
||||||
<span class="truncate">
|
<span class="truncate">
|
||||||
{#if r}
|
{#if r}
|
||||||
<a href={riderPath(r.id)} class="hover:underline">{fullName(r) || '—'}</a>
|
<a href={riderPath(r.id)} class="hover:underline">{fullName(r) || '—'}</a>
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@
|
||||||
*/
|
*/
|
||||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||||
|
import { getEventContext } from '$lib/stores/events.svelte'
|
||||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
@ -18,8 +18,8 @@
|
||||||
|
|
||||||
const teams = getTeamContext()
|
const teams = getTeamContext()
|
||||||
const riders = getRiderContext()
|
const riders = getRiderContext()
|
||||||
|
const events = getEventContext()
|
||||||
const times = getTimeContext()
|
const times = getTimeContext()
|
||||||
const participants = getEventParticipantContext()
|
|
||||||
|
|
||||||
let query = $state('')
|
let query = $state('')
|
||||||
|
|
||||||
|
|
@ -27,20 +27,25 @@
|
||||||
const q = query.toLowerCase().trim()
|
const q = query.toLowerCase().trim()
|
||||||
if (!q) return riders.scoped
|
if (!q) return riders.scoped
|
||||||
|
|
||||||
return riders.scoped.filter((r) => fullName(r).toLowerCase().includes(q))
|
return riders.scoped.filter(
|
||||||
|
(r) =>
|
||||||
|
(r.number ?? '').toLowerCase().includes(q) ||
|
||||||
|
fullName(r).toLowerCase().includes(q),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
teams.loadMembers()
|
teams.loadMembers()
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
function eventName(id: string | undefined) {
|
||||||
* Zur Fahrerseite geht es über ein Event, denn Zeiten hängen am Run und
|
if (!id) return null
|
||||||
* der am Event. Bei mehreren Teilnahmen führt der Link zur jüngsten.
|
return events.getById(id)?.name ?? 'Unbekanntes Event'
|
||||||
*/
|
}
|
||||||
function riderPath(riderId: string) {
|
|
||||||
const latest = participants.byRider(riderId).at(-1)
|
/** Zur Fahrerseite geht es nur mit Event — Zeiten hängen daran. */
|
||||||
return latest ? `/dashboard/events/${latest.event}/riders/${riderId}` : null
|
function riderPath(r: { id: string; event?: string }) {
|
||||||
|
return r.event ? `/dashboard/events/${r.event}/riders/${r.id}` : null
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -64,7 +69,7 @@
|
||||||
|
|
||||||
<div class="relative max-w-sm">
|
<div class="relative max-w-sm">
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
<Search class="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||||
<Input bind:value={query} placeholder="Name…" class="pl-9" />
|
<Input bind:value={query} placeholder="Nummer oder Name…" class="pl-9" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if riders.scoped.length === 0}
|
{#if riders.scoped.length === 0}
|
||||||
|
|
@ -91,16 +96,20 @@
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
|
<Table.Head class="w-20">#</Table.Head>
|
||||||
<Table.Head>Name</Table.Head>
|
<Table.Head>Name</Table.Head>
|
||||||
<Table.Head class="w-28">Events</Table.Head>
|
<Table.Head>Event</Table.Head>
|
||||||
<Table.Head class="text-right w-24">Zeiten</Table.Head>
|
<Table.Head class="text-right w-24">Zeiten</Table.Head>
|
||||||
<Table.Head class="w-48">Konto</Table.Head>
|
<Table.Head class="w-48">Konto</Table.Head>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each roster as r (r.id)}
|
{#each roster as r (r.id)}
|
||||||
{@const path = riderPath(r.id)}
|
{@const path = riderPath(r)}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
|
<Table.Cell>
|
||||||
|
<Badge variant="outline">{r.number || '—'}</Badge>
|
||||||
|
</Table.Cell>
|
||||||
<Table.Cell class="font-medium">
|
<Table.Cell class="font-medium">
|
||||||
{#if path}
|
{#if path}
|
||||||
<a href={path} class="hover:underline">{fullName(r) || '— ohne Name —'}</a>
|
<a href={path} class="hover:underline">{fullName(r) || '— ohne Name —'}</a>
|
||||||
|
|
@ -109,7 +118,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="text-muted-foreground text-sm">
|
<Table.Cell class="text-muted-foreground text-sm">
|
||||||
{participants.byRider(r.id).length}
|
{eventName(r.event) ?? '—'}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="text-right text-muted-foreground text-sm">
|
<Table.Cell class="text-right text-muted-foreground text-sm">
|
||||||
{times.getByRider(r.id).length}
|
{times.getByRider(r.id).length}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
/**
|
||||||
* Rahmen der Einstellungen: eine Überschrift, darunter Reiter je Bereich.
|
* Rahmen der Einstellungen: eine Überschrift, darunter Reiter je Bereich.
|
||||||
*
|
* Der Reiter „Fahrer" erscheint nur für die Teamleitung — dort wird
|
||||||
* „Team" ist die Übersicht des Teams, „Fahrer & Konten" führt die
|
* angelegt und verknüpft, was alle anderen nur unter „Fahrer" im
|
||||||
* Menschen: Jedes Mitglied ist ein Fahrer, ein Konto hat aber nicht jeder.
|
* Hauptmenü zu sehen bekommen.
|
||||||
* Beides an einer Stelle, weil es sonst zwei Wahrheiten über dieselbe
|
|
||||||
* Person gäbe. Der Reiter erscheint nur für die Teamleitung.
|
|
||||||
*/
|
*/
|
||||||
import { page } from '$app/state'
|
import { page } from '$app/state'
|
||||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||||
|
|
@ -19,7 +17,7 @@
|
||||||
{ href: '/dashboard/settings', label: 'Profil', icon: User },
|
{ href: '/dashboard/settings', label: 'Profil', icon: User },
|
||||||
{ href: '/dashboard/settings/team', label: 'Team', icon: Users },
|
{ href: '/dashboard/settings/team', label: 'Team', icon: Users },
|
||||||
...(teams.isAdmin()
|
...(teams.isAdmin()
|
||||||
? [{ href: '/dashboard/settings/riders', label: 'Fahrer & Konten', icon: Bike }]
|
? [{ href: '/dashboard/settings/riders', label: 'Fahrer', icon: Bike }]
|
||||||
: []),
|
: []),
|
||||||
{ href: '/dashboard/settings/flags', label: 'Flag-Typen', icon: Flag },
|
{ href: '/dashboard/settings/flags', label: 'Flag-Typen', icon: Flag },
|
||||||
])
|
])
|
||||||
|
|
|
||||||
|
|
@ -1,269 +1,162 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
/**
|
||||||
* Fahrer und Konten an einer Stelle — nur für die Teamleitung.
|
* Fahrerverwaltung — nur für die Teamleitung. Was alle sehen dürfen,
|
||||||
*
|
* steht unter „Fahrer" im Hauptmenü; angelegt, geändert und mit einem
|
||||||
* Jedes Teammitglied ist ein Fahrer; ein Fahrer braucht aber kein Konto.
|
* Login verknüpft wird ausschließlich hier.
|
||||||
* Beides getrennt zu pflegen hieße, dieselben Menschen zweimal anzulegen
|
|
||||||
* und die Zuordnung von Hand zu halten. Die Liste zeigt deshalb den Kader
|
|
||||||
* und daneben, wer sich anmelden kann und mit welcher Rolle.
|
|
||||||
*/
|
*/
|
||||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
import { getEventContext } from '$lib/stores/events.svelte'
|
||||||
import Avatar from '$lib/components/Avatar.svelte'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import * as Dialog from '@/components/ui/dialog'
|
import * as Dialog from '@/components/ui/dialog'
|
||||||
|
import * as Select from '@/components/ui/select'
|
||||||
import * as Table from '@/components/ui/table'
|
import * as Table from '@/components/ui/table'
|
||||||
import {
|
import { KeyRound, Pencil, Plus, Trash2, Unlink, UserCheck } from 'lucide-svelte'
|
||||||
Crown, KeyRound, Mail, Pencil, Plus, Shield, Trash2, Unlink, UserMinus,
|
import type { RidersResponse } from '$lib/types'
|
||||||
} from 'lucide-svelte'
|
|
||||||
import type { RidersResponse, UsersResponse } from '$lib/types'
|
|
||||||
|
|
||||||
const teams = getTeamContext()
|
const teams = getTeamContext()
|
||||||
const riders = getRiderContext()
|
const riders = getRiderContext()
|
||||||
const participants = getEventParticipantContext()
|
const events = getEventContext()
|
||||||
|
|
||||||
const canManage = $derived(riders.canManage)
|
const canManage = $derived(riders.canManage)
|
||||||
const team = $derived(teams.active)
|
const roster = $derived(riders.scoped)
|
||||||
const iAmOwner = $derived(teams.isOwner())
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
teams.loadMembers()
|
teams.loadMembers()
|
||||||
})
|
})
|
||||||
|
|
||||||
type Row = {
|
function eventName(id: string | undefined) {
|
||||||
rider?: RidersResponse
|
if (!id) return null
|
||||||
userId?: string
|
return events.getById(id)?.name ?? 'Unbekanntes Event'
|
||||||
name: string
|
|
||||||
role: 'owner' | 'admin' | 'mitglied' | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function roleOf(userId: string | undefined): Row['role'] {
|
// --- Fahrer anlegen und ändern ---------------------------------------
|
||||||
if (!userId || !team) return null
|
let riderDialog = $state(false)
|
||||||
if (team.owner === userId) return 'owner'
|
|
||||||
return (team.admins ?? []).includes(userId) ? 'admin' : 'mitglied'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Eine Zeile je Person: alle Fahrer des Kaders, dazu Konten im Team, die
|
|
||||||
* noch an keinem Fahrer hängen — sonst verschwänden sie aus der Ansicht,
|
|
||||||
* obwohl sie Mitglied sind.
|
|
||||||
*/
|
|
||||||
const rows = $derived.by((): Row[] => {
|
|
||||||
const list: Row[] = riders.scoped.map((rider) => ({
|
|
||||||
rider,
|
|
||||||
userId: rider.user || undefined,
|
|
||||||
name: fullName(rider) || '— ohne Name —',
|
|
||||||
role: roleOf(rider.user || undefined),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const linked = new Set(riders.scoped.map((r) => r.user).filter(Boolean))
|
|
||||||
for (const userId of team?.users ?? []) {
|
|
||||||
if (linked.has(userId)) continue
|
|
||||||
list.push({ userId, name: teams.userLabel(userId), role: roleOf(userId) })
|
|
||||||
}
|
|
||||||
|
|
||||||
return list.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- Person anlegen ---------------------------------------------------
|
|
||||||
let personDialog = $state(false)
|
|
||||||
let editing = $state<RidersResponse | null>(null)
|
let editing = $state<RidersResponse | null>(null)
|
||||||
let form = $state({ firstname: '', lastname: '', withAccount: false, email: '', password: '' })
|
let form = $state({ number: '', firstname: '', lastname: '', event: '' })
|
||||||
let busy = $state(false)
|
let riderError = $state<string | null>(null)
|
||||||
let error = $state<string | null>(null)
|
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editing = null
|
editing = null
|
||||||
form = { firstname: '', lastname: '', withAccount: false, email: '', password: '' }
|
form = { number: '', firstname: '', lastname: '', event: '' }
|
||||||
error = null
|
riderError = null
|
||||||
personDialog = true
|
riderDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(rider: RidersResponse) {
|
function openEdit(r: RidersResponse) {
|
||||||
editing = rider
|
editing = r
|
||||||
form = {
|
form = {
|
||||||
firstname: rider.firstname ?? '',
|
number: r.number ?? '',
|
||||||
lastname: rider.lastname ?? '',
|
firstname: r.firstname ?? '',
|
||||||
withAccount: false,
|
lastname: r.lastname ?? '',
|
||||||
email: '',
|
event: r.event ?? '',
|
||||||
password: '',
|
|
||||||
}
|
}
|
||||||
error = null
|
riderError = null
|
||||||
personDialog = true
|
riderDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function savePerson() {
|
async function saveRider() {
|
||||||
if (!form.firstname.trim() && !form.lastname.trim()) {
|
if (!form.number.trim()) {
|
||||||
error = 'Bitte wenigstens einen Namen angeben.'
|
riderError = 'Die Startnummer ist Pflicht.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (form.withAccount) {
|
// Startnummern sind je Event eindeutig; Fahrer ohne Event teilen sich
|
||||||
if (!form.email.trim()) {
|
// den gemeinsamen Topf des Teams.
|
||||||
error = 'Für ein Konto braucht es eine E-Mail-Adresse.'
|
if (riders.isNumberTaken(form.number.trim(), form.event, editing?.id)) {
|
||||||
return
|
riderError = `Startnummer ${form.number} ist dort bereits vergeben.`
|
||||||
}
|
|
||||||
if (form.password.length < 8) {
|
|
||||||
error = 'Das Passwort braucht mindestens 8 Zeichen.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
busy = true
|
|
||||||
error = null
|
|
||||||
try {
|
|
||||||
const data = { firstname: form.firstname.trim(), lastname: form.lastname.trim() }
|
|
||||||
|
|
||||||
if (editing) {
|
|
||||||
await riders.edit(editing.id, data)
|
|
||||||
} else {
|
|
||||||
const rider = await riders.create(data)
|
|
||||||
if (form.withAccount) {
|
|
||||||
await riders.createLogin(rider as RidersResponse, {
|
|
||||||
email: form.email,
|
|
||||||
password: form.password,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
personDialog = false
|
|
||||||
} catch (e: any) {
|
|
||||||
error = e.message ?? 'Die Person konnte nicht gespeichert werden.'
|
|
||||||
} finally {
|
|
||||||
busy = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Konto zu einem vorhandenen Fahrer --------------------------------
|
|
||||||
let accountDialog = $state(false)
|
|
||||||
let accountRider = $state<RidersResponse | null>(null)
|
|
||||||
let accountMode = $state<'neu' | 'vorhanden'>('neu')
|
|
||||||
let accountForm = $state({ email: '', password: '' })
|
|
||||||
let searchEmail = $state('')
|
|
||||||
let searching = $state(false)
|
|
||||||
let found = $state<UsersResponse | null>(null)
|
|
||||||
let accountBusy = $state(false)
|
|
||||||
let accountError = $state<string | null>(null)
|
|
||||||
|
|
||||||
function openAccount(rider: RidersResponse) {
|
|
||||||
accountRider = rider
|
|
||||||
accountMode = 'neu'
|
|
||||||
accountForm = { email: '', password: '' }
|
|
||||||
searchEmail = ''
|
|
||||||
found = null
|
|
||||||
accountError = null
|
|
||||||
accountDialog = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createAccount() {
|
|
||||||
if (!accountRider) return
|
|
||||||
|
|
||||||
if (!accountForm.email.trim()) {
|
|
||||||
accountError = 'Ohne E-Mail-Adresse gibt es keinen Anmeldenamen.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (accountForm.password.length < 8) {
|
|
||||||
accountError = 'Das Passwort braucht mindestens 8 Zeichen.'
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
accountBusy = true
|
riderError = null
|
||||||
accountError = null
|
|
||||||
try {
|
try {
|
||||||
await riders.createLogin(accountRider, { ...accountForm })
|
const data = {
|
||||||
accountDialog = false
|
number: form.number.trim(),
|
||||||
} catch (e: any) {
|
firstname: form.firstname.trim(),
|
||||||
accountError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
|
lastname: form.lastname.trim(),
|
||||||
} finally {
|
event: form.event,
|
||||||
accountBusy = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function searchAccount() {
|
|
||||||
if (!searchEmail.trim()) return
|
|
||||||
|
|
||||||
searching = true
|
|
||||||
accountError = null
|
|
||||||
found = null
|
|
||||||
try {
|
|
||||||
const user = await teams.findUserByEmail(searchEmail.trim())
|
|
||||||
if (user) found = user
|
|
||||||
else accountError = `Kein Konto mit „${searchEmail}" gefunden.`
|
|
||||||
} catch (e: any) {
|
|
||||||
accountError = e.message ?? 'Die Suche ist fehlgeschlagen.'
|
|
||||||
} finally {
|
|
||||||
searching = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function linkFound() {
|
|
||||||
if (!accountRider || !found || !team) return
|
|
||||||
|
|
||||||
accountBusy = true
|
|
||||||
accountError = null
|
|
||||||
try {
|
|
||||||
// Erst ins Team, dann verknüpfen: Ein Konto, das nicht im Team
|
|
||||||
// ist, sähe in der Liste wie ein Mitglied aus, ohne eines zu sein.
|
|
||||||
if (!(team.users ?? []).includes(found.id)) {
|
|
||||||
await teams.addMember(team.id, found.id)
|
|
||||||
}
|
}
|
||||||
await riders.linkUser(accountRider.id, found.id)
|
if (editing) await riders.edit(editing.id, data)
|
||||||
accountDialog = false
|
else await riders.create(data)
|
||||||
|
riderDialog = false
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
accountError = e.message ?? 'Die Verknüpfung ist fehlgeschlagen.'
|
riderError = e.message ?? 'Der Fahrer konnte nicht gespeichert werden.'
|
||||||
} finally {
|
|
||||||
accountBusy = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unlink(rider: RidersResponse) {
|
// --- Login verknüpfen -------------------------------------------------
|
||||||
if (!confirm('Verknüpfung lösen? Das Konto bleibt bestehen, der Fahrer auch.')) return
|
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 {
|
try {
|
||||||
await riders.linkUser(rider.id, null)
|
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) {
|
} catch (e: any) {
|
||||||
alert(e.message ?? 'Die Verknüpfung konnte nicht gelöst werden.')
|
alert(e.message ?? 'Die Verknüpfung konnte nicht gelöst werden.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ein Konto ohne Fahrer nachträglich in den Kader holen. */
|
async function doCreateLogin() {
|
||||||
async function adoptAccount(userId: string) {
|
if (!loginRider) return
|
||||||
const user = teams.users[userId]
|
|
||||||
|
|
||||||
try {
|
if (!loginForm.email.trim()) {
|
||||||
const rider = await riders.create({
|
loginError = 'Ohne E-Mail-Adresse gibt es keinen Anmeldenamen.'
|
||||||
firstname: user?.name ?? '',
|
return
|
||||||
lastname: '',
|
|
||||||
})
|
|
||||||
await riders.linkUser(rider.id, userId)
|
|
||||||
} catch (e: any) {
|
|
||||||
alert(e.message ?? 'Der Fahrer konnte nicht angelegt werden.')
|
|
||||||
}
|
}
|
||||||
}
|
if (loginForm.password.length < 8) {
|
||||||
|
loginError = 'Das Passwort braucht mindestens 8 Zeichen.'
|
||||||
async function removeFromTeam(userId: string) {
|
return
|
||||||
if (!team) return
|
|
||||||
if (userId === team.owner) return alert('Der Owner kann nicht entfernt werden.')
|
|
||||||
if (!confirm('Konto aus dem Team entfernen? Der Fahrer bleibt im Kader.')) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await teams.removeMember(team.id, userId)
|
|
||||||
} catch (e: any) {
|
|
||||||
alert(e.message ?? 'Das Mitglied konnte nicht entfernt werden.')
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleAdmin(userId: string, makeAdmin: boolean) {
|
|
||||||
if (!team) return
|
|
||||||
|
|
||||||
|
loginBusy = true
|
||||||
|
loginError = null
|
||||||
try {
|
try {
|
||||||
if (makeAdmin) await teams.promoteToAdmin(team.id, userId)
|
await riders.createLogin(loginRider, { ...loginForm })
|
||||||
else await teams.demoteFromAdmin(team.id, userId)
|
loginDialog = false
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e.message ?? 'Die Rolle konnte nicht geändert werden.')
|
loginError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
|
||||||
|
} finally {
|
||||||
|
loginBusy = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -271,27 +164,27 @@
|
||||||
{#if !canManage}
|
{#if !canManage}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent class="py-12 text-center text-muted-foreground">
|
<CardContent class="py-12 text-center text-muted-foreground">
|
||||||
Fahrer und Konten verwaltet, wer das Team führt. Den Kader siehst du
|
Fahrer verwaltet, wer das Team führt. Die Fahrer deines Teams siehst du
|
||||||
unter <a href="/dashboard/riders" class="underline">Fahrer</a>.
|
unter <a href="/dashboard/riders" class="underline">Fahrer</a>.
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
Jedes Teammitglied ist ein Fahrer — ein Konto braucht es dafür nicht.
|
Fahrer des Teams. Ein verknüpftes Konto erlaubt der Person, sich selbst
|
||||||
Wer sich anmelden können soll, bekommt eins.
|
anzumelden.
|
||||||
</p>
|
</p>
|
||||||
<Button onclick={openCreate}>
|
<Button onclick={openCreate}>
|
||||||
<Plus class="size-4 mr-2" />
|
<Plus class="size-4 mr-2" />
|
||||||
Person anlegen
|
Fahrer anlegen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if rows.length === 0}
|
{#if roster.length === 0}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent class="py-12 text-center text-muted-foreground">
|
<CardContent class="py-12 text-center text-muted-foreground">
|
||||||
Noch niemand im Team.
|
Noch keine Fahrer im Team.
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -300,129 +193,56 @@
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
|
<Table.Head class="w-20">#</Table.Head>
|
||||||
<Table.Head>Name</Table.Head>
|
<Table.Head>Name</Table.Head>
|
||||||
<Table.Head class="w-56">Konto</Table.Head>
|
<Table.Head>Event</Table.Head>
|
||||||
<Table.Head class="w-32">Rolle</Table.Head>
|
<Table.Head>Konto</Table.Head>
|
||||||
<Table.Head class="w-24">Events</Table.Head>
|
|
||||||
<Table.Head class="text-right">Aktionen</Table.Head>
|
<Table.Head class="text-right">Aktionen</Table.Head>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each rows as row (row.rider?.id ?? row.userId)}
|
{#each roster as r (r.id)}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
|
<Table.Cell>
|
||||||
|
<Badge variant="outline">{r.number || '—'}</Badge>
|
||||||
|
</Table.Cell>
|
||||||
<Table.Cell class="font-medium">
|
<Table.Cell class="font-medium">
|
||||||
<span class="flex items-center gap-2">
|
{fullName(r) || '— ohne Name —'}
|
||||||
<Avatar user={row.userId ? teams.users[row.userId] : null} size={28} />
|
</Table.Cell>
|
||||||
{row.name}
|
<Table.Cell class="text-muted-foreground text-sm">
|
||||||
{#if !row.rider}
|
{eventName(r.event) ?? '—'}
|
||||||
<Badge variant="outline">kein Fahrer</Badge>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{#if row.userId}
|
{#if r.user}
|
||||||
<span class="text-sm text-muted-foreground truncate">
|
<span class="inline-flex items-center gap-2">
|
||||||
{teams.userLabel(row.userId)}
|
<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>
|
</span>
|
||||||
{:else}
|
|
||||||
<span class="text-sm text-muted-foreground">kein Konto</span>
|
|
||||||
{/if}
|
|
||||||
</Table.Cell>
|
|
||||||
|
|
||||||
<Table.Cell>
|
|
||||||
{#if row.role === 'owner'}
|
|
||||||
<Badge class="gap-1">
|
|
||||||
<Crown class="size-3" />
|
|
||||||
Owner
|
|
||||||
</Badge>
|
|
||||||
{:else if row.role === 'admin'}
|
|
||||||
<Badge variant="secondary" class="gap-1">
|
|
||||||
<Shield class="size-3" />
|
|
||||||
Admin
|
|
||||||
</Badge>
|
|
||||||
{:else if row.role === 'mitglied'}
|
|
||||||
<Badge variant="outline">Mitglied</Badge>
|
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-sm text-muted-foreground">—</span>
|
<span class="text-sm text-muted-foreground">—</span>
|
||||||
{/if}
|
{/if}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
||||||
<Table.Cell class="text-muted-foreground text-sm">
|
|
||||||
{row.rider ? participants.byRider(row.rider.id).length : '—'}
|
|
||||||
</Table.Cell>
|
|
||||||
|
|
||||||
<Table.Cell class="text-right whitespace-nowrap">
|
<Table.Cell class="text-right whitespace-nowrap">
|
||||||
{#if iAmOwner && row.userId && row.role !== 'owner'}
|
{#if r.user}
|
||||||
<Button
|
<Button variant="ghost" size="icon" title="Verknüpfung lösen" onclick={() => doUnlink(r)}>
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => toggleAdmin(row.userId!, row.role !== 'admin')}
|
|
||||||
>
|
|
||||||
{row.role === 'admin' ? 'Admin entziehen' : 'Zu Admin'}
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if row.rider && !row.userId}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
tooltip="Konto anlegen oder verknüpfen"
|
|
||||||
onclick={() => openAccount(row.rider!)}
|
|
||||||
>
|
|
||||||
<KeyRound class="size-4" />
|
|
||||||
</Button>
|
|
||||||
{:else if row.rider && row.userId}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
tooltip="Verknüpfung lösen"
|
|
||||||
onclick={() => unlink(row.rider!)}
|
|
||||||
>
|
|
||||||
<Unlink class="size-4" />
|
<Unlink class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{:else if row.userId}
|
{:else}
|
||||||
<Button
|
<Button variant="ghost" size="icon" title="Konto verknüpfen" onclick={() => openLogin(r)}>
|
||||||
variant="ghost"
|
<KeyRound class="size-4" />
|
||||||
size="sm"
|
|
||||||
onclick={() => adoptAccount(row.userId!)}
|
|
||||||
>
|
|
||||||
Als Fahrer übernehmen
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if row.rider}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
tooltip="Bearbeiten"
|
|
||||||
onclick={() => openEdit(row.rider!)}
|
|
||||||
>
|
|
||||||
<Pencil class="size-4" />
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if row.userId && row.role !== 'owner'}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
tooltip="Aus dem Team entfernen"
|
|
||||||
onclick={() => removeFromTeam(row.userId!)}
|
|
||||||
>
|
|
||||||
<UserMinus class="size-4" />
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if row.rider}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
tooltip="Fahrer löschen"
|
|
||||||
onclick={() => riders.remove(row.rider!)}
|
|
||||||
>
|
|
||||||
<Trash2 class="size-4 text-destructive" />
|
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/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.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -434,77 +254,61 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Person anlegen oder bearbeiten -->
|
<!-- Fahrer anlegen/bearbeiten -->
|
||||||
<Dialog.Root bind:open={personDialog}>
|
<Dialog.Root bind:open={riderDialog}>
|
||||||
<Dialog.Content>
|
<Dialog.Content>
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>{editing ? 'Fahrer bearbeiten' : 'Person anlegen'}</Dialog.Title>
|
<Dialog.Title>{editing ? 'Fahrer bearbeiten' : 'Neuer Fahrer'}</Dialog.Title>
|
||||||
<Dialog.Description>
|
|
||||||
{editing
|
|
||||||
? 'Der Name gilt teamweit; Startnummern hängen am jeweiligen Event.'
|
|
||||||
: 'Kommt als Fahrer in den Kader. Ein Konto ist optional.'}
|
|
||||||
</Dialog.Description>
|
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<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="grid grid-cols-2 gap-3">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="p-first">Vorname</Label>
|
<Label for="rider-first">Vorname</Label>
|
||||||
<Input id="p-first" bind:value={form.firstname} placeholder="Max" />
|
<Input id="rider-first" bind:value={form.firstname} placeholder="Max" />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="p-last">Nachname</Label>
|
<Label for="rider-last">Nachname</Label>
|
||||||
<Input id="p-last" bind:value={form.lastname} placeholder="Mustermann" />
|
<Input id="rider-last" bind:value={form.lastname} placeholder="Mustermann" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
{#if !editing}
|
<Label>Event</Label>
|
||||||
<label class="flex items-center gap-2 text-sm">
|
<Select.Root type="single" bind:value={form.event}>
|
||||||
<input
|
<Select.Trigger class="w-full">
|
||||||
type="checkbox"
|
{eventName(form.event) ?? 'Keinem Event zugeordnet'}
|
||||||
bind:checked={form.withAccount}
|
</Select.Trigger>
|
||||||
class="size-4 rounded border-input accent-primary"
|
<Select.Content>
|
||||||
/>
|
<Select.Item value="">Keinem Event zugeordnet</Select.Item>
|
||||||
kann sich anmelden
|
{#each events.scoped as e (e.id)}
|
||||||
</label>
|
<Select.Item value={e.id}>{e.name || e.description}</Select.Item>
|
||||||
|
{/each}
|
||||||
{#if form.withAccount}
|
</Select.Content>
|
||||||
<div class="space-y-2">
|
</Select.Root>
|
||||||
<Label for="p-mail">E-Mail</Label>
|
<p class="text-xs text-muted-foreground">
|
||||||
<Input id="p-mail" type="email" bind:value={form.email} placeholder="max@example.com" />
|
Zeiten hängen am Event. Ohne Zuordnung steht der Fahrer nur im
|
||||||
</div>
|
Team-Verzeichnis.
|
||||||
<div class="space-y-2">
|
</p>
|
||||||
<Label for="p-pw">Passwort</Label>
|
</div>
|
||||||
<Input id="p-pw" type="password" bind:value={form.password} placeholder="mindestens 8 Zeichen" />
|
{#if riderError}
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-sm text-destructive">{riderError}</p>
|
||||||
Das Konto kommt zugleich ins Team. Gib das Passwort weiter; es lässt
|
|
||||||
sich unter „Profil" ändern.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<p class="text-sm text-destructive">{error}</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog.Footer>
|
<Dialog.Footer>
|
||||||
<Button variant="ghost" onclick={() => (personDialog = false)}>Abbrechen</Button>
|
<Button variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button>
|
||||||
<Button onclick={savePerson} disabled={busy}>
|
<Button onclick={saveRider}>{editing ? 'Speichern' : 'Anlegen'}</Button>
|
||||||
{busy ? 'Wird gespeichert …' : editing ? 'Speichern' : 'Anlegen'}
|
|
||||||
</Button>
|
|
||||||
</Dialog.Footer>
|
</Dialog.Footer>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|
||||||
<!-- Konto für einen vorhandenen Fahrer -->
|
<!-- Konto verknüpfen oder anlegen -->
|
||||||
<Dialog.Root bind:open={accountDialog}>
|
<Dialog.Root bind:open={loginDialog}>
|
||||||
<Dialog.Content>
|
<Dialog.Content>
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>
|
<Dialog.Title>Konto für {loginRider ? fullName(loginRider) : 'Fahrer'}</Dialog.Title>
|
||||||
Konto für {accountRider ? fullName(accountRider) : 'Fahrer'}
|
|
||||||
</Dialog.Title>
|
|
||||||
<Dialog.Description>
|
<Dialog.Description>
|
||||||
Mit einem Konto kann sich die Person selbst anmelden.
|
Mit einem Konto kann sich die Person selbst anmelden.
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
|
|
@ -512,75 +316,69 @@
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant={accountMode === 'neu' ? 'default' : 'outline'}
|
variant={loginMode === 'link' ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={() => (accountMode = 'neu')}
|
onclick={() => (loginMode = 'link')}
|
||||||
>
|
|
||||||
Neues anlegen
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={accountMode === 'vorhanden' ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
onclick={() => (accountMode = 'vorhanden')}
|
|
||||||
>
|
>
|
||||||
Vorhandenes verknüpfen
|
Vorhandenes verknüpfen
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={loginMode === 'create' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onclick={() => (loginMode = 'create')}
|
||||||
|
>
|
||||||
|
Neues anlegen
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{#if accountMode === 'neu'}
|
{#if loginMode === 'link'}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="a-mail">E-Mail</Label>
|
<Label>Teammitglied</Label>
|
||||||
<Input id="a-mail" type="email" bind:value={accountForm.email} placeholder="max@example.com" />
|
<Select.Root type="single" bind:value={linkUserId}>
|
||||||
</div>
|
<Select.Trigger class="w-full">
|
||||||
<div class="space-y-2">
|
{linkUserId ? teams.userLabel(linkUserId) : 'Mitglied wählen…'}
|
||||||
<Label for="a-pw">Passwort</Label>
|
</Select.Trigger>
|
||||||
<Input id="a-pw" type="password" bind:value={accountForm.password} placeholder="mindestens 8 Zeichen" />
|
<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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="a-search">E-Mail des Kontos</Label>
|
<Label for="login-mail">E-Mail</Label>
|
||||||
<div class="flex gap-2">
|
<Input id="login-mail" type="email" bind:value={loginForm.email} placeholder="max@example.com" />
|
||||||
<Input
|
</div>
|
||||||
id="a-search"
|
<div class="space-y-2">
|
||||||
type="email"
|
<Label for="login-pw">Passwort</Label>
|
||||||
bind:value={searchEmail}
|
<Input id="login-pw" type="password" bind:value={loginForm.password} placeholder="mindestens 8 Zeichen" />
|
||||||
placeholder="max@example.com"
|
<p class="text-xs text-muted-foreground">
|
||||||
onkeydown={(e) => e.key === 'Enter' && searchAccount()}
|
Das Konto kommt zugleich ins Team. Gib das Passwort weiter; es lässt
|
||||||
/>
|
sich unter „Profil" ändern.
|
||||||
<Button variant="outline" onclick={searchAccount} disabled={searching}>
|
</p>
|
||||||
<Mail class="size-4 mr-1" />
|
|
||||||
{searching ? 'Suche …' : 'Suchen'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if found}
|
|
||||||
<div class="flex items-center gap-3 rounded-lg border bg-accent/40 p-3">
|
|
||||||
<Avatar user={found} size={36} />
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="font-medium truncate">{found.name || found.email}</p>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
Wird ins Team aufgenommen und verknüpft.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if accountError}
|
{#if loginError}
|
||||||
<p class="text-sm text-destructive">{accountError}</p>
|
<p class="text-sm text-destructive">{loginError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog.Footer>
|
<Dialog.Footer>
|
||||||
<Button variant="ghost" onclick={() => (accountDialog = false)}>Abbrechen</Button>
|
<Button variant="ghost" onclick={() => (loginDialog = false)}>Abbrechen</Button>
|
||||||
{#if accountMode === 'neu'}
|
{#if loginMode === 'link'}
|
||||||
<Button onclick={createAccount} disabled={accountBusy}>
|
<Button onclick={doLink} disabled={!linkUserId || loginBusy}>Verknüpfen</Button>
|
||||||
{accountBusy ? 'Wird angelegt …' : 'Konto anlegen'}
|
|
||||||
</Button>
|
|
||||||
{:else}
|
{:else}
|
||||||
<Button onclick={linkFound} disabled={!found || accountBusy}>Verknüpfen</Button>
|
<Button onclick={doCreateLogin} disabled={loginBusy}>
|
||||||
|
{loginBusy ? 'Wird angelegt …' : 'Konto anlegen'}
|
||||||
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</Dialog.Footer>
|
</Dialog.Footer>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,6 @@
|
||||||
import { api } from '$lib/stores/pocketbase.svelte'
|
import { api } from '$lib/stores/pocketbase.svelte'
|
||||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||||
import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte'
|
import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte'
|
||||||
import { getEventContext } from '$lib/stores/events.svelte'
|
|
||||||
import { getRiderContext } from '$lib/stores/riders.svelte'
|
|
||||||
import { getTrailContext } from '$lib/stores/trails.svelte'
|
|
||||||
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
@ -17,25 +13,13 @@
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import * as Dialog from '@/components/ui/dialog'
|
import * as Dialog from '@/components/ui/dialog'
|
||||||
import Avatar from '$lib/components/Avatar.svelte'
|
import Avatar from '$lib/components/Avatar.svelte'
|
||||||
import Logo from '$lib/components/Logo.svelte'
|
import { Crown, LogOut, Plus, Shield, Trash2, UserMinus, UserPlus, Users } from 'lucide-svelte'
|
||||||
import {
|
import type { TeamsResponse } from '$lib/types'
|
||||||
Bike, Calendar, Image, LogOut, Plus, Route, Trash2, Users,
|
|
||||||
} from 'lucide-svelte'
|
|
||||||
import type { TeamsResponse, UsersResponse } from '$lib/types'
|
|
||||||
|
|
||||||
const teams = getTeamContext()
|
const teams = getTeamContext()
|
||||||
const flags = getTrailFlagContext()
|
const flags = getTrailFlagContext()
|
||||||
const events = getEventContext()
|
|
||||||
const riders = getRiderContext()
|
|
||||||
const trails = getTrailContext()
|
|
||||||
const markers = getTrailMarkerContext()
|
|
||||||
const me = $derived(api.authStore.record?.id ?? null)
|
const me = $derived(api.authStore.record?.id ?? null)
|
||||||
|
|
||||||
const accounts = $derived(riders.scoped.filter((r) => r.user).length)
|
|
||||||
const openMarkers = $derived(
|
|
||||||
trails.scoped.reduce((sum, t) => sum + markers.openByTrail(t.id).length, 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Namen der Mitglieder nachladen — ein Team kennt nur deren IDs.
|
// Namen der Mitglieder nachladen — ein Team kennt nur deren IDs.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
for (const t of teams.records) teams.loadMembers(t)
|
for (const t of teams.records) teams.loadMembers(t)
|
||||||
|
|
@ -98,51 +82,44 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kennzahlen des aktiven Teams — was hier steht, gehört dem Team und
|
// ---- Mitglied anlegen ----
|
||||||
// wandert beim Wechsel mit.
|
// Bestehende Konten lassen sich nicht suchen: users.listRule gibt nur den
|
||||||
const stats = $derived([
|
// eigenen Datensatz frei, und das soll auch so bleiben. Ein neues Konto
|
||||||
{
|
// anzulegen deckt den Alltag ab — Vereinsmitglieder haben vorher keins.
|
||||||
label: 'Events',
|
let memberDialog = $state(false)
|
||||||
value: events.scoped.length,
|
let memberTeam = $state<TeamsResponse | null>(null)
|
||||||
hint: `${events.upcoming.length} anstehend`,
|
let memberForm = $state({ name: '', email: '', password: '' })
|
||||||
icon: Calendar,
|
let memberBusy = $state(false)
|
||||||
},
|
let memberError = $state<string | null>(null)
|
||||||
{ label: 'Fahrer', value: riders.scoped.length, hint: `${accounts} mit Konto`, icon: Bike },
|
|
||||||
{
|
|
||||||
label: 'Trails',
|
|
||||||
value: trails.scoped.length,
|
|
||||||
hint: `${openMarkers} offene Meldungen`,
|
|
||||||
icon: Route,
|
|
||||||
},
|
|
||||||
{ label: 'Mitglieder', value: (teams.active?.users ?? []).length, icon: Users },
|
|
||||||
])
|
|
||||||
|
|
||||||
// ---- Logo ----
|
function openMember(t: TeamsResponse) {
|
||||||
let logoDialog = $state(false)
|
memberTeam = t
|
||||||
let logoTeam = $state<TeamsResponse | null>(null)
|
memberForm = { name: '', email: '', password: '' }
|
||||||
let logoFile = $state<File | null>(null)
|
memberError = null
|
||||||
let logoBusy = $state(false)
|
memberDialog = true
|
||||||
let logoError = $state<string | null>(null)
|
|
||||||
|
|
||||||
function openLogo(t: TeamsResponse) {
|
|
||||||
logoTeam = t
|
|
||||||
logoFile = null
|
|
||||||
logoError = null
|
|
||||||
logoDialog = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveLogo(file: File | null) {
|
async function doCreateMember() {
|
||||||
if (!logoTeam) return
|
if (!memberTeam) return
|
||||||
|
|
||||||
logoBusy = true
|
if (!memberForm.email.trim()) {
|
||||||
logoError = null
|
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 {
|
try {
|
||||||
await teams.setLogo(logoTeam.id, file)
|
await teams.createMember({ ...memberForm }, memberTeam.id)
|
||||||
logoDialog = false
|
memberDialog = false
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logoError = e.message ?? 'Das Logo konnte nicht gespeichert werden.'
|
memberError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
|
||||||
} finally {
|
} finally {
|
||||||
logoBusy = false
|
memberBusy = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,29 +133,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-4">
|
||||||
{#if teams.active}
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
{#each stats as stat (stat.label)}
|
|
||||||
{@const Icon = stat.icon}
|
|
||||||
<Card>
|
|
||||||
<CardContent class="py-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<p class="text-sm text-muted-foreground">{stat.label}</p>
|
|
||||||
<Icon class="size-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-2xl font-bold">{stat.value}</p>
|
|
||||||
{#if stat.hint}
|
|
||||||
<p class="text-xs text-muted-foreground">{stat.hint}</p>
|
|
||||||
{/if}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
Teams, in denen du bist. Das aktive Team bestimmt, welche Events, Fahrer und
|
Teams, in denen du bist. Das aktive Team bestimmt, welche Events, Fahrer und
|
||||||
|
|
@ -209,19 +175,16 @@
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||||
<div class="flex items-center gap-3">
|
<div>
|
||||||
<Logo team={t} class="size-10 shrink-0" />
|
<CardTitle class="flex items-center gap-2">
|
||||||
<div>
|
{t.name}
|
||||||
<CardTitle class="flex items-center gap-2">
|
{#if t.id === teams.activeId}
|
||||||
{t.name}
|
<Badge>Aktiv</Badge>
|
||||||
{#if t.id === teams.activeId}
|
{/if}
|
||||||
<Badge>Aktiv</Badge>
|
</CardTitle>
|
||||||
{/if}
|
<CardDescription>
|
||||||
</CardTitle>
|
{(t.users ?? []).length} Mitglied{(t.users ?? []).length === 1 ? '' : 'er'}
|
||||||
<CardDescription>
|
</CardDescription>
|
||||||
{(t.users ?? []).length} Mitglied{(t.users ?? []).length === 1 ? '' : 'er'}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
{#if t.id !== teams.activeId}
|
{#if t.id !== teams.activeId}
|
||||||
|
|
@ -233,9 +196,9 @@
|
||||||
<Button variant="outline" size="sm" onclick={() => openRename(t)}>
|
<Button variant="outline" size="sm" onclick={() => openRename(t)}>
|
||||||
Umbenennen
|
Umbenennen
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onclick={() => openLogo(t)}>
|
<Button variant="outline" size="sm" onclick={() => openMember(t)}>
|
||||||
<Image class="size-4 mr-1" />
|
<UserPlus class="size-4 mr-1" />
|
||||||
Logo
|
Mitglied
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !iAmOwner}
|
{#if !iAmOwner}
|
||||||
|
|
@ -244,16 +207,56 @@
|
||||||
Verlassen
|
Verlassen
|
||||||
</Button>
|
</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<Button variant="ghost" size="icon" tooltip="Team löschen" onclick={() => teams.remove(t)}>
|
<Button variant="ghost" size="icon" title="Team löschen" onclick={() => teams.remove(t)}>
|
||||||
<Trash2 class="size-4 text-destructive" />
|
<Trash2 class="size-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="text-sm text-muted-foreground">
|
<CardContent>
|
||||||
{(t.users ?? []).length} Mitglied{(t.users ?? []).length === 1 ? '' : 'er'}
|
<ul class="divide-y border-t">
|
||||||
· verwaltet unter <a href="/dashboard/settings/riders" class="underline">Fahrer</a>
|
{#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">
|
||||||
|
<Avatar user={teams.users[userId]} size={28} />
|
||||||
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -305,47 +308,39 @@
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|
||||||
<!-- Logo -->
|
<!-- Mitglied anlegen -->
|
||||||
<Dialog.Root bind:open={logoDialog}>
|
<Dialog.Root bind:open={memberDialog}>
|
||||||
<Dialog.Content>
|
<Dialog.Content>
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>Logo für „{logoTeam?.name}"</Dialog.Title>
|
<Dialog.Title>Mitglied anlegen</Dialog.Title>
|
||||||
<Dialog.Description>
|
<Dialog.Description>
|
||||||
Erscheint in der Kopfzeile und blass im Seitenhintergrund. Ohne eigenes
|
Legt ein Konto an und nimmt es in „{memberTeam?.name}" auf.
|
||||||
Logo steht dort das Wappen des Stammtischs.
|
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center gap-4">
|
<div class="space-y-2">
|
||||||
<Logo team={logoTeam} class="size-20 shrink-0" />
|
<Label for="member-name">Name</Label>
|
||||||
<div class="space-y-1 min-w-0">
|
<Input id="member-name" bind:value={memberForm.name} placeholder="Max Mustermann" />
|
||||||
<Label for="team-logo">Datei</Label>
|
|
||||||
<input
|
|
||||||
id="team-logo"
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
class="block w-full text-sm"
|
|
||||||
onchange={(e) => (logoFile = e.currentTarget.files?.[0] ?? null)}
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-muted-foreground">PNG, SVG oder WebP, bis 5 MB.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
{#if logoError}
|
<Label for="member-mail">E-Mail</Label>
|
||||||
<p class="text-sm text-destructive">{logoError}</p>
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog.Footer>
|
<Dialog.Footer>
|
||||||
<Button variant="ghost" onclick={() => (logoDialog = false)}>Abbrechen</Button>
|
<Button variant="ghost" onclick={() => (memberDialog = false)}>Abbrechen</Button>
|
||||||
{#if logoTeam?.logo}
|
<Button onclick={doCreateMember} disabled={memberBusy}>
|
||||||
<Button variant="outline" disabled={logoBusy} onclick={() => saveLogo(null)}>
|
{memberBusy ? 'Wird angelegt …' : 'Anlegen'}
|
||||||
Entfernen
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
<Button disabled={!logoFile || logoBusy} onclick={() => saveLogo(logoFile)}>
|
|
||||||
{logoBusy ? 'Wird gespeichert …' : 'Speichern'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</Dialog.Footer>
|
</Dialog.Footer>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
|
|
|
||||||
|
|
@ -5,24 +5,18 @@
|
||||||
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
|
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
|
||||||
import TrailMap from '$lib/components/TrailMap.svelte'
|
import TrailMap from '$lib/components/TrailMap.svelte'
|
||||||
import { trailStatus } from '$lib/trailStatus'
|
import { trailStatus } from '$lib/trailStatus'
|
||||||
import { tooltip } from '$lib/stores/app.svelte'
|
|
||||||
import { getTrailCommentContext } from '$lib/stores/trailComments.svelte'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import * as Dialog from '@/components/ui/dialog'
|
import * as Dialog from '@/components/ui/dialog'
|
||||||
import {
|
import { Plus, Route, AlertTriangle } from 'lucide-svelte'
|
||||||
Plus, Route, AlertTriangle, Ruler, TrendingUp, MapPin, MessageSquare,
|
|
||||||
} from 'lucide-svelte'
|
|
||||||
import { RECENT_DAYS } from '$lib/time'
|
|
||||||
import type { TrailVersionsResponse } from '$lib/types'
|
import type { TrailVersionsResponse } from '$lib/types'
|
||||||
|
|
||||||
const trails = getTrailContext()
|
const trails = getTrailContext()
|
||||||
const versions = getTrailVersionContext()
|
const versions = getTrailVersionContext()
|
||||||
const markers = getTrailMarkerContext()
|
const markers = getTrailMarkerContext()
|
||||||
const comments = getTrailCommentContext()
|
|
||||||
|
|
||||||
let dialog = $state(false)
|
let dialog = $state(false)
|
||||||
let form = $state({ name: '', description: '' })
|
let form = $state({ name: '', description: '' })
|
||||||
|
|
@ -115,10 +109,6 @@
|
||||||
{@const version = currentVersion(trail.id)}
|
{@const version = currentVersion(trail.id)}
|
||||||
{@const open = markers.openByTrail(trail.id).length}
|
{@const open = markers.openByTrail(trail.id).length}
|
||||||
{@const status = trailStatus(trail.status)}
|
{@const status = trailStatus(trail.status)}
|
||||||
{@const markerCount = markers.byTrail(trail.id).length}
|
|
||||||
{@const commentCount = comments.byTrail(trail.id).length}
|
|
||||||
{@const freshMarkers = markers.hasRecent(trail.id)}
|
|
||||||
{@const freshComments = comments.hasRecent(trail.id)}
|
|
||||||
<a href="/dashboard/trails/{trail.id}" class="block">
|
<a href="/dashboard/trails/{trail.id}" class="block">
|
||||||
<Card class="h-full hover:border-primary transition-colors">
|
<Card class="h-full hover:border-primary transition-colors">
|
||||||
<CardHeader class="pb-3">
|
<CardHeader class="pb-3">
|
||||||
|
|
@ -149,66 +139,15 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex items-center gap-4 text-sm text-muted-foreground">
|
<div class="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
<!-- Dieselben Icons wie im Kopf der Trail-Seite. -->
|
<span>{formatKm(version?.distance_m)}</span>
|
||||||
<span class="flex items-center gap-1.5">
|
<span>{version?.ascent_m ? `${version.ascent_m} hm` : '–'}</span>
|
||||||
<Ruler class="size-4" />
|
|
||||||
{formatKm(version?.distance_m)}
|
|
||||||
</span>
|
|
||||||
<span class="flex items-center gap-1.5">
|
|
||||||
<TrendingUp class="size-4" />
|
|
||||||
{version?.ascent_m ? `${version.ascent_m} hm` : '–'}
|
|
||||||
</span>
|
|
||||||
{#if open > 0}
|
{#if open > 0}
|
||||||
<span
|
<span class="flex items-center gap-1 text-amber-600 ml-auto">
|
||||||
class="flex items-center gap-1 text-amber-600 ml-auto"
|
|
||||||
use:tooltip={{
|
|
||||||
content: `${open} offene Meldung${open === 1 ? '' : 'en'}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertTriangle class="size-4" />
|
<AlertTriangle class="size-4" />
|
||||||
{open}
|
{open}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!--
|
|
||||||
Ein Punkt markiert Bewegung der letzten zwei Wochen. Die
|
|
||||||
Zahl allein sagt nichts darüber, ob sich gerade etwas tut.
|
|
||||||
-->
|
|
||||||
<div class="flex items-center gap-4 text-sm border-t pt-3">
|
|
||||||
<span
|
|
||||||
class="flex items-center gap-1.5"
|
|
||||||
class:text-muted-foreground={!freshMarkers}
|
|
||||||
class:font-medium={freshMarkers}
|
|
||||||
use:tooltip={{
|
|
||||||
content: freshMarkers
|
|
||||||
? `Marker, neue in den letzten ${RECENT_DAYS} Tagen`
|
|
||||||
: 'Marker',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MapPin class="size-4" />
|
|
||||||
{markerCount}
|
|
||||||
{#if freshMarkers}
|
|
||||||
<span class="size-1.5 rounded-full bg-primary"></span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="flex items-center gap-1.5"
|
|
||||||
class:text-muted-foreground={!freshComments}
|
|
||||||
class:font-medium={freshComments}
|
|
||||||
use:tooltip={{
|
|
||||||
content: freshComments
|
|
||||||
? `Kommentare, neue in den letzten ${RECENT_DAYS} Tagen`
|
|
||||||
: 'Kommentare',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MessageSquare class="size-4" />
|
|
||||||
{commentCount}
|
|
||||||
{#if freshComments}
|
|
||||||
<span class="size-1.5 rounded-full bg-primary"></span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
import { getTrailVersionContext } from '$lib/stores/trailVersions.svelte'
|
import { getTrailVersionContext } from '$lib/stores/trailVersions.svelte'
|
||||||
import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte'
|
import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte'
|
||||||
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
|
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
|
||||||
import { getTrailCommentContext } from '$lib/stores/trailComments.svelte'
|
|
||||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||||
import TrailMap from '$lib/components/TrailMap.svelte'
|
import TrailMap from '$lib/components/TrailMap.svelte'
|
||||||
import ElevationProfile from '$lib/components/ElevationProfile.svelte'
|
import ElevationProfile from '$lib/components/ElevationProfile.svelte'
|
||||||
|
|
@ -31,7 +30,7 @@
|
||||||
} from 'lucide-svelte'
|
} from 'lucide-svelte'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
import { browser } from '$app/environment'
|
import { browser } from '$app/environment'
|
||||||
import type { TrailVersionsResponse } from '$lib/types'
|
import type { TrailCommentsResponse, TrailVersionsResponse, UsersResponse } from '$lib/types'
|
||||||
|
|
||||||
// Generics der JSON-Felder, wie in trails/+page.svelte — die
|
// Generics der JSON-Felder, wie in trails/+page.svelte — die
|
||||||
// Rohdaten kommen ungetypt aus PocketBase zurück.
|
// Rohdaten kommen ungetypt aus PocketBase zurück.
|
||||||
|
|
@ -48,7 +47,6 @@
|
||||||
const versions = getTrailVersionContext()
|
const versions = getTrailVersionContext()
|
||||||
const flags = getTrailFlagContext()
|
const flags = getTrailFlagContext()
|
||||||
const markers = getTrailMarkerContext()
|
const markers = getTrailMarkerContext()
|
||||||
const commentStore = getTrailCommentContext()
|
|
||||||
const teams = getTeamContext()
|
const teams = getTeamContext()
|
||||||
|
|
||||||
const trail = $derived(page.params.id ? trails.getById(page.params.id) : undefined)
|
const trail = $derived(page.params.id ? trails.getById(page.params.id) : undefined)
|
||||||
|
|
@ -258,17 +256,69 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Kommentare ------------------------------------------------------
|
// --- Kommentare ------------------------------------------------------
|
||||||
const comments = $derived(trail ? commentStore.byTrail(trail.id) : [])
|
// Eigener Store wäre Overkill: Kommentare werden nur hier gebraucht.
|
||||||
|
type Comment = TrailCommentsResponse<{ created_by?: UsersResponse }>
|
||||||
|
|
||||||
|
let comments = $state<Comment[]>([])
|
||||||
let commentText = $state('')
|
let commentText = $state('')
|
||||||
let commentError = $state<string | null>(null)
|
let commentError = $state<string | null>(null)
|
||||||
|
let unsubComments: (() => void) | null = null
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = trail?.id
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
api.collection('trail_comments')
|
||||||
|
.getFullList({
|
||||||
|
filter: `trail="${id}"`,
|
||||||
|
sort: '-created',
|
||||||
|
expand: 'created_by',
|
||||||
|
requestKey: null,
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
if (!cancelled) comments = r as Comment[]
|
||||||
|
})
|
||||||
|
.catch((e) => console.error('Kommentare laden fehlgeschlagen:', e))
|
||||||
|
|
||||||
|
api.collection('trail_comments')
|
||||||
|
.subscribe(
|
||||||
|
'*',
|
||||||
|
(e) => {
|
||||||
|
if (e.record.trail !== id) return
|
||||||
|
const record = e.record as Comment
|
||||||
|
const idx = comments.findIndex((c) => c.id === record.id)
|
||||||
|
if (e.action === 'create' && idx === -1) comments = [record, ...comments]
|
||||||
|
else if (e.action === 'update' && idx !== -1) comments[idx] = record
|
||||||
|
else if (e.action === 'delete' && idx !== -1) comments = comments.filter((c) => c.id !== record.id)
|
||||||
|
},
|
||||||
|
{ expand: 'created_by' },
|
||||||
|
)
|
||||||
|
.then((u) => {
|
||||||
|
if (cancelled) u()
|
||||||
|
else unsubComments = u
|
||||||
|
})
|
||||||
|
.catch((e) => console.warn('trail_comments subscribe failed:', e))
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
unsubComments?.()
|
||||||
|
unsubComments = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
async function addComment() {
|
async function addComment() {
|
||||||
if (!trail || !commentText.trim()) return
|
if (!trail || !commentText.trim()) return
|
||||||
|
|
||||||
commentError = null
|
commentError = null
|
||||||
try {
|
try {
|
||||||
await commentStore.create(trail.id, trail.team, commentText)
|
await api.collection('trail_comments').create({
|
||||||
|
trail: trail.id,
|
||||||
|
team: trail.team,
|
||||||
|
text: commentText.trim(),
|
||||||
|
created_by: auth.user?.id,
|
||||||
|
})
|
||||||
commentText = ''
|
commentText = ''
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
commentError = e.message ?? 'Der Kommentar konnte nicht gespeichert werden.'
|
commentError = e.message ?? 'Der Kommentar konnte nicht gespeichert werden.'
|
||||||
|
|
@ -459,7 +509,7 @@
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="size-8"
|
class="size-8"
|
||||||
tooltip={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
|
title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
|
||||||
onclick={async () => {
|
onclick={async () => {
|
||||||
try {
|
try {
|
||||||
await markers.toggleResolved(m.id)
|
await markers.toggleResolved(m.id)
|
||||||
|
|
@ -474,7 +524,7 @@
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="size-8"
|
class="size-8"
|
||||||
tooltip="Marker löschen"
|
title="Marker löschen"
|
||||||
onclick={() => removeMarker(m)}
|
onclick={() => removeMarker(m)}
|
||||||
>
|
>
|
||||||
<Trash2 class="size-4 text-destructive" />
|
<Trash2 class="size-4 text-destructive" />
|
||||||
|
|
@ -583,7 +633,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if v.gpx}
|
{#if v.gpx}
|
||||||
<Button variant="ghost" size="icon" class="size-8" href={getFileURL(v, v.gpx)} tooltip="GPX herunterladen">
|
<Button variant="ghost" size="icon" class="size-8" href={getFileURL(v, v.gpx)} title="GPX herunterladen">
|
||||||
<Download class="size-4" />
|
<Download class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue