Merge remote-tracking branch 'origin/dev' into dj-franc

This commit is contained in:
ghost2023
2026-09-06 21:57:27 +03:00
149 changed files with 10387 additions and 566 deletions

View File

@@ -91,6 +91,7 @@ import type {
TrainScheduleFilters,
TrainScheduleListFilters,
TrainScheduleListResponse,
ReduceScheduleCloseOffsetPayload,
UpdateScheduleWindowRulePayload,
TrainSchedulePreviewPayload,
TrainSchedulePreviewResponse,
@@ -713,6 +714,18 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
reduceScheduleCloseOffset: endpoint<
{ id: string; payload: ReduceScheduleCloseOffsetPayload },
TrainScheduleDetail
>(
"train-scheduling",
"reduce-schedule-close-offset",
({ id, payload }) =>
trainSchedulingService.reduceScheduleCloseOffset(id, payload),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
updateScheduleDate: endpoint<
{ id: string; scheduleDate: string },
TrainScheduleDetail
@@ -3220,11 +3233,23 @@ export const api = {
bookingsService.reviewOperation(id, decision, { note }),
),
proceedToOperation: endpoint<
{ id: string; scheduledDate: string },
rescheduleOperation: endpoint<
{
id: string;
scheduledDate: string;
trainScheduleId?: string;
note?: string;
},
BookingDetail
>("bookings", "proceedToOperation", ({ id, scheduledDate }) =>
bookingsService.proceedToOperation(id, scheduledDate),
>("bookings", "rescheduleOperation", ({ id, ...payload }) =>
bookingsService.rescheduleOperation(id, payload),
),
proceedToOperation: endpoint<
{ id: string; scheduledDate: string; trainScheduleId?: string },
BookingDetail
>("bookings", "proceedToOperation", ({ id, scheduledDate, trainScheduleId }) =>
bookingsService.proceedToOperation(id, scheduledDate, trainScheduleId),
),
generateContract: endpoint<{ id: string }, BookingDetail>(

View File

@@ -355,8 +355,21 @@ export const bookingsService = {
* customer path uses the same endpoint from the portal; GL needs it here
* because a customs booking is GL's to fix, not the customer's.
*/
proceedToOperation: (id: string, scheduledDate: string) =>
postBooking<BookingDetail>(B.CLEARANCE_PROCEED(id), { scheduledDate }),
proceedToOperation: (id: string, scheduledDate: string, trainScheduleId?: string) =>
postBooking<BookingDetail>(B.CLEARANCE_PROCEED(id), {
scheduledDate,
...(trainScheduleId ? { trainScheduleId } : {}),
}),
/**
* Operations changes the shipment day (and, for export rail, the train) of a
* pending operation request on the customer's behalf — the booking stays at
* OPERATION_REQUEST_PENDING for the normal accept.
*/
rescheduleOperation: (
id: string,
payload: { scheduledDate: string; trainScheduleId?: string; note?: string },
) => postBooking<BookingDetail>(B.OPERATION_RESCHEDULE(id), payload),
generateContract: (id: string) =>
postBooking<BookingDetail>(B.CONTRACT_GENERATE(id)),

View File

@@ -303,6 +303,14 @@ export const contractsService = {
resume: (id: string, note?: string) =>
postContract<Freight.IContract>(C.RESUME(id), { note }),
/**
* Add validity days to an EXPIRED contract the customer asked to extend; it
* returns to the status it held before it lapsed. The API refuses it while
* no customer request is pending.
*/
extend: (id: string, payload: Freight.ExtendContractDto) =>
postContract<Freight.IContract>(C.EXTEND(id), payload),
/**
* Approve the next pending step. The server resolves the step's required role
* and authorizes against it — the client never declares its own role.

View File

@@ -0,0 +1,114 @@
import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
export type TrainCrewRole =
| 'TRAIN_DRIVER'
| 'FEDERAL_POLICE'
| 'TECHNICIAN'
| 'REEFER_TECHNICIAN'
| 'HAZMAT_ESCORT'
| 'LASHING_INSPECTOR'
| 'LIVESTOCK_HANDLER';
export type TrainCrewNationality = 'ETHIOPIAN' | 'DJIBOUTIAN';
export type TrainCrewStatus = 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'ON_LEAVE';
export interface TrainCrewMember {
id: string;
firstName: string;
lastName: string;
role: TrainCrewRole;
nationality: TrainCrewNationality;
status: TrainCrewStatus;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface TrainCrewListFilters {
search?: string;
role?: TrainCrewRole;
nationality?: TrainCrewNationality;
status?: TrainCrewStatus;
isActive?: boolean;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
/** Paginated envelope returned by GET /train-crew. */
export interface TrainCrewListResponse {
data: TrainCrewMember[];
total: number;
page: number;
limit: number;
}
export type SaveTrainCrewMemberPayload = Omit<
TrainCrewMember,
'id' | 'createdAt' | 'updatedAt'
>;
export const trainCrewService = {
getAll: (filters: TrainCrewListFilters = {}) => {
const params = new URLSearchParams();
if (filters.search) params.set('search', filters.search);
if (filters.role) params.set('role', filters.role);
if (filters.nationality) params.set('nationality', filters.nationality);
if (filters.status) params.set('status', filters.status);
if (filters.isActive !== undefined) {
params.set('isActive', String(filters.isActive));
}
if (filters.page) params.set('page', String(filters.page));
if (filters.limit) params.set('limit', String(filters.limit));
if (filters.sortBy) params.set('sortBy', filters.sortBy);
if (filters.sortOrder) params.set('sortOrder', filters.sortOrder);
const qs = params.toString();
return apiClient.get<TrainCrewListResponse>(
`${URL_CONSTANTS.TRAIN_CREW.BASE}${qs ? `?${qs}` : ''}`,
);
},
getById: (id: string) =>
apiClient.get<TrainCrewMember>(URL_CONSTANTS.TRAIN_CREW.BY_ID(id)),
create: (data: Partial<SaveTrainCrewMemberPayload>) =>
apiClient.post(URL_CONSTANTS.TRAIN_CREW.BASE, data),
update: (id: string, data: Partial<SaveTrainCrewMemberPayload>) =>
apiClient.patch(URL_CONSTANTS.TRAIN_CREW.BY_ID(id), data),
delete: (id: string) => apiClient.delete(URL_CONSTANTS.TRAIN_CREW.BY_ID(id)),
};
export const TRAIN_CREW_ROLE_OPTIONS: Array<{ label: string; value: TrainCrewRole }> = [
{ label: 'Train Driver', value: 'TRAIN_DRIVER' },
{ label: 'Federal Police', value: 'FEDERAL_POLICE' },
{ label: 'Technician', value: 'TECHNICIAN' },
{ label: 'Reefer Technician', value: 'REEFER_TECHNICIAN' },
{ label: 'HAZMAT Escort', value: 'HAZMAT_ESCORT' },
{ label: 'Lashing Inspector', value: 'LASHING_INSPECTOR' },
{ label: 'Livestock Handler', value: 'LIVESTOCK_HANDLER' },
];
export const TRAIN_CREW_NATIONALITY_OPTIONS: Array<{
label: string;
value: TrainCrewNationality;
}> = [
{ label: 'Ethiopian', value: 'ETHIOPIAN' },
{ label: 'Djiboutian', value: 'DJIBOUTIAN' },
];
export const TRAIN_CREW_STATUS_OPTIONS: Array<{ label: string; value: TrainCrewStatus }> = [
{ label: 'Active', value: 'ACTIVE' },
{ label: 'Inactive', value: 'INACTIVE' },
{ label: 'Suspended', value: 'SUSPENDED' },
{ label: 'On leave', value: 'ON_LEAVE' },
];
export const trainCrewRoleLabel = (role: TrainCrewRole): string =>
TRAIN_CREW_ROLE_OPTIONS.find((o) => o.value === role)?.label ?? role;
export const trainCrewNationalityLabel = (n: TrainCrewNationality): string =>
TRAIN_CREW_NATIONALITY_OPTIONS.find((o) => o.value === n)?.label ?? n;
export const trainCrewStatusLabel = (s: TrainCrewStatus): string =>
TRAIN_CREW_STATUS_OPTIONS.find((o) => o.value === s)?.label ?? s;

View File

@@ -0,0 +1,104 @@
import { api as apiClient } from '../auth/http';
import type { TrainCrewMember, TrainCrewRole } from './trainCrew.service';
export type CrewDutyRole = 'PRIMARY' | 'ASSISTANT' | 'BENCH_RELIEF';
/** A yard on the schedule's route, ordered along the corridor. */
export interface CorridorYard {
id: string;
label: string;
country: string;
displayOrder: number;
}
export interface CrewAssignment {
id: string;
trainScheduleId: string;
crewMemberId: string;
role: TrainCrewRole;
dutyRole?: CrewDutyRole | null;
fromYardId?: string | null;
toYardId?: string | null;
status: string;
crewMember?: TrainCrewMember;
}
/** What the consist and its cargo demand (§1.2), detected server-side. */
export interface CrewDemand {
hasBadOrderWagon: boolean;
badOrderWagonLabels: string[];
hasReeferCargo: boolean;
reeferSources: string[];
hasHazmatCargo: boolean;
hazmatSources: string[];
hasBreakBulkCargo: boolean;
breakBulkSources: string[];
hasLivestockCargo: boolean;
livestockSources: string[];
}
export interface CrewRequirement {
role: TrainCrewRole;
/** Hard floor — 0 unless the cargo or consist forces someone aboard. */
min: number;
/** The count §1.2 suggests. A hint only; nothing enforces it. */
typical: number;
reason: string;
}
export interface CrewValidation {
complete: boolean;
issues: Array<{ code: string; message: string }>;
runType: 'SHORT_RUN' | 'LONG_RUN' | null;
}
export interface ScheduleCrewResponse {
scheduleId: string;
assignments: CrewAssignment[];
/** Yards a driver leg may use — bounded by the schedule's own endpoints. */
corridorYards: CorridorYard[];
demand: CrewDemand;
requirements: {
technician: CrewRequirement;
specialized: CrewRequirement[];
};
validation: CrewValidation;
}
export interface SaveCrewPayload {
assignments: Array<{
crewMemberId: string;
role: TrainCrewRole;
dutyRole?: CrewDutyRole;
fromYardId?: string;
toYardId?: string;
}>;
}
const base = (scheduleId: string) => `/train-schedules/${scheduleId}/crew`;
export const trainCrewAssignmentService = {
get: (scheduleId: string) =>
apiClient.get<ScheduleCrewResponse>(base(scheduleId)),
eligibleDrivers: (scheduleId: string, fromYardId: string, toYardId: string) =>
apiClient.get<TrainCrewMember[]>(
`${base(scheduleId)}/eligible-drivers?fromYardId=${fromYardId}&toYardId=${toYardId}`,
),
corridorYards: (scheduleId: string) =>
apiClient.get<CorridorYard[]>(`${base(scheduleId)}/corridor-yards`),
save: (scheduleId: string, payload: SaveCrewPayload) =>
apiClient.put<CrewValidation>(base(scheduleId), payload),
};
export const DUTY_ROLE_OPTIONS: Array<{ value: CrewDutyRole; label: string }> = [
{ value: 'PRIMARY', label: 'Primary Driver' },
{ value: 'ASSISTANT', label: 'Assistant Driver' },
{ value: 'BENCH_RELIEF', label: 'Bench/Relief Driver' },
];
export const dutyRoleLabel = (dutyRole: CrewDutyRole): string =>
({
PRIMARY: 'Primary Driver',
ASSISTANT: 'Assistant Driver',
BENCH_RELIEF: 'Bench/Relief Driver',
})[dutyRole];

View File

@@ -36,6 +36,7 @@ import type {
MarshallingStop,
ScheduleMergePreview,
TrainScheduleDetail,
ReduceScheduleCloseOffsetPayload,
UpdateScheduleWindowRulePayload,
TrainScheduleFilters,
TrainScheduleListFilters,
@@ -307,6 +308,17 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
reduceScheduleCloseOffset: async (
scheduleId: string,
payload: ReduceScheduleCloseOffsetPayload,
): Promise<TrainScheduleDetail> => {
const response = await client.patch<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.CLOSE_OFFSET(scheduleId),
payload,
);
return unwrap(response.data);
},
updateScheduleDate: async (
scheduleId: string,
scheduleDate: string,