Merge pull request #640 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-13 08:13:01 +03:00
committed by GitHub
120 changed files with 3798 additions and 1409 deletions

View File

@@ -20,6 +20,8 @@ import {
CreateDropdownSettingDto,
DropdownOption,
DropdownSetting,
DropdownSettingListQuery,
PaginatedDropdownSettings,
UpdateDropdownOptionDto,
UpdateDropdownSettingDto,
} from "@/types/dropdownSettings";
@@ -61,7 +63,8 @@ import type {
StaffBookingWindow,
TrainScheduleDetail,
TrainScheduleFilters,
TrainScheduleListItem,
TrainScheduleListFilters,
TrainScheduleListResponse,
UpdateScheduleWindowRulePayload,
TrainSchedulePreviewPayload,
TrainSchedulePreviewResponse,
@@ -214,13 +217,14 @@ export const api = {
trainScheduling: {
// ── Queries ────────────────────────────────────────────────────────────
scheduleList: endpoint<
{ freightType?: FreightType },
TrainScheduleListItem[]
{ freightType?: FreightType; filters?: TrainScheduleListFilters },
TrainScheduleListResponse
>(
"train-scheduling",
"schedules",
({ freightType }) => trainSchedulingService.listSchedules(freightType),
() => QUERY_KEYS.TRAIN_SCHEDULING.schedules(),
({ freightType, filters }) =>
trainSchedulingService.listSchedules(freightType, filters),
({ filters }) => QUERY_KEYS.TRAIN_SCHEDULING.schedules(filters),
),
batchBoard: endpoint<
@@ -1398,7 +1402,7 @@ export const api = {
yards: endpoint<void, YardRef[]>(
"routes",
"yards",
() => routesService.getYards().then((r) => r.data.data),
() => routesService.getYards(),
() => ["routes", "yards"],
),
@@ -1970,6 +1974,15 @@ export const api = {
dropdownSettingsService.list,
),
listPaged: endpoint<
{ query: DropdownSettingListQuery },
PaginatedDropdownSettings
>(
"dropdown-settings",
"listPaged",
({ query }) => dropdownSettingsService.listPaged(query),
),
getById: endpoint<{ id: string }, DropdownSetting>(
"dropdown-settings",
"getById",

View File

@@ -19,6 +19,8 @@ export interface BookingListFilter {
freightType?: string;
/** ONE_TIME | GENERAL_CONTRACT — the booking-kind tab filter. */
bookingType?: string;
/** 'true' → customs bookings, 'false' → self-clearance (non-customs). */
customsClearingEnabled?: "true" | "false";
tradeDirection?: string;
paymentCurrency?: string;
paymentStatus?: string;
@@ -34,6 +36,8 @@ export interface BookingListFilter {
destinationYardId?: string;
/** "true" = government bookings only, "false" = private only. */
isGovernment?: "true" | "false";
/** Free-text search: booking reference, customer name, contract reference (server-side). */
search?: string;
page?: number;
pageSize?: number;
sortBy?: string;
@@ -183,6 +187,9 @@ export const bookingsService = {
if (filter.originYardId) params.originYardId = filter.originYardId;
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
if (filter.customsClearingEnabled)
params.customsClearingEnabled = filter.customsClearingEnabled;
if (filter.search) params.search = filter.search;
}
const response = await client.get<PaginatedBookings>(B.BASE, {
params,

View File

@@ -1,15 +1,8 @@
import { api } from "../auth/http";
type ListResponse<T> = T[] | { data: T[] };
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
import { ruleEngineService } from "./ruleEngine/ruleEngine.service";
export const cargoTypesService = {
/** All active cargo types (page-walked — the API caps pageSize at 100). */
async getCargoTypes() {
const response = await api.get<ListResponse<unknown>>('/cargo-types', {
params: { isActive: true, pageSize: 500 },
});
return asList(response.data);
return ruleEngineService.listAll("cargo-types", { isActive: true });
},
};

View File

@@ -1,15 +1,8 @@
import { api } from "../auth/http";
type ListResponse<T> = T[] | { data: T[] };
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
import { ruleEngineService } from "./ruleEngine/ruleEngine.service";
export const containerTypesService = {
/** All active container types (page-walked — the API caps pageSize at 100). */
async getContainerTypes() {
const response = await api.get<ListResponse<unknown>>('/container-types', {
params: { isActive: true, pageSize: 500 },
});
return asList(response.data);
return ruleEngineService.listAll("container-types", { isActive: true });
},
};

View File

@@ -16,6 +16,8 @@ export interface ContractListFilter {
tradeDirection?: string;
contractKind?: string;
paymentCurrency?: string;
/** Server-side free-text search (contract reference, company name). */
search?: string;
page?: number;
pageSize?: number;
sortBy?: string;
@@ -58,6 +60,10 @@ export interface ShipmentValidation {
pairingErrors: string[];
/** Lines above the container type's hard max capacity — booking cannot be created. */
capacityErrors?: string[];
/** Containers already on another active booking for the same day + route — booking cannot be created. */
containerClashErrors?: string[];
/** EXPORT only: no single open train on the chosen day can carry the whole booking — booking cannot be created. */
spaceErrors?: string[];
lineItems?: ShipmentPriceLine[];
totalAmount?: number;
}
@@ -123,6 +129,7 @@ function buildListParams(filter?: ContractListFilter) {
if (filter) {
if (filter.statuses) params.statuses = filter.statuses;
else if (filter.status) params.status = filter.status;
if (filter.search) params.search = filter.search;
if (filter.page != null) params.page = filter.page;
if (filter.pageSize != null) params.pageSize = filter.pageSize;
if (filter.sortBy) params.sortBy = filter.sortBy;
@@ -450,9 +457,14 @@ export const contractsService = {
},
// ── Path A self-clearance (Operations review) ──
getOpsClearanceQueue: async (): Promise<PaginatedContracts> => {
getOpsClearanceQueue: async (filter?: {
page?: number;
pageSize?: number;
search?: string;
}): Promise<PaginatedContracts> => {
const response = await client.get<PaginatedContracts>(
C.OPS_CLEARANCE_QUEUE,
{ params: filter },
);
const data = unwrap(response.data);
return {
@@ -467,8 +479,15 @@ export const contractsService = {
return { items: (data.items ?? []) as Freight.IContract[], total: data.total ?? 0 };
},
getOpsClearanceHistory: async (): Promise<PaginatedContracts> => {
const response = await client.get<PaginatedContracts>(C.OPS_CLEARANCE_HISTORY);
getOpsClearanceHistory: async (filter?: {
page?: number;
pageSize?: number;
search?: string;
}): Promise<PaginatedContracts> => {
const response = await client.get<PaginatedContracts>(
C.OPS_CLEARANCE_HISTORY,
{ params: filter },
);
const data = unwrap(response.data);
return { items: (data.items ?? []) as Freight.IContract[], total: data.total ?? 0 };
},

View File

@@ -7,6 +7,8 @@ import type {
CreateDropdownSettingDto,
DropdownOption,
DropdownSetting,
DropdownSettingListQuery,
PaginatedDropdownSettings,
UpdateDropdownOptionDto,
UpdateDropdownSettingDto,
} from "@/types/dropdownSettings";
@@ -19,6 +21,16 @@ export const dropdownSettingsService = {
return unwrap(response.data);
},
listPaged: async (
query: DropdownSettingListQuery,
): Promise<PaginatedDropdownSettings> => {
const response = await client.get<ApiResponse<PaginatedDropdownSettings>>(
`${BASE}/paged`,
{ params: query },
);
return unwrap(response.data);
},
getById: async (id: string): Promise<DropdownSetting> => {
const response = await client.get<ApiResponse<DropdownSetting>>(
URL_CONSTANTS.DROPDOWN_SETTINGS.BY_ID(id),

View File

@@ -1,6 +1,7 @@
import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
import { ruleEngineService } from './ruleEngine/ruleEngine.service';
export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING';
@@ -76,10 +77,6 @@ export const ROUTE_STATUS_OPTIONS: Array<{ value: RouteStatus; label: string }>
{ value: 'STOP_WORKING', label: 'Stop working' },
];
interface YardListResponse {
data: YardRef[];
}
export const routesService = {
getAll: (params?: { status?: RouteStatus; search?: string }) =>
apiClient.get<RouteRecord[]>(URL_CONSTANTS.ROUTES.BASE, { params }),
@@ -88,8 +85,9 @@ export const routesService = {
update: (id: string, data: Partial<SaveRoutePayload>) =>
apiClient.patch(URL_CONSTANTS.ROUTES.BY_ID(id), data),
deactivate: (id: string) => apiClient.delete(URL_CONSTANTS.ROUTES.BY_ID(id)),
getYards: () =>
apiClient.get<YardListResponse>(URL_CONSTANTS.RULE_ENGINE.YARDS, {
params: { isActive: true, pageSize: 200 },
}),
/** All active yards (page-walked — the yards list API caps pageSize at 100). */
getYards: async (): Promise<YardRef[]> => {
const rows = await ruleEngineService.listAll("yards", { isActive: true });
return rows as unknown as YardRef[];
},
};

View File

@@ -68,50 +68,63 @@ const defaultMeta = (
dataLength: number,
page = 1,
pageSize = 10,
): RuleEngineListMeta => ({
total: dataLength,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(dataLength / pageSize)),
});
): RuleEngineListMeta => {
const totalPages = Math.max(1, Math.ceil(dataLength / pageSize));
return {
total: dataLength,
page,
pageSize,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
};
};
const isPaginatedListResult = <T extends RuleEngineRecord>(
/** Standard envelope from the shared pagination toolkit: `{ items, meta }`. */
const isItemsEnvelope = <T extends RuleEngineRecord>(
value: unknown,
): value is RuleEngineListResult<T> =>
Boolean(value) &&
typeof value === "object" &&
"data" in (value ?? {}) &&
Array.isArray((value as RuleEngineListResult<T>).data);
Array.isArray((value as { items?: unknown }).items);
/** Legacy envelope (`{ data, meta }`) — still returned by wagon-types. */
const isLegacyEnvelope = <T extends RuleEngineRecord>(
value: unknown,
): value is { data: T[]; meta?: RuleEngineListMeta } =>
Boolean(value) &&
typeof value === "object" &&
Array.isArray((value as { data?: unknown }).data);
const normalizeList = <T extends RuleEngineRecord>(
payload: unknown,
page = 1,
pageSize = 10,
): RuleEngineListResult<T> => {
if (isPaginatedListResult<T>(payload)) {
return {
data: payload.data,
meta: payload.meta ?? defaultMeta(payload.data.length, page, pageSize),
};
const candidates: unknown[] = [payload, unwrap(payload as { data: unknown })];
for (const body of candidates) {
if (isItemsEnvelope<T>(body)) {
return {
items: body.items,
meta: body.meta ?? defaultMeta(body.items.length, page, pageSize),
};
}
if (isLegacyEnvelope<T>(body)) {
return {
items: body.data,
meta: body.meta ?? defaultMeta(body.data.length, page, pageSize),
};
}
if (Array.isArray(body)) {
return {
items: body as T[],
meta: defaultMeta(body.length, page, pageSize),
};
}
}
const body = unwrap(payload as { data: unknown }) as unknown;
if (isPaginatedListResult<T>(body)) {
return {
data: body.data,
meta: body.meta ?? defaultMeta(body.data.length, page, pageSize),
};
}
if (Array.isArray(body)) {
return {
data: body as T[],
meta: defaultMeta(body.length, page, pageSize),
};
}
return { data: [], meta: defaultMeta(0, page, pageSize) };
return { items: [], meta: defaultMeta(0, page, pageSize) };
};
const normalizeEntity = <T extends RuleEngineRecord>(payload: unknown): T => {
@@ -140,6 +153,34 @@ export const ruleEngineService = {
return normalizeList<T>(response.data, page, pageSize);
},
/**
* Fetch every row of a resource by walking the pages. The API caps pageSize
* at 100, so option/dropdown consumers that used to ask for 200-500 rows in
* one shot go through here instead of getting silently capped (or a 400).
*/
listAll: async <T extends RuleEngineRecord>(
resource: RuleEngineResourceSlug,
params?: Omit<RuleEngineListParams, "page" | "pageSize">,
): Promise<T[]> => {
const pageSize = 100;
const first = await ruleEngineService.list<T>(resource, {
...params,
page: 1,
pageSize,
});
const items = [...first.items];
const totalPages = first.meta.totalPages ?? 1;
for (let page = 2; page <= totalPages; page += 1) {
const next = await ruleEngineService.list<T>(resource, {
...params,
page,
pageSize,
});
items.push(...next.items);
}
return items;
},
getById: async <T extends RuleEngineRecord>(
resource: RuleEngineResourceSlug,
id: string,

View File

@@ -30,7 +30,8 @@ import type {
TrainScheduleDetail,
UpdateScheduleWindowRulePayload,
TrainScheduleFilters,
TrainScheduleListItem,
TrainScheduleListFilters,
TrainScheduleListResponse,
TrainSchedulePreviewPayload,
TrainSchedulePreviewResponse,
TrainSchedulingGlobalRules,
@@ -94,9 +95,22 @@ export const trainSchedulingService = {
listSchedules: async (
freightType: FreightType = "CONTAINER",
): Promise<TrainScheduleListItem[]> => {
const response = await client.get<TrainScheduleListItem[]>(
filters: TrainScheduleListFilters = {},
): Promise<TrainScheduleListResponse> => {
const params: Record<string, string | number> = {};
if (filters.page) params.page = filters.page;
if (filters.pageSize) params.pageSize = filters.pageSize;
if (filters.search?.trim()) params.search = filters.search.trim();
if (filters.status) params.status = filters.status;
if (filters.freightType) params.freightType = filters.freightType;
if (filters.originStationId) params.originStationId = filters.originStationId;
if (filters.destinationStationId)
params.destinationStationId = filters.destinationStationId;
if (filters.sortBy) params.sortBy = filters.sortBy;
if (filters.sortOrder) params.sortOrder = filters.sortOrder;
const response = await client.get<TrainScheduleListResponse>(
pathsFor(freightType === "MIXED" ? undefined : freightType).SCHEDULES,
{ params },
);
return unwrap(response.data);
},

View File

@@ -219,7 +219,11 @@ export const warehouseService = {
apiClient.post<Warehouse>(URL_CONSTANTS.WAREHOUSES.BASE, payload),
update: (id: string, payload: Partial<SaveWarehousePayload>) =>
apiClient.patch<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id), payload),
listFacilities: () => apiClient.get<WarehouseFacility[]>(URL_CONSTANTS.RULE_ENGINE.YARDS),
// Yards list now returns the standard paginated envelope ({ items, meta }).
listFacilities: () =>
apiClient.get<{ items: WarehouseFacility[] }>(URL_CONSTANTS.RULE_ENGINE.YARDS, {
params: { pageSize: 100 },
}),
// ── Yards ────────────────────────────────────────────────────────────────
listYards: (warehouseId: string) =>