mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 13:38:20 +00:00
feat: add wagon usage computation and maintenance logging features
- Implemented utility to calculate wagon usage metrics for train schedules. - Created for sending wagons to maintenance with optional notes. - Added unit tests for train builder maintenance functionalities, including formatting train run labels and building maintenance notes. - Developed component for merging train schedules with detailed previews and reasons for merging. - Introduced component for selecting wagons with search functionality and selection limits. - Created for displaying and filtering audit logs, including detailed views of individual log entries. - Added for handling API interactions related to audit logs, including fetching logs and entity types.
This commit is contained in:
@@ -67,6 +67,7 @@ import type {
|
||||
PinWagonsPayload,
|
||||
RecordCheckpointPayload,
|
||||
StaffBookingWindow,
|
||||
ScheduleMergePreview,
|
||||
TrainScheduleDetail,
|
||||
TrainScheduleFilters,
|
||||
TrainScheduleListFilters,
|
||||
@@ -596,6 +597,36 @@ export const api = {
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
previewScheduleMerge: endpoint<
|
||||
{ id: string; targetTrainId: string },
|
||||
ScheduleMergePreview
|
||||
>(
|
||||
"train-scheduling",
|
||||
"merge-preview",
|
||||
({ id, targetTrainId }) =>
|
||||
trainSchedulingService.previewScheduleMerge(id, targetTrainId),
|
||||
({ id, targetTrainId }) => [
|
||||
"train-scheduling",
|
||||
"merge-preview",
|
||||
id,
|
||||
targetTrainId,
|
||||
],
|
||||
),
|
||||
|
||||
mergeScheduleTrain: endpoint<
|
||||
{ id: string; targetTrainId: string; reason?: string },
|
||||
TrainScheduleDetail
|
||||
>(
|
||||
"train-scheduling",
|
||||
"merge-train",
|
||||
({ id, ...payload }) =>
|
||||
trainSchedulingService.mergeScheduleTrain(id, payload),
|
||||
undefined,
|
||||
// Wagons and bookings move between trains and schedules, so the wagon and
|
||||
// train caches are stale too — not just the scheduling ones.
|
||||
() => [...TRAIN_SCHEDULING_INVALIDATIONS, ["wagons"], ["trains"]],
|
||||
),
|
||||
|
||||
markBookingPaid: endpoint<string, void>(
|
||||
"train-scheduling",
|
||||
"mark-booking-paid",
|
||||
@@ -2040,11 +2071,14 @@ export const api = {
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
sendWagonToMaintenance: endpoint<{ id: string; wagonId: string }, TrainComposition>(
|
||||
sendWagonToMaintenance: endpoint<
|
||||
{ id: string; wagonId: string; note?: string },
|
||||
TrainComposition
|
||||
>(
|
||||
"train-builder",
|
||||
"sendWagonToMaintenance",
|
||||
({ id, wagonId }) =>
|
||||
trainBuilderService.sendWagonToMaintenance(id, wagonId).then((r) => r.data),
|
||||
({ id, wagonId, note }) =>
|
||||
trainBuilderService.sendWagonToMaintenance(id, wagonId, note).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { PaginatedResponse } from "@edr/types";
|
||||
|
||||
import { api as client } from "../auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type { ApiResponse } from "@/types/apiResponse";
|
||||
|
||||
const BASE = URL_CONSTANTS.AUDIT_LOGS.BASE;
|
||||
|
||||
/** Methods the audit trail records. Reads are never audited. */
|
||||
export const AUDIT_METHODS = ["POST", "PUT", "PATCH", "DELETE"] as const;
|
||||
export type AuditMethod = (typeof AUDIT_METHODS)[number];
|
||||
|
||||
/**
|
||||
* One recorded backoffice action.
|
||||
*
|
||||
* Mirrors `AuditLog` in the freight API. `userName` / `userRole` are snapshots
|
||||
* taken when the action happened, not live lookups — an old row keeps the name
|
||||
* and role the actor had at the time.
|
||||
*/
|
||||
export interface AuditLog {
|
||||
id: string;
|
||||
/** Readable action, e.g. "Approve contract". */
|
||||
title: string;
|
||||
method: AuditMethod;
|
||||
/** URL as called, query string included (secrets already redacted server-side). */
|
||||
url: string;
|
||||
/** Route template, e.g. `/api/contracts/:id/cancel`. */
|
||||
routePath: string | null;
|
||||
/** Entity the action touched, e.g. "Contract". */
|
||||
type: string;
|
||||
isSuccess: boolean;
|
||||
statusCode: number | null;
|
||||
errorMessage: string | null;
|
||||
userId: string | null;
|
||||
userName: string | null;
|
||||
userRole: string | null;
|
||||
resourceId: string | null;
|
||||
/** Sanitized request body; files appear as `__file` descriptors. */
|
||||
request: Record<string, unknown> | null;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
requestId: string | null;
|
||||
durationMs: number | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AuditLogQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
type?: string;
|
||||
userId?: string;
|
||||
method?: AuditMethod;
|
||||
resourceId?: string;
|
||||
/** Omit for "any outcome". */
|
||||
isSuccess?: boolean;
|
||||
/** Inclusive ISO 8601 bounds. */
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop empty filters so the request carries only what the user actually set —
|
||||
* an empty string would otherwise be sent and fail the API's validation.
|
||||
*/
|
||||
function toParams(query: AuditLogQuery): Record<string, string | number> {
|
||||
const params: Record<string, string | number> = {};
|
||||
|
||||
if (query.page) params.page = query.page;
|
||||
if (query.pageSize) params.pageSize = query.pageSize;
|
||||
if (query.type) params.type = query.type;
|
||||
if (query.userId) params.userId = query.userId;
|
||||
if (query.method) params.method = query.method;
|
||||
if (query.resourceId) params.resourceId = query.resourceId;
|
||||
if (query.isSuccess !== undefined) params.isSuccess = String(query.isSuccess);
|
||||
if (query.from) params.from = query.from;
|
||||
if (query.to) params.to = query.to;
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export const auditLogsService = {
|
||||
/** Paginated audit history, newest first. */
|
||||
list: async (query: AuditLogQuery = {}): Promise<PaginatedResponse<AuditLog>> => {
|
||||
const response = await client.get<ApiResponse<PaginatedResponse<AuditLog>>>(
|
||||
`${BASE}/logs`,
|
||||
{ params: toParams(query) },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/** Distinct entity types present, for the filter dropdown. */
|
||||
types: async (): Promise<string[]> => {
|
||||
const response = await client.get<ApiResponse<string[]>>(`${BASE}/types`);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
};
|
||||
@@ -420,11 +420,11 @@ export const bookingsService = {
|
||||
|
||||
uploadDeliveryOrder: async (
|
||||
id: string,
|
||||
file: File,
|
||||
files: File[],
|
||||
dates: { vesselArrivalDate: string; doCollectedDate: string },
|
||||
): Promise<BookingDetail> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
files.forEach((file) => form.append("files", file));
|
||||
form.append("vesselArrivalDate", dates.vesselArrivalDate);
|
||||
form.append("doCollectedDate", dates.doCollectedDate);
|
||||
const response = await client.post(B.CLEARANCE_DELIVERY_ORDER(id), form, {
|
||||
@@ -435,11 +435,11 @@ export const bookingsService = {
|
||||
|
||||
uploadReleaseOrder: async (
|
||||
id: string,
|
||||
file: File,
|
||||
files: File[],
|
||||
vesselDepartureDate: string,
|
||||
): Promise<{ hold?: boolean; holdReason?: string }> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
files.forEach((file) => form.append("files", file));
|
||||
form.append("vesselDepartureDate", vesselDepartureDate);
|
||||
const response = await client.post(B.CLEARANCE_RELEASE_ORDER(id), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
|
||||
@@ -468,11 +468,11 @@ export const contractsService = {
|
||||
|
||||
uploadDeliveryOrder: async (
|
||||
id: string,
|
||||
file: File,
|
||||
files: File[],
|
||||
dates: { vesselArrivalDate: string; doCollectedDate: string },
|
||||
): Promise<Freight.IContract> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
files.forEach((file) => form.append("files", file));
|
||||
form.append("vesselArrivalDate", dates.vesselArrivalDate);
|
||||
form.append("doCollectedDate", dates.doCollectedDate);
|
||||
const response = await client.post(C.CLEARANCE_DELIVERY_ORDER(id), form, {
|
||||
@@ -483,11 +483,11 @@ export const contractsService = {
|
||||
|
||||
uploadReleaseOrder: async (
|
||||
id: string,
|
||||
file: File,
|
||||
files: File[],
|
||||
vesselDepartureDate: string,
|
||||
): Promise<{ contract: Freight.IContract; hold: boolean; holdReason?: string }> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
files.forEach((file) => form.append("files", file));
|
||||
form.append("vesselDepartureDate", vesselDepartureDate);
|
||||
const response = await client.post(C.CLEARANCE_RELEASE_ORDER(id), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
|
||||
@@ -309,8 +309,11 @@ export const trainBuilderService = {
|
||||
removeWagon: (id: string, wagonId: string) =>
|
||||
apiClient.delete<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}`),
|
||||
/** Detach a wagon and move it to MAINTENANCE status. */
|
||||
sendWagonToMaintenance: (id: string, wagonId: string) =>
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`),
|
||||
/** `note` is the maintenance reason — recorded with the train it came off. */
|
||||
sendWagonToMaintenance: (id: string, wagonId: string, note?: string) =>
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`, {
|
||||
note,
|
||||
}),
|
||||
reorderWagons: (id: string, wagonIds: string[]) =>
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
|
||||
/** Park the train indefinitely — only allowed with no active schedule. */
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
PinWagonsPayload,
|
||||
RecordCheckpointPayload,
|
||||
StaffBookingWindow,
|
||||
ScheduleMergePreview,
|
||||
TrainScheduleDetail,
|
||||
UpdateScheduleWindowRulePayload,
|
||||
TrainScheduleFilters,
|
||||
@@ -294,6 +295,29 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/** What a merge would do — drives the confirmation modal. Read-only. */
|
||||
previewScheduleMerge: async (
|
||||
scheduleId: string,
|
||||
targetTrainId: string,
|
||||
): Promise<ScheduleMergePreview> => {
|
||||
const response = await client.get<ScheduleMergePreview>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.MERGE_PREVIEW(scheduleId, targetTrainId),
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/** Merge another train into this schedule. This schedule always survives. */
|
||||
mergeScheduleTrain: async (
|
||||
scheduleId: string,
|
||||
payload: { targetTrainId: string; reason?: string },
|
||||
): Promise<TrainScheduleDetail> => {
|
||||
const response = await client.post<TrainScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.MERGE_TRAIN(scheduleId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
markBookingPaid: async (bookingId: string): Promise<void> => {
|
||||
await client.post(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.MARK_BOOKING_PAID(bookingId),
|
||||
|
||||
@@ -168,6 +168,10 @@ export interface WagonTransferRequest {
|
||||
closedShortByUserId?: string | null;
|
||||
/** Why the wagons are needed — required for new requests, shown on the queue. */
|
||||
reason?: string | null;
|
||||
/** The wagons the requester hand-picked, if any. A preference, not a reservation. */
|
||||
preferredWagonIds?: string[] | null;
|
||||
/** Those same picks resolved to wagon numbers by the API, for display. */
|
||||
preferredWagons?: Array<{ id: string; wagonNumber: string }>;
|
||||
note: string | null;
|
||||
fromYard?: { id: string; label?: string; code?: string } | null;
|
||||
toYard?: { id: string; label?: string; code?: string } | null;
|
||||
@@ -182,6 +186,8 @@ export interface CreateTransferRequestPayload {
|
||||
quantity: number;
|
||||
/** Mandatory: why the wagons are needed. */
|
||||
reason: string;
|
||||
/** Specific wagons the requester wants — at most `quantity` of them. */
|
||||
preferredWagonIds?: string[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user