update locaomotive and fix wagon transfer issue

This commit is contained in:
Marshal
2026-07-27 20:34:14 +00:00
parent deadc59277
commit 496c66017f
23 changed files with 811 additions and 230 deletions

View File

@@ -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;
}

View File

@@ -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' })

View File

@@ -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<Locomotive> {
}
/**
* 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<Locomotive[]> {
search?: string;
createdFrom?: string;
createdTo?: string;
}): SelectQueryBuilder<Locomotive> {
const qb = this.repository
.createQueryBuilder('locomotive')
.leftJoinAndSelect('locomotive.currentYard', 'currentYard')
@@ -39,6 +43,25 @@ export class LocomotivesRepository extends BaseRepository<Locomotive> {
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<Locomotive> {
qb.andWhere(`NOT EXISTS (${sub.getQuery()})`).setParameters(sub.getParameters());
}
return qb.getMany();
return qb;
}
findForCoupling(opts: Parameters<LocomotivesRepository['buildListQuery']>[0]): Promise<Locomotive[]> {
return this.buildListQuery(opts).getMany();
}
/**

View File

@@ -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<PaginatedResponse<Locomotive>> {
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;

View File

@@ -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'])

View File

@@ -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) {

View File

@@ -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<PaginatedResponse<Route>> {
return paginateArray(await this.findAll(filter), filter);
}
async findById(id: string): Promise<Route> {
const route = await this.dataSource.getRepository(Route).findOne({
where: { id },

View File

@@ -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,
});
}
}

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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' })

View File

@@ -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<Wagon[]> {
/** Shared filter/sort builder behind `findAll` (array) and `findAllPaged` (envelope). */
private buildListQuery(query: ListWagonsQueryDto): SelectQueryBuilder<Wagon> {
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<Wagon[]> {
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<PaginatedResponse<Wagon>> {
return paginateQuery(this.buildListQuery(query), query);
}
async findById(id: string): Promise<Wagon> {
const wagon = await this.wagonRepo.findOne({
where: { id },

View File

@@ -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),
}),

View File

@@ -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<string, unknown>;
@@ -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<FleetRecord>[] => {
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

View File

@@ -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<RouteRecord | null>(null);
const [editing, setEditing] = useState<RouteRecord | null>(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() {
<KpiStrip
loading={routesQuery.isLoading}
items={[
{ label: "Total routes", value: allRoutes.length, icon: RouteIcon },
{ label: "Total routes", value: totalRoutes, icon: RouteIcon },
{ label: "Available", value: availableCount, icon: CircleCheck, color: "edr-green" },
{
label: "Unavailable",
value: allRoutes.length - availableCount,
value: totalRoutes - availableCount,
icon: Ban,
color: "gray",
},
@@ -522,7 +527,7 @@ export default function RoutesPage() {
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filteredRoutes.length,
totalCount: matchCount,
}}
tableOptions={{
manualPagination: true,
@@ -581,7 +586,7 @@ export default function RoutesPage() {
<RuleEngineListFooter
pagination={pagination}
pageCount={pageCount}
totalCount={filteredRoutes.length}
totalCount={matchCount}
itemLabel="routes"
onPaginationChange={setPagination}
/>

View File

@@ -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,
}
: {},
},

View File

@@ -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<T>(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<string, string> = {
LOADED: "Carried cargo",
EMPTY_REPOSITION: "Repositioned empty",
MANUAL: "Manual move",
};
function EmptyState({ label }: { label: string }) {
return (
<Stack align="center" gap={6} py="xl">
<ThemeIcon variant="light" color="gray" radius="xl" size={44}>
<History size={20} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{label}
</Text>
</Stack>
);
}
function RequestItem({ request }: { request: WagonTransferRequest }) {
const meta = STATUS_META[request.status];
return (
<Timeline.Item
bullet={<Inbox size={12} />}
color={meta?.color ?? "gray"}
lineVariant="dotted"
>
<Group justify="space-between" align="flex-start" gap="md" wrap="wrap">
<Stack gap={4} style={{ flex: 1, minWidth: 220 }}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
{yardLabel(request.fromYard)}
</Text>
<ArrowRight size={13} className="shrink-0 opacity-60" />
<Text size="sm" fw={600}>
{yardLabel(request.toYard)}
</Text>
<Badge variant="default" radius="sm" size="sm">
{wagonTypeLabel(request.wagonType)}
</Badge>
</Group>
{request.reason ? (
<Text size="xs" c="dimmed" lineClamp={1}>
{request.reason}
</Text>
) : null}
</Stack>
<Group gap="sm" wrap="nowrap">
<TransferProgress request={request} />
<TransferStatusBadge status={request.status} />
<Text size="xs" c="dimmed" w={38} ta="right">
{fmtTime(request.createdAt)}
</Text>
</Group>
</Group>
</Timeline.Item>
);
}
function MovementItem({ movement }: { movement: WagonMovementRecord }) {
return (
<Timeline.Item
bullet={<Truck size={12} />}
color={movement.transferRequestId ? "edr-green" : "gray"}
lineVariant="dotted"
>
<Group justify="space-between" align="center" gap="md" wrap="wrap">
<Group gap={8} wrap="nowrap" style={{ flex: 1, minWidth: 220 }}>
<Badge variant="light" color="gray" radius="sm" ff="monospace">
{movement.wagon?.wagonNumber ?? "Wagon"}
</Badge>
<Text size="sm" fw={500}>
{yardLabel(movement.fromYard)}
</Text>
<ArrowRight size={13} className="shrink-0 opacity-60" />
<Text size="sm" fw={500}>
{yardLabel(movement.toYard)}
</Text>
</Group>
<Group gap="sm" wrap="nowrap">
{movement.transferRequestId ? (
<Tooltip label="Delivered against a transfer request" withArrow>
<Badge variant="dot" color="teal" radius="sm" size="sm">
Transfer
</Badge>
</Tooltip>
) : (
<Badge variant="light" color="gray" radius="sm" size="sm">
{MOVEMENT_KIND_LABEL[movement.kind] ?? movement.kind}
</Badge>
)}
<Text size="xs" c="dimmed" w={38} ta="right">
{fmtTime(movement.occurredAt)}
</Text>
</Group>
</Group>
</Timeline.Item>
);
}
/**
* 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 (
<Card withBorder radius="md" p="md">
<Stack gap="md">
<Group justify="space-between" align="flex-start" gap="md" wrap="wrap">
<Stack gap={2}>
<Text fw={600}>Transfer history</Text>
<Text size="xs" c="dimmed">
{scopeAll
? "Every staffer's requests and wagon moves"
: "Requests you filed or fulfilled, and the wagons you moved"}
</Text>
</Stack>
<Group gap="sm" wrap="wrap">
<SegmentedControl
size="xs"
radius="md"
value={view}
onChange={(v) => {
setView(v as "requests" | "movements");
setPage(1);
}}
data={[
{
value: "requests",
label: `Requests ${meta?.requestsTotal ?? 0}`,
},
{
value: "movements",
label: `Wagons moved ${meta?.movementsTotal ?? 0}`,
},
]}
/>
{canSeeAll ? (
<Switch
label="All staff"
checked={allStaff}
onChange={(e) => {
setAllStaff(e.currentTarget.checked);
setPage(1);
}}
/>
) : null}
</Group>
</Group>
{source.isLoading ? (
<Stack gap="sm">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} height={44} radius="md" />
))}
</Stack>
) : groups.length === 0 ? (
<EmptyState
label={
showingRequests
? "No transfer requests recorded yet."
: "No wagon moves recorded yet."
}
/>
) : (
<Stack gap="lg">
{groups.map((group) => (
<Stack key={group.key} gap="xs">
<Group gap="xs" wrap="nowrap">
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
{group.label}
</Text>
<Text size="xs" c="dimmed">
· {group.items.length}
</Text>
</Group>
<Timeline
bulletSize={22}
lineWidth={2}
active={group.items.length}
>
{showingRequests
? (group.items as WagonTransferRequest[]).map((r) => (
<RequestItem key={r.id} request={r} />
))
: (group.items as WagonMovementRecord[]).map((m) => (
<MovementItem key={m.id} movement={m} />
))}
</Timeline>
</Stack>
))}
</Stack>
)}
<Group justify="space-between" gap="sm" wrap="wrap">
<Text size="xs" c="dimmed">
{total} {showingRequests ? "request(s)" : "move(s)"} · page{" "}
{meta?.page ?? page} of {totalPages}
</Text>
<Group gap="xs">
<Button
variant="default"
size="xs"
radius="md"
leftSection={<ChevronLeft size={14} />}
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</Button>
<Button
variant="default"
size="xs"
radius="md"
rightSection={<ChevronRight size={14} />}
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</Group>
</Group>
</Stack>
</Card>
);
}

View File

@@ -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<WagonTransferRequest | null>(null);
const [fulfilling, setFulfilling] = useState<WagonTransferRequest | null>(null);
const [withdrawing, setWithdrawing] = useState<WagonTransferRequest | null>(
null,
);
const [closingShort, setClosingShort] = useState<WagonTransferRequest | null>(
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
</Button>
@@ -494,132 +489,53 @@ export default function WagonTransfersPage() {
}
}}
/>
<Modal
opened={Boolean(withdrawing)}
onClose={() => setWithdrawing(null)}
radius="md"
title="Withdraw this request?"
>
{!withdrawing ? null : (
<Stack gap="sm">
<Text size="sm">
{yardLabel(withdrawing.fromYard)} {" "}
{yardLabel(withdrawing.toYard)} ·{" "}
{wagonTypeLabel(withdrawing.wagonType)} ·{" "}
{withdrawing.quantity} wagon(s)
</Text>
<Text size="sm" c="dimmed">
The source yard stops seeing it. Withdrawing can't be undone
raise a new request if you still need the wagons.
</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setWithdrawing(null)}
>
Keep it
</Button>
<Button
color="red"
radius="md"
leftSection={<XCircle size={15} />}
loading={cancel.isPending}
onClick={async () => {
try {
await cancel.mutateAsync({ id: withdrawing.id });
toast.success("Request withdrawn");
setWithdrawing(null);
} catch {
// interceptor surfaces the reason
}
}}
>
Withdraw
</Button>
</Group>
</Stack>
)}
</Modal>
</PageContainer>
);
}
/**
* 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 (
<Card withBorder radius="md" p="md">
<Stack gap="md">
<Group justify="space-between" wrap="wrap">
<Text fw={600}>Transfer history</Text>
{canSeeAll ? (
<Switch
label="All staff"
checked={allStaff}
onChange={(e) => {
setAllStaff(e.currentTarget.checked);
setPage(1);
}}
/>
) : null}
</Group>
{source.isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : (
<Group align="flex-start" grow gap="lg" wrap="wrap">
<Stack gap={6} miw={280}>
<Text size="sm" fw={700} c="dimmed" tt="uppercase">
Requests ({meta?.requestsTotal ?? 0})
</Text>
{requests.length === 0 ? (
<Text size="sm" c="dimmed">
Nothing yet.
</Text>
) : (
requests.map((r) => (
<Group key={r.id} gap={8} wrap="nowrap" justify="space-between">
<Text size="sm" truncate>
{yardLabel(r.fromYard)} {yardLabel(r.toYard)} ·{" "}
{r.fulfilledQuantity}/{r.quantity}
</Text>
<TransferStatusBadge status={r.status} />
</Group>
))
)}
</Stack>
<Stack gap={6} miw={280}>
<Text size="sm" fw={700} c="dimmed" tt="uppercase">
Wagons moved ({meta?.movementsTotal ?? 0})
</Text>
{movements.length === 0 ? (
<Text size="sm" c="dimmed">
Nothing yet.
</Text>
) : (
movements.map((m) => (
<Group key={m.id} gap={8} wrap="nowrap" justify="space-between">
<Text size="sm" truncate>
{m.wagon?.wagonNumber ?? "Wagon"} · {yardLabel(m.fromYard)} {" "}
{yardLabel(m.toYard)}
</Text>
<Text size="xs" c="dimmed">
{fmtDateTime(m.occurredAt)}
</Text>
</Group>
))
)}
</Stack>
</Group>
)}
<Group justify="center" gap="sm">
<Button
variant="default"
size="xs"
radius="md"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</Button>
<Text size="sm" c="dimmed">
Page {meta?.page ?? page} of {meta?.totalPages ?? 1}
</Text>
<Button
variant="default"
size="xs"
radius="md"
disabled={page >= (meta?.totalPages ?? 1)}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</Group>
</Stack>
</Card>
);
}

View File

@@ -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<RouteListFilters, PaginatedResponse<RouteRecord>>(
"routes",
"listPaged",
(filters) => routesService.getPaged(filters).then((r) => r.data),
(filters) => ["routes", "paged", filters],
),
yards: endpoint<void, YardRef[]>(
"routes",
"yards",
@@ -2082,6 +2090,16 @@ export const api = {
({ slug, filters }) => [...QUERY_KEYS.FLEET.list(slug), filters ?? {}],
),
listPaged: endpoint<
{ slug: FleetResourceSlug; filters?: FleetListFilters },
PaginatedResponse<FleetRecord>
>(
"fleet",
"listPaged",
({ slug, filters }) => fleetService.listPaged(slug, filters),
({ slug, filters }) => [...QUERY_KEYS.FLEET.list(slug), "paged", filters ?? {}],
),
create: endpoint<
{ slug: FleetResourceSlug; data: Record<string, unknown> },
unknown

View File

@@ -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<FleetResourceSlug, (filters: FleetListFilters) => Promise<PaginatedResponse<FleetRecord>>>
> = {
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<FleetResourceSlug, (data: Record<string, unknown>) => Promise<unknown>> = {
locomotives: (data) => locomotivesService.create(data),
trains: (data) => trainService.create(data),
@@ -63,6 +79,12 @@ const removeHandlers: Record<FleetResourceSlug, (id: string) => Promise<unknown>
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<string, unknown>) => createHandlers[slug](data),
update: (slug: FleetResourceSlug, id: string, data: Record<string, unknown>) =>
updateHandlers[slug](id, data),

View File

@@ -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<Locomotive[]>(
`${URL_CONSTANTS.LOCOMOTIVES.BASE}${qs ? `?${qs}` : ''}`,
);
},
getAll: (filters: LocomotiveListFilters = {}) =>
apiClient.get<Locomotive[]>(
`${URL_CONSTANTS.LOCOMOTIVES.BASE}${locomotiveListQuery(filters)}`,
),
/** Same filters as `getAll` plus search, server-paginated ({items, meta}). */
getPaged: (filters: LocomotiveListFilters = {}) =>
apiClient.get<PaginatedResponse<Locomotive>>(
`${URL_CONSTANTS.LOCOMOTIVES.BASE}/paged${locomotiveListQuery(filters)}`,
),
getById: (id: string) => apiClient.get<Locomotive>(URL_CONSTANTS.LOCOMOTIVES.BY_ID(id)),
create: (data: Partial<SaveLocomotivePayload>) =>
apiClient.post(URL_CONSTANTS.LOCOMOTIVES.BASE, data),

View File

@@ -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<RouteRecord[]>(URL_CONSTANTS.ROUTES.BASE, { params }),
/** Same filters as `getAll`, server-paginated ({items, meta}). */
getPaged: (params: RouteListFilters = {}) =>
apiClient.get<PaginatedResponse<RouteRecord>>(
`${URL_CONSTANTS.ROUTES.BASE}/paged`,
{ params },
),
getById: (id: string) => apiClient.get<RouteRecord>(URL_CONSTANTS.ROUTES.BY_ID(id)),
create: (data: SaveRoutePayload) => apiClient.post(URL_CONSTANTS.ROUTES.BASE, data),
update: (id: string, data: Partial<SaveRoutePayload>) =>

View File

@@ -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<Wagon[]>(`/wagons${qs ? `?${qs}` : ''}`);
},
getAll: (filters: WagonListFilters = {}) =>
apiClient.get<Wagon[]>(`/wagons${wagonListQuery(filters)}`),
/** Same filters as `getAll`, server-paginated ({items, meta}). */
getPaged: (filters: WagonListFilters = {}) =>
apiClient.get<PaginatedResponse<Wagon>>(`/wagons/paged${wagonListQuery(filters)}`),
getById: (id: string) => apiClient.get<Wagon>(`/wagons/${id}`),
getMovements: (id: string) =>
apiClient.get<WagonMovementRecord[]>(`/wagons/${id}/movements`),