mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
update locaomotive and fix wagon transfer issue
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
: {},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>) =>
|
||||
|
||||
@@ -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`),
|
||||
|
||||
Reference in New Issue
Block a user