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.
|
||||
|
||||
**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
|
||||
|
||||
- **Repo-Struktur**: Monorepo mit `frontend/` (SvelteKit) und `backend/`
|
||||
|
|
|
|||
150
TODO.md
150
TODO.md
|
|
@ -1,8 +1,40 @@
|
|||
# Offene Punkte
|
||||
|
||||
Stand: 2026-09-02, abgeglichen mit der produktiven Instanz. Die fachlichen
|
||||
Entscheidungen sind gefallen und gebaut; was hier steht, passiert außerhalb des
|
||||
Repos oder wartet auf den nächsten Deploy.
|
||||
Stand: 2026-09-02. Was hier steht, ist bewusst nicht erledigt — entweder weil
|
||||
es eine Entscheidung braucht oder weil es außerhalb des Repos passiert.
|
||||
|
||||
## 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
|
||||
|
||||
|
|
@ -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
|
||||
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
|
||||
hat alles bis `1754500900` mitgenommen, ab `1754501000` steht es noch aus.
|
||||
Fünf neue Migrationen liegen in `backend/pb_migrations/`:
|
||||
|
||||
| Datei | Was sie tut | Stand |
|
||||
|---|---|---|
|
||||
| `1754500500_riders_user_and_admin_rules` | `riders.user`, Fahrerpflege nur Teamleitung, `users.viewRule`/`createRule` | gelaufen |
|
||||
| `1754500600_events_dates_instead_of_status` | `events.status` raus, `starts`/`ends` rein | gelaufen |
|
||||
| `1754500700_trail_status_meta` | `trails.status_changed`, `status_by` | gelaufen |
|
||||
| `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** |
|
||||
- `1754500500_riders_user_and_admin_rules.js`
|
||||
- `1754500600_events_dates_instead_of_status.js`
|
||||
- `1754500700_trail_status_meta.js`
|
||||
- `1754500800_trail_markers_custom.js`
|
||||
- `1754500900_users_superadmin.js`
|
||||
|
||||
Bis zum nächsten Deploy laufen deshalb ins Leere: Team-Logo, Zustandsmeldung
|
||||
durch Mitglieder, Kontosuche per E-Mail, Teilnehmerlisten, die Event-Schalter
|
||||
und die Serien. Die Oberfläche dafür ist da und wird beim Speichern eine
|
||||
Fehlermeldung von PocketBase zeigen.
|
||||
Solange sie nicht gelaufen sind, scheitert alles, was auf den neuen Regeln
|
||||
aufbaut. Am deutlichsten beim Anlegen eines Mitglieds oder eines Fahrer-Logins:
|
||||
**„Only superusers can perform this action"** — genau das sagt die noch
|
||||
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
|
||||
danach `riders.event` und `riders.number`. Eine Startnummer an einem Fahrer
|
||||
**ohne** Event hat danach keinen Platz mehr und geht verloren — sie war
|
||||
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.
|
||||
Ebenfalls neu: Fahrer anlegen, ändern und löschen darf ab dann nur noch die
|
||||
Teamleitung. Mitglieder ohne Admin-Rolle verlieren diese Möglichkeit auch auf
|
||||
der Event-Seite.
|
||||
|
||||
### Typen einmal neu erzeugen
|
||||
|
||||
`frontend/src/lib/types.d.ts` wurde bei allen Schema-Änderungen von Hand
|
||||
gepflegt. Grund: `pocketbase-typegen` braucht native sqlite3-Bindings, die in
|
||||
der Arbeitsumgebung nicht gebaut werden durften.
|
||||
`frontend/src/lib/types.d.ts` wurde bei den letzten Schema-Änderungen von Hand
|
||||
ergänzt (`riders.user`, `trails.status_by`/`status_changed`,
|
||||
`events.starts`/`ends` statt `status`). Grund: `pocketbase-typegen` braucht
|
||||
native sqlite3-Bindings, die in der Arbeitsumgebung nicht gebaut werden durften.
|
||||
|
||||
```bash
|
||||
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
|
||||
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
|
||||
|
||||
- Wer Regeln direkt im PocketBase-Admin ändert, hinterlässt keine Spur im Repo.
|
||||
Die Migrationen bleiben die Quelle: Sie setzen dieselben Werte noch einmal und
|
||||
überschreiben eine abweichende Handänderung stillschweigend.
|
||||
- Die Rolle **Superadmin** ist gesetzt, gibt in der App aber noch keine
|
||||
besonderen Rechte. Sie ist bewusst nur in PocketBase vergebbar: Die
|
||||
`updateRule` der users-Collection schließt `superadmin` vom Selbstsetzen aus.
|
||||
Sobald klar ist, was sie dürfen soll, kommen die Regeln dazu.
|
||||
- Flag-Typen sind **teamspezifisch**. Ein neues Team bekommt beim Anlegen den
|
||||
hartkodierten Standardsatz aus `trailFlags.svelte.ts` und kann ihn frei
|
||||
ändern.
|
||||
- „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.
|
||||
- Der Melder eines Trail-Markers („wer") zeigt bis zum Deploy „Unbekannt". Die
|
||||
gelockerte `users.viewRule` kommt mit `1754500500`.
|
||||
- Die Rolle **Superadmin** existiert seit `1754500900` und ist für
|
||||
stammtisch@dne.name gesetzt, gibt in der App aber noch keine besonderen
|
||||
Rechte. Sie ist bewusst nur in PocketBase vergebbar: Die `updateRule` der
|
||||
users-Collection schließt `superadmin` vom Selbstsetzen aus. Sobald klar ist,
|
||||
was sie dürfen soll, kommen die Regeln dazu.
|
||||
- Flag-Typen bleiben **teamspezifisch**. Ein neues Team bekommt beim Anlegen
|
||||
den hartkodierten Standardsatz aus `trailFlags.svelte.ts`, den es danach frei
|
||||
ändern kann. Eine appweite Verwaltung gibt es damit bewusst nicht.
|
||||
- `NAMING.md` und `frontend/static/pedaler.svg` liegen bewusst nur lokal.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ migrate((app) => {
|
|||
const collections = [
|
||||
{
|
||||
"id": "_pb_users_auth_",
|
||||
"listRule": "@request.auth.id != \"\"",
|
||||
"listRule": "id = @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\"",
|
||||
"createRule": "@request.auth.id != \"\"",
|
||||
"updateRule": "id = @request.auth.id && @request.body.superadmin:isset = false",
|
||||
|
|
@ -334,27 +334,6 @@ migrate((app) => {
|
|||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "file_team_logo",
|
||||
"maxSelect": 1,
|
||||
"maxSize": 5242880,
|
||||
"mimeTypes": [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/svg+xml",
|
||||
"image/gif",
|
||||
"image/webp"
|
||||
],
|
||||
"name": "logo",
|
||||
"presentable": false,
|
||||
"protected": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"thumbs": null,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate2990389176",
|
||||
|
|
@ -451,59 +430,6 @@ migrate((app) => {
|
|||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation_event_created_by",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "created_by",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "bool_event_timing",
|
||||
"name": "timing",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "select_event_participation",
|
||||
"maxSelect": 1,
|
||||
"name": "participation",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"offen",
|
||||
"bestaetigung",
|
||||
"geschlossen"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "pbc_event_series",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation_event_series",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "series",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate2990389176",
|
||||
|
|
@ -730,6 +656,20 @@ migrate((app) => {
|
|||
"thumbs": [],
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "xiqlzggqxg1e2y7",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "anbugepo",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "event",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "_pb_users_auth_",
|
||||
|
|
@ -764,6 +704,21 @@ migrate((app) => {
|
|||
"system": false,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text2526027604",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "number",
|
||||
"pattern": "",
|
||||
"presentable": true,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
|
|
@ -1010,7 +965,7 @@ migrate((app) => {
|
|||
"listRule": "team.users.id ?= @request.auth.id",
|
||||
"viewRule": "team.users.id ?= @request.auth.id",
|
||||
"createRule": "team.users.id ?= @request.auth.id",
|
||||
"updateRule": "stewards.id ?= @request.auth.id || team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id || (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)",
|
||||
"updateRule": "stewards.id ?= @request.auth.id || team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id",
|
||||
"deleteRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id",
|
||||
"name": "trails",
|
||||
"type": "base",
|
||||
|
|
@ -1798,313 +1753,6 @@ migrate((app) => {
|
|||
"created": "2026-08-06 14:55:19.513Z",
|
||||
"updated": "2026-08-06 14:55:19.513Z",
|
||||
"system": false
|
||||
},
|
||||
{
|
||||
"id": "pbc_event_participants",
|
||||
"listRule": "event.team.users.id ?= @request.auth.id",
|
||||
"viewRule": "event.team.users.id ?= @request.auth.id",
|
||||
"createRule": "event.created_by.id ?= @request.auth.id || event.team.owner.id ?= @request.auth.id || event.team.admins.id ?= @request.auth.id || (event.team.users.id ?= @request.auth.id && rider.user.id ?= @request.auth.id && ((event.participation = \"offen\" && status = \"zugesagt\") || (event.participation = \"bestaetigung\" && status = \"angefragt\")))",
|
||||
"updateRule": "event.created_by.id ?= @request.auth.id || event.team.owner.id ?= @request.auth.id || event.team.admins.id ?= @request.auth.id || (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\")",
|
||||
"deleteRule": "event.created_by.id ?= @request.auth.id || event.team.owner.id ?= @request.auth.id || event.team.admins.id ?= @request.auth.id",
|
||||
"name": "event_participants",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "xiqlzggqxg1e2y7",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation_participant_event",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "event",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "spgs7e4anufen9u",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation_participant_rider",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "rider",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text_participant_number",
|
||||
"max": 20,
|
||||
"min": 0,
|
||||
"name": "number",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "select_participant_status",
|
||||
"maxSelect": 1,
|
||||
"name": "status",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"angefragt",
|
||||
"zugesagt",
|
||||
"abgesagt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation_participant_created_by",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "created_by",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate_created",
|
||||
"name": "created",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate_updated",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"system": false
|
||||
},
|
||||
{
|
||||
"id": "pbc_event_series",
|
||||
"listRule": "team.users.id ?= @request.auth.id",
|
||||
"viewRule": "team.users.id ?= @request.auth.id",
|
||||
"createRule": "team.users.id ?= @request.auth.id",
|
||||
"updateRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id || created_by.id ?= @request.auth.id",
|
||||
"deleteRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id || created_by.id ?= @request.auth.id",
|
||||
"name": "event_series",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "pbc_1568971955",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation_series_team",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "team",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text_series_name",
|
||||
"max": 200,
|
||||
"min": 0,
|
||||
"name": "name",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text_series_location",
|
||||
"max": 200,
|
||||
"min": 0,
|
||||
"name": "location",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text_series_description",
|
||||
"max": 2000,
|
||||
"min": 0,
|
||||
"name": "description",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "select_series_rule",
|
||||
"maxSelect": 1,
|
||||
"name": "rule",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"woechentlich",
|
||||
"zweiwoechentlich",
|
||||
"monatlich"
|
||||
]
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "date_series_starts",
|
||||
"max": "",
|
||||
"min": "",
|
||||
"name": "starts",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "date_series_until",
|
||||
"max": "",
|
||||
"min": "",
|
||||
"name": "until",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "bool_series_timing",
|
||||
"name": "timing",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "select_series_participation",
|
||||
"maxSelect": 1,
|
||||
"name": "participation",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"offen",
|
||||
"bestaetigung",
|
||||
"geschlossen"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation_series_created_by",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "created_by",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate_created",
|
||||
"name": "created",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate_updated",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"system": false
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
import FlagIcon from './FlagIcon.svelte'
|
||||
import { tooltip } from '$lib/stores/app.svelte'
|
||||
import { FLAG_ICON_SUGGESTIONS, iconExists } from '$lib/flagIcons'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
|
@ -49,8 +48,8 @@
|
|||
{#each FLAG_ICON_SUGGESTIONS as name (name)}
|
||||
<button
|
||||
type="button"
|
||||
title={name}
|
||||
aria-label={name}
|
||||
use:tooltip={{ content: name }}
|
||||
aria-pressed={value === name}
|
||||
class="grid place-items-center rounded-md p-1.5 hover:bg-accent"
|
||||
class:bg-accent={value === name}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,10 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Logo eines Teams. Ist eins hinterlegt, steht es allein; sonst bleibt das
|
||||
* Wappen des Stammtischs als Rückfalloption — zwei übereinanderliegende
|
||||
* 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)
|
||||
// Groesse ueber eine Klasse steuerbar, damit dasselbe Logo im Header
|
||||
// (klein) und auf der Login-Karte (gross) verwendet werden kann.
|
||||
let { class: className = 'w-24 h-24' }: { class?: string } = $props()
|
||||
</script>
|
||||
|
||||
<div class="relative {className}" id="logo">
|
||||
{#if url}
|
||||
<img alt="" class="absolute inset-0 h-full w-full object-contain" src={url} />
|
||||
{: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>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@
|
|||
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
||||
import { ticker } from '$lib/stores/ticker.svelte'
|
||||
import TimeDialog from './TimeDialog.svelte'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -23,7 +22,6 @@
|
|||
const times = getTimeContext()
|
||||
const riders = getRiderContext()
|
||||
const runs = getRunContext()
|
||||
const participants = getEventParticipantContext()
|
||||
const clock = ticker()
|
||||
|
||||
const running = $derived(times.allRunning(clock.now))
|
||||
|
|
@ -51,14 +49,10 @@
|
|||
// Nutzer. Fällt die Anzahl wieder auf eine, ist der Zustand egal.
|
||||
const open = $derived(single || expanded)
|
||||
|
||||
/** Die Startnummer gilt je Event, deshalb der Umweg über den Run. */
|
||||
function riderLabel(riderId: string | undefined, runId: string | undefined) {
|
||||
const eventId = runId ? runs.getById(runId)?.event : undefined
|
||||
if (!eventId) {
|
||||
function riderLabel(riderId: string | undefined) {
|
||||
const r = riderId ? riders.getById(riderId) : undefined
|
||||
return r ? fullName(r) : 'Unbekannter Fahrer'
|
||||
}
|
||||
return participants.riderLabel(eventId, riderId)
|
||||
if (!r) return 'Unbekannter Fahrer'
|
||||
return `#${r.number || '—'} ${fullName(r)}`.trim()
|
||||
}
|
||||
|
||||
function runPath(runId: string | undefined) {
|
||||
|
|
@ -98,7 +92,7 @@
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
class="ml-auto size-7 shrink-0"
|
||||
tooltip={expanded ? 'Einklappen' : 'Alle anzeigen'}
|
||||
title={expanded ? 'Einklappen' : 'Alle anzeigen'}
|
||||
onclick={() => (expanded = !expanded)}
|
||||
>
|
||||
{#if expanded}
|
||||
|
|
@ -116,7 +110,7 @@
|
|||
{@const path = runPath(t.run)}
|
||||
<li class="flex items-center gap-2 px-3 py-2">
|
||||
<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">
|
||||
{#if path}
|
||||
<a href={path} class="hover:underline">{runLabel(t.run)}</a>
|
||||
|
|
@ -134,7 +128,7 @@
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
class="shrink-0"
|
||||
tooltip="Zeit erfassen"
|
||||
title="Zeit erfassen"
|
||||
onclick={() => openTimeDialog(t.rider, t.run)}
|
||||
>
|
||||
<Timer class="size-4" />
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
import { getTimeContext, type TimeStatus } from '$lib/stores/times.svelte'
|
||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
||||
import { ticker } from '$lib/stores/ticker.svelte'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
|
@ -34,7 +33,6 @@
|
|||
const times = getTimeContext()
|
||||
const riders = getRiderContext()
|
||||
const runs = getRunContext()
|
||||
const participants = getEventParticipantContext()
|
||||
const clock = ticker()
|
||||
|
||||
let selectedRider = $state('')
|
||||
|
|
@ -54,19 +52,16 @@
|
|||
confirmDnf = false
|
||||
})
|
||||
|
||||
// Zur Auswahl steht, wer beim Event zugesagt hat — nicht der ganze Kader.
|
||||
const eventRiders = $derived(
|
||||
eventId
|
||||
? participants
|
||||
.confirmedByEvent(eventId)
|
||||
.map((p) => riders.getById(p.rider))
|
||||
.filter((r) => !!r)
|
||||
: [],
|
||||
)
|
||||
const eventRiders = $derived(eventId ? riders.getByEvent(eventId) : [])
|
||||
const eventRuns = $derived(eventId ? runs.getByEvent(eventId) : [])
|
||||
|
||||
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(
|
||||
selectedRun ? runs.getById(selectedRun)?.description ?? 'Run wählen…' : 'Run wählen…',
|
||||
|
|
@ -140,7 +135,7 @@
|
|||
<Select.Content>
|
||||
{#each eventRiders as r (r.id)}
|
||||
<Select.Item value={r.id}>
|
||||
{participants.riderLabel(eventId, r.id)}
|
||||
#{r.number || '—'} {fullName(r)}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
|
|
|
|||
|
|
@ -38,18 +38,10 @@
|
|||
WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: ButtonVariant;
|
||||
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 lang="ts">
|
||||
import { tooltip as tooltipAction } from "$lib/stores/app.svelte";
|
||||
|
||||
let {
|
||||
class: className,
|
||||
variant = "default",
|
||||
|
|
@ -58,7 +50,6 @@
|
|||
href = undefined,
|
||||
type = "button",
|
||||
disabled,
|
||||
tooltip,
|
||||
children,
|
||||
...restProps
|
||||
}: ButtonProps = $props();
|
||||
|
|
@ -73,9 +64,7 @@
|
|||
aria-disabled={disabled}
|
||||
role={disabled ? "link" : undefined}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
aria-label={tooltip}
|
||||
{...restProps}
|
||||
use:tooltipAction={{ content: tooltip }}
|
||||
>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
|
|
@ -86,9 +75,7 @@
|
|||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{type}
|
||||
{disabled}
|
||||
aria-label={tooltip}
|
||||
{...restProps}
|
||||
use:tooltipAction={{ content: tooltip }}
|
||||
>
|
||||
{@render children?.()}
|
||||
</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 = (
|
||||
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',
|
||||
allowHTML: false,
|
||||
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
|
||||
allowHTML: true,
|
||||
}
|
||||
|
||||
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')
|
||||
|
||||
/** Wie man zu einem Event dazukommt. */
|
||||
export const PARTICIPATION_LABEL: Record<string, string> = {
|
||||
offen: 'Offen',
|
||||
bestaetigung: 'Mit Bestätigung',
|
||||
geschlossen: 'Geschlossen',
|
||||
}
|
||||
|
||||
export class EventStore {
|
||||
records = $state<EventsResponse[]>([])
|
||||
loading = $state(false)
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ import type { RidersResponse, UsersResponse } from '$lib/types'
|
|||
|
||||
const KEY = Symbol('rider')
|
||||
|
||||
/**
|
||||
* Der Kader wird nach Namen sortiert. Nach Startnummern ginge es nicht mehr:
|
||||
* Die gelten seit 1754501300 je Event und stehen am Teilnehmer.
|
||||
*/
|
||||
function sortByName(records: RidersResponse[]) {
|
||||
return [...records].sort((a, b) => fullName(a).localeCompare(fullName(b)))
|
||||
function sortByNumber(records: RidersResponse[]) {
|
||||
return [...records].sort((a, b) => {
|
||||
const na = parseInt(a.number ?? '0', 10)
|
||||
const nb = parseInt(b.number ?? '0', 10)
|
||||
return na - nb
|
||||
})
|
||||
}
|
||||
|
||||
export function fullName(r: RidersResponse) {
|
||||
|
|
@ -48,7 +48,7 @@ export class RiderStore {
|
|||
this.error = null
|
||||
try {
|
||||
const raw = await api.collection('riders').getFullList({ requestKey: null })
|
||||
this.records = sortByName(raw)
|
||||
this.records = sortByNumber(raw)
|
||||
} catch (e: any) {
|
||||
this.error = e.message ?? 'Fehler beim Laden der Fahrer'
|
||||
console.error(e)
|
||||
|
|
@ -62,10 +62,10 @@ export class RiderStore {
|
|||
api.collection('riders').subscribe('*', (e) => {
|
||||
const idx = this.records.findIndex((r) => r.id === e.record.id)
|
||||
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) {
|
||||
this.records[idx] = e.record
|
||||
this.records = sortByName(this.records)
|
||||
this.records = sortByNumber(this.records)
|
||||
} else if (e.action === 'delete' && idx !== -1) {
|
||||
this.records = this.records.filter((r) => r.id !== e.record.id)
|
||||
}
|
||||
|
|
@ -88,7 +88,7 @@ export class RiderStore {
|
|||
return new Promise<void>((resolve, reject) => {
|
||||
app.confirm.request({
|
||||
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 () => {
|
||||
try {
|
||||
await api.collection('riders').delete(record.id)
|
||||
|
|
@ -139,11 +139,22 @@ export class RiderStore {
|
|||
return user
|
||||
}
|
||||
|
||||
getByEvent(eventId: string) {
|
||||
return this.scoped.filter((r) => r.event === eventId)
|
||||
}
|
||||
|
||||
isNumberTaken(number: string, eventId: string, excludeId?: string) {
|
||||
return this.scoped.some(
|
||||
(r) => r.number === number && r.event === eventId && r.id !== excludeId,
|
||||
)
|
||||
}
|
||||
|
||||
get filtered() {
|
||||
const q = this.searchQuery.toLowerCase().trim()
|
||||
if (!q) return this.scoped
|
||||
return this.scoped.filter((r) => {
|
||||
return (
|
||||
(r.number ?? '').toLowerCase().includes(q) ||
|
||||
(r.firstname ?? '').toLowerCase().includes(q) ||
|
||||
(r.lastname ?? '').toLowerCase().includes(q) ||
|
||||
(r.name ?? '').toLowerCase().includes(q)
|
||||
|
|
|
|||
|
|
@ -112,20 +112,6 @@ export class TeamStore {
|
|||
return await api.collection('teams').update(id, { name })
|
||||
}
|
||||
|
||||
/**
|
||||
* Logo setzen oder entfernen. Mit Datei als FormData — ein leeres Feld im
|
||||
* FormData würde ein vorhandenes Bild löschen, deshalb der zweite Weg.
|
||||
*/
|
||||
async setLogo(id: string, file: File | null) {
|
||||
if (!file) {
|
||||
return await api.collection('teams').update(id, { logo: null })
|
||||
}
|
||||
|
||||
const data = new FormData()
|
||||
data.append('logo', file)
|
||||
return await api.collection('teams').update(id, data)
|
||||
}
|
||||
|
||||
/** Mitglied hinzufügen (Owner/Admin only) */
|
||||
async addMember(teamId: string, userId: string) {
|
||||
const team = this.records.find((t) => t.id === teamId)
|
||||
|
|
|
|||
|
|
@ -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 { api, auth } from './pocketbase.svelte'
|
||||
import { getTeamContext } from './teams.svelte'
|
||||
import { isRecent } from '$lib/time'
|
||||
import type { TrailMarkersResponse, UsersResponse } from '$lib/types'
|
||||
|
||||
const KEY = Symbol('trailMarker')
|
||||
|
|
@ -29,11 +28,6 @@ export class TrailMarkerStore {
|
|||
.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 */
|
||||
openByTrail(trailId: string): TrailMarker[] {
|
||||
return this.byTrail(trailId).filter((r) => !r.resolved)
|
||||
|
|
|
|||
|
|
@ -70,19 +70,3 @@ export function formatDateRange(starts: string | undefined, ends: string | undef
|
|||
|
||||
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'
|
||||
|
||||
export enum Collections {
|
||||
EventParticipants = "event_participants",
|
||||
EventSeries = "event_series",
|
||||
Events = "events",
|
||||
Riders = "riders",
|
||||
Runs = "runs",
|
||||
|
|
@ -48,78 +46,28 @@ export type AuthSystemFields<T = unknown> = {
|
|||
|
||||
// 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 = {
|
||||
created?: IsoDateString
|
||||
created_by?: RecordIdString
|
||||
description?: string
|
||||
ends?: IsoDateString
|
||||
gps?: string
|
||||
id: string
|
||||
location?: string
|
||||
name?: string
|
||||
participation?: EventsParticipationOptions
|
||||
series?: RecordIdString
|
||||
starts?: IsoDateString
|
||||
team?: RecordIdString
|
||||
timing?: boolean
|
||||
updated?: IsoDateString
|
||||
}
|
||||
|
||||
export type RidersRecord = {
|
||||
avatar?: string
|
||||
created?: IsoDateString
|
||||
event?: RecordIdString
|
||||
firstname?: string
|
||||
id: string
|
||||
lastname?: string
|
||||
name?: string
|
||||
number?: string
|
||||
team?: RecordIdString
|
||||
updated?: IsoDateString
|
||||
user?: RecordIdString
|
||||
|
|
@ -138,7 +86,6 @@ export type TeamsRecord = {
|
|||
admins?: RecordIdString[]
|
||||
created?: IsoDateString
|
||||
id: string
|
||||
logo?: string
|
||||
name: string
|
||||
owner: RecordIdString
|
||||
updated?: IsoDateString
|
||||
|
|
@ -263,8 +210,6 @@ export type UsersRecord = {
|
|||
}
|
||||
|
||||
// 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 RidersResponse<Texpand = unknown> = Required<RidersRecord> & 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
|
||||
|
||||
export type CollectionRecords = {
|
||||
event_participants: EventParticipantsRecord
|
||||
event_series: EventSeriesRecord
|
||||
events: EventsRecord
|
||||
riders: RidersRecord
|
||||
runs: RunsRecord
|
||||
|
|
@ -296,8 +239,6 @@ export type CollectionRecords = {
|
|||
}
|
||||
|
||||
export type CollectionResponses = {
|
||||
event_participants: EventParticipantsResponse
|
||||
event_series: EventSeriesResponse
|
||||
events: EventsResponse
|
||||
riders: RidersResponse
|
||||
runs: RunsResponse
|
||||
|
|
@ -315,8 +256,6 @@ export type CollectionResponses = {
|
|||
// https://github.com/pocketbase/js-sdk#specify-typescript-definitions
|
||||
|
||||
export type TypedPocketBase = PocketBase & {
|
||||
collection(idOrName: 'event_participants'): RecordService<EventParticipantsResponse>
|
||||
collection(idOrName: 'event_series'): RecordService<EventSeriesResponse>
|
||||
collection(idOrName: 'events'): RecordService<EventsResponse>
|
||||
collection(idOrName: 'riders'): RecordService<RidersResponse>
|
||||
collection(idOrName: 'runs'): RecordService<RunsResponse>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,16 @@
|
|||
<script lang="ts">
|
||||
import { auth } from '$lib/stores/pocketbase.svelte'
|
||||
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 { setEventContext } from '$lib/stores/events.svelte'
|
||||
import { setRunContext } from '$lib/stores/runs.svelte'
|
||||
import { setRiderContext } from '$lib/stores/riders.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 { setTrailVersionContext } from '$lib/stores/trailVersions.svelte'
|
||||
import { setTrailFlagContext } from '$lib/stores/trailFlags.svelte'
|
||||
import { setTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
|
||||
import { setTrailCommentContext } from '$lib/stores/trailComments.svelte'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import Logo from '$lib/components/Logo.svelte'
|
||||
|
|
@ -33,14 +30,10 @@
|
|||
const runs = setRunContext()
|
||||
const riders = setRiderContext()
|
||||
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 trailVersions = setTrailVersionContext()
|
||||
const trailFlags = setTrailFlagContext()
|
||||
const trailMarkers = setTrailMarkerContext()
|
||||
const trailComments = setTrailCommentContext()
|
||||
|
||||
let isMobileMenuOpen = $state(false)
|
||||
|
||||
|
|
@ -53,21 +46,17 @@
|
|||
await teams.load()
|
||||
teams.subscribe()
|
||||
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(),
|
||||
trailComments.load(),
|
||||
])
|
||||
events.subscribe()
|
||||
runs.subscribe()
|
||||
riders.subscribe()
|
||||
times.subscribe()
|
||||
participants.subscribe()
|
||||
series.subscribe()
|
||||
trails.subscribe()
|
||||
trailVersions.subscribe()
|
||||
trailFlags.subscribe()
|
||||
trailMarkers.subscribe()
|
||||
trailComments.subscribe()
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
|
|
@ -76,13 +65,10 @@
|
|||
runs.destroy()
|
||||
riders.destroy()
|
||||
times.destroy()
|
||||
participants.destroy()
|
||||
series.destroy()
|
||||
trails.destroy()
|
||||
trailVersions.destroy()
|
||||
trailFlags.destroy()
|
||||
trailMarkers.destroy()
|
||||
trailComments.destroy()
|
||||
})
|
||||
|
||||
function handleLogout() {
|
||||
|
|
@ -110,28 +96,15 @@
|
|||
</script>
|
||||
|
||||
{#if auth.user}
|
||||
<div class="relative min-h-screen bg-background">
|
||||
<!--
|
||||
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="min-h-screen bg-background">
|
||||
<header class="border-b bg-card">
|
||||
<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">
|
||||
<!-- Links: Logo und Menü. Das Logo fuellt seine Box, in
|
||||
voller Groesse wuerde es die 64 px hohe Leiste
|
||||
ueberragen und beschnitten wirken. -->
|
||||
<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">
|
||||
{#each nav as item}
|
||||
|
|
@ -155,8 +128,8 @@
|
|||
<div class="flex items-center justify-between gap-3">
|
||||
<a
|
||||
href="/dashboard/settings"
|
||||
title="Profil"
|
||||
class="flex min-w-0 items-center gap-2"
|
||||
use:tooltip={{ content: 'Profil' }}
|
||||
>
|
||||
<Avatar user={auth.user} size={24} />
|
||||
<span class="truncate text-sm font-medium leading-none">
|
||||
|
|
@ -170,7 +143,7 @@
|
|||
size="icon"
|
||||
class="size-7"
|
||||
onclick={toggleMode}
|
||||
tooltip="Design wechseln"
|
||||
title="Design wechseln"
|
||||
>
|
||||
{#if mode.current === 'dark'}
|
||||
<Sun class="h-4 w-4" />
|
||||
|
|
@ -183,7 +156,7 @@
|
|||
size="icon"
|
||||
class="size-7"
|
||||
onclick={handleLogout}
|
||||
tooltip="Abmelden"
|
||||
title="Abmelden"
|
||||
>
|
||||
<LogOut class="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -200,7 +173,7 @@
|
|||
size="icon"
|
||||
class="md:hidden"
|
||||
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
|
||||
tooltip="Menü"
|
||||
title="Menü"
|
||||
>
|
||||
{#if isMobileMenuOpen}
|
||||
<X class="h-4 w-4" />
|
||||
|
|
@ -238,14 +211,14 @@
|
|||
<TeamSwitcher />
|
||||
</div>
|
||||
<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'}
|
||||
<Sun class="h-4 w-4" />
|
||||
{:else}
|
||||
<Moon class="h-4 w-4" />
|
||||
{/if}
|
||||
</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" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -255,7 +228,7 @@
|
|||
{/if}
|
||||
</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?.()}
|
||||
</main>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,56 +1,28 @@
|
|||
<script lang="ts">
|
||||
import { getEventContext } from '$lib/stores/events.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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import * as Dialog from '@/components/ui/dialog'
|
||||
import { dayOf, formatDateRange, formatDay } from '$lib/time'
|
||||
import { auth } from '$lib/stores/pocketbase.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 { dayOf, formatDateRange } from '$lib/time'
|
||||
import { Calendar, MapPin, Plus, Pencil, Trash2 } from 'lucide-svelte'
|
||||
import type { EventsResponse } from '$lib/types'
|
||||
|
||||
const events = getEventContext()
|
||||
const runs = getRunContext()
|
||||
const participants = getEventParticipantContext()
|
||||
const series = getEventSeriesContext()
|
||||
|
||||
let showDialog = $state(false)
|
||||
let editing = $state<EventsResponse | null>(null)
|
||||
let form = $state({
|
||||
name: '',
|
||||
description: '',
|
||||
location: '',
|
||||
starts: '',
|
||||
ends: '',
|
||||
timing: false,
|
||||
participation: 'offen',
|
||||
})
|
||||
let form = $state({ name: '', description: '', location: '', starts: '', ends: '' })
|
||||
let multiDay = $state(false)
|
||||
let formError = $state<string | null>(null)
|
||||
|
||||
function openCreate() {
|
||||
editing = null
|
||||
form = {
|
||||
name: '',
|
||||
description: '',
|
||||
location: '',
|
||||
starts: '',
|
||||
ends: '',
|
||||
timing: false,
|
||||
participation: 'offen',
|
||||
}
|
||||
form = { name: '', description: '', location: '', starts: '', ends: '' }
|
||||
multiDay = false
|
||||
formError = null
|
||||
showDialog = true
|
||||
|
|
@ -64,8 +36,6 @@
|
|||
location: e.location ?? '',
|
||||
starts: dayOf(e.starts),
|
||||
ends: dayOf(e.ends),
|
||||
timing: !!e.timing,
|
||||
participation: e.participation || 'offen',
|
||||
}
|
||||
// Ein gespeichertes Enddatum ist das Kennzeichen für mehrtägig; ein
|
||||
// eigenes Feld dafür wäre eine zweite Wahrheit über dieselbe Sache.
|
||||
|
|
@ -92,109 +62,14 @@
|
|||
// dasselbe Datum zweimal.
|
||||
const ends = multiDay && form.ends !== form.starts ? form.ends : ''
|
||||
const data = { ...form, ends }
|
||||
if (editing) await events.edit(editing.id, data as any)
|
||||
else await events.create({ ...data, created_by: auth.user?.id } as any)
|
||||
if (editing) await events.edit(editing.id, data)
|
||||
else await events.create(data)
|
||||
showDialog = false
|
||||
} catch (e: any) {
|
||||
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([
|
||||
{ title: 'Läuft gerade', items: events.active },
|
||||
{ title: 'Anstehend', items: events.upcoming },
|
||||
|
|
@ -217,69 +92,6 @@
|
|||
</Button>
|
||||
</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}
|
||||
<div class="text-center py-12 text-muted-foreground">Lade Events…</div>
|
||||
{:else if events.scoped.length === 0}
|
||||
|
|
@ -324,28 +136,16 @@
|
|||
{#if event.description && event.description !== event.name}
|
||||
<p class="text-sm text-muted-foreground line-clamp-2">{event.description}</p>
|
||||
{/if}
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
{#if event.timing}
|
||||
<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 class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">{runCount} Run{runCount === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</a>
|
||||
<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" />
|
||||
</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" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -407,37 +207,6 @@
|
|||
</div>
|
||||
{/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}
|
||||
<p class="text-sm text-destructive">{formError}</p>
|
||||
{/if}
|
||||
|
|
@ -449,86 +218,3 @@
|
|||
</form>
|
||||
</Dialog.Content>
|
||||
</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 { fullName, getRiderContext } from '$lib/stores/riders.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 * as Select from '@/components/ui/select'
|
||||
import { formatDateRange } from '$lib/time'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
|
@ -32,39 +24,18 @@
|
|||
Timer,
|
||||
Trash2,
|
||||
Users,
|
||||
UserPlus,
|
||||
Check,
|
||||
X,
|
||||
} from 'lucide-svelte'
|
||||
import type { EventParticipantsResponse, RunsResponse } from '$lib/types'
|
||||
import type { RidersResponse, RunsResponse } from '$lib/types'
|
||||
|
||||
const events = getEventContext()
|
||||
const runs = getRunContext()
|
||||
const riders = getRiderContext()
|
||||
const times = getTimeContext()
|
||||
const teams = getTeamContext()
|
||||
const participants = getEventParticipantContext()
|
||||
|
||||
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) : [])
|
||||
|
||||
/**
|
||||
* 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.
|
||||
// Die Übersicht zeigt deshalb pro Zeile, was dort schon passiert ist.
|
||||
function riderPath(riderId: string) {
|
||||
|
|
@ -78,87 +49,42 @@
|
|||
// Zeit-Dialog: der einzige Weg, eine Zeit zu starten oder zu stoppen.
|
||||
let timeDialog = $state(false)
|
||||
|
||||
// --- Teilnehmer -------------------------------------------------------
|
||||
// Fahrer-Dialog
|
||||
let riderDialog = $state(false)
|
||||
let editing = $state<EventParticipantsResponse | null>(null)
|
||||
let form = $state({ rider: '', number: '' })
|
||||
let formError = $state<string | null>(null)
|
||||
let ridingEdit = $state<RidersResponse | null>(null)
|
||||
let riderForm = $state({ number: '', firstname: '', lastname: '' })
|
||||
|
||||
const available = $derived(event ? participants.available(event.id) : [])
|
||||
|
||||
function openAdd() {
|
||||
editing = null
|
||||
form = { rider: available[0]?.id ?? '', number: '' }
|
||||
formError = null
|
||||
function openRiderCreate() {
|
||||
ridingEdit = null
|
||||
riderForm = { number: '', firstname: '', lastname: '' }
|
||||
riderDialog = true
|
||||
}
|
||||
|
||||
function openEdit(p: EventParticipantsResponse) {
|
||||
editing = p
|
||||
form = { rider: p.rider, number: p.number ?? '' }
|
||||
formError = null
|
||||
function openRiderEdit(r: RidersResponse) {
|
||||
ridingEdit = r
|
||||
riderForm = {
|
||||
number: r.number ?? '',
|
||||
firstname: r.firstname ?? '',
|
||||
lastname: r.lastname ?? '',
|
||||
}
|
||||
riderDialog = true
|
||||
}
|
||||
|
||||
async function saveParticipant() {
|
||||
async function saveRider() {
|
||||
if (!event) return
|
||||
|
||||
if (!form.rider) {
|
||||
formError = 'Bitte einen Fahrer wählen.'
|
||||
return
|
||||
if (!riderForm.number.trim()) return alert('Startnummer ist Pflicht')
|
||||
if (riders.isNumberTaken(riderForm.number, event.id, ridingEdit?.id)) {
|
||||
return alert(`Startnummer ${riderForm.number} ist in diesem Event bereits vergeben`)
|
||||
}
|
||||
if (participants.isNumberTaken(event.id, form.number, editing?.id)) {
|
||||
formError = `Startnummer ${form.number} ist in diesem Event bereits vergeben.`
|
||||
return
|
||||
}
|
||||
|
||||
formError = null
|
||||
try {
|
||||
if (editing) {
|
||||
await participants.edit(editing.id, { number: form.number.trim() })
|
||||
if (ridingEdit) {
|
||||
await riders.edit(ridingEdit.id, riderForm)
|
||||
} else {
|
||||
await participants.add({
|
||||
event: event.id,
|
||||
rider: form.rider,
|
||||
number: form.number.trim(),
|
||||
})
|
||||
await riders.create({ ...riderForm, event: event.id })
|
||||
}
|
||||
riderDialog = false
|
||||
} catch (e: any) {
|
||||
formError = e.message ?? 'Der Teilnehmer konnte nicht gespeichert werden.'
|
||||
}
|
||||
}
|
||||
|
||||
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.')
|
||||
alert(`Fehler: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -236,7 +162,7 @@
|
|||
|
||||
<Button
|
||||
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" />
|
||||
Zeit erfassen
|
||||
|
|
@ -245,58 +171,42 @@
|
|||
|
||||
<Separator />
|
||||
|
||||
<!-- Teilnehmer -->
|
||||
<!-- Fahrer -->
|
||||
<section class="space-y-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold flex items-center gap-2">
|
||||
<Users class="h-5 w-5" />
|
||||
Teilnehmer
|
||||
<span class="text-muted-foreground font-normal text-base">({eventParticipants.length})</span>
|
||||
Fahrer
|
||||
<span class="text-muted-foreground font-normal text-base">({eventRiders.length})</span>
|
||||
</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{#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>
|
||||
<p class="text-sm text-muted-foreground">Klick auf einen Fahrer zeigt seine Runs</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}>
|
||||
{#if riders.canManage}
|
||||
<Button onclick={openRiderCreate}>
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Teilnehmer
|
||||
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>
|
||||
<CardContent class="p-0">
|
||||
{#if eventParticipants.length === 0}
|
||||
{#if eventRiders.length === 0}
|
||||
<div class="py-12 text-center">
|
||||
<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>
|
||||
{#if riders.canManage}
|
||||
<p class="text-sm text-muted-foreground mt-1 mb-4">Füge den ersten Fahrer hinzu.</p>
|
||||
<Button onclick={openRiderCreate}>
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Ersten Fahrer hinzufügen
|
||||
</Button>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Fahrer gehören dem Team; hier steht, wer bei diesem Event
|
||||
mitfährt.
|
||||
Fahrer legt die Teamleitung an.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<Table.Root>
|
||||
|
|
@ -304,102 +214,38 @@
|
|||
<Table.Row>
|
||||
<Table.Head class="w-20">#</Table.Head>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head class="w-32">Status</Table.Head>
|
||||
{#if event.timing}
|
||||
<Table.Head class="w-40">Zeiten</Table.Head>
|
||||
{/if}
|
||||
<Table.Head class="text-right">Aktionen</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each eventParticipants as p (p.id)}
|
||||
{@const rider = riders.getById(p.rider)}
|
||||
{@const done = times.getByRider(p.rider).filter((t) => t.status === 'finished').length}
|
||||
<Table.Row class="cursor-pointer" onclick={() => goto(riderPath(p.rider))}>
|
||||
{#each eventRiders as rider (rider.id)}
|
||||
{@const done = times.getByRider(rider.id).filter((t) => t.status === 'finished').length}
|
||||
<Table.Row class="cursor-pointer" onclick={() => goto(riderPath(rider.id))}>
|
||||
<Table.Cell>
|
||||
<Badge variant="outline">{p.number || '—'}</Badge>
|
||||
<Badge variant="outline">{rider.number || '—'}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-medium">
|
||||
<!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. -->
|
||||
<a href={riderPath(p.rider)} class="hover:underline">
|
||||
{#if rider && fullName(rider)}
|
||||
<a href={riderPath(rider.id)} class="hover:underline">
|
||||
{#if fullName(rider)}
|
||||
{fullName(rider)}
|
||||
{:else}
|
||||
<span class="text-muted-foreground italic">— ohne Name —</span>
|
||||
{/if}
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge
|
||||
variant={p.status === 'zugesagt'
|
||||
? 'default'
|
||||
: p.status === 'angefragt'
|
||||
? 'secondary'
|
||||
: 'outline'}
|
||||
>
|
||||
{PARTICIPANT_STATUS_LABEL[p.status] ?? p.status}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
{#if event.timing}
|
||||
<Table.Cell class="text-muted-foreground text-sm">
|
||||
{done} von {eventRuns.length} Runs
|
||||
</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)}
|
||||
>
|
||||
<Table.Cell class="text-right" onclick={(e) => e.stopPropagation()}>
|
||||
{#if riders.canManage}
|
||||
<Button variant="ghost" size="icon" onclick={() => openRiderEdit(rider)}>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
tooltip="Entfernen"
|
||||
onclick={() => removeParticipant(p)}
|
||||
>
|
||||
<Button variant="ghost" size="icon" onclick={() => riders.remove(rider)}>
|
||||
<Trash2 class="h-4 w-4 text-destructive" />
|
||||
</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}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
|
|
@ -411,8 +257,9 @@
|
|||
</Card>
|
||||
</section>
|
||||
|
||||
<!-- Runs — nur bei Events mit Zeitnahme -->
|
||||
{#if event.timing}
|
||||
<Separator />
|
||||
|
||||
<!-- Runs -->
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
|
|
@ -481,61 +328,44 @@
|
|||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Teilnehmer -->
|
||||
<!-- Fahrer-Dialog -->
|
||||
<Dialog.Root bind:open={riderDialog}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{editing ? 'Startnummer ändern' : 'Teilnehmer hinzufügen'}</Dialog.Title>
|
||||
<Dialog.Title>{ridingEdit ? 'Fahrer bearbeiten' : 'Neuer Fahrer'}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{#if editing}
|
||||
Die Startnummer gilt nur für „{event?.name}".
|
||||
{:else}
|
||||
Aus dem Kader des Teams. Neue Fahrer legt die Teamleitung in den
|
||||
Einstellungen an.
|
||||
{/if}
|
||||
Fahrer für „{event?.name}"
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#if !editing}
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault()
|
||||
saveRider()
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<Label>Fahrer</Label>
|
||||
<Select.Root type="single" bind:value={form.rider}>
|
||||
<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>
|
||||
<Label for="r-number">Startnummer *</Label>
|
||||
<Input id="r-number" bind:value={riderForm.number} placeholder="42" required />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-2">
|
||||
<Label for="p-number">Startnummer</Label>
|
||||
<Input id="p-number" bind:value={form.number} placeholder="optional" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Gilt je Event. Ohne Zeitnahme braucht es sie meist gar nicht.
|
||||
</p>
|
||||
<Label for="r-first">Vorname</Label>
|
||||
<Input id="r-first" bind:value={riderForm.firstname} placeholder="Max" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="r-last">Nachname</Label>
|
||||
<Input id="r-last" bind:value={riderForm.lastname} placeholder="Mustermann" />
|
||||
</div>
|
||||
|
||||
{#if formError}
|
||||
<p class="text-sm text-destructive">{formError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button>
|
||||
<Button onclick={saveParticipant}>{editing ? 'Speichern' : 'Hinzufügen'}</Button>
|
||||
<Button type="button" variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button>
|
||||
<Button type="submit">{ridingEdit ? 'Speichern' : 'Hinzufügen'}</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
||||
import { ticker } from '$lib/stores/ticker.svelte'
|
||||
import { TIME_STATUS_LABEL, formatDuration } from '$lib/time'
|
||||
import TimeDialog from '$lib/components/TimeDialog.svelte'
|
||||
|
|
@ -25,16 +24,13 @@
|
|||
const runs = getRunContext()
|
||||
const riders = getRiderContext()
|
||||
const times = getTimeContext()
|
||||
const participants = getEventParticipantContext()
|
||||
|
||||
const clock = ticker()
|
||||
|
||||
const event = $derived(page.params.id ? events.getById(page.params.id) : 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 entry = $derived(event && rider ? participants.get(event.id, rider.id) : undefined)
|
||||
const valid = $derived(!!event && !!rider && !!entry)
|
||||
const valid = $derived(!!event && !!rider && rider.event === event.id)
|
||||
|
||||
/**
|
||||
* Eine Zeile je Run des Events — auch für Runs ohne Zeit, damit sichtbar
|
||||
|
|
@ -113,9 +109,7 @@
|
|||
{event.name || 'Event'}
|
||||
</a>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if entry?.number}
|
||||
<Badge variant="outline" class="text-lg px-3 py-1">{entry.number}</Badge>
|
||||
{/if}
|
||||
<Badge variant="outline" class="text-lg px-3 py-1">{rider.number || '—'}</Badge>
|
||||
<h1 class="text-3xl font-bold tracking-tight">
|
||||
{fullName(rider) || 'Ohne Name'}
|
||||
</h1>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
||||
import { ticker } from '$lib/stores/ticker.svelte'
|
||||
import { TIME_STATUS_LABEL } from '$lib/time'
|
||||
import TimeDialog from '$lib/components/TimeDialog.svelte'
|
||||
|
|
@ -25,7 +24,6 @@
|
|||
const runs = getRunContext()
|
||||
const riders = getRiderContext()
|
||||
const times = getTimeContext()
|
||||
const participants = getEventParticipantContext()
|
||||
|
||||
const clock = ticker()
|
||||
|
||||
|
|
@ -36,8 +34,7 @@
|
|||
// sie von Hand zusammengebaut oder veraltet.
|
||||
const valid = $derived(!!event && !!run && run.event === event.id)
|
||||
|
||||
// Am Start steht, wer beim Event zugesagt hat.
|
||||
const starters = $derived(event ? participants.confirmedByEvent(event.id) : [])
|
||||
const eventRiders = $derived(event ? riders.getByEvent(event.id) : [])
|
||||
const running = $derived(valid && run ? times.runningByRun(run.id, clock.now) : [])
|
||||
const leaderboard = $derived(valid && run ? times.getLeaderboard(run.id) : [])
|
||||
const unranked = $derived(valid && run ? times.unrankedByRun(run.id) : [])
|
||||
|
|
@ -74,7 +71,7 @@
|
|||
const r = rider(t.rider)
|
||||
rows.push([
|
||||
String(i + 1),
|
||||
event ? participants.numberOf(event.id, t.rider) : '',
|
||||
r?.number ?? '',
|
||||
r ? fullName(r) : '',
|
||||
t.formattedTime ?? '',
|
||||
String(t.correction ?? 0),
|
||||
|
|
@ -122,7 +119,7 @@
|
|||
</a>
|
||||
<h1 class="text-3xl font-bold tracking-tight">{run.description || 'Run'}</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
|
@ -133,18 +130,18 @@
|
|||
CSV
|
||||
</Button>
|
||||
{/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" />
|
||||
Zeit erfassen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if starters.length === 0}
|
||||
{#if eventRiders.length === 0}
|
||||
<Card>
|
||||
<CardContent class="py-6 text-sm text-muted-foreground">
|
||||
Für dieses Event hat noch niemand zugesagt.
|
||||
<a href="/dashboard/events/{event.id}" class="underline">Teilnehmer eintragen</a>
|
||||
Für dieses Event gibt es noch keine Fahrer.
|
||||
<a href="/dashboard/events/{event.id}" class="underline">Jetzt anlegen</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
|
@ -189,9 +186,7 @@
|
|||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant="outline">
|
||||
{participants.numberOf(event.id, t.rider) || '—'}
|
||||
</Badge>
|
||||
<Badge variant="outline">{r?.number ?? '—'}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-medium">
|
||||
{#if r}
|
||||
|
|
@ -228,9 +223,7 @@
|
|||
{@const r = rider(t.rider)}
|
||||
<li class="px-6 py-3 flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Badge variant="outline">
|
||||
{participants.numberOf(event.id, t.rider) || '—'}
|
||||
</Badge>
|
||||
<Badge variant="outline">{r?.number ?? '—'}</Badge>
|
||||
<span class="truncate">
|
||||
{#if r}
|
||||
<a href={riderPath(r.id)} class="hover:underline">{fullName(r) || '—'}</a>
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
*/
|
||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getEventContext } from '$lib/stores/events.svelte'
|
||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
|
@ -18,8 +18,8 @@
|
|||
|
||||
const teams = getTeamContext()
|
||||
const riders = getRiderContext()
|
||||
const events = getEventContext()
|
||||
const times = getTimeContext()
|
||||
const participants = getEventParticipantContext()
|
||||
|
||||
let query = $state('')
|
||||
|
||||
|
|
@ -27,20 +27,25 @@
|
|||
const q = query.toLowerCase().trim()
|
||||
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(() => {
|
||||
teams.loadMembers()
|
||||
})
|
||||
|
||||
/**
|
||||
* Zur Fahrerseite geht es über ein Event, denn Zeiten hängen am Run und
|
||||
* der am Event. Bei mehreren Teilnahmen führt der Link zur jüngsten.
|
||||
*/
|
||||
function riderPath(riderId: string) {
|
||||
const latest = participants.byRider(riderId).at(-1)
|
||||
return latest ? `/dashboard/events/${latest.event}/riders/${riderId}` : null
|
||||
function eventName(id: string | undefined) {
|
||||
if (!id) return null
|
||||
return events.getById(id)?.name ?? 'Unbekanntes Event'
|
||||
}
|
||||
|
||||
/** Zur Fahrerseite geht es nur mit Event — Zeiten hängen daran. */
|
||||
function riderPath(r: { id: string; event?: string }) {
|
||||
return r.event ? `/dashboard/events/${r.event}/riders/${r.id}` : null
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -64,7 +69,7 @@
|
|||
|
||||
<div class="relative max-w-sm">
|
||||
<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>
|
||||
|
||||
{#if riders.scoped.length === 0}
|
||||
|
|
@ -91,16 +96,20 @@
|
|||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-20">#</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="w-48">Konto</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each roster as r (r.id)}
|
||||
{@const path = riderPath(r.id)}
|
||||
{@const path = riderPath(r)}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<Badge variant="outline">{r.number || '—'}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-medium">
|
||||
{#if path}
|
||||
<a href={path} class="hover:underline">{fullName(r) || '— ohne Name —'}</a>
|
||||
|
|
@ -109,7 +118,7 @@
|
|||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground text-sm">
|
||||
{participants.byRider(r.id).length}
|
||||
{eventName(r.event) ?? '—'}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right text-muted-foreground text-sm">
|
||||
{times.getByRider(r.id).length}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Rahmen der Einstellungen: eine Überschrift, darunter Reiter je Bereich.
|
||||
*
|
||||
* „Team" ist die Übersicht des Teams, „Fahrer & Konten" führt die
|
||||
* Menschen: Jedes Mitglied ist ein Fahrer, ein Konto hat aber nicht jeder.
|
||||
* Beides an einer Stelle, weil es sonst zwei Wahrheiten über dieselbe
|
||||
* Person gäbe. Der Reiter erscheint nur für die Teamleitung.
|
||||
* Der Reiter „Fahrer" erscheint nur für die Teamleitung — dort wird
|
||||
* angelegt und verknüpft, was alle anderen nur unter „Fahrer" im
|
||||
* Hauptmenü zu sehen bekommen.
|
||||
*/
|
||||
import { page } from '$app/state'
|
||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||
|
|
@ -19,7 +17,7 @@
|
|||
{ href: '/dashboard/settings', label: 'Profil', icon: User },
|
||||
{ href: '/dashboard/settings/team', label: 'Team', icon: Users },
|
||||
...(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 },
|
||||
])
|
||||
|
|
|
|||
|
|
@ -1,269 +1,162 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Fahrer und Konten an einer Stelle — nur für die Teamleitung.
|
||||
*
|
||||
* Jedes Teammitglied ist ein Fahrer; ein Fahrer braucht aber kein Konto.
|
||||
* 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.
|
||||
* Fahrerverwaltung — nur für die Teamleitung. Was alle sehen dürfen,
|
||||
* steht unter „Fahrer" im Hauptmenü; angelegt, geändert und mit einem
|
||||
* Login verknüpft wird ausschließlich hier.
|
||||
*/
|
||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
||||
import Avatar from '$lib/components/Avatar.svelte'
|
||||
import { getEventContext } from '$lib/stores/events.svelte'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import * as Dialog from '@/components/ui/dialog'
|
||||
import * as Select from '@/components/ui/select'
|
||||
import * as Table from '@/components/ui/table'
|
||||
import {
|
||||
Crown, KeyRound, Mail, Pencil, Plus, Shield, Trash2, Unlink, UserMinus,
|
||||
} from 'lucide-svelte'
|
||||
import type { RidersResponse, UsersResponse } from '$lib/types'
|
||||
import { KeyRound, Pencil, Plus, Trash2, Unlink, UserCheck } from 'lucide-svelte'
|
||||
import type { RidersResponse } from '$lib/types'
|
||||
|
||||
const teams = getTeamContext()
|
||||
const riders = getRiderContext()
|
||||
const participants = getEventParticipantContext()
|
||||
const events = getEventContext()
|
||||
|
||||
const canManage = $derived(riders.canManage)
|
||||
const team = $derived(teams.active)
|
||||
const iAmOwner = $derived(teams.isOwner())
|
||||
const roster = $derived(riders.scoped)
|
||||
|
||||
$effect(() => {
|
||||
teams.loadMembers()
|
||||
})
|
||||
|
||||
type Row = {
|
||||
rider?: RidersResponse
|
||||
userId?: string
|
||||
name: string
|
||||
role: 'owner' | 'admin' | 'mitglied' | null
|
||||
function eventName(id: string | undefined) {
|
||||
if (!id) return null
|
||||
return events.getById(id)?.name ?? 'Unbekanntes Event'
|
||||
}
|
||||
|
||||
function roleOf(userId: string | undefined): Row['role'] {
|
||||
if (!userId || !team) return null
|
||||
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)
|
||||
// --- Fahrer anlegen und ändern ---------------------------------------
|
||||
let riderDialog = $state(false)
|
||||
let editing = $state<RidersResponse | null>(null)
|
||||
let form = $state({ firstname: '', lastname: '', withAccount: false, email: '', password: '' })
|
||||
let busy = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
let form = $state({ number: '', firstname: '', lastname: '', event: '' })
|
||||
let riderError = $state<string | null>(null)
|
||||
|
||||
function openCreate() {
|
||||
editing = null
|
||||
form = { firstname: '', lastname: '', withAccount: false, email: '', password: '' }
|
||||
error = null
|
||||
personDialog = true
|
||||
form = { number: '', firstname: '', lastname: '', event: '' }
|
||||
riderError = null
|
||||
riderDialog = true
|
||||
}
|
||||
|
||||
function openEdit(rider: RidersResponse) {
|
||||
editing = rider
|
||||
function openEdit(r: RidersResponse) {
|
||||
editing = r
|
||||
form = {
|
||||
firstname: rider.firstname ?? '',
|
||||
lastname: rider.lastname ?? '',
|
||||
withAccount: false,
|
||||
email: '',
|
||||
password: '',
|
||||
number: r.number ?? '',
|
||||
firstname: r.firstname ?? '',
|
||||
lastname: r.lastname ?? '',
|
||||
event: r.event ?? '',
|
||||
}
|
||||
error = null
|
||||
personDialog = true
|
||||
riderError = null
|
||||
riderDialog = true
|
||||
}
|
||||
|
||||
async function savePerson() {
|
||||
if (!form.firstname.trim() && !form.lastname.trim()) {
|
||||
error = 'Bitte wenigstens einen Namen angeben.'
|
||||
async function saveRider() {
|
||||
if (!form.number.trim()) {
|
||||
riderError = 'Die Startnummer ist Pflicht.'
|
||||
return
|
||||
}
|
||||
if (form.withAccount) {
|
||||
if (!form.email.trim()) {
|
||||
error = 'Für ein Konto braucht es eine E-Mail-Adresse.'
|
||||
// Startnummern sind je Event eindeutig; Fahrer ohne Event teilen sich
|
||||
// den gemeinsamen Topf des Teams.
|
||||
if (riders.isNumberTaken(form.number.trim(), form.event, editing?.id)) {
|
||||
riderError = `Startnummer ${form.number} ist dort bereits vergeben.`
|
||||
return
|
||||
}
|
||||
if (form.password.length < 8) {
|
||||
error = 'Das Passwort braucht mindestens 8 Zeichen.'
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
busy = true
|
||||
error = null
|
||||
riderError = null
|
||||
try {
|
||||
const data = { firstname: form.firstname.trim(), lastname: form.lastname.trim() }
|
||||
const data = {
|
||||
number: form.number.trim(),
|
||||
firstname: form.firstname.trim(),
|
||||
lastname: form.lastname.trim(),
|
||||
event: form.event,
|
||||
}
|
||||
if (editing) await riders.edit(editing.id, data)
|
||||
else await riders.create(data)
|
||||
riderDialog = false
|
||||
} catch (e: any) {
|
||||
riderError = e.message ?? 'Der Fahrer konnte nicht gespeichert werden.'
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
// --- Login verknüpfen -------------------------------------------------
|
||||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
personDialog = false
|
||||
} catch (e: any) {
|
||||
error = e.message ?? 'Die Person konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
|
||||
function openLogin(r: RidersResponse) {
|
||||
loginRider = r
|
||||
loginMode = 'link'
|
||||
linkUserId = r.user ?? ''
|
||||
loginForm = { email: '', password: '' }
|
||||
loginError = null
|
||||
loginDialog = true
|
||||
}
|
||||
|
||||
// --- 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)
|
||||
async function doLink() {
|
||||
if (!loginRider || !linkUserId) return
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
accountBusy = true
|
||||
accountError = null
|
||||
loginBusy = true
|
||||
loginError = null
|
||||
try {
|
||||
await riders.createLogin(accountRider, { ...accountForm })
|
||||
accountDialog = false
|
||||
await riders.linkUser(loginRider.id, linkUserId)
|
||||
loginDialog = false
|
||||
} catch (e: any) {
|
||||
accountError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
|
||||
loginError = e.message ?? 'Die Verknüpfung ist fehlgeschlagen.'
|
||||
} finally {
|
||||
accountBusy = false
|
||||
loginBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function searchAccount() {
|
||||
if (!searchEmail.trim()) return
|
||||
|
||||
searching = true
|
||||
accountError = null
|
||||
found = null
|
||||
async function doUnlink(r: RidersResponse) {
|
||||
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)
|
||||
accountDialog = false
|
||||
} catch (e: any) {
|
||||
accountError = e.message ?? 'Die Verknüpfung ist fehlgeschlagen.'
|
||||
} finally {
|
||||
accountBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function unlink(rider: RidersResponse) {
|
||||
if (!confirm('Verknüpfung lösen? Das Konto bleibt bestehen, der Fahrer auch.')) return
|
||||
|
||||
try {
|
||||
await riders.linkUser(rider.id, null)
|
||||
await riders.linkUser(r.id, null)
|
||||
} catch (e: any) {
|
||||
alert(e.message ?? 'Die Verknüpfung konnte nicht gelöst werden.')
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein Konto ohne Fahrer nachträglich in den Kader holen. */
|
||||
async function adoptAccount(userId: string) {
|
||||
const user = teams.users[userId]
|
||||
async function doCreateLogin() {
|
||||
if (!loginRider) return
|
||||
|
||||
if (!loginForm.email.trim()) {
|
||||
loginError = 'Ohne E-Mail-Adresse gibt es keinen Anmeldenamen.'
|
||||
return
|
||||
}
|
||||
if (loginForm.password.length < 8) {
|
||||
loginError = 'Das Passwort braucht mindestens 8 Zeichen.'
|
||||
return
|
||||
}
|
||||
|
||||
loginBusy = true
|
||||
loginError = null
|
||||
try {
|
||||
const rider = await riders.create({
|
||||
firstname: user?.name ?? '',
|
||||
lastname: '',
|
||||
})
|
||||
await riders.linkUser(rider.id, userId)
|
||||
await riders.createLogin(loginRider, { ...loginForm })
|
||||
loginDialog = false
|
||||
} catch (e: any) {
|
||||
alert(e.message ?? 'Der Fahrer konnte nicht angelegt werden.')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFromTeam(userId: string) {
|
||||
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
|
||||
|
||||
try {
|
||||
if (makeAdmin) await teams.promoteToAdmin(team.id, userId)
|
||||
else await teams.demoteFromAdmin(team.id, userId)
|
||||
} 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>
|
||||
|
|
@ -271,27 +164,27 @@
|
|||
{#if !canManage}
|
||||
<Card>
|
||||
<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>.
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else}
|
||||
<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">
|
||||
Jedes Teammitglied ist ein Fahrer — ein Konto braucht es dafür nicht.
|
||||
Wer sich anmelden können soll, bekommt eins.
|
||||
Fahrer des Teams. Ein verknüpftes Konto erlaubt der Person, sich selbst
|
||||
anzumelden.
|
||||
</p>
|
||||
<Button onclick={openCreate}>
|
||||
<Plus class="size-4 mr-2" />
|
||||
Person anlegen
|
||||
Fahrer anlegen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if rows.length === 0}
|
||||
{#if roster.length === 0}
|
||||
<Card>
|
||||
<CardContent class="py-12 text-center text-muted-foreground">
|
||||
Noch niemand im Team.
|
||||
Noch keine Fahrer im Team.
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else}
|
||||
|
|
@ -300,129 +193,56 @@
|
|||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-20">#</Table.Head>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head class="w-56">Konto</Table.Head>
|
||||
<Table.Head class="w-32">Rolle</Table.Head>
|
||||
<Table.Head class="w-24">Events</Table.Head>
|
||||
<Table.Head>Event</Table.Head>
|
||||
<Table.Head>Konto</Table.Head>
|
||||
<Table.Head class="text-right">Aktionen</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each rows as row (row.rider?.id ?? row.userId)}
|
||||
{#each roster as r (r.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<Badge variant="outline">{r.number || '—'}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-medium">
|
||||
<span class="flex items-center gap-2">
|
||||
<Avatar user={row.userId ? teams.users[row.userId] : null} size={28} />
|
||||
{row.name}
|
||||
{#if !row.rider}
|
||||
<Badge variant="outline">kein Fahrer</Badge>
|
||||
{/if}
|
||||
</span>
|
||||
{fullName(r) || '— ohne Name —'}
|
||||
</Table.Cell>
|
||||
|
||||
<Table.Cell>
|
||||
{#if row.userId}
|
||||
<span class="text-sm text-muted-foreground truncate">
|
||||
{teams.userLabel(row.userId)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-sm text-muted-foreground">kein Konto</span>
|
||||
{/if}
|
||||
<Table.Cell class="text-muted-foreground text-sm">
|
||||
{eventName(r.event) ?? '—'}
|
||||
</Table.Cell>
|
||||
|
||||
<Table.Cell>
|
||||
{#if row.role === 'owner'}
|
||||
<Badge class="gap-1">
|
||||
<Crown class="size-3" />
|
||||
Owner
|
||||
</Badge>
|
||||
{:else if row.role === 'admin'}
|
||||
{#if r.user}
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<Badge variant="secondary" class="gap-1">
|
||||
<Shield class="size-3" />
|
||||
Admin
|
||||
<UserCheck class="size-3" />
|
||||
kann sich einloggen
|
||||
</Badge>
|
||||
{:else if row.role === 'mitglied'}
|
||||
<Badge variant="outline">Mitglied</Badge>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{teams.userLabel(r.user)}
|
||||
</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-sm text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</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">
|
||||
{#if iAmOwner && row.userId && row.role !== 'owner'}
|
||||
<Button
|
||||
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!)}
|
||||
>
|
||||
{#if r.user}
|
||||
<Button variant="ghost" size="icon" title="Verknüpfung lösen" onclick={() => doUnlink(r)}>
|
||||
<Unlink class="size-4" />
|
||||
</Button>
|
||||
{:else if row.userId}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => adoptAccount(row.userId!)}
|
||||
>
|
||||
Als Fahrer übernehmen
|
||||
{:else}
|
||||
<Button variant="ghost" size="icon" title="Konto verknüpfen" onclick={() => openLogin(r)}>
|
||||
<KeyRound class="size-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if row.rider}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
tooltip="Bearbeiten"
|
||||
onclick={() => openEdit(row.rider!)}
|
||||
>
|
||||
<Button variant="ghost" size="icon" title="Bearbeiten" onclick={() => openEdit(r)}>
|
||||
<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!)}
|
||||
>
|
||||
<Button variant="ghost" size="icon" title="Löschen" onclick={() => riders.remove(r)}>
|
||||
<Trash2 class="size-4 text-destructive" />
|
||||
</Button>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
|
|
@ -434,77 +254,61 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Person anlegen oder bearbeiten -->
|
||||
<Dialog.Root bind:open={personDialog}>
|
||||
<!-- Fahrer anlegen/bearbeiten -->
|
||||
<Dialog.Root bind:open={riderDialog}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{editing ? 'Fahrer bearbeiten' : 'Person anlegen'}</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.Title>{editing ? 'Fahrer bearbeiten' : 'Neuer Fahrer'}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<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="space-y-2">
|
||||
<Label for="p-first">Vorname</Label>
|
||||
<Input id="p-first" bind:value={form.firstname} placeholder="Max" />
|
||||
<Label for="rider-first">Vorname</Label>
|
||||
<Input id="rider-first" bind:value={form.firstname} placeholder="Max" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="p-last">Nachname</Label>
|
||||
<Input id="p-last" bind:value={form.lastname} placeholder="Mustermann" />
|
||||
<Label for="rider-last">Nachname</Label>
|
||||
<Input id="rider-last" bind:value={form.lastname} placeholder="Mustermann" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !editing}
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={form.withAccount}
|
||||
class="size-4 rounded border-input accent-primary"
|
||||
/>
|
||||
kann sich anmelden
|
||||
</label>
|
||||
|
||||
{#if form.withAccount}
|
||||
<div class="space-y-2">
|
||||
<Label for="p-mail">E-Mail</Label>
|
||||
<Input id="p-mail" type="email" bind:value={form.email} placeholder="max@example.com" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="p-pw">Passwort</Label>
|
||||
<Input id="p-pw" type="password" bind:value={form.password} placeholder="mindestens 8 Zeichen" />
|
||||
<Label>Event</Label>
|
||||
<Select.Root type="single" bind:value={form.event}>
|
||||
<Select.Trigger class="w-full">
|
||||
{eventName(form.event) ?? 'Keinem Event zugeordnet'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="">Keinem Event zugeordnet</Select.Item>
|
||||
{#each events.scoped as e (e.id)}
|
||||
<Select.Item value={e.id}>{e.name || e.description}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Das Konto kommt zugleich ins Team. Gib das Passwort weiter; es lässt
|
||||
sich unter „Profil" ändern.
|
||||
Zeiten hängen am Event. Ohne Zuordnung steht der Fahrer nur im
|
||||
Team-Verzeichnis.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-destructive">{error}</p>
|
||||
{#if riderError}
|
||||
<p class="text-sm text-destructive">{riderError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="ghost" onclick={() => (personDialog = false)}>Abbrechen</Button>
|
||||
<Button onclick={savePerson} disabled={busy}>
|
||||
{busy ? 'Wird gespeichert …' : editing ? 'Speichern' : 'Anlegen'}
|
||||
</Button>
|
||||
<Button variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button>
|
||||
<Button onclick={saveRider}>{editing ? 'Speichern' : 'Anlegen'}</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Konto für einen vorhandenen Fahrer -->
|
||||
<Dialog.Root bind:open={accountDialog}>
|
||||
<!-- Konto verknüpfen oder anlegen -->
|
||||
<Dialog.Root bind:open={loginDialog}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
Konto für {accountRider ? fullName(accountRider) : 'Fahrer'}
|
||||
</Dialog.Title>
|
||||
<Dialog.Title>Konto für {loginRider ? fullName(loginRider) : 'Fahrer'}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Mit einem Konto kann sich die Person selbst anmelden.
|
||||
</Dialog.Description>
|
||||
|
|
@ -512,75 +316,69 @@
|
|||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant={accountMode === 'neu' ? 'default' : 'outline'}
|
||||
variant={loginMode === 'link' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => (accountMode = 'neu')}
|
||||
>
|
||||
Neues anlegen
|
||||
</Button>
|
||||
<Button
|
||||
variant={accountMode === 'vorhanden' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => (accountMode = 'vorhanden')}
|
||||
onclick={() => (loginMode = 'link')}
|
||||
>
|
||||
Vorhandenes verknüpfen
|
||||
</Button>
|
||||
<Button
|
||||
variant={loginMode === 'create' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => (loginMode = 'create')}
|
||||
>
|
||||
Neues anlegen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#if accountMode === 'neu'}
|
||||
{#if loginMode === 'link'}
|
||||
<div class="space-y-2">
|
||||
<Label for="a-mail">E-Mail</Label>
|
||||
<Input id="a-mail" type="email" bind:value={accountForm.email} placeholder="max@example.com" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="a-pw">Passwort</Label>
|
||||
<Input id="a-pw" type="password" bind:value={accountForm.password} placeholder="mindestens 8 Zeichen" />
|
||||
<Label>Teammitglied</Label>
|
||||
<Select.Root type="single" bind:value={linkUserId}>
|
||||
<Select.Trigger class="w-full">
|
||||
{linkUserId ? teams.userLabel(linkUserId) : 'Mitglied wählen…'}
|
||||
</Select.Trigger>
|
||||
<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>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
<Label for="a-search">E-Mail des Kontos</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
id="a-search"
|
||||
type="email"
|
||||
bind:value={searchEmail}
|
||||
placeholder="max@example.com"
|
||||
onkeydown={(e) => e.key === 'Enter' && searchAccount()}
|
||||
/>
|
||||
<Button variant="outline" onclick={searchAccount} disabled={searching}>
|
||||
<Mail class="size-4 mr-1" />
|
||||
{searching ? 'Suche …' : 'Suchen'}
|
||||
</Button>
|
||||
<Label for="login-mail">E-Mail</Label>
|
||||
<Input id="login-mail" type="email" bind:value={loginForm.email} placeholder="max@example.com" />
|
||||
</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>
|
||||
<div class="space-y-2">
|
||||
<Label for="login-pw">Passwort</Label>
|
||||
<Input id="login-pw" type="password" bind:value={loginForm.password} placeholder="mindestens 8 Zeichen" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Wird ins Team aufgenommen und verknüpft.
|
||||
Das Konto kommt zugleich ins Team. Gib das Passwort weiter; es lässt
|
||||
sich unter „Profil" ändern.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if accountError}
|
||||
<p class="text-sm text-destructive">{accountError}</p>
|
||||
{#if loginError}
|
||||
<p class="text-sm text-destructive">{loginError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="ghost" onclick={() => (accountDialog = false)}>Abbrechen</Button>
|
||||
{#if accountMode === 'neu'}
|
||||
<Button onclick={createAccount} disabled={accountBusy}>
|
||||
{accountBusy ? 'Wird angelegt …' : 'Konto anlegen'}
|
||||
</Button>
|
||||
<Button variant="ghost" onclick={() => (loginDialog = false)}>Abbrechen</Button>
|
||||
{#if loginMode === 'link'}
|
||||
<Button onclick={doLink} disabled={!linkUserId || loginBusy}>Verknüpfen</Button>
|
||||
{:else}
|
||||
<Button onclick={linkFound} disabled={!found || accountBusy}>Verknüpfen</Button>
|
||||
<Button onclick={doCreateLogin} disabled={loginBusy}>
|
||||
{loginBusy ? 'Wird angelegt …' : 'Konto anlegen'}
|
||||
</Button>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
|
|
|
|||
|
|
@ -6,10 +6,6 @@
|
|||
import { api } from '$lib/stores/pocketbase.svelte'
|
||||
import { getTeamContext } from '$lib/stores/teams.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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
|
@ -17,25 +13,13 @@
|
|||
import { Label } from '@/components/ui/label'
|
||||
import * as Dialog from '@/components/ui/dialog'
|
||||
import Avatar from '$lib/components/Avatar.svelte'
|
||||
import Logo from '$lib/components/Logo.svelte'
|
||||
import {
|
||||
Bike, Calendar, Image, LogOut, Plus, Route, Trash2, Users,
|
||||
} from 'lucide-svelte'
|
||||
import type { TeamsResponse, UsersResponse } from '$lib/types'
|
||||
import { Crown, LogOut, Plus, Shield, Trash2, UserMinus, UserPlus, Users } from 'lucide-svelte'
|
||||
import type { TeamsResponse } from '$lib/types'
|
||||
|
||||
const teams = getTeamContext()
|
||||
const flags = getTrailFlagContext()
|
||||
const events = getEventContext()
|
||||
const riders = getRiderContext()
|
||||
const trails = getTrailContext()
|
||||
const markers = getTrailMarkerContext()
|
||||
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.
|
||||
$effect(() => {
|
||||
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
|
||||
// wandert beim Wechsel mit.
|
||||
const stats = $derived([
|
||||
{
|
||||
label: 'Events',
|
||||
value: events.scoped.length,
|
||||
hint: `${events.upcoming.length} anstehend`,
|
||||
icon: Calendar,
|
||||
},
|
||||
{ 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 },
|
||||
])
|
||||
// ---- Mitglied anlegen ----
|
||||
// Bestehende Konten lassen sich nicht suchen: users.listRule gibt nur den
|
||||
// eigenen Datensatz frei, und das soll auch so bleiben. Ein neues Konto
|
||||
// anzulegen deckt den Alltag ab — Vereinsmitglieder haben vorher keins.
|
||||
let memberDialog = $state(false)
|
||||
let memberTeam = $state<TeamsResponse | null>(null)
|
||||
let memberForm = $state({ name: '', email: '', password: '' })
|
||||
let memberBusy = $state(false)
|
||||
let memberError = $state<string | null>(null)
|
||||
|
||||
// ---- Logo ----
|
||||
let logoDialog = $state(false)
|
||||
let logoTeam = $state<TeamsResponse | null>(null)
|
||||
let logoFile = $state<File | null>(null)
|
||||
let logoBusy = $state(false)
|
||||
let logoError = $state<string | null>(null)
|
||||
|
||||
function openLogo(t: TeamsResponse) {
|
||||
logoTeam = t
|
||||
logoFile = null
|
||||
logoError = null
|
||||
logoDialog = true
|
||||
function openMember(t: TeamsResponse) {
|
||||
memberTeam = t
|
||||
memberForm = { name: '', email: '', password: '' }
|
||||
memberError = null
|
||||
memberDialog = true
|
||||
}
|
||||
|
||||
async function saveLogo(file: File | null) {
|
||||
if (!logoTeam) return
|
||||
async function doCreateMember() {
|
||||
if (!memberTeam) return
|
||||
|
||||
logoBusy = true
|
||||
logoError = null
|
||||
if (!memberForm.email.trim()) {
|
||||
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 {
|
||||
await teams.setLogo(logoTeam.id, file)
|
||||
logoDialog = false
|
||||
await teams.createMember({ ...memberForm }, memberTeam.id)
|
||||
memberDialog = false
|
||||
} catch (e: any) {
|
||||
logoError = e.message ?? 'Das Logo konnte nicht gespeichert werden.'
|
||||
memberError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
|
||||
} 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>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#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="space-y-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Teams, in denen du bist. Das aktive Team bestimmt, welche Events, Fahrer und
|
||||
|
|
@ -209,8 +175,6 @@
|
|||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<Logo team={t} class="size-10 shrink-0" />
|
||||
<div>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
{t.name}
|
||||
|
|
@ -222,7 +186,6 @@
|
|||
{(t.users ?? []).length} Mitglied{(t.users ?? []).length === 1 ? '' : 'er'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#if t.id !== teams.activeId}
|
||||
<Button variant="outline" size="sm" onclick={() => teams.setActive(t.id)}>
|
||||
|
|
@ -233,9 +196,9 @@
|
|||
<Button variant="outline" size="sm" onclick={() => openRename(t)}>
|
||||
Umbenennen
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onclick={() => openLogo(t)}>
|
||||
<Image class="size-4 mr-1" />
|
||||
Logo
|
||||
<Button variant="outline" size="sm" onclick={() => openMember(t)}>
|
||||
<UserPlus class="size-4 mr-1" />
|
||||
Mitglied
|
||||
</Button>
|
||||
{/if}
|
||||
{#if !iAmOwner}
|
||||
|
|
@ -244,16 +207,56 @@
|
|||
Verlassen
|
||||
</Button>
|
||||
{: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" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="text-sm text-muted-foreground">
|
||||
{(t.users ?? []).length} Mitglied{(t.users ?? []).length === 1 ? '' : 'er'}
|
||||
· verwaltet unter <a href="/dashboard/settings/riders" class="underline">Fahrer</a>
|
||||
<CardContent>
|
||||
<ul class="divide-y border-t">
|
||||
{#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>
|
||||
</Card>
|
||||
{/each}
|
||||
|
|
@ -305,47 +308,39 @@
|
|||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Logo -->
|
||||
<Dialog.Root bind:open={logoDialog}>
|
||||
<!-- Mitglied anlegen -->
|
||||
<Dialog.Root bind:open={memberDialog}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Logo für „{logoTeam?.name}"</Dialog.Title>
|
||||
<Dialog.Title>Mitglied anlegen</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Erscheint in der Kopfzeile und blass im Seitenhintergrund. Ohne eigenes
|
||||
Logo steht dort das Wappen des Stammtischs.
|
||||
Legt ein Konto an und nimmt es in „{memberTeam?.name}" auf.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<Logo team={logoTeam} class="size-20 shrink-0" />
|
||||
<div class="space-y-1 min-w-0">
|
||||
<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 class="space-y-2">
|
||||
<Label for="member-name">Name</Label>
|
||||
<Input id="member-name" bind:value={memberForm.name} placeholder="Max Mustermann" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="member-mail">E-Mail</Label>
|
||||
<Input id="member-mail" type="email" bind:value={memberForm.email} placeholder="max@example.com" />
|
||||
</div>
|
||||
|
||||
{#if logoError}
|
||||
<p class="text-sm text-destructive">{logoError}</p>
|
||||
<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}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="ghost" onclick={() => (logoDialog = false)}>Abbrechen</Button>
|
||||
{#if logoTeam?.logo}
|
||||
<Button variant="outline" disabled={logoBusy} onclick={() => saveLogo(null)}>
|
||||
Entfernen
|
||||
</Button>
|
||||
{/if}
|
||||
<Button disabled={!logoFile || logoBusy} onclick={() => saveLogo(logoFile)}>
|
||||
{logoBusy ? 'Wird gespeichert …' : 'Speichern'}
|
||||
<Button variant="ghost" onclick={() => (memberDialog = false)}>Abbrechen</Button>
|
||||
<Button onclick={doCreateMember} disabled={memberBusy}>
|
||||
{memberBusy ? 'Wird angelegt …' : 'Anlegen'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
|
|
|
|||
|
|
@ -5,24 +5,18 @@
|
|||
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
|
||||
import TrailMap from '$lib/components/TrailMap.svelte'
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import * as Dialog from '@/components/ui/dialog'
|
||||
import {
|
||||
Plus, Route, AlertTriangle, Ruler, TrendingUp, MapPin, MessageSquare,
|
||||
} from 'lucide-svelte'
|
||||
import { RECENT_DAYS } from '$lib/time'
|
||||
import { Plus, Route, AlertTriangle } from 'lucide-svelte'
|
||||
import type { TrailVersionsResponse } from '$lib/types'
|
||||
|
||||
const trails = getTrailContext()
|
||||
const versions = getTrailVersionContext()
|
||||
const markers = getTrailMarkerContext()
|
||||
const comments = getTrailCommentContext()
|
||||
|
||||
let dialog = $state(false)
|
||||
let form = $state({ name: '', description: '' })
|
||||
|
|
@ -115,10 +109,6 @@
|
|||
{@const version = currentVersion(trail.id)}
|
||||
{@const open = markers.openByTrail(trail.id).length}
|
||||
{@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">
|
||||
<Card class="h-full hover:border-primary transition-colors">
|
||||
<CardHeader class="pb-3">
|
||||
|
|
@ -149,66 +139,15 @@
|
|||
{/if}
|
||||
|
||||
<div class="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<!-- Dieselben Icons wie im Kopf der Trail-Seite. -->
|
||||
<span class="flex items-center gap-1.5">
|
||||
<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>
|
||||
<span>{formatKm(version?.distance_m)}</span>
|
||||
<span>{version?.ascent_m ? `${version.ascent_m} hm` : '–'}</span>
|
||||
{#if open > 0}
|
||||
<span
|
||||
class="flex items-center gap-1 text-amber-600 ml-auto"
|
||||
use:tooltip={{
|
||||
content: `${open} offene Meldung${open === 1 ? '' : 'en'}`,
|
||||
}}
|
||||
>
|
||||
<span class="flex items-center gap-1 text-amber-600 ml-auto">
|
||||
<AlertTriangle class="size-4" />
|
||||
{open}
|
||||
</span>
|
||||
{/if}
|
||||
</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>
|
||||
</Card>
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
import { getTrailVersionContext } from '$lib/stores/trailVersions.svelte'
|
||||
import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte'
|
||||
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
|
||||
import { getTrailCommentContext } from '$lib/stores/trailComments.svelte'
|
||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||
import TrailMap from '$lib/components/TrailMap.svelte'
|
||||
import ElevationProfile from '$lib/components/ElevationProfile.svelte'
|
||||
|
|
@ -31,7 +30,7 @@
|
|||
} from 'lucide-svelte'
|
||||
import DOMPurify from 'dompurify'
|
||||
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
|
||||
// Rohdaten kommen ungetypt aus PocketBase zurück.
|
||||
|
|
@ -48,7 +47,6 @@
|
|||
const versions = getTrailVersionContext()
|
||||
const flags = getTrailFlagContext()
|
||||
const markers = getTrailMarkerContext()
|
||||
const commentStore = getTrailCommentContext()
|
||||
const teams = getTeamContext()
|
||||
|
||||
const trail = $derived(page.params.id ? trails.getById(page.params.id) : undefined)
|
||||
|
|
@ -258,17 +256,69 @@
|
|||
}
|
||||
|
||||
// --- 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 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() {
|
||||
if (!trail || !commentText.trim()) return
|
||||
|
||||
commentError = null
|
||||
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 = ''
|
||||
} catch (e: any) {
|
||||
commentError = e.message ?? 'Der Kommentar konnte nicht gespeichert werden.'
|
||||
|
|
@ -459,7 +509,7 @@
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
tooltip={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
|
||||
title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
|
||||
onclick={async () => {
|
||||
try {
|
||||
await markers.toggleResolved(m.id)
|
||||
|
|
@ -474,7 +524,7 @@
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
tooltip="Marker löschen"
|
||||
title="Marker löschen"
|
||||
onclick={() => removeMarker(m)}
|
||||
>
|
||||
<Trash2 class="size-4 text-destructive" />
|
||||
|
|
@ -583,7 +633,7 @@
|
|||
</div>
|
||||
|
||||
{#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" />
|
||||
</Button>
|
||||
{/if}
|
||||
|
|
|
|||
Loading…
Reference in a new issue