mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Replace the ad-hoc filter controls with a URL-linkable pill filter bar: each filter is a pill that opens a type-aware popover (text/enum/date/ number/boolean, each with the right operator set), overflow filters live behind a searchable "More filters" menu, sorting is a separate control, and filter state round-trips through the URL query string (shareable, back/forward-safe, backward compatible with existing ?statuses=A,B links). Frontend (apps/edr-freight-web/backoffice/src/components/filters/): - FilterDef schema + a pure url.ts codec (parse/serialize/toApiParams), with a 24-case round-trip + malformed-input test suite - useFilters hook driving react-query params straight from useSearchParams, debounced search, saved views in localStorage (@mantine/hooks useLocalStorage), page-reset-on-filter-change baked into one setSearchParams call instead of a separate effect - FilterBar/FilterPill/OperatorSelect/MoreFiltersMenu/SortControl + per-type popover bodies (Mantine) - ContractRequestsPage migrated end to end as the pilot Backend (apps/edr-freight-api): - pagination.util: applySort() — whitelisted sortBy resolved against a per-module column map (never interpolated), with a mandatory `id ASC` tiebreaker so paginating a non-unique sort can't drop/duplicate rows - facets.util: computeFacets() — one GROUP BY per enum column, each omitting its own predicate, so picking a value doesn't hide its siblings - contracts/bookings: list-summary now returns real filter-scoped facet counts (contracts' getStatusCounts was unfiltered/global; superseded) - deleted drivers/vehicles findAllWithFilters — dead code that interpolated an unwhitelisted sortBy straight into orderBy() - migration: missing bookings(status)/wagons(status) indexes + (created_at DESC, id ASC) partials on the hot list tables UI polish pass: inactive pill uses the opaque "default" variant instead of a faint tinted outline, active pill uses "light" not "filled", larger X hit target, applied filters sort first, sort control separated behind a divider on the right and wraps independently from the filter row, popover option rows are fully clickable (count moved inside the native label) with bigger hit area and font, fixed a real date-filter bug where the calendar's own portal falsely registered as an "outside click" and closed the popover, and fixed a timezone bug where bare YYYY-MM-DD strings were parsed as UTC instead of local time (shifts a day for EAT). Not in this commit: rollout to the other ~59 list pages, the Ethiopian- calendar DateBody branch, and the Family-B (client-side) bridge mode — tracked in the filter-bar plan.
826 lines
28 KiB
TypeScript
826 lines
28 KiB
TypeScript
import { api as client } from "../auth/http";
|
|
import { unwrap } from "@/utils/endpoint";
|
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
|
import type { Freight } from "@edr/types";
|
|
|
|
const C = URL_CONSTANTS.CONTRACTS;
|
|
|
|
export interface ContractListFilter {
|
|
status?: string;
|
|
/** Comma-separated statuses for grouped tabs. */
|
|
statuses?: string;
|
|
/** Tab key for React Query cache (not sent to API). */
|
|
tab?: string;
|
|
companyId?: string;
|
|
freightType?: string;
|
|
tradeDirection?: string;
|
|
contractKind?: string;
|
|
paymentCurrency?: string;
|
|
/** Created-at range (ISO strings, inclusive). */
|
|
createdFrom?: string;
|
|
createdTo?: string;
|
|
/** Server-side free-text search (contract reference, company name). */
|
|
search?: string;
|
|
page?: number;
|
|
pageSize?: number;
|
|
sortBy?: string;
|
|
sortOrder?: "ASC" | "DESC";
|
|
}
|
|
|
|
export interface PaginatedContracts {
|
|
items: Freight.IContract[];
|
|
total: number;
|
|
}
|
|
|
|
/** One line of the server-priced booking breakdown (mirrors PriceLineItemDto). */
|
|
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-create validation + authoritative price preview for a booking under a
|
|
* contract. `lineItems`/`totalAmount` are the full server-computed breakdown —
|
|
* the same pricing pass the booking persists at create (rail freight,
|
|
* first/last mile, overweight and every other surcharge). `pairingErrors` and
|
|
* `capacityErrors` are HARD BLOCKS; `overweightLines` are warnings.
|
|
*/
|
|
export interface ShipmentValidation {
|
|
overweightLines: Array<{
|
|
containerTypeCode: string;
|
|
totalVgmTons: number;
|
|
maxAllowedTons: number;
|
|
excessTons: number;
|
|
}>;
|
|
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 ContractListSummaryMetrics {
|
|
inQueue: number;
|
|
needsAction: number;
|
|
urgent: number;
|
|
completed: number;
|
|
}
|
|
|
|
export interface ContractListSummaryTabs {
|
|
all: number;
|
|
intake: number;
|
|
in_approval: number;
|
|
approved_contract: number;
|
|
clearance: number;
|
|
active: number;
|
|
closed: number;
|
|
}
|
|
|
|
export interface ContractListSummary {
|
|
metrics: ContractListSummaryMetrics;
|
|
tabs: ContractListSummaryTabs;
|
|
/**
|
|
* Per-column value counts for the filter bar's enum popovers, scoped to
|
|
* every OTHER currently-active filter. Optional: pages built before the
|
|
* pill filter bar don't read it, and it degrades gracefully — an absent
|
|
* key just means that popover shows no counts.
|
|
*/
|
|
facets?: Record<string, { value: string; count: number }[]>;
|
|
}
|
|
|
|
export interface ContractView {
|
|
contractId: string;
|
|
reference: string;
|
|
status: string;
|
|
freightType: "BULK" | "CONTAINER";
|
|
templateKey: string;
|
|
title: string;
|
|
html: string;
|
|
canSignCustomer: boolean;
|
|
canSignStaff: boolean;
|
|
hasContractDocument: boolean;
|
|
signatures: Array<{
|
|
role: string;
|
|
signerDisplayName: string;
|
|
signedAt: string;
|
|
signatureImageUrl?: string | null;
|
|
stampImageUrl?: string | null;
|
|
}>;
|
|
savedSignature?: {
|
|
signerDisplayName: string;
|
|
signatureImageUrl?: string | null;
|
|
stampImageUrl?: string | null;
|
|
} | null;
|
|
}
|
|
|
|
/**
|
|
* No stamp field: the backoffice only ever counter-signs as STAFF, and EDR's
|
|
* seal is the ONE global company stamp, snapshotted server-side from
|
|
* StampSettingsService when the signature is stored.
|
|
*/
|
|
export interface SignContractPayload {
|
|
role: "CUSTOMER" | "STAFF";
|
|
signatureImageBase64: string;
|
|
signerDisplayName: string;
|
|
consentText?: string;
|
|
}
|
|
|
|
/**
|
|
* One stored version of a clearance document. The current row plus every
|
|
* superseded upload — staff corrections never erase what the customer sent.
|
|
*/
|
|
export interface ClearanceDocumentVersion {
|
|
id: string;
|
|
name: string;
|
|
url: string;
|
|
size: number;
|
|
mimeType: string;
|
|
uploadedAt: string;
|
|
isCurrent: boolean;
|
|
replacedAt: string | null;
|
|
replacedByUserId: string | null;
|
|
replaceReason: string | null;
|
|
}
|
|
|
|
async function postContract<T>(url: string, body?: unknown): Promise<T> {
|
|
const response = await client.post<T>(url, body ?? {});
|
|
return unwrap(response.data);
|
|
}
|
|
|
|
function buildListParams(filter?: ContractListFilter) {
|
|
const params: Record<string, string | number | boolean | undefined> = {};
|
|
if (filter) {
|
|
if (filter.statuses) params.statuses = filter.statuses;
|
|
else if (filter.status) params.status = filter.status;
|
|
if (filter.search) params.search = filter.search;
|
|
if (filter.page != null) params.page = filter.page;
|
|
if (filter.pageSize != null) params.pageSize = filter.pageSize;
|
|
if (filter.sortBy) params.sortBy = filter.sortBy;
|
|
if (filter.sortOrder) params.sortOrder = filter.sortOrder;
|
|
if (filter.companyId) params.companyId = filter.companyId;
|
|
if (filter.freightType) params.freightType = filter.freightType;
|
|
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
|
if (filter.contractKind) params.contractKind = filter.contractKind;
|
|
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
|
|
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
|
|
if (filter.createdTo) params.createdTo = filter.createdTo;
|
|
}
|
|
return params;
|
|
}
|
|
|
|
export const contractsService = {
|
|
getListSummary: async (
|
|
filter?: ContractListFilter,
|
|
): Promise<ContractListSummary> => {
|
|
const response = await client.get<ContractListSummary>(C.LIST_SUMMARY, {
|
|
params: buildListParams(filter),
|
|
});
|
|
return unwrap(response.data) as ContractListSummary;
|
|
},
|
|
|
|
list: async (filter?: ContractListFilter): Promise<PaginatedContracts> => {
|
|
const response = await client.get<PaginatedContracts>(C.BASE, {
|
|
params: buildListParams(filter),
|
|
});
|
|
const data = unwrap(response.data);
|
|
return {
|
|
items: (data.items ?? []) as Freight.IContract[],
|
|
total: data.total ?? 0,
|
|
};
|
|
},
|
|
|
|
getById: async (
|
|
id: string,
|
|
opts?: { suppressErrorModal?: boolean },
|
|
): Promise<Freight.IContract> => {
|
|
const response = await client.get<Freight.IContract>(C.BY_ID(id), opts);
|
|
return unwrap(response.data) as Freight.IContract;
|
|
},
|
|
|
|
// ── Staff review ──
|
|
staffAccept: (
|
|
id: string,
|
|
validityDays: number,
|
|
documentSnapshot?: Freight.IContractDocumentSnapshot,
|
|
window?: { validFrom?: string; validUntil?: string },
|
|
) =>
|
|
postContract<Freight.IContract>(C.STAFF_ACCEPT(id), {
|
|
validityDays,
|
|
documentSnapshot,
|
|
...window,
|
|
}),
|
|
|
|
/** The editable per-contract document draft (snapshot or live template). */
|
|
getContractDocumentDraft: async (
|
|
id: string,
|
|
): Promise<Freight.IContractDocumentDraft> => {
|
|
const response = await client.get(C.CONTRACT_DOCUMENT_DRAFT(id));
|
|
return unwrap(response.data) as Freight.IContractDocumentDraft;
|
|
},
|
|
|
|
/** Audit trail of edits to this contract's document, newest first. */
|
|
getContractDocumentRevisions: async (
|
|
id: string,
|
|
): Promise<Freight.IContractDocumentRevision[]> => {
|
|
const response = await client.get(C.CONTRACT_DOCUMENT_REVISIONS(id));
|
|
return unwrap(response.data) as Freight.IContractDocumentRevision[];
|
|
},
|
|
|
|
/** Save this contract's edited document articles (never touches the templates). */
|
|
updateContractDocument: async (
|
|
id: string,
|
|
snapshot: Freight.IContractDocumentSnapshot,
|
|
): Promise<Freight.IContract> => {
|
|
const response = await client.put(
|
|
C.CONTRACT_DOCUMENT_ARTICLES(id),
|
|
snapshot,
|
|
);
|
|
return unwrap(response.data) as Freight.IContract;
|
|
},
|
|
|
|
requestChanges: (id: string, note: string) =>
|
|
postContract<Freight.IContract>(C.STAFF_REQUEST_CHANGES(id), { note }),
|
|
|
|
reject: (id: string, reason: string) =>
|
|
postContract<Freight.IContract>(C.STAFF_REJECT(id), { reason }),
|
|
|
|
/** Freeze a signed contract. Reversible — see {@link resume}. */
|
|
suspend: (id: string, reason: string) =>
|
|
postContract<Freight.IContract>(C.SUSPEND(id), { reason }),
|
|
|
|
/** Lift a suspension; the contract returns to the status it was frozen at. */
|
|
resume: (id: string, note?: string) =>
|
|
postContract<Freight.IContract>(C.RESUME(id), { note }),
|
|
|
|
/**
|
|
* Approve the next pending step. The server resolves the step's required role
|
|
* and authorizes against it — the client never declares its own role.
|
|
*/
|
|
approveStep: ({ id, stepId }: { id: string; stepId: string }) =>
|
|
postContract<Freight.IContract>(C.APPROVE_STEP(id, stepId)),
|
|
|
|
/**
|
|
* Reject the current step. Without `returnToStepId` the contract is rejected
|
|
* to the customer (terminal). With it, the contract is sent back to that
|
|
* earlier approved step and the chain re-runs from there.
|
|
*/
|
|
rejectStep: ({
|
|
id,
|
|
stepId,
|
|
reason,
|
|
returnToStepId,
|
|
}: {
|
|
id: string;
|
|
stepId: string;
|
|
reason: string;
|
|
returnToStepId?: string;
|
|
}) =>
|
|
postContract<Freight.IContract>(C.REJECT_STEP(id, stepId), {
|
|
reason,
|
|
...(returnToStepId ? { returnToStepId } : {}),
|
|
}),
|
|
|
|
// ── Contract document ──
|
|
generateContract: (id: string) =>
|
|
postContract<Freight.IContract>(C.CONTRACT_GENERATE(id)),
|
|
|
|
getContractView: async (id: string): Promise<ContractView> => {
|
|
const response = await client.get<ContractView>(C.CONTRACT_VIEW(id));
|
|
return unwrap(response.data) as ContractView;
|
|
},
|
|
|
|
downloadContractDocument: async (id: string): Promise<Blob> => {
|
|
const response = await client.get(C.CONTRACT_DOCUMENT(id), {
|
|
responseType: "blob",
|
|
});
|
|
return response.data as Blob;
|
|
},
|
|
|
|
signContract: (id: string, payload: SignContractPayload) =>
|
|
postContract<Freight.IContract>(C.CONTRACT_SIGN(id), payload),
|
|
|
|
// ── Pre-booking clearance (Path B — GL ET) ──
|
|
getClearanceQueue: async (): Promise<PaginatedContracts> => {
|
|
const response = await client.get<PaginatedContracts>(C.CLEARANCE_QUEUE);
|
|
const data = unwrap(response.data);
|
|
return {
|
|
items: (data.items ?? []) as Freight.IContract[],
|
|
total: data.total ?? 0,
|
|
};
|
|
},
|
|
|
|
getClearance: async (
|
|
id: string,
|
|
opts?: { suppressErrorModal?: boolean },
|
|
): Promise<Freight.ContractClearanceView> => {
|
|
const response = await client.get(C.CLEARANCE(id), opts);
|
|
return unwrap(response.data) as Freight.ContractClearanceView;
|
|
},
|
|
|
|
reviewClearanceDocument: (
|
|
id: string,
|
|
payload: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string },
|
|
) =>
|
|
postContract<Freight.IContract>(C.CLEARANCE_REVIEW(id), payload),
|
|
|
|
/** GL ET asks Djibouti to name the officer handling the shipment in transit. */
|
|
requestTransitAssignee: (id: string, note?: string) =>
|
|
postContract<Freight.IContract>(
|
|
C.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id),
|
|
{ note },
|
|
),
|
|
|
|
/** GL Djibouti picks (or changes) that officer — unblocks the declaration. */
|
|
assignTransitAssignee: (id: string, transitAgentId: string) =>
|
|
postContract<Freight.IContract>(C.CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN(id), {
|
|
transitAgentId,
|
|
}),
|
|
|
|
/**
|
|
* Replace a clearance document in place. The customer's original is retired
|
|
* into the version history rather than overwritten, and the new file comes
|
|
* back unreviewed so it still has to be approved.
|
|
*/
|
|
replaceClearanceDocument: async (
|
|
id: string,
|
|
fileKey: string,
|
|
file: File,
|
|
reason: string,
|
|
): Promise<Freight.IContract> => {
|
|
const form = new FormData();
|
|
form.append("file", file);
|
|
form.append("reason", reason);
|
|
const response = await client.post(
|
|
C.CLEARANCE_DOC_REPLACE(id, fileKey),
|
|
form,
|
|
{ headers: { "Content-Type": "multipart/form-data" } },
|
|
);
|
|
return unwrap(response.data) as Freight.IContract;
|
|
},
|
|
|
|
/** Every stored version of one clearance document, newest first. */
|
|
getClearanceDocumentVersions: async (
|
|
id: string,
|
|
fileKey: string,
|
|
): Promise<ClearanceDocumentVersion[]> => {
|
|
const response = await client.get(C.CLEARANCE_DOC_VERSIONS(id, fileKey));
|
|
return (unwrap(response.data) as ClearanceDocumentVersion[]) ?? [];
|
|
},
|
|
|
|
uploadClearanceOutput: async (
|
|
id: string,
|
|
files: Record<string, File | null>,
|
|
): Promise<Freight.IContract> => {
|
|
const form = new FormData();
|
|
for (const [key, file] of Object.entries(files)) {
|
|
if (file) form.append(key, file);
|
|
}
|
|
const response = await client.post(C.CLEARANCE_OUTPUT_DOCUMENTS(id), form, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return unwrap(response.data) as Freight.IContract;
|
|
},
|
|
|
|
finalizeClearance: (id: string) =>
|
|
postContract<Freight.IContract>(C.CLEARANCE_FINALIZE(id)),
|
|
|
|
/**
|
|
* GL worklist: executed one-time customs contracts with no shipment instance
|
|
* yet. GL initiates the booking; the customer then uploads his clearance
|
|
* documents on it.
|
|
*/
|
|
getAwaitingShipmentContracts: async (): Promise<Freight.IContract[]> => {
|
|
const response = await client.get(C.AWAITING_SHIPMENT);
|
|
const data = unwrap(response.data);
|
|
return (Array.isArray(data) ? data : (data?.items ?? [])) as Freight.IContract[];
|
|
},
|
|
|
|
/** Open a bare shipment instance under a contract (no cargo, no day). */
|
|
initiateBookingUnderContract: async (
|
|
id: string,
|
|
contractRouteId?: string,
|
|
): Promise<{ id: string; reference: string }> => {
|
|
const result = await postContract<{
|
|
booking?: { id: string; reference: string };
|
|
id?: string;
|
|
reference?: string;
|
|
}>(C.BOOKINGS_INITIATE(id), contractRouteId ? { contractRouteId } : {});
|
|
const booking = result.booking ?? result;
|
|
return { id: booking.id ?? "", reference: booking.reference ?? "" };
|
|
},
|
|
|
|
uploadDeclaration: async (
|
|
id: string,
|
|
files: Record<string, File | null>,
|
|
): Promise<Freight.IContract> => {
|
|
const form = new FormData();
|
|
for (const [key, file] of Object.entries(files)) {
|
|
if (file) form.append(key, file);
|
|
}
|
|
const response = await client.post(C.CLEARANCE_DECLARATION(id), form, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return unwrap(response.data) as Freight.IContract;
|
|
},
|
|
|
|
adviseContractDuty: async (
|
|
id: string,
|
|
payload: {
|
|
dutyRequired: boolean;
|
|
amount?: number;
|
|
currency?: string;
|
|
declarationSerial?: string;
|
|
attachment?: File | null;
|
|
},
|
|
): Promise<Freight.IContract> => {
|
|
const form = new FormData();
|
|
form.append("dutyRequired", String(payload.dutyRequired));
|
|
if (payload.amount != null) form.append("amount", String(payload.amount));
|
|
if (payload.currency) form.append("currency", payload.currency);
|
|
if (payload.declarationSerial) {
|
|
form.append("declarationSerial", payload.declarationSerial);
|
|
}
|
|
if (payload.attachment) form.append("attachment", payload.attachment);
|
|
const response = await client.post(C.CLEARANCE_DUTY(id), form, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return unwrap(response.data) as Freight.IContract;
|
|
},
|
|
|
|
finalizePreClearance: (id: string) =>
|
|
postContract<Freight.IContract>(C.CLEARANCE_FINALIZE_PRE(id)),
|
|
|
|
uploadContractTransitPermit: async (
|
|
id: string,
|
|
files: Record<string, File | null>,
|
|
): Promise<Freight.IContract> => {
|
|
const form = new FormData();
|
|
for (const [key, file] of Object.entries(files)) {
|
|
if (file) form.append(key, file);
|
|
}
|
|
const response = await client.post(C.CLEARANCE_TRANSIT_PERMIT(id), form, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return unwrap(response.data) as Freight.IContract;
|
|
},
|
|
|
|
uploadDeliveryOrder: async (
|
|
id: string,
|
|
files: File[],
|
|
dates: { vesselArrivalDate: string; doCollectedDate: string },
|
|
): Promise<Freight.IContract> => {
|
|
const form = new FormData();
|
|
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, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return unwrap(response.data) as Freight.IContract;
|
|
},
|
|
|
|
uploadReleaseOrder: async (
|
|
id: string,
|
|
files: File[],
|
|
vesselDepartureDate: string,
|
|
): Promise<{ contract: Freight.IContract; hold: boolean; holdReason?: string }> => {
|
|
const form = new FormData();
|
|
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" },
|
|
});
|
|
return unwrap(response.data) as {
|
|
contract: Freight.IContract;
|
|
hold: boolean;
|
|
holdReason?: string;
|
|
};
|
|
},
|
|
|
|
requestRoAmendment: (id: string, note?: string) =>
|
|
postContract<Freight.IContract>(C.CLEARANCE_RO_AMENDMENT(id), { note }),
|
|
|
|
confirmExportRelease: (id: string) =>
|
|
postContract<Freight.IContract>(C.CLEARANCE_EXPORT_RELEASE(id)),
|
|
|
|
finalizeExportClearance: (id: string) =>
|
|
postContract<Freight.IContract>(C.CLEARANCE_FINALIZE_EXPORT(id)),
|
|
|
|
uploadTransportDocument: async (
|
|
bookingId: string,
|
|
files: Record<string, File | null>,
|
|
) => {
|
|
const form = new FormData();
|
|
for (const [key, file] of Object.entries(files)) {
|
|
if (file) form.append(key, file);
|
|
}
|
|
const response = await client.post(C.BOOKING_TRANSPORT_DOCUMENT(bookingId), form, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return unwrap(response.data);
|
|
},
|
|
|
|
/** GL Djibouti uploads T1 transit documents (multi-file, post wagon allocation). */
|
|
uploadT1Documents: async (
|
|
bookingId: string,
|
|
files: Record<string, File | null>,
|
|
) => {
|
|
const form = new FormData();
|
|
for (const [key, file] of Object.entries(files)) {
|
|
if (file) form.append(key, file);
|
|
}
|
|
const response = await client.post(C.BOOKING_T1_DOCUMENTS(bookingId), form, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return unwrap(response.data);
|
|
},
|
|
|
|
/** GL Ethiopia closes (accepts) the T1 document set after the train arrives. */
|
|
closeT1: async (bookingId: string): Promise<Freight.ClearanceT1State> => {
|
|
const response = await client.post(C.BOOKING_T1_CLOSE(bookingId));
|
|
return unwrap(response.data) as Freight.ClearanceT1State;
|
|
},
|
|
|
|
/** GL DJ raises the post-offload final invoice (amount + invoice document). */
|
|
sendFinalInvoice: async (
|
|
bookingId: string,
|
|
payload: { amount: number; currency: string; description?: string; file: File },
|
|
): Promise<Freight.ClearanceFinalInvoiceSummary> => {
|
|
const form = new FormData();
|
|
form.append("amount", String(payload.amount));
|
|
form.append("currency", payload.currency);
|
|
if (payload.description) form.append("description", payload.description);
|
|
form.append("file", payload.file);
|
|
const response = await client.post(C.BOOKING_FINAL_INVOICE(bookingId), form, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return unwrap(response.data) as Freight.ClearanceFinalInvoiceSummary;
|
|
},
|
|
|
|
/** GL (ET or DJ) confirms the payment slip — settles the final invoice. */
|
|
confirmFinalInvoicePaid: async (
|
|
bookingId: string,
|
|
): Promise<Freight.ClearanceFinalInvoiceSummary> => {
|
|
const response = await client.post(C.BOOKING_FINAL_INVOICE_CONFIRM(bookingId));
|
|
return unwrap(response.data) as Freight.ClearanceFinalInvoiceSummary;
|
|
},
|
|
|
|
/** GL ET advises (or skips) the post-arrival additional duty/tax round (import). */
|
|
adviseSecondDuty: async (
|
|
bookingId: string,
|
|
payload: {
|
|
dutyRequired: boolean;
|
|
amount?: number;
|
|
currency?: string;
|
|
declarationSerial?: string;
|
|
attachment?: File | null;
|
|
},
|
|
): Promise<{ advised: boolean; skipped: boolean }> => {
|
|
const form = new FormData();
|
|
form.append("dutyRequired", String(payload.dutyRequired));
|
|
if (payload.amount != null) form.append("amount", String(payload.amount));
|
|
if (payload.currency) form.append("currency", payload.currency);
|
|
if (payload.declarationSerial)
|
|
form.append("declarationSerial", payload.declarationSerial);
|
|
if (payload.attachment) form.append("attachment", payload.attachment);
|
|
const response = await client.post(C.BOOKING_SECOND_DUTY(bookingId), form, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return unwrap(response.data) as { advised: boolean; skipped: boolean };
|
|
},
|
|
|
|
getClearanceHistory: async (): Promise<PaginatedContracts> => {
|
|
const response = await client.get<PaginatedContracts>(C.CLEARANCE_HISTORY);
|
|
const data = unwrap(response.data);
|
|
return { items: (data.items ?? []) as Freight.IContract[], total: data.total ?? 0 };
|
|
},
|
|
|
|
getOpsClearanceHistory: async (filter?: {
|
|
page?: number;
|
|
pageSize?: number;
|
|
search?: string;
|
|
}): Promise<PaginatedContracts> => {
|
|
const response = await client.get<PaginatedContracts>(
|
|
C.OPS_CLEARANCE_HISTORY,
|
|
{ params: filter },
|
|
);
|
|
const data = unwrap(response.data);
|
|
return { items: (data.items ?? []) as Freight.IContract[], total: data.total ?? 0 };
|
|
},
|
|
|
|
opsReviewClearanceDocument: (
|
|
id: string,
|
|
payload: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string },
|
|
) => postContract<Freight.IContract>(C.OPS_CLEARANCE_REVIEW(id), payload),
|
|
|
|
opsFinalizeClearance: (id: string) =>
|
|
postContract<Freight.IContract>(C.OPS_CLEARANCE_FINALIZE(id)),
|
|
|
|
// ── Booking under contract (GL ET — Path B) ──
|
|
// The API returns { booking, warnings } (CreateBookingUnderContractResult) —
|
|
// unwrap to the booking itself so callers can use its id directly.
|
|
createBookingUnderContract: async (
|
|
id: string,
|
|
payload: Freight.CreateBookingUnderContractDto,
|
|
): Promise<{ id: string; reference: string; warnings?: string[] }> => {
|
|
const result = await postContract<{
|
|
booking?: { id: string; reference: string };
|
|
id?: string;
|
|
reference?: string;
|
|
warnings?: string[];
|
|
}>(C.BOOKINGS(id), payload);
|
|
const booking = result.booking ?? result;
|
|
return {
|
|
id: booking.id ?? "",
|
|
reference: booking.reference ?? "",
|
|
warnings: result.warnings,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Complete a bare initiated booking instance once its per-booking clearance
|
|
* is CLEARANCE_READY — same payload as create; the API persists cargo,
|
|
* prices, invoices, checks the booking window and moves the booking to the
|
|
* operations queue.
|
|
*/
|
|
completeBookingUnderContract: async (
|
|
id: string,
|
|
bookingId: string,
|
|
payload: Freight.CreateBookingUnderContractDto,
|
|
): Promise<{ id: string; reference: string; warnings?: string[] }> => {
|
|
const result = await postContract<{
|
|
booking?: { id: string; reference: string };
|
|
id?: string;
|
|
reference?: string;
|
|
warnings?: string[];
|
|
}>(C.BOOKINGS_COMPLETE(id, bookingId), payload);
|
|
const booking = result.booking ?? result;
|
|
return {
|
|
id: booking.id ?? "",
|
|
reference: booking.reference ?? "",
|
|
warnings: result.warnings,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Pre-create validation + authoritative price preview: the same
|
|
* BookingPricingService pass that prices the booking on create (rail +
|
|
* first/last mile + every surcharge), plus overweight warnings and 20ft
|
|
* pairing hard-blocks. Shown in the GL price-confirm modal.
|
|
*/
|
|
validateShipment: (
|
|
id: string,
|
|
payload: Freight.CreateBookingUnderContractDto,
|
|
// Completion/resubmit preview: exclude this booking's own containers from
|
|
// the same-train clash check.
|
|
excludeBookingId?: string,
|
|
) =>
|
|
postContract<ShipmentValidation>(
|
|
excludeBookingId
|
|
? `${C.VALIDATE_SHIPMENT(id)}?bookingId=${excludeBookingId}`
|
|
: C.VALIDATE_SHIPMENT(id),
|
|
payload,
|
|
),
|
|
|
|
/** Remaining bookable quantity per cargo line (GENERAL draw-down cap). */
|
|
getCapacity: async (id: string): Promise<Freight.ContractCapacityLine[]> => {
|
|
const response = await client.get(C.CAPACITY(id));
|
|
return (unwrap(response.data) ?? []) as Freight.ContractCapacityLine[];
|
|
},
|
|
|
|
// ── Shipment requests (GENERAL + customs) ──
|
|
/** GL queue of pending shipment requests across contracts. */
|
|
getBookingRequestQueue: async (): Promise<Freight.IBookingRequest[]> => {
|
|
const response = await client.get(C.BOOKING_REQUEST_QUEUE);
|
|
return (unwrap(response.data) ?? []) as Freight.IBookingRequest[];
|
|
},
|
|
|
|
listBookingRequests: async (
|
|
id: string,
|
|
): Promise<Freight.IBookingRequest[]> => {
|
|
const response = await client.get(C.BOOKING_REQUESTS(id));
|
|
return (unwrap(response.data) ?? []) as Freight.IBookingRequest[];
|
|
},
|
|
|
|
getBookingRequest: async (
|
|
reqId: string,
|
|
): Promise<Freight.IBookingRequest> => {
|
|
const response = await client.get(C.BOOKING_REQUEST_BY_ID(reqId));
|
|
return unwrap(response.data) as Freight.IBookingRequest;
|
|
},
|
|
|
|
acceptBookingRequest: (reqId: string, bookingId: string) =>
|
|
postContract<Freight.IBookingRequest>(C.BOOKING_REQUEST_ACCEPT(reqId), {
|
|
bookingId,
|
|
}),
|
|
|
|
rejectBookingRequest: (reqId: string, note?: string) =>
|
|
postContract<Freight.IBookingRequest>(C.BOOKING_REQUEST_REJECT(reqId), {
|
|
note,
|
|
}),
|
|
|
|
// ── Clearance milestones ──
|
|
listMilestonesForContract: async (
|
|
id: string,
|
|
): Promise<Freight.IClearanceMilestone[]> => {
|
|
const response = await client.get(C.MILESTONES(id));
|
|
return (unwrap(response.data) ?? []) as Freight.IClearanceMilestone[];
|
|
},
|
|
|
|
listMilestonesForBooking: async (
|
|
bookingId: string,
|
|
): Promise<Freight.IClearanceMilestone[]> => {
|
|
const response = await client.get(C.BOOKING_MILESTONES(bookingId));
|
|
return (unwrap(response.data) ?? []) as Freight.IClearanceMilestone[];
|
|
},
|
|
|
|
completeMilestone: (bookingId: string, code: string, note?: string) =>
|
|
postContract<Freight.IClearanceMilestone>(
|
|
C.COMPLETE_BOOKING_MILESTONE(bookingId, code),
|
|
{ note },
|
|
),
|
|
|
|
// ── GL post-booking operational actions ──
|
|
assignRisk: (
|
|
bookingId: string,
|
|
payload: { riskLevel: Freight.CustomsRiskLevel; note?: string },
|
|
) =>
|
|
postContract<Freight.IClearanceMilestone>(
|
|
C.BOOKING_RISK(bookingId),
|
|
payload,
|
|
),
|
|
|
|
adviseDuty: (
|
|
bookingId: string,
|
|
payload: {
|
|
amount: number;
|
|
currency: string;
|
|
declarationSerial?: string;
|
|
note?: string;
|
|
},
|
|
) =>
|
|
postContract<Freight.IClearanceMilestone>(
|
|
C.BOOKING_DUTY(bookingId),
|
|
payload,
|
|
),
|
|
|
|
assignStation: (
|
|
bookingId: string,
|
|
payload: { stationYardId: string; staffId?: string },
|
|
) => postContract(C.BOOKING_STATION_ASSIGN(bookingId), payload),
|
|
|
|
uploadGlDocuments: async (
|
|
bookingId: string,
|
|
files: Record<string, File | null>,
|
|
): Promise<{ uploaded: number; completedMilestones: string[] }> => {
|
|
const form = new FormData();
|
|
for (const [key, file] of Object.entries(files)) {
|
|
if (file) form.append(key, file);
|
|
}
|
|
const response = await client.post(C.BOOKING_GL_DOCUMENTS(bookingId), form, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return unwrap(response.data) as {
|
|
uploaded: number;
|
|
completedMilestones: string[];
|
|
};
|
|
},
|
|
|
|
listIncidents: async (
|
|
bookingId: string,
|
|
): Promise<Freight.IClearanceIncident[]> => {
|
|
const response = await client.get(C.BOOKING_INCIDENTS(bookingId));
|
|
return (unwrap(response.data) ?? []) as Freight.IClearanceIncident[];
|
|
},
|
|
|
|
reportIncident: async (
|
|
bookingId: string,
|
|
payload: {
|
|
incidentType: Freight.IncidentType;
|
|
description: string;
|
|
photos: File[];
|
|
},
|
|
): Promise<Freight.IClearanceIncident> => {
|
|
const form = new FormData();
|
|
form.append("incidentType", payload.incidentType);
|
|
form.append("description", payload.description);
|
|
for (const photo of payload.photos) form.append("photos", photo);
|
|
const response = await client.post(C.BOOKING_INCIDENTS(bookingId), form, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return unwrap(response.data) as Freight.IClearanceIncident;
|
|
},
|
|
};
|