mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
- Create migration for booking_requests table with necessary fields and indexes. - Implement BookingRequestRepository for database operations related to booking requests. - Develop BookingRequestService to handle business logic for submitting, accepting, rejecting, and canceling booking requests. - Create DTOs for creating booking requests and reviewing them. - Define BookingRequest entity to map to the booking_requests table. - Add UI components for managing shipment requests, including detail and list pages. - Implement OperationDatePicker component for selecting available shipment days.
370 lines
12 KiB
TypeScript
370 lines
12 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;
|
|
page?: number;
|
|
pageSize?: number;
|
|
sortBy?: string;
|
|
sortOrder?: "ASC" | "DESC";
|
|
}
|
|
|
|
export interface PaginatedContracts {
|
|
items: Freight.IContract[];
|
|
total: 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;
|
|
}
|
|
|
|
export interface ContractView {
|
|
contractId: string;
|
|
reference: string;
|
|
status: string;
|
|
templateKey: string;
|
|
title: string;
|
|
html: string;
|
|
canSignCustomer: boolean;
|
|
canSignStaff: boolean;
|
|
hasContractDocument: boolean;
|
|
signatures: Array<{
|
|
role: string;
|
|
signerDisplayName: string;
|
|
signedAt: string;
|
|
signatureImageUrl?: string | null;
|
|
}>;
|
|
savedSignature?: {
|
|
signerDisplayName: string;
|
|
signatureImageUrl?: string | null;
|
|
} | null;
|
|
}
|
|
|
|
export interface SignContractPayload {
|
|
role: "CUSTOMER" | "STAFF";
|
|
signatureImageBase64: string;
|
|
signerDisplayName: string;
|
|
consentText?: string;
|
|
}
|
|
|
|
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.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;
|
|
}
|
|
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): Promise<Freight.IContract> => {
|
|
const response = await client.get<Freight.IContract>(C.BY_ID(id));
|
|
return unwrap(response.data) as Freight.IContract;
|
|
},
|
|
|
|
// ── Staff review ──
|
|
staffAccept: (id: string, validityDays: number) =>
|
|
postContract<Freight.IContract>(C.STAFF_ACCEPT(id), { validityDays }),
|
|
|
|
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 }),
|
|
|
|
approveStep: ({
|
|
id,
|
|
stepId,
|
|
requiredRole,
|
|
}: {
|
|
id: string;
|
|
stepId: string;
|
|
requiredRole: string;
|
|
}) =>
|
|
postContract<Freight.IContract>(C.APPROVE_STEP(id, stepId), {
|
|
requiredRole,
|
|
}),
|
|
|
|
// ── 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;
|
|
},
|
|
|
|
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): Promise<Freight.ContractClearanceView> => {
|
|
const response = await client.get(C.CLEARANCE(id));
|
|
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),
|
|
|
|
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)),
|
|
|
|
// ── Path A self-clearance (Operations review) ──
|
|
getOpsClearanceQueue: async (): Promise<PaginatedContracts> => {
|
|
const response = await client.get<PaginatedContracts>(
|
|
C.OPS_CLEARANCE_QUEUE,
|
|
);
|
|
const data = unwrap(response.data);
|
|
return {
|
|
items: (data.items ?? []) as Freight.IContract[],
|
|
total: data.total ?? 0,
|
|
};
|
|
},
|
|
|
|
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 (): Promise<PaginatedContracts> => {
|
|
const response = await client.get<PaginatedContracts>(C.OPS_CLEARANCE_HISTORY);
|
|
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) ──
|
|
createBookingUnderContract: (
|
|
id: string,
|
|
payload: Freight.CreateBookingUnderContractDto,
|
|
) => postContract<{ id: string; reference: string }>(C.BOOKINGS(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;
|
|
},
|
|
};
|