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")}
+ />
+ }
+ onClick={onRefresh}
+ disabled={trains.isFetching}
+ >
+ {t("vehicle:list.refreshButton")}
+
+ }
+ onClick={() => {
+ setEditingTrain(null);
+ setDialogOpen(true);
+ }}
+ >
+ {t("vehicle:trainList.addButton")}
+
+
{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) ? (
+ }
+ onClick={() =>
+ addTrainToRoster.mutate({
+ layoutId,
+ trainId: tr.id,
+ })
+ }
+ disabled={addTrainToRoster.isPending}
+ >
+ {t("vehicle:trainList.actions.addToLayout")}
+
) : 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")}
- >
-
-
-
+ }
+ onClick={() => {
+ setEditingTrain(tr);
+ setDialogOpen(true);
+ }}
+ >
+ {t("vehicle:trainList.actions.edit")}
+
+ }
+ onClick={() => onDeleteTrain(tr)}
+ >
+ {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")}
+
+
+
+ }
+ onClick={onRefresh}
+ disabled={vehicles.isFetching}
+ >
+ {t("vehicle:list.refreshButton")}
+
+ }
+ onClick={() => {
+ setEditingVehicle(null);
+ setDialogOpen(true);
+ }}
+ >
+ {t("vehicle:list.addButton")}
+
+
);
- 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) ? (
+ }
+ onClick={() =>
+ removeVehicleFromRoster.mutate({
+ layoutId,
+ vehicleId: v.id,
+ })
+ }
+ disabled={removeVehicleFromRoster.isPending}
+ >
+ {t("vehicle:roster.removeButton")}
+
+ ) : null
+ ) : canAddToLayout(me, v.ownerId) ? (
+ }
+ onClick={() =>
+ addVehicleToRoster.mutate({
+ layoutId,
+ vehicleId: v.id,
+ })
+ }
+ disabled={addVehicleToRoster.isPending}
+ >
+ {t("vehicle:list.actions.addToLayout")}
+
+ ) : null}
+ {showLendButton(isAdmin, isOwner) && (
+
+
+ }
+ disabled={!lendable}
+ onClick={() => {
+ setLeaseInitialTarget({
+ kind: "vehicle",
+ targetId: v.id,
+ targetName: v.name,
+ });
+ setLeaseDialogOpen(true);
+ }}
+ >
+ {t("rentals:granted.lend")}
+
+
+
)}
-
-
-
- setPage(nextPage)}
- rowsPerPage={ROWS_PER_PAGE}
- rowsPerPageOptions={[ROWS_PER_PAGE]}
- labelDisplayedRows={({ from, to, count }) =>
- t("vehicle:catalogue.pagination", { from, to, count })
- }
- />
-
+ {canMutateVehicle(v.ownerId) && (
+ <>
+ }
+ onClick={() => navigate(`/my/vehicles/${v.id}/functions`)}
+ >
+ {t("vehicle:list.actions.editFunctions")}
+
+ }
+ onClick={() => {
+ setEditingVehicle(v);
+ setDialogOpen(true);
+ }}
+ >
+ {t("vehicle:list.actions.edit")}
+
+ }
+ onClick={() => onDeleteVehicle(v)}
+ >
+ {t("vehicle:list.actions.delete")}
+
+ >
+ )}
+ >
+ );
+ }}
+ />
(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")}
-
- }
- variant="contained"
- onClick={() => {
- setEditingTrain(null);
- setDialogOpen(true);
- }}
- >
- {t("vehicle:trainList.addButton")}
-
-
-
-
-
-
- {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")}
-
-
- ) : (
-
- )
- }
- onClick={refreshList}
- disabled={isRefreshing}
- >
- {t("vehicle:list.refreshButton")}
-
- }
- variant="contained"
- onClick={() => {
- setEditingVehicle(null);
- setDialogOpen(true);
- }}
- >
- {t("vehicle:list.addButton")}
-
-
-
-
-
-
- {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 (