mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
Merge branch 'dev' into freight/nati-2
# Conflicts: # apps/edr-freight-api/src/app.module.ts # apps/edr-freight-api/src/seed/freight-permissions.registry.ts # apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx # apps/edr-freight-web/backoffice/src/constants/URLS.ts # apps/edr-freight-web/backoffice/src/lib/permissions.ts
This commit is contained in:
@@ -290,6 +290,32 @@ const TRAIN_BUILDER_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
|
||||
QUERY_KEYS.FLEET.ROOT,
|
||||
];
|
||||
|
||||
/**
|
||||
* Coupling/uncoupling wagons moves wagons between the available pool and one
|
||||
* train — it does not touch locomotives, so those roots stay valid. Trimming
|
||||
* the set keeps a drag-reorder from refetching the whole fleet.
|
||||
*/
|
||||
const TRAIN_BUILDER_WAGON_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
|
||||
QUERY_KEYS.TRAIN_BUILDER.ROOT,
|
||||
QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||
["wagons"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Every train-builder mutation responds with the train's full, fresh
|
||||
* composition — write it straight into the detail cache so the workspace
|
||||
* repaints from the response instead of refetching what it was just handed.
|
||||
*/
|
||||
const seedComposition = (
|
||||
input: { id: string } | string,
|
||||
data: TrainComposition,
|
||||
): ReadonlyArray<readonly [readonly unknown[], unknown]> => [
|
||||
[
|
||||
QUERY_KEYS.TRAIN_BUILDER.composition(typeof input === "string" ? input : input.id),
|
||||
data,
|
||||
],
|
||||
];
|
||||
|
||||
export const api = {
|
||||
trainScheduling: {
|
||||
// ── Queries ────────────────────────────────────────────────────────────
|
||||
@@ -2076,6 +2102,7 @@ export const api = {
|
||||
trainBuilderService.setLocomotives(id, locomotiveIds).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
setYard: endpoint<{ id: string; currentYardId: string }, TrainComposition>(
|
||||
@@ -2085,6 +2112,7 @@ export const api = {
|
||||
trainBuilderService.setYard(id, currentYardId).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
updateDetails: endpoint<
|
||||
@@ -2097,6 +2125,7 @@ export const api = {
|
||||
trainBuilderService.updateDetails(id, payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
assignWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
|
||||
@@ -2105,7 +2134,21 @@ export const api = {
|
||||
({ id, wagonIds }) =>
|
||||
trainBuilderService.assignWagons(id, wagonIds).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
setWagonYard: endpoint<
|
||||
{ id: string; wagonId: string; currentYardId: string },
|
||||
TrainComposition
|
||||
>(
|
||||
"train-builder",
|
||||
"setWagonYard",
|
||||
({ id, wagonId, currentYardId }) =>
|
||||
trainBuilderService.setWagonYard(id, wagonId, currentYardId).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
removeWagon: endpoint<{ id: string; wagonId: string }, TrainComposition>(
|
||||
@@ -2114,7 +2157,8 @@ export const api = {
|
||||
({ id, wagonId }) =>
|
||||
trainBuilderService.removeWagon(id, wagonId).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
sendWagonToMaintenance: endpoint<
|
||||
@@ -2126,7 +2170,8 @@ export const api = {
|
||||
({ id, wagonId, note }) =>
|
||||
trainBuilderService.sendWagonToMaintenance(id, wagonId, note).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
reorderWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
|
||||
@@ -2135,7 +2180,8 @@ export const api = {
|
||||
({ id, wagonIds }) =>
|
||||
trainBuilderService.reorderWagons(id, wagonIds).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
deactivate: endpoint<string, TrainComposition>(
|
||||
@@ -2144,6 +2190,7 @@ export const api = {
|
||||
(id) => trainBuilderService.deactivate(id).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
activate: endpoint<string, TrainComposition>(
|
||||
@@ -2152,6 +2199,7 @@ export const api = {
|
||||
(id) => trainBuilderService.activate(id).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
disband: endpoint<string, void>(
|
||||
|
||||
@@ -6,6 +6,35 @@ import type { Freight } from "@edr/types";
|
||||
|
||||
const B = URL_CONSTANTS.BOOKINGS;
|
||||
|
||||
/**
|
||||
* One shared-wagon approval. Covers BOTH bookings on the wagon — the pair is
|
||||
* decided as a unit, never one side at a time.
|
||||
*/
|
||||
export interface ConsolidationApprovalRow {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
partnerBookingId: string;
|
||||
status: "PENDING" | "APPROVED" | "REJECTED";
|
||||
requestedBy?: string | null;
|
||||
requestedAt: string;
|
||||
decidedBy?: string | null;
|
||||
decidedAt?: string | null;
|
||||
decisionNote?: string | null;
|
||||
scheduledDate?: string | null;
|
||||
bookingReference?: string | null;
|
||||
partnerBookingReference?: string | null;
|
||||
booking?: {
|
||||
id: string;
|
||||
reference?: string;
|
||||
company?: { name?: string } | null;
|
||||
} | null;
|
||||
partnerBooking?: {
|
||||
id: string;
|
||||
reference?: string;
|
||||
company?: { name?: string } | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface BookingListFilter {
|
||||
status?: string;
|
||||
/** Comma-separated statuses for grouped tabs */
|
||||
@@ -327,6 +356,60 @@ export const bookingsService = {
|
||||
cancel: (id: string, reason: string) =>
|
||||
postBooking<BookingDetail>(B.CANCEL(id), { reason }),
|
||||
|
||||
// ── Shared-wagon approval gate ──────────────────────────────────────────
|
||||
|
||||
/** Pairings awaiting a decision, oldest first. */
|
||||
consolidationApprovalQueue: async (): Promise<ConsolidationApprovalRow[]> => {
|
||||
const response = await client.get(B.CONSOLIDATION_APPROVAL_QUEUE);
|
||||
return (unwrap(response.data) ?? []) as ConsolidationApprovalRow[];
|
||||
},
|
||||
|
||||
/** Decision history for one booking's shared wagon — who, when, and why. */
|
||||
consolidationApprovalHistory: async (
|
||||
bookingId: string,
|
||||
): Promise<ConsolidationApprovalRow[]> => {
|
||||
const response = await client.get(
|
||||
B.CONSOLIDATION_APPROVAL_HISTORY(bookingId),
|
||||
);
|
||||
return (unwrap(response.data) ?? []) as ConsolidationApprovalRow[];
|
||||
},
|
||||
|
||||
/** Approve: both bookings leave the gate and continue to Operations. */
|
||||
approveConsolidation: async (approvalId: string, note?: string) => {
|
||||
const response = await client.post(B.CONSOLIDATION_APPROVE(approvalId), {
|
||||
note,
|
||||
});
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/** Reject: both bookings go back to GL for changes with the reason. */
|
||||
rejectConsolidation: async (approvalId: string, reason: string) => {
|
||||
const response = await client.post(B.CONSOLIDATION_REJECT(approvalId), {
|
||||
reason,
|
||||
});
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply one staff decision to BOTH halves of a consolidated pair. The two
|
||||
* bookings share a wagon, so they advance or cancel together — all-or-nothing
|
||||
* on the server. Each half keeps its own invoice and payment.
|
||||
*/
|
||||
pairedDecision: async (
|
||||
id: string,
|
||||
decision: "accept" | "cancel" | "operationAccept" | "requestChanges",
|
||||
options: { reason?: string; note?: string; validityDays?: number } = {},
|
||||
): Promise<{ booking: BookingDetail; partner: BookingDetail }> => {
|
||||
const response = await client.post(B.PAIRED_DECISION(id), {
|
||||
decision,
|
||||
...options,
|
||||
});
|
||||
return unwrap(response.data) as {
|
||||
booking: BookingDetail;
|
||||
partner: BookingDetail;
|
||||
};
|
||||
},
|
||||
|
||||
create: async (payload: Record<string, unknown>): Promise<BookingDetail> => {
|
||||
const response = await client.post<{ booking: BookingDetail } | BookingDetail>(
|
||||
B.BASE,
|
||||
@@ -349,6 +432,77 @@ export const bookingsService = {
|
||||
return unwrap(response.data) as Freight.ClearanceView;
|
||||
},
|
||||
|
||||
/** Clearance action history — reviews, workflow steps, charges (newest first). */
|
||||
getClearanceHistory: async (
|
||||
id: string,
|
||||
): Promise<Freight.ClearanceHistoryEvent[]> => {
|
||||
const response = await client.get(`/bookings/${id}/clearance/history`);
|
||||
return unwrap(response.data) as Freight.ClearanceHistoryEvent[];
|
||||
},
|
||||
|
||||
// ── Clearance charges (post-finalization customer billing) ──
|
||||
getClearanceCharges: async (id: string): Promise<Freight.ClearanceCharge[]> => {
|
||||
const response = await client.get(`/bookings/${id}/clearance/charges`);
|
||||
return unwrap(response.data) as Freight.ClearanceCharge[];
|
||||
},
|
||||
|
||||
/** GL Djibouti uploads (or replaces, until billed) the port-charges document. */
|
||||
uploadPortChargeDocument: async (
|
||||
id: string,
|
||||
file: File,
|
||||
): Promise<Freight.ClearanceCharge[]> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const response = await client.post(
|
||||
`/bookings/${id}/clearance/charges/port-document`,
|
||||
form,
|
||||
{ headers: { "Content-Type": "multipart/form-data" } },
|
||||
);
|
||||
return unwrap(response.data) as Freight.ClearanceCharge[];
|
||||
},
|
||||
|
||||
/** GL Ethiopia sets or revises a charge's amount + currency. */
|
||||
billClearanceCharge: async (
|
||||
id: string,
|
||||
chargeId: string,
|
||||
payload: { amount: number; currency: string },
|
||||
): Promise<Freight.ClearanceCharge[]> => {
|
||||
const response = await client.patch(
|
||||
`/bookings/${id}/clearance/charges/${chargeId}/bill`,
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data) as Freight.ClearanceCharge[];
|
||||
},
|
||||
|
||||
/** GL Ethiopia issues the charge's payable invoice to the customer. */
|
||||
sendClearanceCharge: async (
|
||||
id: string,
|
||||
chargeId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> => {
|
||||
const response = await client.post(
|
||||
`/bookings/${id}/clearance/charges/${chargeId}/send`,
|
||||
);
|
||||
return unwrap(response.data) as Freight.ClearanceCharge[];
|
||||
},
|
||||
|
||||
/** GL Ethiopia creates the miscellaneous charge (document + amount + currency). */
|
||||
createMiscellaneousCharge: async (
|
||||
id: string,
|
||||
file: File,
|
||||
payload: { amount: number; currency: string },
|
||||
): Promise<Freight.ClearanceCharge[]> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("amount", String(payload.amount));
|
||||
form.append("currency", payload.currency);
|
||||
const response = await client.post(
|
||||
`/bookings/${id}/clearance/charges/miscellaneous`,
|
||||
form,
|
||||
{ headers: { "Content-Type": "multipart/form-data" } },
|
||||
);
|
||||
return unwrap(response.data) as Freight.ClearanceCharge[];
|
||||
},
|
||||
|
||||
/** GL ET asks Djibouti to name the officer handling the shipment in transit. */
|
||||
requestTransitAssignee: (id: string, note?: string) =>
|
||||
postBooking<BookingDetail>(B.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id), {
|
||||
|
||||
@@ -73,6 +73,32 @@ export interface ShipmentValidation {
|
||||
totalAmount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A booking GL may pick as the shared-wagon partner of an odd-20ft customs
|
||||
* booking. `hasCargo` is false for a bare instance whose containers GL still
|
||||
* enters on the split completion form.
|
||||
*/
|
||||
export interface ConsolidationCandidate {
|
||||
id: string;
|
||||
reference: string;
|
||||
contractId: string | null;
|
||||
companyName: string | null;
|
||||
status: string;
|
||||
tradeDirection: string | null;
|
||||
originYardId: string | null;
|
||||
destinationYardId: string | null;
|
||||
scheduledDate: string | null;
|
||||
ft20Quantity: number;
|
||||
hasCargo: boolean;
|
||||
}
|
||||
|
||||
/** Both halves of a shared-wagon completion, each with its own full payload. */
|
||||
export interface CompleteConsolidatedPairPayload {
|
||||
partnerBookingId: string;
|
||||
booking: Freight.CreateBookingUnderContractDto;
|
||||
partner: Freight.CreateBookingUnderContractDto;
|
||||
}
|
||||
|
||||
export interface ContractListSummaryMetrics {
|
||||
inQueue: number;
|
||||
needsAction: number;
|
||||
@@ -677,6 +703,46 @@ export const contractsService = {
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Bookings GL may link to an odd-20ft customs booking as its shared-wagon
|
||||
* partner (same route and direction, customs, odd 20ft, not already paired).
|
||||
*/
|
||||
listConsolidationCandidates: async (
|
||||
id: string,
|
||||
bookingId: string,
|
||||
): Promise<ConsolidationCandidate[]> => {
|
||||
const response = await client.get(
|
||||
C.CONSOLIDATION_CANDIDATES(id, bookingId),
|
||||
);
|
||||
return (unwrap(response.data) ?? []) as ConsolidationCandidate[];
|
||||
},
|
||||
|
||||
/**
|
||||
* Complete an odd-20ft booking together with the partner booking sharing its
|
||||
* wagon. All-or-nothing on the server: either both bookings complete and are
|
||||
* linked, or neither does. Each booking keeps its own price and its own
|
||||
* invoice — only the wagon is shared.
|
||||
*/
|
||||
completeConsolidatedPair: async (
|
||||
id: string,
|
||||
bookingId: string,
|
||||
payload: CompleteConsolidatedPairPayload,
|
||||
): Promise<{
|
||||
booking: { id: string; reference: string };
|
||||
partner: { id: string; reference: string };
|
||||
warnings?: string[];
|
||||
}> => {
|
||||
const response = await client.post(
|
||||
C.BOOKINGS_COMPLETE_CONSOLIDATED(id, bookingId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data) as {
|
||||
booking: { id: string; reference: string };
|
||||
partner: { id: string; reference: string };
|
||||
warnings?: string[];
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Pre-create validation + authoritative price preview: the same
|
||||
* BookingPricingService pass that prices the booking on create (rail +
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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.MANUAL_PAYMENT_SETTINGS.BASE;
|
||||
|
||||
/**
|
||||
* Whether Finance may settle invoices by hand (bank transfer / counter) rather
|
||||
* than the customer paying online — switched per currency, because the two
|
||||
* channels are operationally different.
|
||||
*/
|
||||
export interface ManualPaymentSettings {
|
||||
etbEnabled: boolean;
|
||||
usdEnabled: boolean;
|
||||
updatedById: string | null;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export const manualPaymentSettingsService = {
|
||||
get: async (): Promise<ManualPaymentSettings> => {
|
||||
const response = await client.get<ApiResponse<ManualPaymentSettings>>(BASE);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/** Partial: an omitted currency keeps its current setting. */
|
||||
update: async (
|
||||
patch: Partial<Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled">>,
|
||||
): Promise<ManualPaymentSettings> => {
|
||||
const response = await client.patch<ApiResponse<ManualPaymentSettings>>(
|
||||
BASE,
|
||||
patch,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
};
|
||||
@@ -67,6 +67,8 @@ export interface TrainCompositionWagon {
|
||||
wagonNumber: string;
|
||||
sequenceNumber: number | null;
|
||||
status: string;
|
||||
currentYardId: string | null;
|
||||
currentYard: YardRefLite | null;
|
||||
wagonType: {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -77,6 +79,14 @@ export interface TrainCompositionWagon {
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** Where a built train's wagons physically stand, largest group first. */
|
||||
export interface TrainWagonYardGroup {
|
||||
yardId: string | null;
|
||||
code: string | null;
|
||||
label: string | null;
|
||||
wagonCount: number;
|
||||
}
|
||||
|
||||
export interface TrainCompositionTotals {
|
||||
wagonCount: number;
|
||||
totalTareTons: number;
|
||||
@@ -104,6 +114,7 @@ export interface TrainComposition {
|
||||
currentYard: YardRefLite | null;
|
||||
locomotives: TrainCompositionLocomotive[];
|
||||
wagons: TrainCompositionWagon[];
|
||||
wagonYards: TrainWagonYardGroup[];
|
||||
totals: TrainCompositionTotals;
|
||||
activeSchedules: ActiveScheduleRef[];
|
||||
editable: boolean;
|
||||
@@ -304,6 +315,9 @@ export const trainBuilderService = {
|
||||
/** Relocate the train — coupled locomotives and wagons move with it. */
|
||||
setYard: (id: string, currentYardId: string) =>
|
||||
apiClient.patch<TrainComposition>(`${BASE}/${id}/yard`, { currentYardId }),
|
||||
/** Move one coupled wagon to another yard; the train stays put. */
|
||||
setWagonYard: (id: string, wagonId: string, currentYardId: string) =>
|
||||
apiClient.patch<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/yard`, { currentYardId }),
|
||||
assignWagons: (id: string, wagonIds: string[]) =>
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
|
||||
removeWagon: (id: string, wagonId: string) =>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { api as apiClient } from "../auth/http";
|
||||
|
||||
// NOTE: `auth/http`'s response interceptor already unwraps the API's
|
||||
// `{ success, data }` envelope, so `response.data` IS the payload here — a
|
||||
// second `.data` hop reads undefined and silently yields an empty list.
|
||||
|
||||
/** A desk mapped to a yard, joined to its IAM position for display. */
|
||||
export interface YardPositionRow {
|
||||
id: string;
|
||||
yardId: string;
|
||||
yardCode: string;
|
||||
yardLabel: string;
|
||||
positionId: string;
|
||||
positionName: { am?: string; en?: string } | null;
|
||||
positionTypeKey: string | null;
|
||||
}
|
||||
|
||||
export interface SelectablePosition {
|
||||
id: string;
|
||||
name: { am?: string; en?: string } | null;
|
||||
positionTypeKey: string | null;
|
||||
unitKey: string | null;
|
||||
}
|
||||
|
||||
export interface MyYardScope {
|
||||
/** null = unrestricted (super admin or `yards:view_all`). */
|
||||
yardIds: string[] | null;
|
||||
unrestricted: boolean;
|
||||
/** False while the backend is still shadow-logging instead of denying. */
|
||||
enforced: boolean;
|
||||
}
|
||||
|
||||
export const yardPositionsService = {
|
||||
listByYard: async (yardId: string): Promise<YardPositionRow[]> => {
|
||||
const { data } = await apiClient.get(`/yard-positions`, {
|
||||
params: { yardId },
|
||||
});
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
listPositions: async (): Promise<SelectablePosition[]> => {
|
||||
const { data } = await apiClient.get(`/yard-positions/positions`);
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
myScope: async (): Promise<MyYardScope> => {
|
||||
const { data } = await apiClient.get(`/yard-positions/my-yards`);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Replaces the yard's whole desk set — send every position that should remain
|
||||
* mapped, not just the additions.
|
||||
*/
|
||||
setForYard: async (
|
||||
yardId: string,
|
||||
positionIds: string[],
|
||||
): Promise<YardPositionRow[]> => {
|
||||
const { data } = await apiClient.put(`/yard-positions/yard/${yardId}`, {
|
||||
positionIds,
|
||||
});
|
||||
return data ?? [];
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user