feat: enhance train scheduling and contract management features

- Added StationWorkControls to manage loading/unloading phases in TrainScheduleV2DetailPage.
- Implemented API endpoints for recording station work and managing wagon detach requests.
- Updated contract templates to include Ethiopian customs handling options.
- Enhanced shipment forms to collect customs clearing agent details for without-customs bookings.
- Introduced NUMBER_OF_WAGONS as a unit of measure for bulk cargo, allowing customers to specify wagon counts.
- Improved validation for customs clearing agent information in shipment forms.
- Updated various components and services to accommodate new features and ensure data integrity.
This commit is contained in:
Marshal
2026-08-25 21:44:21 +00:00
parent d5a5085d6d
commit b926a3116e
67 changed files with 2998 additions and 255 deletions

View File

@@ -241,6 +241,7 @@ import {
type TrainComposition,
type UpdateTrainDetailsPayload,
type UsedTrainNumbers,
type WagonDetachRequestRow,
} from "./trainBuilder.service";
import {
trainSchedulingService,
@@ -897,6 +898,24 @@ export const api = {
({ scheduleId }) => ["train-scheduling", "yard-work", scheduleId],
),
recordStationWork: endpoint<
{
scheduleId: string;
yardId: string;
phase: "loading" | "unloading";
edge: "start" | "end";
at?: string;
},
import("@/types/trainScheduling").StationWorkPhaseLog
>(
"train-scheduling",
"station-work",
({ scheduleId, yardId, phase, edge, at }) =>
trainSchedulingService.recordStationWork(scheduleId, yardId, phase, edge, at),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
loadScheduleBooking: endpoint<
{ scheduleId: string; bookingId: string },
import("@/types/trainScheduling").BookingLoadResult
@@ -2248,6 +2267,51 @@ export const api = {
seedComposition,
),
// Key derives to ["train-builder", "detachRequests", input] — the shared
// TRAIN_BUILDER.ROOT invalidation refreshes it after every consist edit.
detachRequests: endpoint<{ id: string }, WagonDetachRequestRow[]>(
"train-builder",
"detachRequests",
({ id }) => trainBuilderService.detachRequests(id).then((r) => r.data),
),
createDetachRequest: endpoint<
{ id: string; wagonId: string; action: "DETACH" | "MAINTENANCE"; reason: string },
WagonDetachRequestRow
>(
"train-builder",
"createDetachRequest",
({ id, wagonId, action, reason }) =>
trainBuilderService.createDetachRequest(id, wagonId, { action, reason }).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
approveDetachRequest: endpoint<
{ id: string; requestId: string; note?: string },
TrainComposition
>(
"train-builder",
"approveDetachRequest",
({ id, requestId, note }) =>
trainBuilderService.approveDetachRequest(id, requestId, note).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
seedComposition,
),
rejectDetachRequest: endpoint<
{ id: string; requestId: string; note: string },
TrainComposition
>(
"train-builder",
"rejectDetachRequest",
({ id, requestId, note }) =>
trainBuilderService.rejectDetachRequest(id, requestId, note).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
reorderWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
"train-builder",
"reorderWagons",

View File

@@ -34,7 +34,12 @@ export interface ContractTemplate {
* Null for intercity — domestic movements have no customs leg.
*/
withCustoms?: boolean | null;
/** The five seeded container templates — cannot be deleted. */
/**
* Bulk templates only: the with-customs variant restricted to Ethiopian-side
* clearing (Djibouti stays with the client).
*/
ethiopianCustomsOnly?: boolean | null;
/** The seeded container templates — cannot be deleted. */
isSystem: boolean;
createdAt: string;
updatedAt: string;
@@ -45,6 +50,8 @@ export interface CreateContractTemplatePayload {
tradeDirection: BulkTemplateDirection;
/** Omitted for INTERCITY — the API rejects the flag there. */
withCustoms?: boolean;
/** Ethiopian-side clearing only; requires withCustoms: true. */
ethiopianCustomsOnly?: boolean;
name?: string;
description?: string;
}

View File

@@ -386,6 +386,22 @@ export interface UpdateScheduleWagonYardsPayload {
export type UpdateScheduleWagonYardsResult = ScheduleWagonYards & { warnings: string[] };
/** One detach/maintenance approval request — pending or decided (audit trail). */
export interface WagonDetachRequestRow {
id: string;
wagonId: string;
wagonNumber: string;
action: "DETACH" | "MAINTENANCE";
reason: string;
status: "PENDING" | "APPROVED" | "REJECTED";
requestedById: string | null;
requestedBy: string | null;
requestedAt: string;
decidedBy: string | null;
decidedAt: string | null;
decisionNote: string | null;
}
export const trainBuilderService = {
list: (filters: BuiltTrainListFilters = {}) =>
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
@@ -428,6 +444,29 @@ export const trainBuilderService = {
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`, {
note,
}),
/** Requests to detach a wagon from a SCHEDULED train, newest first. */
detachRequests: (id: string) =>
apiClient.get<WagonDetachRequestRow[]>(`${BASE}/${id}/detach-requests`),
/** File a detach/maintenance approval request (reason required). */
createDetachRequest: (
id: string,
wagonId: string,
payload: { action: "DETACH" | "MAINTENANCE"; reason: string },
) =>
apiClient.post<WagonDetachRequestRow>(
`${BASE}/${id}/wagons/${wagonId}/detach-requests`,
payload,
),
/** Approve a pending request — executes the detach immediately. */
approveDetachRequest: (id: string, requestId: string, note?: string) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/detach-requests/${requestId}/approve`, {
note,
}),
/** Reject a pending request — a note explaining why is required. */
rejectDetachRequest: (id: string, requestId: string, note: string) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/detach-requests/${requestId}/reject`, {
note,
}),
reorderWagons: (id: string, wagonIds: string[]) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
/** Park the train indefinitely — only allowed with no active schedule. */

View File

@@ -471,6 +471,23 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
/** Start/end (or correct, via `at`) a station's loading/unloading time window. */
recordStationWork: async (
scheduleId: string,
yardId: string,
phase: "loading" | "unloading",
edge: "start" | "end",
at?: string,
): Promise<import("@/types/trainScheduling").StationWorkPhaseLog> => {
const response = await client.post<
import("@/types/trainScheduling").StationWorkPhaseLog
>(
URL_CONSTANTS.TRAIN_SCHEDULING.STATION_WORK(scheduleId, yardId, phase, edge),
at ? { at } : {},
);
return unwrap(response.data);
},
loadScheduleBooking: async (
scheduleId: string,
bookingId: string,