mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
- Added functionality to cancel contracts, allowing users to provide a reason for cancellation. - Updated contract statuses to include SUSPENDED and changed CLOSED to COMPLETED. - Enhanced the UI to reflect the new cancellation option and updated messaging for contract statuses. - Refactored contract booking actions to accommodate changes in booking logic for ONE_TIME and GENERAL contracts. - Removed clearance document management from the contract detail page, as it is now handled per booking. - Introduced a SQL script to reset bookings and train schedules for development purposes.
472 lines
15 KiB
TypeScript
472 lines
15 KiB
TypeScript
import type { Freight, PaginatedResponse } from "@edr/types";
|
|
|
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
|
import { client } from "../utils/api";
|
|
import type { ContractView, SignContractPayload } from "./bookings.service";
|
|
|
|
const C = URL_CONSTANTS.CONTRACTS;
|
|
|
|
export type CreateContractPayload = Freight.CreateContractDto;
|
|
export type UpdateContractPayload = Freight.UpdateContractDto;
|
|
|
|
/** Document files keyed by upload field — same shape as booking documents. */
|
|
export type ContractDocuments = Record<string, File | File[] | null>;
|
|
|
|
/**
|
|
* Unit-rate pricing returned by generate-price. Mirrors the contract
|
|
* `pricing_breakdown` (doc §9.1) — line items with a per-unit price and NO
|
|
* total, since quantities are unknown at the contract stage.
|
|
*/
|
|
export interface GenerateContractPriceResponse {
|
|
contractId: string;
|
|
currency: string;
|
|
lineItems: Freight.ContractUnitRateLineItem[];
|
|
warnings?: string[];
|
|
}
|
|
|
|
export interface SubmitContractResponse {
|
|
contractId: string;
|
|
status: Freight.ContractStatus;
|
|
priceChanged: boolean;
|
|
currency: string;
|
|
lineItems?: Freight.ContractUnitRateLineItem[];
|
|
message?: string;
|
|
}
|
|
|
|
/** A container line whose total VGM exceeds the weight-limit rule. */
|
|
export interface OverweightLine {
|
|
containerTypeCode: string;
|
|
totalVgmTons: number;
|
|
maxAllowedTons: number;
|
|
excessTons: number;
|
|
}
|
|
|
|
/**
|
|
* One line of the server-priced booking breakdown — the exact line the booking
|
|
* will persist at create time (rail freight, first/last mile, surcharges…).
|
|
*/
|
|
export interface ShipmentPriceLine {
|
|
code: string;
|
|
description: string;
|
|
amount: number;
|
|
unitAmount: number;
|
|
/** Rate unit as stored: PER_CONTAINER | PER_WAGON | PER_TON | PER_KM | FLAT | … */
|
|
unit: string;
|
|
quantity: number;
|
|
currency: string;
|
|
}
|
|
|
|
/**
|
|
* Pre-submit validation + authoritative price preview for a shipment booking.
|
|
* `overweightLines` are WARNINGS only (an overweight surcharge applies — the
|
|
* customer may still submit); `pairingErrors` are HARD BLOCKS (20ft containers
|
|
* that cannot be balanced onto wagons) and must prevent booking.
|
|
* `lineItems`/`totalAmount` are the full server-computed breakdown — the same
|
|
* BookingPricingService pass that prices the booking on create, so the confirm
|
|
* modal shows first/last mile, overweight, and every surcharge, not just the
|
|
* container estimate.
|
|
*/
|
|
export interface ShipmentValidation {
|
|
overweightLines: OverweightLine[];
|
|
overweightSurchargeAmount: number;
|
|
currency: string | null;
|
|
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;
|
|
}
|
|
|
|
export interface ContractListFilter {
|
|
status?: string;
|
|
statuses?: string;
|
|
/** ONE_TIME or GENERAL. */
|
|
contractKind?: string;
|
|
/** CONTAINER or BULK. */
|
|
freightType?: string;
|
|
/** IMPORT / EXPORT / DOMESTIC. */
|
|
tradeDirection?: string;
|
|
companyProfileId?: string;
|
|
createdFrom?: string;
|
|
createdTo?: string;
|
|
/** Server-side free-text search (contract reference, company name). */
|
|
search?: string;
|
|
page?: number;
|
|
pageSize?: number;
|
|
sortBy?: string;
|
|
sortOrder?: "ASC" | "DESC";
|
|
}
|
|
|
|
function appendScalar(formData: FormData, key: string, value: unknown) {
|
|
if (value === undefined || value === null) return;
|
|
if (typeof value === "boolean") {
|
|
formData.append(key, value ? "true" : "false");
|
|
return;
|
|
}
|
|
if (typeof value === "number") {
|
|
formData.append(key, String(value));
|
|
return;
|
|
}
|
|
if (typeof value === "string") {
|
|
formData.append(key, value);
|
|
}
|
|
}
|
|
|
|
function appendDocuments(formData: FormData, documents?: ContractDocuments) {
|
|
if (!documents) return;
|
|
for (const [key, fileOrFiles] of Object.entries(documents)) {
|
|
if (!fileOrFiles) continue;
|
|
if (Array.isArray(fileOrFiles)) {
|
|
for (const f of fileOrFiles) formData.append(key, f);
|
|
} else {
|
|
formData.append(key, fileOrFiles);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Flatten a contract payload (and optional intake documents) into multipart
|
|
* FormData. The cargo-scope and route arrays are sent index-bracketed
|
|
* (`cargoScope[0][containerSize]`, `routes[0][originYardId]`, …), mirroring the
|
|
* booking form-data builder.
|
|
*/
|
|
export function buildContractFormData(
|
|
payload: Partial<CreateContractPayload>,
|
|
documents?: ContractDocuments,
|
|
): FormData {
|
|
const formData = new FormData();
|
|
const skipKeys = new Set(["cargoScope", "routes"]);
|
|
|
|
for (const [key, value] of Object.entries(payload)) {
|
|
if (skipKeys.has(key)) continue;
|
|
appendScalar(formData, key, value);
|
|
}
|
|
|
|
payload.cargoScope?.forEach((scope, i) => {
|
|
appendScalar(formData, `cargoScope[${i}][containerSize]`, scope.containerSize);
|
|
appendScalar(formData, `cargoScope[${i}][cargoTypeId]`, scope.cargoTypeId);
|
|
appendScalar(formData, `cargoScope[${i}][cargoFreeText]`, scope.cargoFreeText);
|
|
});
|
|
|
|
payload.routes?.forEach((route, i) => {
|
|
appendScalar(formData, `routes[${i}][originYardId]`, route.originYardId);
|
|
appendScalar(
|
|
formData,
|
|
`routes[${i}][destinationYardId]`,
|
|
route.destinationYardId,
|
|
);
|
|
appendScalar(formData, `routes[${i}][km]`, route.km);
|
|
appendScalar(formData, `routes[${i}][sortOrder]`, route.sortOrder ?? i);
|
|
});
|
|
|
|
appendDocuments(formData, documents);
|
|
return formData;
|
|
}
|
|
|
|
export const contractsService = {
|
|
list: async (
|
|
filter: ContractListFilter | void = {},
|
|
): Promise<PaginatedResponse<Freight.IContract>> => {
|
|
const { data } = await client.get(C.BASE, { params: filter ?? {} });
|
|
return data.data;
|
|
},
|
|
|
|
listMy: async (
|
|
filter: ContractListFilter | void = {},
|
|
): Promise<PaginatedResponse<Freight.IContract>> => {
|
|
const { data } = await client.get(C.MY, { params: filter ?? {} });
|
|
return data.data;
|
|
},
|
|
|
|
get: async (id: string): Promise<Freight.IContract> => {
|
|
const { data } = await client.get(C.BY_ID(id));
|
|
return data.data;
|
|
},
|
|
|
|
create: async (
|
|
payload: CreateContractPayload,
|
|
documents?: ContractDocuments,
|
|
): Promise<Freight.IContract> => {
|
|
const formData = buildContractFormData(payload, documents);
|
|
const { data } = await client.post(C.BASE, formData, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return data.data.contract ?? data.data;
|
|
},
|
|
|
|
update: async (
|
|
id: string,
|
|
payload: UpdateContractPayload,
|
|
documents?: ContractDocuments,
|
|
): Promise<Freight.IContract> => {
|
|
const formData = buildContractFormData(payload, documents);
|
|
const { data } = await client.patch(C.BY_ID(id), formData, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return data.data.contract ?? data.data;
|
|
},
|
|
|
|
remove: async (id: string): Promise<void> => {
|
|
await client.delete(C.BY_ID(id));
|
|
},
|
|
|
|
uploadDocuments: async (
|
|
id: string,
|
|
files: ContractDocuments,
|
|
): Promise<Freight.IContract> => {
|
|
const formData = new FormData();
|
|
appendDocuments(formData, files);
|
|
const { data } = await client.post(C.DOCUMENTS(id), formData, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return data.data;
|
|
},
|
|
|
|
generatePrice: async (
|
|
id: string,
|
|
): Promise<GenerateContractPriceResponse> => {
|
|
const { data } = await client.post(C.GENERATE_PRICE(id));
|
|
return data.data;
|
|
},
|
|
|
|
submit: async (id: string): Promise<SubmitContractResponse> => {
|
|
const { data } = await client.post(C.SUBMIT(id));
|
|
return data.data;
|
|
},
|
|
|
|
confirmSubmit: async (id: string): Promise<SubmitContractResponse> => {
|
|
const { data } = await client.post(C.CONFIRM_SUBMIT(id));
|
|
return data.data;
|
|
},
|
|
|
|
generateContract: async (id: string): Promise<Freight.IContract> => {
|
|
const { data } = await client.post(C.CONTRACT_GENERATE(id));
|
|
return data.data ?? data;
|
|
},
|
|
|
|
getContractView: async (id: string): Promise<ContractView> => {
|
|
const { data } = await client.get(C.CONTRACT_VIEW(id));
|
|
return data.data ?? data;
|
|
},
|
|
|
|
downloadContractDocument: async (id: string): Promise<Blob> => {
|
|
const { data } = await client.get(C.CONTRACT_DOCUMENT(id), {
|
|
responseType: "blob",
|
|
});
|
|
return data;
|
|
},
|
|
|
|
signContract: async (
|
|
id: string,
|
|
payload: SignContractPayload,
|
|
): Promise<Freight.IContract> => {
|
|
const { data } = await client.post(C.CONTRACT_SIGN(id), payload);
|
|
return data.data ?? data;
|
|
},
|
|
|
|
// Ask the server to send the signing OTP to the signer's own registered phone.
|
|
// The client never picks the number (the server resolves it from the
|
|
// authenticated user and verifies against the same one), so send and verify
|
|
// can't disagree. Returns a masked hint.
|
|
sendSigningOtp: async (id: string): Promise<{ sentTo: string }> => {
|
|
const { data } = await client.post(C.CONTRACT_SEND_SIGNING_OTP(id));
|
|
return data.data ?? data;
|
|
},
|
|
|
|
renew: async (
|
|
id: string,
|
|
dto: Freight.RenewContractDto,
|
|
): Promise<Freight.IContract> => {
|
|
const { data } = await client.post(C.RENEW(id), dto);
|
|
return data.data ?? data;
|
|
},
|
|
|
|
/**
|
|
* Cancel own contract so a fresh one can be requested on the same lane. The
|
|
* API rejects it while any shipment on the contract is still live.
|
|
*/
|
|
cancel: async (id: string, reason?: string): Promise<Freight.IContract> => {
|
|
const { data } = await client.post(C.CANCEL(id), { reason });
|
|
return data.data ?? data;
|
|
},
|
|
|
|
// ── Pre-booking clearance (Path B) ──
|
|
getClearance: async (id: string): Promise<Freight.ContractClearanceView> => {
|
|
const { data } = await client.get(C.CLEARANCE(id));
|
|
return data.data ?? data;
|
|
},
|
|
|
|
uploadClearanceDocuments: async (
|
|
id: string,
|
|
files: Record<string, File | null>,
|
|
): Promise<Freight.ContractClearanceView> => {
|
|
const formData = new FormData();
|
|
for (const [key, file] of Object.entries(files)) {
|
|
if (file) formData.append(key, file);
|
|
}
|
|
const { data } = await client.post(C.CLEARANCE_DOCUMENTS(id), formData, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return data.data ?? data;
|
|
},
|
|
|
|
/**
|
|
* Reject the advised duty & tax with a reason. The clearance step goes back
|
|
* to GL Ethiopia, who re-advises a corrected amount; this can repeat.
|
|
*/
|
|
disputeContractDuty: async (
|
|
id: string,
|
|
note: string,
|
|
): Promise<Freight.IContract> => {
|
|
const { data } = await client.post(C.CLEARANCE_DUTY_DISPUTE(id), { note });
|
|
return data.data ?? data;
|
|
},
|
|
|
|
uploadContractDutySlip: async (
|
|
id: string,
|
|
file: File,
|
|
): Promise<Freight.IContract> => {
|
|
const form = new FormData();
|
|
form.append("file", file);
|
|
const { data } = await client.post(C.CLEARANCE_DUTY_SLIP(id), form, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return data.data ?? data;
|
|
},
|
|
|
|
listBookingRequests: async (id: string): Promise<Freight.IBookingRequest[]> => {
|
|
const { data } = await client.get(C.BOOKING_REQUESTS(id));
|
|
return data.data ?? data;
|
|
},
|
|
|
|
submitBookingRequest: async (
|
|
id: string,
|
|
dto: Freight.CreateBookingRequestDto,
|
|
): Promise<Freight.IBookingRequest> => {
|
|
const { data } = await client.post(C.BOOKING_REQUESTS(id), dto);
|
|
return data.data ?? data;
|
|
},
|
|
|
|
cancelBookingRequest: async (reqId: string): Promise<Freight.IBookingRequest> => {
|
|
const { data } = await client.post(C.BOOKING_REQUEST_CANCEL(reqId), {});
|
|
return data.data ?? data;
|
|
},
|
|
|
|
// ── Booking under contract (Path A customer) ──
|
|
createBookingUnderContract: async (
|
|
id: string,
|
|
dto: Freight.CreateBookingUnderContractDto,
|
|
): Promise<Freight.IBooking> => {
|
|
const { data } = await client.post(C.BOOKINGS(id), dto);
|
|
return data.data.booking ?? data.data;
|
|
},
|
|
|
|
/**
|
|
* One-click bare booking instance under a GENERAL non-customs contract — no
|
|
* cargo, no date. The instance enters per-booking clearance; the customer
|
|
* completes it (cargo + shipment day) once Operations finalizes.
|
|
*/
|
|
initiateBookingUnderContract: async (
|
|
id: string,
|
|
contractRouteId?: string,
|
|
): Promise<Freight.IBooking> => {
|
|
const { data } = await client.post(
|
|
C.BOOKINGS_INITIATE(id),
|
|
contractRouteId ? { contractRouteId } : {},
|
|
);
|
|
return data.data.booking ?? data.data;
|
|
},
|
|
|
|
/** Complete an initiated booking after clearance — same DTO as create. */
|
|
completeBookingUnderContract: async (
|
|
id: string,
|
|
bookingId: string,
|
|
dto: Freight.CreateBookingUnderContractDto,
|
|
): Promise<Freight.IBooking> => {
|
|
const { data } = await client.post(C.BOOKINGS_COMPLETE(id, bookingId), dto);
|
|
return data.data.booking ?? data.data;
|
|
},
|
|
|
|
/**
|
|
* Pre-submit validation of a shipment booking (same DTO as
|
|
* `createBookingUnderContract`). Returns overweight warnings and hard-block
|
|
* 20ft wagon-pairing errors so the customer can be warned/blocked before the
|
|
* booking is created.
|
|
*/
|
|
validateShipment: async (
|
|
id: string,
|
|
dto: Freight.CreateBookingUnderContractDto,
|
|
): Promise<ShipmentValidation> => {
|
|
const { data } = await client.post(C.VALIDATE_SHIPMENT(id), dto);
|
|
return data.data ?? data;
|
|
},
|
|
|
|
// ── Milestones ──
|
|
getContractMilestones: async (
|
|
id: string,
|
|
): Promise<Freight.IClearanceMilestone[]> => {
|
|
const { data } = await client.get(C.MILESTONES(id));
|
|
return data.data ?? data;
|
|
},
|
|
|
|
getBookingMilestones: async (
|
|
bookingId: string,
|
|
): Promise<Freight.IClearanceMilestone[]> => {
|
|
const { data } = await client.get(C.BOOKING_MILESTONES(bookingId));
|
|
return data.data ?? data;
|
|
},
|
|
|
|
/** Remaining bookable quantity per cargo line (GENERAL draw-down cap). */
|
|
getCapacity: async (id: string): Promise<Freight.ContractCapacityLine[]> => {
|
|
const { data } = await client.get(C.CAPACITY(id));
|
|
return (data.data ?? data) as Freight.ContractCapacityLine[];
|
|
},
|
|
|
|
/** Customer uploads the duty/tax payment slip (doc-triggers DUTY_TAX_PAID). */
|
|
uploadDutySlip: async (
|
|
bookingId: string,
|
|
file: File,
|
|
): Promise<{ milestoneCompleted: boolean }> => {
|
|
const form = new FormData();
|
|
form.append("duty_tax_receipt", file);
|
|
const { data } = await client.post(C.BOOKING_DUTY_SLIP(bookingId), form, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return data.data ?? data;
|
|
},
|
|
|
|
/** Customer attaches the payment slip for the GL final invoice (export). */
|
|
uploadFinalInvoiceSlip: async (
|
|
bookingId: string,
|
|
file: File,
|
|
): Promise<{ uploaded: boolean }> => {
|
|
const form = new FormData();
|
|
form.append("file", file);
|
|
const { data } = await client.post(
|
|
C.BOOKING_FINAL_INVOICE_SLIP(bookingId),
|
|
form,
|
|
{ headers: { "Content-Type": "multipart/form-data" } },
|
|
);
|
|
return data.data ?? data;
|
|
},
|
|
|
|
/** Customer attaches the slip for the post-arrival additional duty round (import). */
|
|
uploadSecondDutySlip: async (
|
|
bookingId: string,
|
|
file: File,
|
|
): Promise<{ milestoneCompleted: boolean }> => {
|
|
const form = new FormData();
|
|
form.append("file", file);
|
|
const { data } = await client.post(
|
|
C.BOOKING_SECOND_DUTY_SLIP(bookingId),
|
|
form,
|
|
{ headers: { "Content-Type": "multipart/form-data" } },
|
|
);
|
|
return data.data ?? data;
|
|
},
|
|
};
|