diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts index a42dcd220..0c3b2aa51 100644 --- a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts +++ b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts @@ -1,13 +1,16 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsBoolean, IsIn, IsOptional, IsUUID } from 'class-validator'; +import { IsBoolean, IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator'; +import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES, } from '../entities/locomotive.entity'; -export class FilterLocomotivesDto { +// Extends the shared pagination DTO for `page`/`pageSize`/`search`; those are +// only read by `GET /locomotives/paged` — the plain list ignores them. +export class FilterLocomotivesDto extends PaginationQueryDto { @ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES }) @IsOptional() @IsIn([...LOCOMOTIVE_STATUSES]) @@ -47,4 +50,14 @@ export class FilterLocomotivesDto { @IsOptional() @IsUUID() excludeTrainId?: string; + + @ApiPropertyOptional({ description: 'Registered on or after this day (YYYY-MM-DD)' }) + @IsOptional() + @IsDateString() + createdFrom?: string; + + @ApiPropertyOptional({ description: 'Registered on or before this day (YYYY-MM-DD)' }) + @IsOptional() + @IsDateString() + createdTo?: string; } diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts index 77d8b0df2..3883f8d99 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts @@ -24,6 +24,14 @@ export class LocomotivesController { return this.locomotivesService.findAll(filter); } + // Must be declared before @Get(':id') so the path isn't captured as an id. + @Get('paged') + @StaffReference() + @ApiOperation({ summary: 'List locomotives, paginated ({items, meta})' }) + findAllPaged(@Query() filter: FilterLocomotivesDto) { + return this.locomotivesService.findAllPaged(filter); + } + @Get(':id') @StaffReference() @ApiOperation({ summary: 'Get a locomotive by ID' }) diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts index 3ad5b3650..11300cfee 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts @@ -1,7 +1,7 @@ import { BaseRepository } from '@edr/api-common'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, SelectQueryBuilder } from 'typeorm'; import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity'; import { TrainLocomotive } from '../trains/entities/train-locomotive.entity'; @@ -16,18 +16,22 @@ export class LocomotivesRepository extends BaseRepository { } /** - * List locomotives for the train-builder coupling picker: the usual - * status/type/yard filters, plus optional exclusion of any loco already - * coupled to a built train. `keepTrainId` spares that one train's own locos - * from the exclusion so they stay selectable while editing its consist. + * Filter/sort builder shared by the coupling picker and the paginated list: + * the usual status/type/yard filters, free-text over code + name, a + * registration-day range, and optional exclusion of any loco already coupled + * to a built train. `keepTrainId` spares that one train's own locos from the + * exclusion so they stay selectable while editing its consist. */ - findForCoupling(opts: { + buildListQuery(opts: { status?: LocomotiveStatus; locomotiveType?: LocomotiveType; currentYardId?: string; excludeCoupled?: boolean; keepTrainId?: string; - }): Promise { + search?: string; + createdFrom?: string; + createdTo?: string; + }): SelectQueryBuilder { const qb = this.repository .createQueryBuilder('locomotive') .leftJoinAndSelect('locomotive.currentYard', 'currentYard') @@ -39,6 +43,25 @@ export class LocomotivesRepository extends BaseRepository { if (opts.currentYardId) qb.andWhere('locomotive.currentYardId = :yardId', { yardId: opts.currentYardId }); + const search = opts.search?.trim(); + if (search) { + qb.andWhere('(locomotive.code ILIKE :search OR locomotive.name ILIKE :search)', { + search: `%${search}%`, + }); + } + + // Registration-day range, both ends inclusive (the UI picks whole days). + if (opts.createdFrom) { + qb.andWhere('locomotive.createdAt >= CAST(:createdFrom AS date)', { + createdFrom: opts.createdFrom, + }); + } + if (opts.createdTo) { + qb.andWhere("locomotive.createdAt < CAST(:createdTo AS date) + INTERVAL '1 day'", { + createdTo: opts.createdTo, + }); + } + if (opts.excludeCoupled) { // NOT EXISTS a link to a DIFFERENT train. Own-train links are kept so the // consist being edited still lists its current locomotives. @@ -53,7 +76,11 @@ export class LocomotivesRepository extends BaseRepository { qb.andWhere(`NOT EXISTS (${sub.getQuery()})`).setParameters(sub.getParameters()); } - return qb.getMany(); + return qb; + } + + findForCoupling(opts: Parameters[0]): Promise { + return this.buildListQuery(opts).getMany(); } /** diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts index 46396893e..1da03072e 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts @@ -1,6 +1,9 @@ +import { PaginatedResponse } from '@edr/types'; import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { paginateQuery } from '../../common/utils/pagination.util'; + import { CreateLocomotiveDto } from './dto/create-locomotive.dto'; import { FilterLocomotivesDto } from './dto/filter-locomotives.dto'; import { UpdateLocomotiveDto } from './dto/update-locomotive.dto'; @@ -53,6 +56,21 @@ export class LocomotivesService { }); } + /** Same filters as `findAll` plus search/date range, on the shared list envelope. */ + findAllPaged(filter: FilterLocomotivesDto): Promise> { + const qb = this.locomotivesRepository.buildListQuery({ + status: filter.status as LocomotiveStatus | undefined, + locomotiveType: filter.locomotiveType as LocomotiveType | undefined, + currentYardId: filter.currentYardId, + excludeCoupled: filter.excludeCoupled, + keepTrainId: filter.excludeTrainId, + search: filter.search, + createdFrom: filter.createdFrom, + createdTo: filter.createdTo, + }); + return paginateQuery(qb, filter); + } + /** Default max pull weight (tons) applied when the caller omits it. */ private static readonly DEFAULT_MAX_PULL_WEIGHT_TONS = 2500; diff --git a/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts index 59188a2a6..dfd9a1a91 100644 --- a/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts +++ b/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts @@ -1,14 +1,13 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { IsEnum, IsOptional } from 'class-validator'; +import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; import { RouteStatus } from '../entities/route.entity'; -export class FilterRoutesDto { - @ApiPropertyOptional({ description: 'Search origin/destination yard codes or names' }) - @IsOptional() - @IsString() - search?: string; - +// `search` (origin/destination/milestone yard codes and names) plus +// `page`/`pageSize` come from the shared pagination DTO; the page window is only +// read by `GET /routes/paged`. +export class FilterRoutesDto extends PaginationQueryDto { @ApiPropertyOptional({ enum: ['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'] }) @IsOptional() @IsEnum(['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING']) diff --git a/apps/edr-freight-api/src/modules/routes/routes.controller.ts b/apps/edr-freight-api/src/modules/routes/routes.controller.ts index cf2314156..259dd4c9f 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.controller.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.controller.ts @@ -21,6 +21,13 @@ export class RoutesController { return this.routesService.findAll(filter); } + // Must be declared before @Get(':id') so the path isn't captured as an id. + @Get('paged') + @ApiOperation({ summary: 'List routes, paginated ({items, meta})' }) + findAllPaged(@Query() filter: FilterRoutesDto) { + return this.routesService.findAllPaged(filter); + } + @Get(':id') @ApiOperation({ summary: 'Get route by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/routes/routes.service.ts b/apps/edr-freight-api/src/modules/routes/routes.service.ts index 855f8ec02..8c2989b87 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.service.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.service.ts @@ -4,9 +4,10 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { TrainScheduleStatus } from '@edr/types'; +import { PaginatedResponse, TrainScheduleStatus } from '@edr/types'; import { DataSource, In, Not } from 'typeorm'; +import { paginateArray } from '../../common/utils/pagination.util'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Yard } from '../rule-engine/entities/yard.entity'; import { YardDistance } from '../rule-engine/entities/yard-distance.entity'; @@ -68,6 +69,18 @@ export class RoutesService { }); } + /** + * `findAll` on the shared `{items, meta}` envelope. + * + * ponytail: slices in memory — the corridor table is small (tens of rows) and + * both the ordering (formatted "A → B → C" label) and the search span the + * milestone collection, which a single SQL page window cannot express. Move to + * a query builder if routes ever grow past a few hundred. + */ + async findAllPaged(filter: FilterRoutesDto): Promise> { + return paginateArray(await this.findAll(filter), filter); + } + async findById(id: string): Promise { const route = await this.dataSource.getRepository(Route).findOne({ where: { id }, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index db18d6804..264e6df84 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -487,7 +487,13 @@ export class BookingJourneyService { currentYardId: booking.destinationYardId, currentTrainScheduleId: null, trainSetWagonId: null, - status: Freight.WagonStatus.Available, + // A wagon that belongs to a built train stays coupled to it (ASSIGNED); + // only loose wagons return to the open AVAILABLE pool. Marking a + // coupled wagon AVAILABLE made it show up in the train-builder's + // "available wagons" picker, where attaching it always 409'd. + status: wagon.trainId + ? Freight.WagonStatus.Assigned + : Freight.WagonStatus.Available, }); } } diff --git a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts index c7eecfc02..ae48a2713 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts @@ -1,7 +1,17 @@ import { WagonStatus } from '@edr/types'; import { ApiPropertyOptional } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; -import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; +import { Transform, Type } from 'class-transformer'; +import { + IsBoolean, + IsDateString, + IsEnum, + IsInt, + IsOptional, + IsString, + IsUUID, + Max, + Min, +} from 'class-validator'; export class ListWagonsQueryDto { @ApiPropertyOptional({ description: 'Search wagon number (partial match)' }) @@ -29,6 +39,15 @@ export class ListWagonsQueryDto { @IsUUID() trainId?: string; + @ApiPropertyOptional({ + description: + 'Only loose wagons (not coupled to a built train) — what a picker can actually take.', + }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => value === true || value === 'true') + @IsBoolean() + unassigned?: boolean; + @ApiPropertyOptional({ description: 'Filter by run number — matches export OR import run (e.g. 8001).', }) @@ -60,4 +79,23 @@ export class ListWagonsQueryDto { @Min(1) @Max(500) limit?: number; + + /** Page size for `GET /wagons/paged`; the legacy `limit` still drives `GET /wagons`. */ + @ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + pageSize?: number; + + @ApiPropertyOptional({ description: 'Registered on or after this day (YYYY-MM-DD)' }) + @IsOptional() + @IsDateString() + createdFrom?: string; + + @ApiPropertyOptional({ description: 'Registered on or before this day (YYYY-MM-DD)' }) + @IsOptional() + @IsDateString() + createdTo?: string; } diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts index 2d2673164..8d4159726 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts @@ -114,6 +114,8 @@ export class WagonTransferRequestsService { currentYardId: yardId, wagonTypeId, status: WagonStatus.Available, + // Coupled to a built train = not movable; bulkTransfer rejects it too. + trainId: IsNull(), }, }); } @@ -399,6 +401,7 @@ export class WagonTransferRequestsService { currentYardId: request.fromYardId, wagonTypeId: request.wagonTypeId, status: WagonStatus.Available, + trainId: IsNull(), }, order: { wagonNumber: 'ASC' }, take: remaining, diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts index bac10299d..3b8a7077b 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -44,6 +44,14 @@ export class WagonsController { return this.wagonsService.findAll(query); } + // Must be declared before @Get(':id') so the path isn't captured as an id. + @Get('paged') + @StaffReference() + @ApiOperation({ summary: 'List wagons, paginated ({items, meta})' }) + findAllPaged(@Query() query: ListWagonsQueryDto) { + return this.wagonsService.findAllPaged(query); + } + @Get(':id') @StaffReference() @ApiOperation({ summary: 'Get a wagon by ID' }) diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 99c28469f..98f5c32b0 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -1,4 +1,4 @@ -import { Freight, WagonMovementKind, WagonStatus } from '@edr/types'; +import { Freight, PaginatedResponse, WagonMovementKind, WagonStatus } from '@edr/types'; import { BadRequestException, Injectable, @@ -6,7 +6,8 @@ import { ConflictException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, DataSource, In } from 'typeorm'; +import { Repository, DataSource, In, SelectQueryBuilder } from 'typeorm'; +import { paginateQuery } from '../../common/utils/pagination.util'; import { CreateWagonDto } from './dto/create-wagon.dto'; import { ListWagonsQueryDto } from './dto/list-wagons-query.dto'; import { UpdateWagonDto } from './dto/update-wagon.dto'; @@ -42,7 +43,8 @@ export class WagonsService { return this.wagonRepo.save(wagon); } - async findAll(query: ListWagonsQueryDto = {}): Promise { + /** Shared filter/sort builder behind `findAll` (array) and `findAllPaged` (envelope). */ + private buildListQuery(query: ListWagonsQueryDto): SelectQueryBuilder { const search = query.search?.trim(); const trainId = query.trainId?.trim(); const wagonTypeId = query.wagonTypeId?.trim(); @@ -61,6 +63,9 @@ export class WagonsService { if (query.currentYardId) qb.andWhere('w.currentYardId = :currentYardId', { currentYardId: query.currentYardId }); if (trainId) qb.andWhere('w.trainId = :trainId', { trainId }); + // Pickers (train-builder, transfer fulfilment) can only take a wagon that is + // not already coupled to a built train — never offer one the API will reject. + if (query.unassigned) qb.andWhere('w.trainId IS NULL'); if (wagonTypeId) qb.andWhere('w.wagonTypeId = :wagonTypeId', { wagonTypeId }); // Filter by run: the odd export run identifies the pair, so match either @@ -72,6 +77,18 @@ export class WagonsService { ); } + // Registration-day range, both ends inclusive (the UI picks whole days). + if (query.createdFrom) { + qb.andWhere('w.createdAt >= CAST(:createdFrom AS date)', { + createdFrom: query.createdFrom, + }); + } + if (query.createdTo) { + qb.andWhere("w.createdAt < CAST(:createdTo AS date) + INTERVAL '1 day'", { + createdTo: query.createdTo, + }); + } + // Search matches the wagon number or either run number. if (search) { qb.andWhere( @@ -95,6 +112,12 @@ export class WagonsService { const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; qb.orderBy(`w.${sortBy}`, sortOrder); + return qb; + } + + async findAll(query: ListWagonsQueryDto = {}): Promise { + const qb = this.buildListQuery(query); + if (query.page && query.limit) { qb.skip((Number(query.page) - 1) * Number(query.limit)); } @@ -103,6 +126,11 @@ export class WagonsService { return qb.getMany(); } + /** Same filters as `findAll`, on the shared `{items, meta}` list envelope. */ + findAllPaged(query: ListWagonsQueryDto = {}): Promise> { + return paginateQuery(this.buildListQuery(query), query); + } + async findById(id: string): Promise { const wagon = await this.wagonRepo.findOne({ where: { id }, diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx index fc33ae45a..1bddb77cd 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx @@ -41,7 +41,12 @@ export default function AvailableWagonsPanel({ const wagonsQuery = useQuery( api.wagons.list.queryOptions({ input: { - filters: { status: Freight.WagonStatus.Available, currentYardId: yardId }, + filters: { + status: Freight.WagonStatus.Available, + currentYardId: yardId, + // Loose wagons only — one already on another train cannot be coupled. + unassigned: true, + }, }, enabled: Boolean(yardId), }), diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 9a017d8c7..83a931b18 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -1,7 +1,7 @@ import type { ColumnDef } from "@edr/ui-common"; import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; import { useAuth } from "@/auth/useAuth"; @@ -30,8 +30,13 @@ import { type FleetFormFieldDef, type FleetResourceSlug, } from "@/pages/fleet/config/resources"; -import type { FleetListFilters, FleetRecord } from "@/services/fleet/fleet.service"; +import { + isFleetServerPaginated, + type FleetListFilters, + type FleetRecord, +} from "@/services/fleet/fleet.service"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; +import { useDebouncedValue } from "@mantine/hooks"; const DEFAULT_SLUG: FleetResourceSlug = "locomotives"; @@ -53,6 +58,10 @@ const FleetResourcePage = () => { const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [search, setSearch] = useState(""); + const [debouncedSearch] = useDebouncedValue(search, 300); + // Wagons and locomotives page in the database; the rest still list in full + // and page in the browser (see `pagedHandlers` in fleet.service). + const serverPaged = isFleetServerPaginated(slug); const [statusFilter, setStatusFilter] = useState("ALL"); // Registration date range. Server-side list filters (status/yard/train) are // applied by the API; this narrows what comes back, alongside search. @@ -93,14 +102,42 @@ const FleetResourcePage = () => { if (wagonTypeId && wagonTypeId !== "ALL") { (filters as { wagonTypeId?: string }).wagonTypeId = wagonTypeId; } - if (slug !== "locomotives" && search.trim()) { - filters.search = search.trim(); + // The plain locomotives list has no server-side search — its page window + // does, so the term is only sent on the paginated path. + if ((serverPaged || slug !== "locomotives") && debouncedSearch.trim()) { + filters.search = debouncedSearch.trim(); } return filters; - }, [slug, listFilterValues, search]); + }, [slug, listFilterValues, debouncedSearch, serverPaged]); - const { data: allRows = [], isLoading, isError, error } = useQuery( - api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }), + // On the server-paged path the page window, the search and the registration + // date range are all resolved by the API — nothing is filtered client-side. + const pagedFilters = useMemo( + (): FleetListFilters => ({ + ...serverListFilters, + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + ...(dateFrom ? { createdFrom: dateFrom } : {}), + ...(dateTo ? { createdTo: dateTo } : {}), + }), + [serverListFilters, pagination.pageIndex, pagination.pageSize, dateFrom, dateTo], + ); + + const listQuery = useQuery({ + ...api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }), + enabled: !serverPaged, + }); + const pagedQuery = useQuery({ + ...api.fleet.listPaged.queryOptions({ input: { slug, filters: pagedFilters } }), + enabled: serverPaged, + placeholderData: keepPreviousData, + }); + + const activeQuery = serverPaged ? pagedQuery : listQuery; + const { isLoading, isError, error } = activeQuery; + const allRows = useMemo( + () => (serverPaged ? (pagedQuery.data?.items ?? []) : (listQuery.data ?? [])), + [serverPaged, pagedQuery.data, listQuery.data], ); const create = useMutation(api.fleet.create.mutationOptions()); const update = useMutation(api.fleet.update.mutationOptions()); @@ -270,6 +307,9 @@ const FleetResourcePage = () => { const filteredRows = useMemo(() => { if (!config) return allRows; + // The API already applied every filter and cut the page — re-filtering here + // would drop rows the server deliberately returned. + if (serverPaged) return allRows; const term = search.trim().toLowerCase(); return allRows.filter((row) => { const record = row as unknown as Record; @@ -287,13 +327,19 @@ const FleetResourcePage = () => { .includes(term), ); }); - }, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo]); + }, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo, serverPaged]); - const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize)); + const totalCount = serverPaged + ? (pagedQuery.data?.meta.total ?? 0) + : filteredRows.length; + const pageCount = serverPaged + ? Math.max(1, pagedQuery.data?.meta.totalPages ?? 1) + : Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize)); const pagedRows = useMemo(() => { + if (serverPaged) return filteredRows; const start = pagination.pageIndex * pagination.pageSize; return filteredRows.slice(start, start + pagination.pageSize); - }, [filteredRows, pagination.pageIndex, pagination.pageSize]); + }, [filteredRows, pagination.pageIndex, pagination.pageSize, serverPaged]); const columns = useMemo((): ColumnDef[] => { if (!config) return []; @@ -584,7 +630,7 @@ const FleetResourcePage = () => { pageIndex: pagination.pageIndex, pageSize: pagination.pageSize, pageCount, - totalCount: filteredRows.length, + totalCount, }} tableOptions={{ manualPagination: true, @@ -610,7 +656,7 @@ const FleetResourcePage = () => { emptyMessage={`No ${itemLabel} found`} pagination={pagination} pageCount={pageCount} - totalCount={filteredRows.length} + totalCount={totalCount} onPaginationChange={setPagination} onEdit={ canUpdate diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx index d4a26133f..4823be33d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx @@ -1,4 +1,4 @@ -import { FormEvent, useMemo, useState } from "react"; +import { FormEvent, useEffect, useMemo, useState } from "react"; import { ArrowRight, Ban, @@ -27,7 +27,8 @@ import { Tooltip, } from "@mantine/core"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useDebouncedValue } from "@mantine/hooks"; +import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import FleetToolbar from "@/components/fleet/FleetToolbar"; @@ -155,6 +156,7 @@ function RouteTimeline({ route }: { route: RouteRecord }) { export default function RoutesPage() { const [search, setSearch] = useState(""); + const [debouncedSearch] = useDebouncedValue(search, 300); const [formOpen, setFormOpen] = useState(false); const [viewing, setViewing] = useState(null); const [editing, setEditing] = useState(null); @@ -167,7 +169,26 @@ export default function RoutesPage() { const canUpdate = canFleetAction(user, "routes", "update"); const canDelete = canFleetAction(user, "routes", "delete"); - const routesQuery = useQuery(api.routes.list.queryOptions()); + const routesQuery = useQuery({ + ...api.routes.listPaged.queryOptions({ + input: { + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + ...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}), + }, + }), + placeholderData: keepPreviousData, + }); + // KPI counts stay whole-fleet (they must not move with the search box), so + // they come from two count-only pages rather than the visible one. + const totalCountQuery = useQuery( + api.routes.listPaged.queryOptions({ input: { page: 1, pageSize: 1 } }), + ); + const availableCountQuery = useQuery( + api.routes.listPaged.queryOptions({ + input: { page: 1, pageSize: 1, status: "AVAILABLE" }, + }), + ); const yardsQuery = useQuery(api.routes.yards.queryOptions()); // Segment km are configured in Configuration → Yard Distances and resolved // by the API on save; this fetch is only to preview them in the form. @@ -182,35 +203,19 @@ export default function RoutesPage() { const updateMutation = useMutation(api.routes.update.mutationOptions()); const deactivateMutation = useMutation(api.routes.deactivate.mutationOptions()); - const filteredRoutes = useMemo(() => { - const query = search.trim().toLowerCase(); - if (!query) return routesQuery.data ?? []; - return (routesQuery.data ?? []).filter((route) => { - const searchable = [ - formatRouteLabel(route), - route.originYard?.label, - route.originYard?.code, - route.destinationYard?.label, - route.destinationYard?.code, - ...(route.milestones ?? []).map( - (m) => m.yard?.label ?? m.yard?.code ?? m.yardId, - ), - ] - .filter(Boolean) - .join(" ") - .toLowerCase(); - return searchable.includes(query); - }); - }, [routesQuery.data, search]); + // Narrowing the result set can strand the user on a page that no longer + // exists (search down to 3 rows while on page 5 → empty table). + useEffect(() => { + setPagination((prev) => (prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 })); + }, [debouncedSearch, setPagination]); - const pageCount = Math.max(1, Math.ceil(filteredRoutes.length / pagination.pageSize)); - const pagedRoutes = useMemo(() => { - const start = pagination.pageIndex * pagination.pageSize; - return filteredRoutes.slice(start, start + pagination.pageSize); - }, [filteredRoutes, pagination.pageIndex, pagination.pageSize]); + // Filtering, sorting and the page window all happen server-side. + const pagedRoutes = routesQuery.data?.items ?? []; + const matchCount = routesQuery.data?.meta.total ?? 0; + const pageCount = Math.max(1, routesQuery.data?.meta.totalPages ?? 1); - const allRoutes = routesQuery.data ?? []; - const availableCount = allRoutes.filter((r) => r.status === "AVAILABLE").length; + const totalRoutes = totalCountQuery.data?.meta.total ?? 0; + const availableCount = availableCountQuery.data?.meta.total ?? 0; const yardOptions = useMemo( () => @@ -489,11 +494,11 @@ export default function RoutesPage() { diff --git a/apps/edr-freight-web/backoffice/src/pages/wagons/TransferFulfillModal.tsx b/apps/edr-freight-web/backoffice/src/pages/wagons/TransferFulfillModal.tsx index dc8c49620..6f5a0c129 100644 --- a/apps/edr-freight-web/backoffice/src/pages/wagons/TransferFulfillModal.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/wagons/TransferFulfillModal.tsx @@ -48,6 +48,8 @@ export function TransferFulfillModal({ currentYardId: request.fromYardId, wagonTypeId: request.wagonTypeId, status: Freight.WagonStatus.Available, + // A coupled wagon cannot be moved out of its train by a transfer. + unassigned: true, } : {}, }, diff --git a/apps/edr-freight-web/backoffice/src/pages/wagons/TransferHistoryPanel.tsx b/apps/edr-freight-web/backoffice/src/pages/wagons/TransferHistoryPanel.tsx new file mode 100644 index 000000000..dff9cdaa3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/wagons/TransferHistoryPanel.tsx @@ -0,0 +1,342 @@ +import { + Badge, + Button, + Card, + Group, + SegmentedControl, + Skeleton, + Stack, + Switch, + Text, + ThemeIcon, + Timeline, + Tooltip, +} from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { + ArrowRight, + ChevronLeft, + ChevronRight, + History, + Inbox, + Truck, +} from "lucide-react"; +import { useState } from "react"; + +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { api } from "@/services/api"; +import type { + WagonMovementRecord, + WagonTransferRequest, +} from "@/services/wagon.service"; + +import { + STATUS_META, + TransferProgress, + TransferStatusBadge, + wagonTypeLabel, + yardLabel, +} from "./wagon-transfer-ui"; + +const fmtTime = (iso?: string | null) => + iso + ? new Date(iso).toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }) + : "—"; + +/** "Today" / "Yesterday" / "Mon 12 Jul 2026" — the header of one timeline block. */ +const dayLabel = (iso: string) => { + const d = new Date(iso); + const days = Math.round( + (new Date().setHours(0, 0, 0, 0) - new Date(iso).setHours(0, 0, 0, 0)) / + 86_400_000, + ); + if (days === 0) return "Today"; + if (days === 1) return "Yesterday"; + return d.toLocaleDateString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + year: "numeric", + }); +}; + +/** Bucket an already-DESC-sorted list into day blocks, order preserved. */ +function groupByDay(items: T[], at: (item: T) => string) { + const groups: Array<{ key: string; label: string; items: T[] }> = []; + for (const item of items) { + const iso = at(item); + const key = new Date(iso).toDateString(); + const last = groups[groups.length - 1]; + if (last?.key === key) last.items.push(item); + else groups.push({ key, label: dayLabel(iso), items: [item] }); + } + return groups; +} + +const MOVEMENT_KIND_LABEL: Record = { + LOADED: "Carried cargo", + EMPTY_REPOSITION: "Repositioned empty", + MANUAL: "Manual move", +}; + +function EmptyState({ label }: { label: string }) { + return ( + + + + + + {label} + + + ); +} + +function RequestItem({ request }: { request: WagonTransferRequest }) { + const meta = STATUS_META[request.status]; + return ( + } + color={meta?.color ?? "gray"} + lineVariant="dotted" + > + + + + + {yardLabel(request.fromYard)} + + + + {yardLabel(request.toYard)} + + + {wagonTypeLabel(request.wagonType)} + + + {request.reason ? ( + + {request.reason} + + ) : null} + + + + + + {fmtTime(request.createdAt)} + + + + + ); +} + +function MovementItem({ movement }: { movement: WagonMovementRecord }) { + return ( + } + color={movement.transferRequestId ? "edr-green" : "gray"} + lineVariant="dotted" + > + + + + {movement.wagon?.wagonNumber ?? "Wagon"} + + + {yardLabel(movement.fromYard)} + + + + {yardLabel(movement.toYard)} + + + + {movement.transferRequestId ? ( + + + Transfer + + + ) : ( + + {MOVEMENT_KIND_LABEL[movement.kind] ?? movement.kind} + + )} + + {fmtTime(movement.occurredAt)} + + + + + ); +} + +/** + * Who moved what. A staffer sees their own activity; holders of + * `transfer_history_all` can widen it to every staffer (the backend enforces + * the scope regardless of the toggle). + */ +export default function TransferHistoryPanel() { + const { user } = useAuth(); + const canSeeAll = hasPermission(user, FREIGHT_PERMS.wagons.transferHistoryAll); + const [allStaff, setAllStaff] = useState(false); + const [view, setView] = useState<"requests" | "movements">("requests"); + const [page, setPage] = useState(1); + const scopeAll = canSeeAll && allStaff; + + const mine = useQuery({ + ...api.wagonTransferRequests.history.queryOptions({ + input: { page, pageSize: 20 }, + }), + enabled: !scopeAll, + }); + const all = useQuery({ + ...api.wagonTransferRequests.historyAll.queryOptions({ + input: { page, pageSize: 20 }, + }), + enabled: scopeAll, + }); + const source = scopeAll ? all : mine; + const requests = source.data?.requests ?? []; + const movements = source.data?.movements ?? []; + const meta = source.data?.meta; + + const showingRequests = view === "requests"; + const total = showingRequests + ? (meta?.requestsTotal ?? 0) + : (meta?.movementsTotal ?? 0); + // Each list pages independently on the server; the pager follows the one on screen. + const pageSize = meta?.pageSize ?? 20; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const groups = showingRequests + ? groupByDay(requests, (r) => r.createdAt) + : groupByDay(movements, (m) => m.occurredAt); + + return ( + + + + + Transfer history + + {scopeAll + ? "Every staffer's requests and wagon moves" + : "Requests you filed or fulfilled, and the wagons you moved"} + + + + { + setView(v as "requests" | "movements"); + setPage(1); + }} + data={[ + { + value: "requests", + label: `Requests ${meta?.requestsTotal ?? 0}`, + }, + { + value: "movements", + label: `Wagons moved ${meta?.movementsTotal ?? 0}`, + }, + ]} + /> + {canSeeAll ? ( + { + setAllStaff(e.currentTarget.checked); + setPage(1); + }} + /> + ) : null} + + + + {source.isLoading ? ( + + {[0, 1, 2, 3].map((i) => ( + + ))} + + ) : groups.length === 0 ? ( + + ) : ( + + {groups.map((group) => ( + + + + {group.label} + + + · {group.items.length} + + + + {showingRequests + ? (group.items as WagonTransferRequest[]).map((r) => ( + + )) + : (group.items as WagonMovementRecord[]).map((m) => ( + + ))} + + + ))} + + )} + + + + {total} {showingRequests ? "request(s)" : "move(s)"} · page{" "} + {meta?.page ?? page} of {totalPages} + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx index 3d8d8fe8c..58eb5a3cd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx @@ -4,10 +4,9 @@ import { Button, Card, Group, - Loader, + Modal, Select, Stack, - Switch, Tabs, Text, TextInput, @@ -46,6 +45,7 @@ import { } from "@edr/ui-common"; import TransferFulfillModal from "./TransferFulfillModal"; +import TransferHistoryPanel from "./TransferHistoryPanel"; import { TransferCloseShortModal, TransferRequestFormModal, @@ -103,6 +103,9 @@ export default function WagonTransfersPage() { const [formOpen, setFormOpen] = useState(false); const [carryOver, setCarryOver] = useState(null); const [fulfilling, setFulfilling] = useState(null); + const [withdrawing, setWithdrawing] = useState( + null, + ); const [closingShort, setClosingShort] = useState( null, ); @@ -269,15 +272,7 @@ export default function WagonTransfersPage() { radius="md" variant="subtle" color="red" - loading={cancel.isPending} - onClick={async () => { - try { - await cancel.mutateAsync({ id: r.id }); - toast.success("Request withdrawn"); - } catch { - // interceptor surfaces the reason - } - }} + onClick={() => setWithdrawing(r)} > Withdraw @@ -494,132 +489,53 @@ export default function WagonTransfersPage() { } }} /> + setWithdrawing(null)} + radius="md" + title="Withdraw this request?" + > + {!withdrawing ? null : ( + + + {yardLabel(withdrawing.fromYard)} →{" "} + {yardLabel(withdrawing.toYard)} ·{" "} + {wagonTypeLabel(withdrawing.wagonType)} ·{" "} + {withdrawing.quantity} wagon(s) + + + The source yard stops seeing it. Withdrawing can't be undone — + raise a new request if you still need the wagons. + + + + + + + )} + ); } - -/** - * Who moved what. A staffer sees their own activity; holders of - * `transfer_history_all` can widen it to every staffer (the backend enforces - * the scope regardless of the toggle). - */ -function TransferHistoryPanel() { - const { user } = useAuth(); - const canSeeAll = hasPermission(user, FREIGHT_PERMS.wagons.transferHistoryAll); - const [allStaff, setAllStaff] = useState(false); - const [page, setPage] = useState(1); - const scopeAll = canSeeAll && allStaff; - - const mine = useQuery({ - ...api.wagonTransferRequests.history.queryOptions({ - input: { page, pageSize: 20 }, - }), - enabled: !scopeAll, - }); - const all = useQuery({ - ...api.wagonTransferRequests.historyAll.queryOptions({ - input: { page, pageSize: 20 }, - }), - enabled: scopeAll, - }); - const source = scopeAll ? all : mine; - const requests = source.data?.requests ?? []; - const movements = source.data?.movements ?? []; - const meta = source.data?.meta; - - return ( - - - - Transfer history - {canSeeAll ? ( - { - setAllStaff(e.currentTarget.checked); - setPage(1); - }} - /> - ) : null} - - - {source.isLoading ? ( - - - - ) : ( - - - - Requests ({meta?.requestsTotal ?? 0}) - - {requests.length === 0 ? ( - - Nothing yet. - - ) : ( - requests.map((r) => ( - - - {yardLabel(r.fromYard)} → {yardLabel(r.toYard)} ·{" "} - {r.fulfilledQuantity}/{r.quantity} - - - - )) - )} - - - - - Wagons moved ({meta?.movementsTotal ?? 0}) - - {movements.length === 0 ? ( - - Nothing yet. - - ) : ( - movements.map((m) => ( - - - {m.wagon?.wagonNumber ?? "Wagon"} · {yardLabel(m.fromYard)} →{" "} - {yardLabel(m.toYard)} - - - {fmtDateTime(m.occurredAt)} - - - )) - )} - - - )} - - - - - Page {meta?.page ?? page} of {meta?.totalPages ?? 1} - - - - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 52ba5c696..a28e15cd2 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -170,6 +170,7 @@ import { } from "./payments.service"; import { routesService, + type RouteListFilters, type RouteRecord, type SaveRoutePayload, type YardRef, @@ -1507,6 +1508,13 @@ export const api = { (input) => ["routes", input?.status ?? "all"], ), + listPaged: endpoint>( + "routes", + "listPaged", + (filters) => routesService.getPaged(filters).then((r) => r.data), + (filters) => ["routes", "paged", filters], + ), + yards: endpoint( "routes", "yards", @@ -2082,6 +2090,16 @@ export const api = { ({ slug, filters }) => [...QUERY_KEYS.FLEET.list(slug), filters ?? {}], ), + listPaged: endpoint< + { slug: FleetResourceSlug; filters?: FleetListFilters }, + PaginatedResponse + >( + "fleet", + "listPaged", + ({ slug, filters }) => fleetService.listPaged(slug, filters), + ({ slug, filters }) => [...QUERY_KEYS.FLEET.list(slug), "paged", filters ?? {}], + ), + create: endpoint< { slug: FleetResourceSlug; data: Record }, unknown diff --git a/apps/edr-freight-web/backoffice/src/services/fleet/fleet.service.ts b/apps/edr-freight-web/backoffice/src/services/fleet/fleet.service.ts index 85d3ce4a3..8d4e60dd3 100644 --- a/apps/edr-freight-web/backoffice/src/services/fleet/fleet.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/fleet/fleet.service.ts @@ -1,3 +1,5 @@ +import type { PaginatedResponse } from "@edr/types"; + import { cargoService, type Cargo } from "@/services/cargoService"; import { containerService, type Container } from "@/services/containerService"; import { @@ -28,6 +30,20 @@ const listHandlers: Record< drivers: (filters) => driversService.getAll(filters ?? {}).then((r) => r.data), }; +/** + * Server-paginated slugs. Everything else still lists in full and pages in the + * browser — add an entry here once its API grows a `/paged` endpoint. + */ +const pagedHandlers: Partial< + Record Promise>> +> = { + locomotives: (filters) => locomotivesService.getPaged(filters).then((r) => r.data), + wagons: (filters) => wagonService.getPaged(filters).then((r) => r.data), +}; + +export const isFleetServerPaginated = (slug: FleetResourceSlug): boolean => + slug in pagedHandlers; + const createHandlers: Record) => Promise> = { locomotives: (data) => locomotivesService.create(data), trains: (data) => trainService.create(data), @@ -63,6 +79,12 @@ const removeHandlers: Record Promise export const fleetService = { list: (slug: FleetResourceSlug, filters?: FleetListFilters) => listHandlers[slug](filters), + /** Only for slugs in `pagedHandlers` — guard with `isFleetServerPaginated`. */ + listPaged: (slug: FleetResourceSlug, filters: FleetListFilters = {}) => { + const handler = pagedHandlers[slug]; + if (!handler) throw new Error(`Fleet resource "${slug}" has no paginated list endpoint`); + return handler(filters); + }, create: (slug: FleetResourceSlug, data: Record) => createHandlers[slug](data), update: (slug: FleetResourceSlug, id: string, data: Record) => updateHandlers[slug](id, data), diff --git a/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts b/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts index 5dec1c05e..fa80ec86c 100644 --- a/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts @@ -1,3 +1,5 @@ +import type { PaginatedResponse } from '@edr/types'; + import { api as apiClient } from '../auth/http'; import { URL_CONSTANTS } from '@/constants/URLS'; @@ -19,8 +21,31 @@ export interface LocomotiveListFilters { excludeCoupled?: boolean; /** With excludeCoupled: keep THIS train's own coupled locos in the list. */ excludeTrainId?: string; + /** Free-text over code + name — only honoured by `getPaged`. */ + search?: string; + /** Registration day range (YYYY-MM-DD), both ends inclusive. */ + createdFrom?: string; + createdTo?: string; + /** Only read by `getPaged`. */ + page?: number; + pageSize?: number; } +const locomotiveListQuery = (filters: LocomotiveListFilters): string => { + const params = new URLSearchParams(); + if (filters.status) params.set('status', filters.status); + if (filters.currentYardId) params.set('currentYardId', filters.currentYardId); + if (filters.excludeCoupled) params.set('excludeCoupled', 'true'); + if (filters.excludeTrainId) params.set('excludeTrainId', filters.excludeTrainId); + if (filters.search?.trim()) params.set('search', filters.search.trim()); + if (filters.createdFrom) params.set('createdFrom', filters.createdFrom); + if (filters.createdTo) params.set('createdTo', filters.createdTo); + if (filters.page) params.set('page', String(filters.page)); + if (filters.pageSize) params.set('pageSize', String(filters.pageSize)); + const qs = params.toString(); + return qs ? `?${qs}` : ''; +}; + export interface Locomotive { id: string; code: string; @@ -45,17 +70,15 @@ export type SaveLocomotivePayload = Omit< >; export const locomotivesService = { - getAll: (filters: LocomotiveListFilters = {}) => { - const params = new URLSearchParams(); - if (filters.status) params.set('status', filters.status); - if (filters.currentYardId) params.set('currentYardId', filters.currentYardId); - if (filters.excludeCoupled) params.set('excludeCoupled', 'true'); - if (filters.excludeTrainId) params.set('excludeTrainId', filters.excludeTrainId); - const qs = params.toString(); - return apiClient.get( - `${URL_CONSTANTS.LOCOMOTIVES.BASE}${qs ? `?${qs}` : ''}`, - ); - }, + getAll: (filters: LocomotiveListFilters = {}) => + apiClient.get( + `${URL_CONSTANTS.LOCOMOTIVES.BASE}${locomotiveListQuery(filters)}`, + ), + /** Same filters as `getAll` plus search, server-paginated ({items, meta}). */ + getPaged: (filters: LocomotiveListFilters = {}) => + apiClient.get>( + `${URL_CONSTANTS.LOCOMOTIVES.BASE}/paged${locomotiveListQuery(filters)}`, + ), getById: (id: string) => apiClient.get(URL_CONSTANTS.LOCOMOTIVES.BY_ID(id)), create: (data: Partial) => apiClient.post(URL_CONSTANTS.LOCOMOTIVES.BASE, data), diff --git a/apps/edr-freight-web/backoffice/src/services/routes.service.ts b/apps/edr-freight-web/backoffice/src/services/routes.service.ts index f279f3f1e..d3795d83f 100644 --- a/apps/edr-freight-web/backoffice/src/services/routes.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/routes.service.ts @@ -1,3 +1,5 @@ +import type { PaginatedResponse } from '@edr/types'; + import { api as apiClient } from '../auth/http'; import { URL_CONSTANTS } from '@/constants/URLS'; @@ -78,9 +80,22 @@ export const ROUTE_STATUS_OPTIONS: Array<{ value: RouteStatus; label: string }> { value: 'STOP_WORKING', label: 'Stop working' }, ]; +export interface RouteListFilters { + status?: RouteStatus; + search?: string; + page?: number; + pageSize?: number; +} + export const routesService = { getAll: (params?: { status?: RouteStatus; search?: string }) => apiClient.get(URL_CONSTANTS.ROUTES.BASE, { params }), + /** Same filters as `getAll`, server-paginated ({items, meta}). */ + getPaged: (params: RouteListFilters = {}) => + apiClient.get>( + `${URL_CONSTANTS.ROUTES.BASE}/paged`, + { params }, + ), getById: (id: string) => apiClient.get(URL_CONSTANTS.ROUTES.BY_ID(id)), create: (data: SaveRoutePayload) => apiClient.post(URL_CONSTANTS.ROUTES.BASE, data), update: (id: string, data: Partial) => diff --git a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts index d562625a9..96791e13d 100644 --- a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts @@ -41,10 +41,35 @@ export interface WagonListFilters { currentYardId?: string; wagonTypeId?: string; trainId?: string; + /** Drop wagons already coupled to a built train — only loose ones can be taken. */ + unassigned?: boolean; /** Run number — matches a wagon whose export OR import run equals it. */ trainNumber?: string; + /** Registration day range (YYYY-MM-DD), both ends inclusive. */ + createdFrom?: string; + createdTo?: string; + /** Only read by `getPaged`. */ + page?: number; + pageSize?: number; } +const wagonListQuery = (filters: WagonListFilters): string => { + const params = new URLSearchParams(); + if (filters.search?.trim()) params.set('search', filters.search.trim()); + if (filters.status) params.set('status', filters.status); + if (filters.currentYardId) params.set('currentYardId', filters.currentYardId); + if (filters.wagonTypeId) params.set('wagonTypeId', filters.wagonTypeId); + if (filters.trainId) params.set('trainId', filters.trainId); + if (filters.unassigned) params.set('unassigned', 'true'); + if (filters.trainNumber) params.set('trainNumber', filters.trainNumber); + if (filters.createdFrom) params.set('createdFrom', filters.createdFrom); + if (filters.createdTo) params.set('createdTo', filters.createdTo); + if (filters.page) params.set('page', String(filters.page)); + if (filters.pageSize) params.set('pageSize', String(filters.pageSize)); + const qs = params.toString(); + return qs ? `?${qs}` : ''; +}; + /** * One row of the wagon_movements ledger: every physical relocation between * yards — a booking's loaded leg, an empty reposition ride, or a manual staff @@ -70,17 +95,11 @@ export interface WagonMovementRecord { } export const wagonService = { - getAll: (filters: WagonListFilters = {}) => { - const params = new URLSearchParams(); - if (filters.search?.trim()) params.set('search', filters.search.trim()); - if (filters.status) params.set('status', filters.status); - if (filters.currentYardId) params.set('currentYardId', filters.currentYardId); - if (filters.wagonTypeId) params.set('wagonTypeId', filters.wagonTypeId); - if (filters.trainId) params.set('trainId', filters.trainId); - if (filters.trainNumber) params.set('trainNumber', filters.trainNumber); - const qs = params.toString(); - return apiClient.get(`/wagons${qs ? `?${qs}` : ''}`); - }, + getAll: (filters: WagonListFilters = {}) => + apiClient.get(`/wagons${wagonListQuery(filters)}`), + /** Same filters as `getAll`, server-paginated ({items, meta}). */ + getPaged: (filters: WagonListFilters = {}) => + apiClient.get>(`/wagons/paged${wagonListQuery(filters)}`), getById: (id: string) => apiClient.get(`/wagons/${id}`), getMovements: (id: string) => apiClient.get(`/wagons/${id}/movements`),