diff --git a/docs/plans/merge_my_into_available.md b/docs/plans/merge_my_into_available.md new file mode 100644 index 00000000..6247aa8e --- /dev/null +++ b/docs/plans/merge_my_into_available.md @@ -0,0 +1,150 @@ +--- +name: Merge my into available +overview: Usunąć Moje pojazdy/składy; scalić w Dostępne z Odśwież/Dodaj/„Pokaż tylko moje”, filtrami kind/epoch, chipem epoki, podpisanymi akcjami; menu Moje; usunąć GET /vehicles i GET /trains. +todos: + - id: nav-routes + content: "Menu Moje: Dostępne pojazdy/składy; usuń Tabor; routing + redirecty fleet→my" + status: in_progress + - id: vehicles-toolbar + content: "AvailableVehicles: Odśwież, Dodaj, Pokaż tylko moje (default on)" + status: pending + - id: trains-toolbar + content: "AvailableTrains: Odśwież, Dodaj skład, Pokaż tylko moje (default on)" + status: pending + - id: frontend-my-hooks + content: Przepiąć TrainDialog/VehicleFunctionsPage z useMy* na catalogue; usuń useMyVehicles/useMyTrains + status: pending + - id: backend-cleanup + content: Usuń GET /vehicles i GET /trains + ListOwned/ListByOwner + testy; zostaw POST/PUT/DELETE i catalogue + status: pending + - id: kind-epoch-filters + content: "Belka pojazdów: filtry po typie (kind) i epoce (+ Pokaż tylko moje)" + status: pending + - id: epoch-chip + content: Chip epoki w szczegółach wiersza pojazdu (jeśli ustawiona, info/niebieski) + status: pending + - id: labeled-actions + content: "Akcje wiersza z podpisami; pojazdy: także Edytuj funkcje (z Moje)" + status: pending + - id: help-preserve + content: Zachować help myVehicles/myTrains na /my/vehicles i /my/trains (FAB z treścią Moje) + status: pending + - id: cleanup + content: Usuń My* pages/catalogues; popraw help/linki/i18n; typecheck + go test + status: pending +isProject: false +--- + +# Scalenie Moje → Dostępne (pojazdy i składy) + +## Decyzja URL +Kanoniczne ścieżki: **`/my/vehicles`** i **`/my/trains`** (menu Moje). Stare `/fleet/vehicles` i `/fleet/trains` → redirect do `/my/*`. Strona funkcji zostaje pod `/my/vehicles/:vehicleId/functions`. + +## Menu ([`AppShell.tsx`](bigfred/web/src/components/AppShell.tsx)) +- Usunąć sekcję **Tabor** (`fleetItems` + `TopBarMenu` / mobile section). +- W **Moje** zamiast `nav.my.vehicles` / `nav.my.trains` wstawić pozycje z etykietami **Dostępne pojazdy** / **Dostępne składy** (`nav.fleet.availableVehicles` / `availableTrains`) wskazujące na `/my/vehicles` i `/my/trains`. +- Kolejność w Moje: Dostępne pojazdy, Dostępne składy, potem Wypożyczenia / Reszta jak dziś. + +## Usunięcie starych widoków +Skasować (po przeniesieniu brakujących elementów do Available*): +- [`MyVehiclesPage.tsx`](bigfred/web/src/pages/MyVehiclesPage.tsx), [`MyVehiclesCatalogue.tsx`](bigfred/web/src/components/MyVehiclesCatalogue.tsx) +- [`MyTrainsPage.tsx`](bigfred/web/src/pages/MyTrainsPage.tsx), [`MyTrainsCatalogue.tsx`](bigfred/web/src/components/MyTrainsCatalogue.tsx) + +W [`App.tsx`](bigfred/web/src/App.tsx): +- Route `/my/vehicles` → `AvailableVehiclesPage`, `/my/trains` → `AvailableTrainsPage` +- Redirect `/fleet/vehicles` → `/my/vehicles`, `/fleet/trains` → `/my/trains` + +## Toolbar na Available (pojazdy i składy) + +W [`AvailableVehiclesCatalogue.tsx`](bigfred/web/src/components/AvailableVehiclesCatalogue.tsx) i [`AvailableTrainsCatalogue.tsx`](bigfred/web/src/components/AvailableTrainsCatalogue.tsx): + +1. **Odśwież listę** — `refetch()` catalogue (+ leases). +2. **Dodaj pojazd** / **Dodaj skład** — otwiera `VehicleDialog` / `TrainDialog` z `null` (create). +3. **Checkbox „Pokaż tylko moje”** — `useState(true)` przy każdym wejściu na widok (bez localStorage); filtr `ownerId === me.id` przed search/paginacją. +4. **Filtr typu pojazdu** — `Select` (single) na belce (opcje z `VEHICLE_KINDS` + „Wszystkie”); filtr `kind`. Single-select (4 zamknięte wartości) — prostsze i wystarczające. +5. **Filtr epoki** — `Select` (single) (opcje z `VEHICLE_EPOCHS` + „Wszystkie” / „Bez epoki”); filtr po `epoch` (puste `epoch` → łapane przez „Bez epoki”). + +Kolejność filtrów na belce (pojazdy): checkbox „Pokaż tylko moje” → typ → epoka → Odśwież → Dodaj. Składy: tylko checkbox + Odśwież + Dodaj (bez kind/epoch). + +Reset `page` przy zmianie dowolnego filtra (jak przy search). Wartości filtrów w `useState` lokalnym (nie localStorage) — domyślnie typ/epoka = wszystkie, mineOnly = true. + +Dla pojazdów: `headerExtra` w [`VehiclesCatalogueTable`](bigfred/web/src/components/vehicles/VehiclesCatalogueTable.tsx) albo toolbar nad tabelą w AvailableVehiclesCatalogue. Filtry kind/epoch mogą być propsami table (`toolbarFilters`) albo żyć w AvailableVehiclesCatalogue i filtrować `rows` przed przekazaniem. + +## Epoka w szczegółach wiersza (pojazdy) + +W górnym podwierszu atrybutów (obok numeru i chipa „Na makiecie”), gdy `epoch` jest niepuste: + +- Chip w stylu jak on-layout, ale **niebieski** — MUI `color="info"` (filled lub outlined). +- Etykieta np. `Epoka {{epoch}}` / `Epoch {{epoch}}` / `Epoche {{epoch}}` (`catalogue.epochChip`), wartość jak w dialogu (`III`, `IVa`, …). +- Puste `epoch` → brak chipa. +- Dodać `epoch` do typu wiersza w `VehiclesCatalogueTable` + mapowanie z catalogue; uwzględnić epokę w search haystack. + +## Podpisane akcje w wierszu (pojazdy i składy) + +Zamiast samych `IconButton` + tooltip — **`Button` `size="small"`** z `startIcon` i widocznym tekstem (istniejące klucze i18n): + +- Dodaj do makiety / usuń z makiety (`list.actions.addToLayout`, `roster.removeButton`) +- Wypożycz (`rentals:granted.lend`) +- **Edytuj funkcje** (`list.actions.editFunctions`, ikona `Tune`) — **przenieść z Moje pojazdy na scaloną listę pojazdów**; widoczne dla właściciela / admina (jak edit/delete); nawigacja do `/my/vehicles/:id/functions` +- Edytuj / Usuń (`list.actions.edit` / `delete`) + +Na składach: te same wzorce bez „Edytuj funkcje”. `variant="text"` lub `outlined`, `flexWrap` na wąskich ekranach. + +## Frontend: usunięcie `useMyVehicles` / `useMyTrains` + +Hooki wołają wyłącznie `GET /api/v1/vehicles` i `GET /api/v1/trains`. Po usunięciu stron My* nadal używane w: + +- [`TrainDialog.tsx`](bigfred/web/src/components/TrainDialog.tsx) — lista pojazdów do składu → `useVehicleCatalogue(layoutId)` + filtr `ownerId === me.id` (ew. admin: własne + potrzebne do edycji cudzego składu — zachować możliwość wyboru pojazdów właściciela składu / własne). +- [`VehicleFunctionsPage.tsx`](bigfred/web/src/pages/VehicleFunctionsPage.tsx) — lookup pojazdu po id → `useVehicleCatalogue(layoutId)` (wymaga `useMe()` dla `layoutId`; `CatalogueVehicle` ma `name`/`epoch`/`kind` więc subtitle działa); przycisk „Wstecz” → `/my/vehicles`. + +Potem usunąć `useMyVehicles` / `useMyTrains` z [`vehicles.ts`](bigfred/web/src/api/vehicles.ts). + +**Uwaga TrainDialog:** przy tworzeniu składu użytkownik wybiera **własne** pojazdy — katalog z filtrem właściciela wystarczy. Przy edycji cudzego składu (admin) lista członków już jest w składzie; picker nowych członków: pojazdy właściciela składu albo wszystkie — **konkret: filtruj catalogue do `ownerId === editingTrain.ownerId` gdy edit, inaczej `me.id`**. + +## Backend: uprzątnięcie endpointów „Moje” + +Usunąć wyłącznie listę „owned-only” (strony Moje). **Zostawić** create/update/delete oraz catalogue: + +| Usunąć | Zostawić | +|--------|----------| +| `GET /api/v1/vehicles` (`VehicleHandler.List`) | `GET /vehicles/catalogue`, `POST /vehicles`, `PUT/DELETE /vehicles/{id}`, by-external-id | +| `GET /api/v1/trains` (`TrainHandler.List`) | `GET /trains/catalogue`, `POST /trains`, `PUT/DELETE /trains/{id}`, patch members | + +Warstwy do usunięcia, jeśli nic innego ich nie woła (stan dziś: tylko te handlery): + +- [`cmd/vehicle.go`](bigfred/pkgs/bigfred/server/cmd/vehicle.go) — `ListOwned` +- [`cmd/train.go`](bigfred/pkgs/bigfred/server/cmd/train.go) — `ListOwned` +- [`repo/vehicles.go`](bigfred/pkgs/bigfred/server/repo/vehicles.go) — `ListByOwner` (nie mylić z lease `ListByOwner`) +- [`repo/trains.go`](bigfred/pkgs/bigfred/server/repo/trains.go) — `ListByOwner` +- Trasy w [`router.go`](bigfred/pkgs/bigfred/server/http/router.go) +- Testy unit pod `ListOwned` / HTTP List, jeśli istnieją + +`ListCatalogue` / `ListAll` pozostają źródłem prawdy dla UI. + +## Help (FAB) — treści z „Moje pojazdy” / „Moje składy” + +Pływający przycisk pomocy ma **zostać** na scalonych podstronach z **tą samą treścią**, co dziś na Moje: + +| Ścieżka kanoniczna | Wpis w [`helpRegistry.tsx`](bigfred/web/src/components/help/helpRegistry.tsx) | Klucz i18n | +|--------------------|-------------------------------------------------------------------------------|------------| +| `/my/vehicles` | `i18nKey: "myVehicles"` + `addIcon` / `functionsIcon` | `help.json` → `myVehicles` | +| `/my/trains` | `i18nKey: "myTrains"` | `help.json` → `myTrains` | + +Ponieważ Available* renderujemy pod `/my/vehicles` i `/my/trains` (a `/fleet/*` tylko redirectuje), istniejące wpisy registry **nie wymagają przenosin** — nie dodawać osobnego helpa dla fleet, nie usuwać `myVehicles`/`myTrains`, nie zmieniać copy. + +Weryfikacja po wdrożeniu: FAB widoczny na obu podstronach; dialog z ikonkami jak wcześniej; Menu → Pomoc nadal włącza ukryty FAB. + +## Linki wewnętrzne +- [`RosterSection.tsx`](bigfred/web/src/components/RosterSection.tsx): manage → `/my/vehicles`, `/my/trains`. + +## i18n +- `vehicle:catalogue.showOnlyMine` / `trainCatalogue.showOnlyMine` (pl/en/de). +- `vehicle:catalogue.filterKind` / `filterEpoch` / `filterAll` / `filterNoEpoch` (pl/en/de). +- `vehicle:catalogue.epochChip`: „Epoka {{epoch}}” / „Epoch {{epoch}}” / „Epoche {{epoch}}”. +- Reuse `list.refreshButton`, `list.addButton`, `trainList.addButton` oraz `vehicle:kind.*` dla etykiet typów. +- Usunąć nieużywane `nav.my.vehicles` / `nav.my.trains`; etykiety menu z `nav.fleet.available*`. +- Opcjonalnie usunąć lub ograniczyć martwe stringi `list.intro` / `trainList.intro` jeśli nikt ich nie renderuje. + +## Poza zakresem +- Przebudowa tabeli składów do layoutu 2-kolumnowego jak pojazdy. +- Zmiana semantyki `POST /vehicles` / catalogue (bez nowych endpointów). diff --git a/docs/plans/unify_vehicles_catalogues.md b/docs/plans/unify_vehicles_catalogues.md new file mode 100644 index 00000000..eaa53798 --- /dev/null +++ b/docs/plans/unify_vehicles_catalogues.md @@ -0,0 +1,95 @@ +--- +name: Unify vehicles catalogues +status: completed +date: 2026-08-07 +overview: "Uwspólnić tabele „Moje pojazdy” i „Dostępne pojazdy” w jeden komponent tabeli (bazujący na obecnym widoku dostępnych), z filtrem właściciela dla „Moje”, oraz przebudować wiersz: lewa kolumna nazwa+DCC, prawa kolumna atrybuty nad przyciskami." +todos: + - id: extract-table + content: Wydzielić VehiclesCatalogueTable + VehicleCatalogueRow (2-kolumnowy layout) + status: completed + - id: wire-available + content: Przepiąć AvailableVehiclesCatalogue na wspólny komponent + status: completed + - id: wire-my + content: Przepiąć MyVehiclesCatalogue (własne pojazdy, Add/Refresh, functions) + status: completed + - id: i18n-tsc + content: Uzupełnić i18n nagłówków jeśli potrzeba + typecheck + status: completed +--- + +# Uwspólnienie tabeli pojazdów + nowy układ wiersza + +**Status:** zrealizowano (2026-08-07) + +## Zakres +Tylko pojazdy (`MyVehiclesCatalogue` / `AvailableVehiclesCatalogue`). Składy i `RosterSection` na dashboardzie bez zmian. + +## Podejście +Jeden wspólny komponent tabeli wyciągnięty ze struktury **Dostępne pojazdy** (wyszukiwanie, paginacja, Paper, Alert błędów mutacji). Strona **Moje pojazdy** używa tego samego komponentu z danymi własnych pojazdów (oraz dotychczasowymi akcjami: Dodaj/Odśwież w nagłówku, przycisk funkcji). + +```mermaid +flowchart LR + MyPage["MyVehiclesPage"] --> MyCat["MyVehiclesCatalogue"] + AvailPage["AvailableVehiclesPage"] --> AvailCat["AvailableVehiclesCatalogue"] + MyCat --> Shared["VehiclesCatalogueTable"] + AvailCat --> Shared + MyCat -->|"useMyVehicles + layout set"| Shared + AvailCat -->|"useVehicleCatalogue"| Shared +``` + +Zachowanie danych bez zmian: +- **Moje:** `useMyVehicles()` + `useLayoutVehicles(layoutId)` do statusu na makiecie. +- **Dostępne:** `useVehicleCatalogue(layoutId)` z polem `onLayout`. +- Uprawnienia i zestaw przycisków pozostają w cienkich wrapperach / slotach. + +## Nowy układ wiersza + +Zamiast wielu kolumn (`Name | Number | DCC | OnLayout | Actions`) — **2 komórki**: + +| Lewa | Prawa | +|------|-------| +| Nazwa (tooltip z id) | **Górny podwiersz:** numer + chip statusu na makiecie (Available) | +| Adres DCC w formacie `DCC: {num}` / chip Dummy | **Dolny podwiersz:** IconButtony (add/remove, lend, functions?, edit, delete) | +| Opcjonalnie caption właściciela (Available) | | + +Technicznie: jedna `TableRow` z dwoma `TableCell`; prawa komórka to `Stack` z dwoma poziomymi `Stack`ami (attributes / actions). Nagłówek: „Pojazd” / „Szczegóły”. + +## Zrealizowane pliki + +- Nowy: `bigfred/web/src/components/vehicles/VehiclesCatalogueTable.tsx` — shell (Paper, search+pagination, loading/empty) + wiersz 2-kolumnowy. +- Przerobione: `MyVehiclesCatalogue.tsx`, `AvailableVehiclesCatalogue.tsx`. +- i18n: `vehicle.json` (pl/en/de) — `catalogue.table.*`, `catalogue.dccLabel`, dłuższe etykiety `onLayout.yes` / `onLayout.no`. + +## Follow-upy po implementacji + +- Chip: „Na makiecie” / „Nie jest na makiecie” (zamiast Tak/Nie). +- Adres DCC: `DCC: {{addr}}` (`catalogue.dccLabel`). + +## API wspólnego komponentu + +```tsx +type VehiclesCatalogueTableProps = { + rows: Array<{ + id: string; + name: string; + number: string; + dccAddress: number | null; + onLayout: boolean; + ownerLabel?: string; // Available only + }>; + loading: boolean; + mutationError?: string | null; + showSearch?: boolean; + headerExtra?: ReactNode; + emptyLabel: string; + renderActions: (row) => ReactNode; +}; +``` + +Na **Moje** `showSearch` włączone (wyszukiwanie lokalne). + +## Poza zakresem +- Składy (`MyTrainsCatalogue` / `AvailableTrainsCatalogue`) +- Dashboard `RosterSection` +- Zmiany backendu / endpointów diff --git a/pkgs/bigfred/server/cmd/train.go b/pkgs/bigfred/server/cmd/train.go index 714dbf5f..1e21f10c 100644 --- a/pkgs/bigfred/server/cmd/train.go +++ b/pkgs/bigfred/server/cmd/train.go @@ -92,15 +92,6 @@ func NewTrain( } } -// ListOwned returns every train owned by the user with member rows hydrated. -func (t *Train) ListOwned(ctx context.Context, ownerID uint) ([]TrainDetail, error) { - trains, err := t.trains.ListByOwner(ctx, ownerID) - if err != nil { - return nil, err - } - return t.hydrateDetails(ctx, trains) -} - // TrainCatalogueEntry is one row of the global train catalogue. type TrainCatalogueEntry struct { Train domain.Train diff --git a/pkgs/bigfred/server/cmd/vehicle.go b/pkgs/bigfred/server/cmd/vehicle.go index 791cef85..163052ab 100644 --- a/pkgs/bigfred/server/cmd/vehicle.go +++ b/pkgs/bigfred/server/cmd/vehicle.go @@ -74,11 +74,6 @@ func (v *Vehicle) GetByExternalID(ctx context.Context, externalID string) (domai return row, nil } -// ListOwned returns every vehicle owned by the user. -func (v *Vehicle) ListOwned(ctx context.Context, ownerID uint) ([]domain.Vehicle, error) { - return v.vehicles.ListByOwner(ctx, ownerID) -} - // VehicleCatalogueEntry is one row of the global vehicle catalogue. type VehicleCatalogueEntry struct { Vehicle domain.Vehicle diff --git a/pkgs/bigfred/server/http/router.go b/pkgs/bigfred/server/http/router.go index c262de8d..f52b9922 100644 --- a/pkgs/bigfred/server/http/router.go +++ b/pkgs/bigfred/server/http/router.go @@ -150,8 +150,7 @@ func NewRouter(cfg RouterConfig) http.Handler { r.Put("/auth/me/profile", authH.UpdateProfile) r.Get("/auth/me/dcc-pool", vehicleH.ListPool) - // Vehicle catalogue (own only for now). - r.Get("/vehicles", vehicleH.List) + // Vehicle catalogue. r.Get("/vehicles/catalogue", vehicleH.ListCatalogue) r.Post("/vehicles", vehicleH.Create) r.Put("/vehicles/by-external-id/{externalId}", vehicleH.UpsertByExternalID) @@ -177,7 +176,6 @@ func NewRouter(cfg RouterConfig) http.Handler { r.Delete("/vehicle-templates/{id}/functions/{num}", functionH.DeleteTemplate) // Train catalogue. - r.Get("/trains", trainH.List) r.Get("/trains/catalogue", trainH.ListCatalogue) r.Post("/trains", trainH.Create) r.Put("/trains/{id}", trainH.Update) diff --git a/pkgs/bigfred/server/http/trains.go b/pkgs/bigfred/server/http/trains.go index accfc310..488b359a 100644 --- a/pkgs/bigfred/server/http/trains.go +++ b/pkgs/bigfred/server/http/trains.go @@ -27,26 +27,6 @@ func NewTrainHandler( return &TrainHandler{svc: svc, layoutTrains: layoutTrains, auth: auth} } -// List handles GET /api/v1/trains — own trains only for now. -func (h *TrainHandler) List(w http.ResponseWriter, r *http.Request) { - id, ok := IdentityFromContext(r.Context()) - if !ok { - writeJSONError(w, http.StatusUnauthorized, "unauthorized") - return - } - rows, err := h.svc.ListOwned(r.Context(), id.User.ID) - if err != nil { - writeJSONError(w, http.StatusInternalServerError, "internal_error") - return - } - out := make([]protocol.TrainResponse, 0, len(rows)) - for _, d := range rows { - out = append(out, protocol.ToTrainResponse(d)) - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(out) -} - // ListCatalogue handles GET /api/v1/trains/catalogue — every // registered train with owner metadata and on-layout flag for the // caller's pinned session layout. diff --git a/pkgs/bigfred/server/http/vehicles.go b/pkgs/bigfred/server/http/vehicles.go index bf94339c..dea1dcef 100644 --- a/pkgs/bigfred/server/http/vehicles.go +++ b/pkgs/bigfred/server/http/vehicles.go @@ -31,28 +31,6 @@ func NewVehicleHandler( return &VehicleHandler{svc: svc, layoutVehicles: layoutVehicles, pool: pool, auth: auth} } -// List handles GET /api/v1/vehicles — own vehicles only for now. -// Leasing and signalman-overrides will join the union in the -// milestone that introduces VehicleLease. -func (h *VehicleHandler) List(w http.ResponseWriter, r *http.Request) { - id, ok := IdentityFromContext(r.Context()) - if !ok { - writeJSONError(w, http.StatusUnauthorized, "unauthorized") - return - } - rows, err := h.svc.ListOwned(r.Context(), id.User.ID) - if err != nil { - writeJSONError(w, http.StatusInternalServerError, "internal_error") - return - } - out := make([]protocol.VehicleResponse, 0, len(rows)) - for _, v := range rows { - out = append(out, protocol.ToVehicleResponse(v)) - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(out) -} - // ListCatalogue handles GET /api/v1/vehicles/catalogue — every // registered vehicle with owner metadata and on-layout flag for the // caller's pinned session layout. diff --git a/pkgs/bigfred/server/repo/trains.go b/pkgs/bigfred/server/repo/trains.go index 139b07b6..48a00cd9 100644 --- a/pkgs/bigfred/server/repo/trains.go +++ b/pkgs/bigfred/server/repo/trains.go @@ -59,19 +59,6 @@ func (t *Trains) CountByOwner(ctx context.Context, ownerID uint) (int, error) { return t.repo.Count(ctx, "trains", where.Eq("owner_user_id", ownerID)) } -// ListByOwner returns every train owned by the user. -func (t *Trains) ListByOwner(ctx context.Context, ownerID uint) ([]domain.Train, error) { - var rows []domain.Train - err := t.repo.FindAll(ctx, &rows, - where.Eq("owner_user_id", ownerID), - sort.Asc("name"), - ) - if err != nil { - return nil, err - } - return rows, nil -} - // ListAll returns every train in the catalogue (all owners). func (t *Trains) ListAll(ctx context.Context) ([]domain.Train, error) { var rows []domain.Train diff --git a/pkgs/bigfred/server/repo/vehicles.go b/pkgs/bigfred/server/repo/vehicles.go index 7954acc4..da612abc 100644 --- a/pkgs/bigfred/server/repo/vehicles.go +++ b/pkgs/bigfred/server/repo/vehicles.go @@ -81,19 +81,6 @@ func (v *Vehicles) ListAll(ctx context.Context) ([]domain.Vehicle, error) { return rows, nil } -// ListByOwner returns every vehicle owned by the user. -func (v *Vehicles) ListByOwner(ctx context.Context, ownerID uint) ([]domain.Vehicle, error) { - var rows []domain.Vehicle - err := v.repo.FindAll(ctx, &rows, - where.Eq("owner_user_id", ownerID), - sort.Asc("name"), - ) - if err != nil { - return nil, err - } - return rows, nil -} - // ListByIDs returns vehicles by primary-key set. func (v *Vehicles) ListByIDs(ctx context.Context, ids []domain.VehicleID) ([]domain.Vehicle, error) { if len(ids) == 0 { diff --git a/web/src/App.tsx b/web/src/App.tsx index 6f58b183..98112c62 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,8 +13,6 @@ import WakeLockKeeper from "./components/WakeLockKeeper"; import HomePage from "./pages/HomePage"; import InterlockingPage from "./pages/InterlockingPage"; import LoginPage from "./pages/LoginPage"; -import MyTrainsPage from "./pages/MyTrainsPage"; -import MyVehiclesPage from "./pages/MyVehiclesPage"; import AvailableVehiclesPage from "./pages/AvailableVehiclesPage"; import AvailableTrainsPage from "./pages/AvailableTrainsPage"; import VehicleFunctionsPage from "./pages/VehicleFunctionsPage"; @@ -61,16 +59,16 @@ const router = createBrowserRouter( }> }> } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> } /> } /> } /> - } /> } /> } /> } /> diff --git a/web/src/api/vehicles.ts b/web/src/api/vehicles.ts index 2c433afe..f7506b4d 100644 --- a/web/src/api/vehicles.ts +++ b/web/src/api/vehicles.ts @@ -128,22 +128,9 @@ export interface DCCAddressRange { to: number; } -const vehiclesQueryKey = ["vehicles"] as const; const vehicleCatalogueQueryKey = ["vehicles", "catalogue"] as const; const dccPoolQueryKey = ["dcc-pool", "me"] as const; -// useMyVehicles returns the caller's own vehicle catalogue and re- -// fetches eagerly whenever the layout dashboard fires a -// `layout.vehiclesChanged` event so add/remove from the roster -// stays visually consistent with the catalogue. -export function useMyVehicles() { - return useQuery({ - queryKey: vehiclesQueryKey, - queryFn: () => apiFetch("/api/v1/vehicles"), - staleTime: 5 * 1000, - }); -} - // useVehicleCatalogue returns every registered vehicle with owner // metadata and on-layout flag for the caller's pinned layout. export function useVehicleCatalogue(layoutId: number | null) { @@ -215,7 +202,6 @@ export function useCreateVehicle() { }), }), onSuccess: () => { - void qc.invalidateQueries({ queryKey: vehiclesQueryKey }); void qc.invalidateQueries({ queryKey: vehicleCatalogueQueryKey }); }, }); @@ -272,7 +258,6 @@ export function useUpdateVehicle() { }); }, onSuccess: () => { - void qc.invalidateQueries({ queryKey: vehiclesQueryKey }); void qc.invalidateQueries({ queryKey: vehicleCatalogueQueryKey }); }, }); @@ -284,7 +269,6 @@ export function useDeleteVehicle() { mutationFn: (id: string) => apiFetch(`/api/v1/vehicles/${id}`, { method: "DELETE" }), onSuccess: () => { - void qc.invalidateQueries({ queryKey: vehiclesQueryKey }); void qc.invalidateQueries({ queryKey: vehicleCatalogueQueryKey }); }, }); @@ -321,17 +305,8 @@ export interface CatalogueTrain extends Train { onLayout: boolean; } -const trainsQueryKey = ["trains"] as const; const trainCatalogueQueryKey = ["trains", "catalogue"] as const; -export function useMyTrains() { - return useQuery({ - queryKey: trainsQueryKey, - queryFn: () => apiFetch("/api/v1/trains"), - staleTime: 5 * 1000, - }); -} - // useTrainCatalogue returns every registered train with owner // metadata and on-layout flag for the caller's pinned layout. export function useTrainCatalogue(layoutId: number | null) { @@ -378,7 +353,6 @@ export function useCreateTrain() { body: JSON.stringify(body), }), onSuccess: () => { - void qc.invalidateQueries({ queryKey: trainsQueryKey }); void qc.invalidateQueries({ queryKey: trainCatalogueQueryKey }); }, }); @@ -406,7 +380,6 @@ export function useUpdateTrain() { }); }, onSuccess: () => { - void qc.invalidateQueries({ queryKey: trainsQueryKey }); void qc.invalidateQueries({ queryKey: trainCatalogueQueryKey }); }, }); @@ -418,7 +391,6 @@ export function useDeleteTrain() { mutationFn: (id: string) => apiFetch(`/api/v1/trains/${id}`, { method: "DELETE" }), onSuccess: () => { - void qc.invalidateQueries({ queryKey: trainsQueryKey }); void qc.invalidateQueries({ queryKey: trainCatalogueQueryKey }); }, }); @@ -622,7 +594,6 @@ export function usePatchTrainMemberSettings() { }, ), onSuccess: () => { - void qc.invalidateQueries({ queryKey: trainsQueryKey }); void qc.invalidateQueries({ queryKey: ["layouts"] }); }, }); diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index f954b880..111e3b22 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -164,36 +164,17 @@ function AppShellContent() { [t], ); - const fleetItems: TopBarMenuItem[] = useMemo( - () => [ - { - id: "available-vehicles", - label: t("nav.fleet.availableVehicles"), - icon: , - onClick: () => navigate("/fleet/vehicles"), - }, - { - id: "available-trains", - label: t("nav.fleet.availableTrains"), - icon: , - onClick: () => navigate("/fleet/trains"), - }, - ], - // eslint-disable-next-line react-hooks/exhaustive-deps - [t], - ); - const myItems: TopBarMenuItem[] = useMemo(() => { const items: TopBarMenuItem[] = [ { - id: "vehicles", - label: t("nav.my.vehicles"), + id: "available-vehicles", + label: t("nav.fleet.availableVehicles"), icon: , onClick: () => navigate("/my/vehicles"), }, { - id: "trains", - label: t("nav.my.trains"), + id: "available-trains", + label: t("nav.fleet.availableTrains"), icon: , onClick: () => navigate("/my/trains"), }, @@ -310,7 +291,6 @@ function AppShellContent() { if (!me) return []; const sections: MobileNavSection[] = [ { id: "quick", items: [...quickNavItems, ...sudoMobileItems] }, - { id: "fleet", label: t("nav.fleet.menuLabel"), items: fleetItems }, { id: "my", label: t("nav.my.menuLabel"), items: myItems }, ]; if (isAdmin) { @@ -331,7 +311,6 @@ function AppShellContent() { isAdmin, quickNavItems, sudoMobileItems, - fleetItems, myItems, administrationItems, accountItems, @@ -436,10 +415,6 @@ function AppShellContent() { )} - {me && !isCompactNav && ( - - )} - {me && !isCompactNav && ( )} diff --git a/web/src/components/AvailableTrainsCatalogue.tsx b/web/src/components/AvailableTrainsCatalogue.tsx index c0546e8e..9bdc2d63 100644 --- a/web/src/components/AvailableTrainsCatalogue.tsx +++ b/web/src/components/AvailableTrainsCatalogue.tsx @@ -2,8 +2,10 @@ import { useEffect, useMemo, useState } from "react"; import { Alert, Box, + Button, + Checkbox, Chip, - IconButton, + FormControlLabel, Paper, Stack, Table, @@ -17,10 +19,12 @@ import { Tooltip, Typography, } from "@mui/material"; -import EditIcon from "@mui/icons-material/Edit"; +import AddIcon from "@mui/icons-material/Add"; import DeleteIcon from "@mui/icons-material/Delete"; +import EditIcon from "@mui/icons-material/Edit"; import HandshakeIcon from "@mui/icons-material/Handshake"; import PlaylistAddIcon from "@mui/icons-material/PlaylistAdd"; +import RefreshIcon from "@mui/icons-material/Refresh"; import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline"; import { useTranslation } from "react-i18next"; @@ -64,6 +68,7 @@ export default function AvailableTrainsCatalogue({ layoutId }: Props) { const deleteTrainMut = useDeleteTrain(); const grantedLeases = useGrantedLeases(); + const [mineOnly, setMineOnly] = useState(true); const [query, setQuery] = useState(""); const [page, setPage] = useState(0); const [dialogOpen, setDialogOpen] = useState(false); @@ -77,7 +82,7 @@ export default function AvailableTrainsCatalogue({ layoutId }: Props) { useEffect(() => { setPage(0); - }, [query]); + }, [query, mineOnly]); const isAdmin = hasEffectiveAdmin(me); const ownsRow = (ownerId: number) => me?.id === ownerId; @@ -89,13 +94,20 @@ export default function AvailableTrainsCatalogue({ layoutId }: Props) { return s; }, [grantedLeases.data]); + const scopedTrains = useMemo(() => { + let list = trains.data ?? []; + if (mineOnly && me?.id != null) { + list = list.filter((tr) => tr.ownerId === me.id); + } + return list; + }, [trains.data, mineOnly, me?.id]); + const filteredRows = useMemo(() => { const q = query.trim().toLowerCase(); - const rows = trains.data ?? []; if (!q) { - return rows; + return scopedTrains; } - return rows.filter((tr) => { + return scopedTrains.filter((tr) => { const ownerLabel = getUserName({ login: tr.ownerLogin, organization: tr.ownerOrganization, @@ -105,7 +117,7 @@ export default function AvailableTrainsCatalogue({ layoutId }: Props) { .toLowerCase(); return haystack.includes(q); }); - }, [trains.data, query]); + }, [scopedTrains, query]); const pagedRows = useMemo(() => { const start = page * ROWS_PER_PAGE; @@ -134,6 +146,11 @@ export default function AvailableTrainsCatalogue({ layoutId }: Props) { deleteTrainMut.mutate(tr.id); }; + const onRefresh = () => { + void trains.refetch(); + void grantedLeases.refetch(); + }; + const renderOnLayout = (onLayout: boolean) => ( {mutationError}} - + + + + setMineOnly(e.target.checked)} + /> + } + label={t("vehicle:trainCatalogue.showOnlyMine")} + /> + + + {renderOnLayout(tr.onLayout)} - + {tr.onLayout ? ( canRemoveFromLayout(me, tr.ownerId) ? ( - - - removeTrainFromRoster.mutate({ - layoutId, - trainId: tr.id, - }) - } - disabled={removeTrainFromRoster.isPending} - aria-label={t("vehicle:roster.removeButton")} - > - - - - ) : null - ) : canAddToLayout(me, tr.ownerId) ? ( - - } onClick={() => - addTrainToRoster.mutate({ + removeTrainFromRoster.mutate({ layoutId, trainId: tr.id, }) } - disabled={addTrainToRoster.isPending} - aria-label={t("vehicle:trainList.actions.addToLayout")} + disabled={removeTrainFromRoster.isPending} > - - - + {t("vehicle:roster.removeButton")} + + ) : null + ) : canAddToLayout(me, tr.ownerId) ? ( + ) : null} {showLendButton(isAdmin, isOwner) && ( - } disabled={!lendable} onClick={() => { setLeaseInitialTarget({ @@ -274,36 +340,34 @@ export default function AvailableTrainsCatalogue({ layoutId }: Props) { }); setLeaseDialogOpen(true); }} - aria-label={t("rentals:granted.lend")} > - - + {t("rentals:granted.lend")} + )} {canMutateTrain(tr.ownerId) && ( <> - - { - setEditingTrain(tr); - setDialogOpen(true); - }} - aria-label={t("vehicle:trainList.actions.edit")} - > - - - - - onDeleteTrain(tr)} - aria-label={t("vehicle:trainList.actions.delete")} - > - - - + + )} diff --git a/web/src/components/AvailableVehiclesCatalogue.tsx b/web/src/components/AvailableVehiclesCatalogue.tsx index 4552d589..0507b5be 100644 --- a/web/src/components/AvailableVehiclesCatalogue.tsx +++ b/web/src/components/AvailableVehiclesCatalogue.tsx @@ -1,38 +1,39 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { - Alert, - Box, - Chip, - IconButton, - Paper, + Button, + Checkbox, + FormControl, + FormControlLabel, + InputLabel, + MenuItem, + Select, Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TablePagination, - TableRow, - TextField, Tooltip, - Typography, } from "@mui/material"; -import EditIcon from "@mui/icons-material/Edit"; +import AddIcon from "@mui/icons-material/Add"; import DeleteIcon from "@mui/icons-material/Delete"; +import EditIcon from "@mui/icons-material/Edit"; import HandshakeIcon from "@mui/icons-material/Handshake"; import PlaylistAddIcon from "@mui/icons-material/PlaylistAdd"; +import RefreshIcon from "@mui/icons-material/Refresh"; import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline"; +import TuneIcon from "@mui/icons-material/Tune"; +import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useMe } from "../api/auth"; import { ApiError } from "../api/client"; import { lendableTargetKey, useGrantedLeases } from "../api/leases"; import { + VEHICLE_EPOCHS, + VEHICLE_KINDS, useAddVehicleToRoster, useDeleteVehicle, useRemoveVehicleFromRoster, useVehicleCatalogue, type CatalogueVehicle, + type VehicleEpoch, + type VehicleKind, } from "../api/vehicles"; import { getUserName } from "../utils/getUserName"; import { @@ -48,8 +49,13 @@ import { } from "../utils/rosterPermissions"; import LeaseCreateDialog from "./leases/LeaseCreateDialog"; import VehicleDialog from "./VehicleDialog"; +import VehiclesCatalogueTable, { + type VehiclesCatalogueRow, +} from "./vehicles/VehiclesCatalogueTable"; -const ROWS_PER_PAGE = 10; +const KIND_ALL = ""; +const EPOCH_ALL = "__all__"; +const EPOCH_NONE = "__none__"; interface Props { layoutId: number; @@ -57,6 +63,7 @@ interface Props { export default function AvailableVehiclesCatalogue({ layoutId }: Props) { const { t } = useTranslation(["vehicle", "errors", "common", "rentals"]); + const navigate = useNavigate(); const me = useMe().data; const vehicles = useVehicleCatalogue(layoutId); const addVehicleToRoster = useAddVehicleToRoster(); @@ -64,8 +71,9 @@ export default function AvailableVehiclesCatalogue({ layoutId }: Props) { const deleteVehicleMut = useDeleteVehicle(); const grantedLeases = useGrantedLeases(); - const [query, setQuery] = useState(""); - const [page, setPage] = useState(0); + const [mineOnly, setMineOnly] = useState(true); + const [kindFilter, setKindFilter] = useState(KIND_ALL); + const [epochFilter, setEpochFilter] = useState(EPOCH_ALL); const [dialogOpen, setDialogOpen] = useState(false); const [editingVehicle, setEditingVehicle] = useState(null); const [leaseDialogOpen, setLeaseDialogOpen] = useState(false); @@ -75,10 +83,6 @@ export default function AvailableVehiclesCatalogue({ layoutId }: Props) { targetName?: string; } | null>(null); - useEffect(() => { - setPage(0); - }, [query]); - const isAdmin = hasEffectiveAdmin(me); const ownsRow = (ownerId: number) => me?.id === ownerId; const canMutateVehicle = (ownerId: number) => ownsRow(ownerId) || isAdmin; @@ -89,35 +93,45 @@ export default function AvailableVehiclesCatalogue({ layoutId }: Props) { return s; }, [grantedLeases.data]); - const filteredRows = useMemo(() => { - const q = query.trim().toLowerCase(); - const rows = vehicles.data ?? []; - if (!q) { - return rows; + const filteredVehicles = useMemo(() => { + let list = vehicles.data ?? []; + if (mineOnly && me?.id != null) { + list = list.filter((v) => v.ownerId === me.id); + } + if (kindFilter !== KIND_ALL) { + list = list.filter((v) => v.kind === (kindFilter as VehicleKind)); + } + if (epochFilter === EPOCH_NONE) { + list = list.filter((v) => !v.epoch); + } else if (epochFilter !== EPOCH_ALL) { + list = list.filter((v) => v.epoch === (epochFilter as VehicleEpoch)); } - return rows.filter((v) => { - const ownerLabel = getUserName({ - login: v.ownerLogin, - organization: v.ownerOrganization, - }).toLowerCase(); - const kindLabel = t(`vehicle:kind.${v.kind}` as const).toLowerCase(); - const haystack = [ - v.name, - v.number, - v.dccAddress != null ? String(v.dccAddress) : "", - kindLabel, - ownerLabel, - ] - .join(" ") - .toLowerCase(); - return haystack.includes(q); - }); - }, [vehicles.data, query, t]); + return list; + }, [vehicles.data, mineOnly, me?.id, kindFilter, epochFilter]); - const pagedRows = useMemo(() => { - const start = page * ROWS_PER_PAGE; - return filteredRows.slice(start, start + ROWS_PER_PAGE); - }, [filteredRows, page]); + const vehicleById = useMemo(() => { + const m = new Map(); + filteredVehicles.forEach((v) => m.set(v.id, v)); + return m; + }, [filteredVehicles]); + + const rows: VehiclesCatalogueRow[] = useMemo( + () => + filteredVehicles.map((v) => ({ + id: v.id, + name: v.name, + number: v.number, + dccAddress: v.dccAddress, + onLayout: v.onLayout, + epoch: v.epoch || undefined, + ownerLabel: getUserName({ + login: v.ownerLogin, + organization: v.ownerOrganization, + }), + searchExtra: t(`vehicle:kind.${v.kind}` as const), + })), + [filteredVehicles, t], + ); const mutationError = (() => { const err = @@ -141,212 +155,205 @@ export default function AvailableVehiclesCatalogue({ layoutId }: Props) { deleteVehicleMut.mutate(v.id); }; - const renderDCC = (vehicle: { dccAddress: number | null }) => - vehicle.dccAddress != null ? ( - String(vehicle.dccAddress) - ) : ( - - ); + const onRefresh = () => { + void vehicles.refetch(); + void grantedLeases.refetch(); + }; - const renderOnLayout = (onLayout: boolean) => ( - + const headerExtra = ( + + setMineOnly(e.target.checked)} + /> + } + label={t("vehicle:catalogue.showOnlyMine")} + /> + + + {t("vehicle:catalogue.filterKind")} + + + + + + {t("vehicle:catalogue.filterEpoch")} + + + + + + ); - const emptyMessage = - (vehicles.data ?? []).length === 0 - ? t("vehicle:catalogue.empty") - : t("vehicle:catalogue.noResults"); - return ( <> - {mutationError && {mutationError}} - - - - setQuery(e.target.value)} - /> - - - - - - {t("vehicle:catalogue.columns.name")} - {t("vehicle:catalogue.columns.number")} - {t("vehicle:catalogue.columns.dccAddress")} - {t("vehicle:catalogue.columns.onLayout")} - - {t("vehicle:catalogue.columns.actions")} - - - - - {vehicles.isLoading ? ( - - - {t("common:loading")} - - - ) : pagedRows.length === 0 ? ( - - - {emptyMessage} - - - ) : ( - pagedRows.map((v) => { - const isOwner = ownsRow(v.ownerId); - const leased = isTargetLeased(leasedTargetKeys, "vehicle", v.id); - const lendable = isVehicleLendable(isAdmin, { - isOwner, - onLayout: v.onLayout, - dccAddress: v.dccAddress, - leased, - }); - const lendTitle = vehicleLendTooltip(t, isAdmin, { - isOwner, - onLayout: v.onLayout, - dccAddress: v.dccAddress, - leased, - }); - return ( - - - - - - {v.name} - - - - {getUserName({ - login: v.ownerLogin, - organization: v.ownerOrganization, - })} - - - - {v.number || "—"} - {renderDCC(v)} - {renderOnLayout(v.onLayout)} - - - {v.onLayout ? ( - canRemoveFromLayout(me, v.ownerId) ? ( - - - removeVehicleFromRoster.mutate({ - layoutId, - vehicleId: v.id, - }) - } - disabled={removeVehicleFromRoster.isPending} - aria-label={t("vehicle:roster.removeButton")} - > - - - - ) : null - ) : canAddToLayout(me, v.ownerId) ? ( - - - addVehicleToRoster.mutate({ - layoutId, - vehicleId: v.id, - }) - } - disabled={addVehicleToRoster.isPending} - aria-label={t("vehicle:list.actions.addToLayout")} - > - - - - ) : null} - {showLendButton(isAdmin, isOwner) && ( - - - { - setLeaseInitialTarget({ - kind: "vehicle", - targetId: v.id, - targetName: v.name, - }); - setLeaseDialogOpen(true); - }} - aria-label={t("rentals:granted.lend")} - > - - - - - )} - {canMutateVehicle(v.ownerId) && ( - <> - - { - setEditingVehicle(v); - setDialogOpen(true); - }} - aria-label={t("vehicle:list.actions.edit")} - > - - - - - onDeleteVehicle(v)} - aria-label={t("vehicle:list.actions.delete")} - > - - - - - )} - - - - ); - }) + { + const v = vehicleById.get(row.id); + if (!v) return null; + const isOwner = ownsRow(v.ownerId); + const leased = isTargetLeased(leasedTargetKeys, "vehicle", v.id); + const lendable = isVehicleLendable(isAdmin, { + isOwner, + onLayout: v.onLayout, + dccAddress: v.dccAddress, + leased, + }); + const lendTitle = vehicleLendTooltip(t, isAdmin, { + isOwner, + onLayout: v.onLayout, + dccAddress: v.dccAddress, + leased, + }); + return ( + <> + {v.onLayout ? ( + canRemoveFromLayout(me, v.ownerId) ? ( + + ) : null + ) : canAddToLayout(me, v.ownerId) ? ( + + ) : null} + {showLendButton(isAdmin, isOwner) && ( + + + + + )} - -
-
- setPage(nextPage)} - rowsPerPage={ROWS_PER_PAGE} - rowsPerPageOptions={[ROWS_PER_PAGE]} - labelDisplayedRows={({ from, to, count }) => - t("vehicle:catalogue.pagination", { from, to, count }) - } - /> -
+ {canMutateVehicle(v.ownerId) && ( + <> + + + + + )} + + ); + }} + /> (null); - const [leaseDialogOpen, setLeaseDialogOpen] = useState(false); - const [leaseInitialTarget, setLeaseInitialTarget] = useState<{ - kind: "train"; - targetId: string; - targetName?: string; - } | null>(null); - - const trainOnLayout = useMemo(() => { - const s = new Set(); - (layoutTrains.data ?? []).forEach((tt) => s.add(tt.id)); - return s; - }, [layoutTrains.data]); - - const leasedTargetKeys = useMemo(() => { - const s = new Set(); - (grantedLeases.data ?? []).forEach((lease) => s.add(lendableTargetKey(lease))); - return s; - }, [grantedLeases.data]); - - const mutationError = (() => { - const err = - addTrainToRoster.error ?? - removeTrainFromRoster.error ?? - deleteTrainMut.error; - if (!err) return null; - if (err instanceof ApiError) { - const key = `errors:${err.code}` as const; - const translated = t(key, { defaultValue: "" }); - if (translated) return translated; - return t("errors:unknown", { code: err.code }); - } - return t("errors:network"); - })(); - - const onDeleteTrain = (tr: Train) => { - if (!window.confirm(t("vehicle:trainList.deleteConfirm", { name: tr.name }))) { - return; - } - deleteTrainMut.mutate(tr.id); - }; - - return ( - <> - {mutationError && {mutationError}} - - - - - {t("vehicle:trainList.title")} - - - - - - - - {t("vehicle:trainList.columns.name")} - {t("vehicle:trainList.columns.members")} - - {t("vehicle:trainList.columns.actions")} - - - - - {trains.isLoading ? ( - - - {t("common:loading")} - - - ) : (trains.data ?? []).length === 0 ? ( - - - {t("vehicle:trainList.empty")} - - - ) : ( - (trains.data ?? []).map((tr) => { - const isOnLayout = trainOnLayout.has(tr.id); - const leased = isTargetLeased(leasedTargetKeys, "train", tr.id); - const lendable = isTrainLendable(isAdmin, { - isOwner: true, - onLayout: isOnLayout, - leased, - }); - const lendTitle = trainLendTooltip(t, isAdmin, { - isOwner: true, - onLayout: isOnLayout, - leased, - }); - return ( - - {tr.name} - - {t("vehicle:trainList.membersCount", { count: tr.members.length })} - - - - {isOnLayout ? ( - - - removeTrainFromRoster.mutate({ - layoutId, - trainId: tr.id, - }) - } - disabled={removeTrainFromRoster.isPending} - aria-label={t("vehicle:roster.removeButton")} - > - - - - ) : ( - - - addTrainToRoster.mutate({ - layoutId, - trainId: tr.id, - }) - } - disabled={addTrainToRoster.isPending} - aria-label={t("vehicle:trainList.actions.addToLayout")} - > - - - - )} - - - { - setLeaseInitialTarget({ - kind: "train", - targetId: tr.id, - targetName: tr.name, - }); - setLeaseDialogOpen(true); - }} - aria-label={t("rentals:granted.lend")} - > - - - - - - { - setEditingTrain(tr); - setDialogOpen(true); - }} - aria-label={t("vehicle:trainList.actions.edit")} - > - - - - - onDeleteTrain(tr)} - aria-label={t("vehicle:trainList.actions.delete")} - > - - - - - - - ); - }) - )} - -
-
-
- - setDialogOpen(false)} - /> - setLeaseDialogOpen(false)} - initialTarget={leaseInitialTarget} - allowUnresolvedTarget={isAdmin} - /> - - ); -} diff --git a/web/src/components/MyVehiclesCatalogue.tsx b/web/src/components/MyVehiclesCatalogue.tsx deleted file mode 100644 index b66f5c2c..00000000 --- a/web/src/components/MyVehiclesCatalogue.tsx +++ /dev/null @@ -1,334 +0,0 @@ -import { useMemo, useState } from "react"; -import AddIcon from "@mui/icons-material/Add"; -import EditIcon from "@mui/icons-material/Edit"; -import TuneIcon from "@mui/icons-material/Tune"; -import DeleteIcon from "@mui/icons-material/Delete"; -import HandshakeIcon from "@mui/icons-material/Handshake"; -import RefreshIcon from "@mui/icons-material/Refresh"; -import { useNavigate } from "react-router-dom"; -import PlaylistAddIcon from "@mui/icons-material/PlaylistAdd"; -import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline"; -import { useTranslation } from "react-i18next"; -import { - Alert, - Box, - Button, - Chip, - CircularProgress, - IconButton, - Paper, - Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Tooltip, - Typography, -} from "@mui/material"; - -import { useMe } from "../api/auth"; -import { ApiError } from "../api/client"; -import { useGrantedLeases, lendableTargetKey } from "../api/leases"; -import { - useAddVehicleToRoster, - useDeleteVehicle, - useLayoutVehicles, - useMyVehicles, - useRemoveVehicleFromRoster, - type Vehicle, -} from "../api/vehicles"; -import { - isTargetLeased, - isVehicleLendable, - vehicleLendTooltip, -} from "../utils/lendAction"; -import { hasEffectiveAdmin } from "../utils/rosterPermissions"; -import LeaseCreateDialog from "./leases/LeaseCreateDialog"; -import VehicleDialog from "./VehicleDialog"; - -interface Props { - layoutId: number; -} - -// MyVehiclesCatalogue is the caller's vehicle catalogue: CRUD plus -// "add to layout". Lives on /my/vehicles; the dashboard roster table -// stays in RosterSection. -export default function MyVehiclesCatalogue({ layoutId }: Props) { - const { t } = useTranslation(["vehicle", "errors", "common", "rentals"]); - const navigate = useNavigate(); - const me = useMe().data; - const vehicles = useMyVehicles(); - const layoutVehicles = useLayoutVehicles(layoutId); - const addVehicleToRoster = useAddVehicleToRoster(); - const removeVehicleFromRoster = useRemoveVehicleFromRoster(); - const deleteVehicleMut = useDeleteVehicle(); - const grantedLeases = useGrantedLeases(); - - const isAdmin = hasEffectiveAdmin(me); - const isRefreshing = - (vehicles.isFetching && !vehicles.isLoading) || - (layoutVehicles.isFetching && !layoutVehicles.isLoading); - - const refreshList = () => { - void vehicles.refetch(); - void layoutVehicles.refetch(); - void grantedLeases.refetch(); - }; - - const [dialogOpen, setDialogOpen] = useState(false); - const [editingVehicle, setEditingVehicle] = useState(null); - const [leaseDialogOpen, setLeaseDialogOpen] = useState(false); - const [leaseInitialTarget, setLeaseInitialTarget] = useState<{ - kind: "vehicle"; - targetId: string; - targetName?: string; - } | null>(null); - - const vehicleOnLayout = useMemo(() => { - const s = new Set(); - (layoutVehicles.data ?? []).forEach((v) => s.add(v.id)); - return s; - }, [layoutVehicles.data]); - - const leasedTargetKeys = useMemo(() => { - const s = new Set(); - (grantedLeases.data ?? []).forEach((lease) => s.add(lendableTargetKey(lease))); - return s; - }, [grantedLeases.data]); - - const mutationError = (() => { - const err = - addVehicleToRoster.error ?? - removeVehicleFromRoster.error ?? - deleteVehicleMut.error; - if (!err) return null; - if (err instanceof ApiError) { - const key = `errors:${err.code}` as const; - const translated = t(key, { defaultValue: "" }); - if (translated) return translated; - return t("errors:unknown", { code: err.code }); - } - return t("errors:network"); - })(); - - const onDeleteVehicle = (v: Vehicle) => { - if (!window.confirm(t("vehicle:list.deleteConfirm", { name: v.name }))) { - return; - } - deleteVehicleMut.mutate(v.id); - }; - - const renderDCC = (vehicle: { dccAddress: number | null }) => - vehicle.dccAddress != null ? ( - String(vehicle.dccAddress) - ) : ( - - ); - - return ( - <> - {mutationError && {mutationError}} - - - - - {t("vehicle:list.title")} - - - - - - - - - {t("vehicle:list.columns.name")} - {t("vehicle:list.columns.number")} - {t("vehicle:list.columns.dccAddress")} - - {t("vehicle:list.columns.actions")} - - - - - {vehicles.isLoading ? ( - - - {t("common:loading")} - - - ) : (vehicles.data ?? []).length === 0 ? ( - - - {t("vehicle:list.empty")} - - - ) : ( - (vehicles.data ?? []).map((v) => { - const isOnLayout = vehicleOnLayout.has(v.id); - const leased = isTargetLeased(leasedTargetKeys, "vehicle", v.id); - const lendable = isVehicleLendable(isAdmin, { - isOwner: true, - onLayout: isOnLayout, - dccAddress: v.dccAddress, - leased, - }); - const lendTitle = vehicleLendTooltip(t, isAdmin, { - isOwner: true, - onLayout: isOnLayout, - dccAddress: v.dccAddress, - leased, - }); - return ( - - - - - {v.name} - - - - {v.number || "—"} - {renderDCC(v)} - - - {isOnLayout ? ( - - - removeVehicleFromRoster.mutate({ - layoutId, - vehicleId: v.id, - }) - } - disabled={removeVehicleFromRoster.isPending} - aria-label={t("vehicle:roster.removeButton")} - > - - - - ) : ( - - - addVehicleToRoster.mutate({ - layoutId, - vehicleId: v.id, - }) - } - disabled={addVehicleToRoster.isPending} - aria-label={t("vehicle:list.actions.addToLayout")} - > - - - - )} - - - { - setLeaseInitialTarget({ - kind: "vehicle", - targetId: v.id, - targetName: v.name, - }); - setLeaseDialogOpen(true); - }} - aria-label={t("rentals:granted.lend")} - > - - - - - - navigate(`/my/vehicles/${v.id}/functions`)} - aria-label={t("vehicle:list.actions.editFunctions")} - > - - - - - { - setEditingVehicle(v); - setDialogOpen(true); - }} - aria-label={t("vehicle:list.actions.edit")} - > - - - - - onDeleteVehicle(v)} - aria-label={t("vehicle:list.actions.delete")} - > - - - - - - - ); - }) - )} - -
-
-
- - setDialogOpen(false)} - /> - setLeaseDialogOpen(false)} - initialTarget={leaseInitialTarget} - allowUnresolvedTarget={isAdmin} - /> - - ); -} diff --git a/web/src/components/TrainDialog.tsx b/web/src/components/TrainDialog.tsx index e576bee0..72d95b16 100644 --- a/web/src/components/TrainDialog.tsx +++ b/web/src/components/TrainDialog.tsx @@ -20,11 +20,12 @@ import DeleteIcon from "@mui/icons-material/Delete"; import AddIcon from "@mui/icons-material/Add"; import { useTranslation } from "react-i18next"; +import { useMe } from "../api/auth"; import { ApiError } from "../api/client"; import { useCreateTrain, - useMyVehicles, useUpdateTrain, + useVehicleCatalogue, type Train, type TrainMemberInput, } from "../api/vehicles"; @@ -41,6 +42,7 @@ interface Props { export default function TrainDialog({ open, train, onClose }: Props) { const { t } = useTranslation(["vehicle", "errors", "common"]); const isEdit = !!train; + const me = useMe().data; const [name, setName] = useState(""); const [members, setMembers] = useState([]); @@ -48,7 +50,7 @@ export default function TrainDialog({ open, train, onClose }: Props) { const create = useCreateTrain(); const update = useUpdateTrain(); - const vehicles = useMyVehicles(); + const vehicles = useVehicleCatalogue(me?.layoutId ?? null); useEffect(() => { if (!open) return; @@ -70,14 +72,21 @@ export default function TrainDialog({ open, train, onClose }: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, train?.id]); + const ownerVehicles = useMemo(() => { + const ownerId = isEdit && train ? train.ownerId : me?.id; + if (ownerId == null || !vehicles.data) return []; + return vehicles.data.filter((v) => v.ownerId === ownerId); + }, [vehicles.data, isEdit, train, me?.id]); + const availableForPicker = useMemo(() => { - if (!vehicles.data) return []; const taken = new Set(members.map((m) => m.vehicleId)); - return vehicles.data.filter((v) => !taken.has(v.id)); - }, [vehicles.data, members]); + return ownerVehicles.filter((v) => !taken.has(v.id)); + }, [ownerVehicles, members]); const labelFor = (vehicleId: string) => { - const v = vehicles.data?.find((x) => x.id === vehicleId); + const v = + vehicles.data?.find((x) => x.id === vehicleId) ?? + ownerVehicles.find((x) => x.id === vehicleId); if (!v) return `#${vehicleId}`; const dcc = v.dccAddress != null ? ` · DCC ${v.dccAddress}` : ""; return `${v.name}${dcc}`; @@ -136,7 +145,7 @@ export default function TrainDialog({ open, train, onClose }: Props) { const submitting = create.isPending || update.isPending; const canSubmit = name.trim().length > 0 && members.length > 0 && !submitting; - const noVehicles = vehicles.data && vehicles.data.length === 0; + const noVehicles = vehicles.isSuccess && ownerVehicles.length === 0; return ( diff --git a/web/src/components/vehicles/VehiclesCatalogueTable.tsx b/web/src/components/vehicles/VehiclesCatalogueTable.tsx new file mode 100644 index 00000000..17c5d3cf --- /dev/null +++ b/web/src/components/vehicles/VehiclesCatalogueTable.tsx @@ -0,0 +1,288 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { + Alert, + Box, + Chip, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; + +export const VEHICLES_CATALOGUE_ROWS_PER_PAGE = 10; + +export type VehiclesCatalogueRow = { + id: string; + name: string; + number: string; + dccAddress: number | null; + onLayout: boolean; + /** Shown under the name (Available catalogue). */ + ownerLabel?: string; + /** Epoch label (e.g. "III"); empty/omitted → no chip. */ + epoch?: string; + /** Extra text included in search haystack (e.g. kind label). */ + searchExtra?: string; +}; + +export type VehiclesCatalogueTableProps = { + rows: VehiclesCatalogueRow[]; + loading: boolean; + mutationError?: string | null; + showSearch?: boolean; + /** Extra controls in the header row (e.g. Add / Refresh / filters). */ + headerExtra?: ReactNode; + /** Optional title next to headerExtra; when omitted, search/headerExtra fill the bar. */ + title?: string; + emptyLabel: string; + noResultsLabel?: string; + /** + * Count before parent filters (mine/kind/epoch). When set and greater than 0 + * while `rows` is empty, shows `noResultsLabel` instead of `emptyLabel`. + */ + sourceCount?: number; + showOnLayoutChip?: boolean; + renderActions: (row: VehiclesCatalogueRow) => ReactNode; +}; + +function VehicleCatalogueRowView({ + row, + showOnLayoutChip, + actions, +}: { + row: VehiclesCatalogueRow; + showOnLayoutChip: boolean; + actions: ReactNode; +}) { + const { t } = useTranslation(["vehicle"]); + + const dccNode = + row.dccAddress != null ? ( + + {t("vehicle:catalogue.dccLabel", { addr: row.dccAddress })} + + ) : ( + + ); + + return ( + + + + + + {row.name} + + + {row.ownerLabel ? ( + + {row.ownerLabel} + + ) : null} + {dccNode} + + + + + + + {row.number || "—"} + + {showOnLayoutChip ? ( + + ) : null} + {row.epoch ? ( + + ) : null} + + + {actions} + + + + + ); +} + +export default function VehiclesCatalogueTable({ + rows, + loading, + mutationError, + showSearch = true, + headerExtra, + title, + emptyLabel, + noResultsLabel, + sourceCount, + showOnLayoutChip = false, + renderActions, +}: VehiclesCatalogueTableProps) { + const { t } = useTranslation(["vehicle", "common"]); + const [query, setQuery] = useState(""); + const [page, setPage] = useState(0); + + useEffect(() => { + setPage(0); + }, [query, rows]); + + const filteredRows = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) { + return rows; + } + return rows.filter((row) => { + const haystack = [ + row.name, + row.number, + row.dccAddress != null ? String(row.dccAddress) : "", + row.ownerLabel ?? "", + row.epoch ?? "", + row.searchExtra ?? "", + ] + .join(" ") + .toLowerCase(); + return haystack.includes(q); + }); + }, [rows, query]); + + const pagedRows = useMemo(() => { + const start = page * VEHICLES_CATALOGUE_ROWS_PER_PAGE; + return filteredRows.slice(start, start + VEHICLES_CATALOGUE_ROWS_PER_PAGE); + }, [filteredRows, page]); + + // Prefer catalogue size before parent filters so mine/kind/epoch empty + // states use noResultsLabel, not "nothing registered yet". + const baseCount = sourceCount ?? rows.length; + const emptyMessage = + baseCount === 0 ? emptyLabel : (noResultsLabel ?? emptyLabel); + + const showHeaderBar = Boolean(title || headerExtra || showSearch); + + return ( + <> + {mutationError ? {mutationError} : null} + + + {showHeaderBar ? ( + + {(title || headerExtra) && ( + + {title ? ( + + {title} + + ) : ( + + )} + {headerExtra} + + )} + {showSearch ? ( + setQuery(e.target.value)} + /> + ) : null} + + ) : null} + + + + + + {t("vehicle:catalogue.table.vehicle")} + {t("vehicle:catalogue.table.details")} + + + + {loading ? ( + + + {t("common:loading")} + + + ) : pagedRows.length === 0 ? ( + + + {emptyMessage} + + + ) : ( + pagedRows.map((row) => ( + + )) + )} + +
+
+ + setPage(nextPage)} + rowsPerPage={VEHICLES_CATALOGUE_ROWS_PER_PAGE} + rowsPerPageOptions={[VEHICLES_CATALOGUE_ROWS_PER_PAGE]} + labelDisplayedRows={({ from, to, count }) => + t("vehicle:catalogue.pagination", { from, to, count }) + } + /> +
+ + ); +} diff --git a/web/src/i18n/locales/de/common.json b/web/src/i18n/locales/de/common.json index 72cc735d..be9c321a 100644 --- a/web/src/i18n/locales/de/common.json +++ b/web/src/i18n/locales/de/common.json @@ -51,15 +51,12 @@ }, "my": { "menuLabel": "Meine", - "vehicles": "Fahrzeuge", - "trains": "Züge", "templates": "Fahrzeugvorlagen", "throttle": "Regler", "rentals": "Ausleihen", "remotes": "Handgeräte" }, "fleet": { - "menuLabel": "Fuhrpark", "availableVehicles": "Verfügbare Fahrzeuge", "availableTrains": "Verfügbare Züge" }, diff --git a/web/src/i18n/locales/de/vehicle.json b/web/src/i18n/locales/de/vehicle.json index 397e2447..acfb4423 100644 --- a/web/src/i18n/locales/de/vehicle.json +++ b/web/src/i18n/locales/de/vehicle.json @@ -48,11 +48,22 @@ "searchLabel": "Suche", "searchPlaceholder": "Name, Nummer, DCC-Adresse, Eigentümer…", "empty": "Es sind noch keine Fahrzeuge registriert.", - "noResults": "Keine Fahrzeuge entsprechen der Suche.", + "noResults": "Keine Fahrzeuge zum Anzeigen — versuchen Sie, die Filter zu ändern.", "pagination": "{{from}}–{{to}} von {{count}}", "onLayout": { - "yes": "Ja", - "no": "Nein" + "yes": "Auf der Anlage", + "no": "Nicht auf der Anlage" + }, + "dccLabel": "DCC: {{addr}}", + "showOnlyMine": "Nur eigene anzeigen", + "filterKind": "Art", + "filterEpoch": "Epoche", + "filterAll": "Alle", + "filterNoEpoch": "Ohne Epoche", + "epochChip": "Epoche {{epoch}}", + "table": { + "vehicle": "Fahrzeug", + "details": "Details" }, "columns": { "name": "Name", @@ -72,7 +83,8 @@ "searchLabel": "Suche", "searchPlaceholder": "Name, Eigentümer…", "empty": "Es sind noch keine Züge registriert.", - "noResults": "Keine Züge entsprechen der Suche.", + "noResults": "Keine Züge zum Anzeigen — versuchen Sie, die Filter zu ändern.", + "showOnlyMine": "Nur eigene anzeigen", "pagination": "{{from}}–{{to}} von {{count}}", "onLayout": { "yes": "Ja", diff --git a/web/src/i18n/locales/en/common.json b/web/src/i18n/locales/en/common.json index 42ca1dd2..020d912d 100644 --- a/web/src/i18n/locales/en/common.json +++ b/web/src/i18n/locales/en/common.json @@ -51,15 +51,12 @@ }, "my": { "menuLabel": "My", - "vehicles": "Vehicles", - "trains": "Trains", "templates": "Vehicle templates", "throttle": "Throttle", "rentals": "Rentals", "remotes": "Handsets" }, "fleet": { - "menuLabel": "Fleet", "availableVehicles": "Available vehicles", "availableTrains": "Available trains" }, diff --git a/web/src/i18n/locales/en/vehicle.json b/web/src/i18n/locales/en/vehicle.json index 54f67fda..e40ae6b5 100644 --- a/web/src/i18n/locales/en/vehicle.json +++ b/web/src/i18n/locales/en/vehicle.json @@ -48,11 +48,22 @@ "searchLabel": "Search", "searchPlaceholder": "Name, number, DCC address, owner…", "empty": "No vehicles have been registered yet.", - "noResults": "No vehicles match your search.", + "noResults": "No vehicles to show — try changing the filters.", "pagination": "{{from}}–{{to}} of {{count}}", "onLayout": { - "yes": "Yes", - "no": "No" + "yes": "On layout", + "no": "Not on layout" + }, + "dccLabel": "DCC: {{addr}}", + "showOnlyMine": "Show only mine", + "filterKind": "Kind", + "filterEpoch": "Epoch", + "filterAll": "All", + "filterNoEpoch": "No epoch", + "epochChip": "Epoch {{epoch}}", + "table": { + "vehicle": "Vehicle", + "details": "Details" }, "columns": { "name": "Name", @@ -72,7 +83,8 @@ "searchLabel": "Search", "searchPlaceholder": "Name, owner…", "empty": "No trains have been registered yet.", - "noResults": "No trains match your search.", + "noResults": "No trains to show — try changing the filters.", + "showOnlyMine": "Show only mine", "pagination": "{{from}}–{{to}} of {{count}}", "onLayout": { "yes": "Yes", diff --git a/web/src/i18n/locales/pl/common.json b/web/src/i18n/locales/pl/common.json index 9a20ceea..f20b9498 100644 --- a/web/src/i18n/locales/pl/common.json +++ b/web/src/i18n/locales/pl/common.json @@ -51,15 +51,12 @@ }, "my": { "menuLabel": "Moje", - "vehicles": "Pojazdy", - "trains": "Składy", "templates": "Szablony pojazdów", "throttle": "Sterowanie", "rentals": "Wypożyczenia", "remotes": "Piloty" }, "fleet": { - "menuLabel": "Tabor", "availableVehicles": "Dostępne pojazdy", "availableTrains": "Dostępne składy" }, diff --git a/web/src/i18n/locales/pl/vehicle.json b/web/src/i18n/locales/pl/vehicle.json index 7c152d31..199619d4 100644 --- a/web/src/i18n/locales/pl/vehicle.json +++ b/web/src/i18n/locales/pl/vehicle.json @@ -48,11 +48,22 @@ "searchLabel": "Szukaj", "searchPlaceholder": "Nazwa, numer, adres DCC, właściciel…", "empty": "Nie zarejestrowano jeszcze żadnych pojazdów.", - "noResults": "Brak pojazdów pasujących do wyszukiwania.", + "noResults": "Brak pojazdów do pokazania, spróbuj zmienić filtry.", "pagination": "{{from}}–{{to}} z {{count}}", "onLayout": { - "yes": "Tak", - "no": "Nie" + "yes": "Na makiecie", + "no": "Nie jest na makiecie" + }, + "dccLabel": "DCC: {{addr}}", + "showOnlyMine": "Pokaż tylko moje", + "filterKind": "Typ", + "filterEpoch": "Epoka", + "filterAll": "Wszystkie", + "filterNoEpoch": "Bez epoki", + "epochChip": "Epoka {{epoch}}", + "table": { + "vehicle": "Pojazd", + "details": "Szczegóły" }, "columns": { "name": "Nazwa", @@ -72,7 +83,8 @@ "searchLabel": "Szukaj", "searchPlaceholder": "Nazwa, właściciel…", "empty": "Nie zarejestrowano jeszcze żadnych składów.", - "noResults": "Brak składów pasujących do wyszukiwania.", + "noResults": "Brak składów do pokazania, spróbuj zmienić filtry.", + "showOnlyMine": "Pokaż tylko moje", "pagination": "{{from}}–{{to}} z {{count}}", "onLayout": { "yes": "Tak", diff --git a/web/src/pages/MyTrainsPage.tsx b/web/src/pages/MyTrainsPage.tsx deleted file mode 100644 index b1b54e5f..00000000 --- a/web/src/pages/MyTrainsPage.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Box, Container, Typography } from "@mui/material"; -import { useTranslation } from "react-i18next"; - -import { useMe } from "../api/auth"; -import MyTrainsCatalogue from "../components/MyTrainsCatalogue"; - -export default function MyTrainsPage() { - const { t } = useTranslation(["vehicle"]); - const me = useMe().data; - - if (!me) { - return null; - } - - return ( - - - {t("vehicle:trainList.title")} - - - - - - ); -} diff --git a/web/src/pages/MyVehiclesPage.tsx b/web/src/pages/MyVehiclesPage.tsx deleted file mode 100644 index fa2e328f..00000000 --- a/web/src/pages/MyVehiclesPage.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Box, Container, Link, Typography } from "@mui/material"; -import { Trans, useTranslation } from "react-i18next"; -import { Link as RouterLink } from "react-router-dom"; - -import { useMe } from "../api/auth"; -import MyVehiclesCatalogue from "../components/MyVehiclesCatalogue"; - -export default function MyVehiclesPage() { - const { t } = useTranslation(["vehicle"]); - const me = useMe().data; - - if (!me) { - return null; - } - - return ( - - - {t("vehicle:list.title")} - - - , - addBtn: , - }} - /> - - - - - - ); -} diff --git a/web/src/pages/VehicleFunctionsPage.tsx b/web/src/pages/VehicleFunctionsPage.tsx index 08d4ab7e..58c7ab91 100644 --- a/web/src/pages/VehicleFunctionsPage.tsx +++ b/web/src/pages/VehicleFunctionsPage.tsx @@ -13,7 +13,8 @@ import { useVehicleTemplates, type FunctionCopySource, } from "../api/functions"; -import { useMyVehicles } from "../api/vehicles"; +import { useMe } from "../api/auth"; +import { useVehicleCatalogue } from "../api/vehicles"; import FunctionListEditor from "../components/functions/FunctionListEditor"; export default function VehicleFunctionsPage() { @@ -21,7 +22,8 @@ export default function VehicleFunctionsPage() { const vehicleId = vehicleIdParam ?? ""; const navigate = useNavigate(); const { t } = useTranslation(["vehicle", "function"]); - const vehicles = useMyVehicles(); + const me = useMe().data; + const vehicles = useVehicleCatalogue(me?.layoutId ?? null); const functions = useVehicleFunctions(vehicleId); const templates = useVehicleTemplates(); const catalogue = useFunctionCatalogue(true);