From 8314b3bd4597737fc1374907d2416c628881a625 Mon Sep 17 00:00:00 2001 From: marshal Date: Tue, 8 Sep 2026 03:54:48 +0000 Subject: [PATCH] feat: enhance customs clearance process for forwarders - Added logic in to determine if a company can clear its own customs. - Updated component to support upload-only mode for forwarders. - Removed customs clearing agent fields from and related schema. - Introduced component to handle customs clearing agent selection. - Created for viewing details of assigned bookings. - Implemented and modal for document uploads by forwarders. - Updated API services to accommodate new customs clearing logic. --- .../modules/bookings/bookings.controller.ts | 8 +- .../src/modules/bookings/bookings.service.ts | 29 ++- .../contracts/contract-booking.service.ts | 241 ++++++++++++++---- .../modules/contracts/contracts.controller.ts | 13 +- .../dto/create-booking-under-contract.dto.ts | 5 +- apps/edr-freight-web/portal/src/App.tsx | 9 +- .../customer-actions/ClearingAgentPicker.tsx | 158 ++++++++++++ .../ContractCustomerAction.tsx | 71 +++++- .../portal/src/hooks/useAuth.ts | 4 + .../bookings/clearance/ClearanceFlow.tsx | 20 +- .../src/pages/contracts/NewShipmentPage.tsx | 139 ---------- .../contracts/new-shipment-form/schema.ts | 52 ---- .../forwarder/AssignedBookingDetailPage.tsx | 229 +++++++++++++++++ .../forwarder/AssignedBookingDocuments.tsx | 205 +++++++++++++++ .../pages/forwarder/AssignedBookingsPage.tsx | 43 +++- .../portal/src/pages/forwarder/index.ts | 1 + .../portal/src/services/api.ts | 7 +- .../portal/src/services/contracts.service.ts | 24 +- packages/types/src/freight/contracts.ts | 5 +- 19 files changed, 977 insertions(+), 286 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/components/customer-actions/ClearingAgentPicker.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/forwarder/AssignedBookingDetailPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/forwarder/AssignedBookingDocuments.tsx diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 85a98ef57..5803899d1 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -408,10 +408,14 @@ export class BookingsController { ) { const booking = await this.bookingsService.findById(id); // Staff see any booking; Global Logistics (clearance:view) may inspect any - // booking for the clearance gate; customers only their own company's. + // booking for the clearance gate; customers only their own company's — + // plus the transit agent / forwarder the booking was assigned to, which + // uploads the clearance documents for the customer and needs the same + // booking view to do it. if ( !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && - !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) + !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) && + !(await this.bookingsService.isTransitAgentForBooking(user?.id, id)) ) { await this.bookingsService.assertCustomerCanAccessBooking( user?.id, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 83daa0286..4f82b98da 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -2253,6 +2253,16 @@ ${footer} return rows.length > 0; } + /** + * Two kinds of account act as the assigned agent. A Djibouti transit officer + * signs in AS the agent (`transit_agents.user_id`). A freight forwarder is a + * customer company that registered itself as an Ethiopian transit agent + * (`companies.transit_agent_id`); any of its users acts for it — but only + * once its roster role (transit agent or forwarder) is approved, matching + * the write gate in TransitAssignmentsService.requireAgentForUser. The + * forwarder clears customs for the customer, so it is the one uploading the + * booking's import/export documents. + */ async isTransitAgentForBooking( userId: string | undefined, bookingId: string, @@ -2262,10 +2272,25 @@ ${footer} `SELECT 1 AS one FROM freight.transit_assignments ta JOIN freight.transit_agents a ON a.id = ta.transit_agent_id - WHERE a.user_id = $1 - AND ta.booking_id = $2 + WHERE ta.booking_id = $2 AND ta.deleted_at IS NULL AND a.deleted_at IS NULL + AND ( + a.user_id = $1 + OR EXISTS ( + SELECT 1 + FROM freight.external_profiles ep + JOIN freight.companies c ON c.id = ep.company_id + JOIN freight.company_profiles cp ON cp.company_id = c.id + WHERE ep.user_id = $1 + AND c.transit_agent_id = a.id + AND cp.type IN ('transit_agent', 'freight_forwarder') + AND cp.status = 'active' + AND ep.deleted_at IS NULL + AND c.deleted_at IS NULL + AND cp.deleted_at IS NULL + ) + ) LIMIT 1`, [userId, bookingId], ); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 821e42e56..f15bd41ce 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -458,7 +458,14 @@ export class ContractBookingService { */ async initiateUnderContract( contractId: string, - dto: Pick, + dto: Pick< + CreateBookingUnderContractDto, + | 'contractRouteId' + | 'transitAgentId' + | 'customsClearingAgent' + | 'customsClearingAgentEmail' + | 'customsClearingAgentPhone' + >, user?: { id?: string } | null, actorPermissions?: unknown, ): Promise { @@ -504,6 +511,24 @@ export class ContractBookingService { const route = await this.resolveRoute(contract, dto.contractRouteId); + // Without-customs import/export: the customer names who clears customs + // here, at initiation, because that party — not the customer — uploads the + // clearance documents on the bare instance that follows. A company that + // is itself a transit agent / freight forwarder clears its own customs, so + // it is recorded as the agent unless it named somebody else. GL may open + // one without any (the backoffice has no such field); a customer may not. + // Intercity was rejected above and customs contracts are cleared by GL. + const collectsClearingAgent = !contract.customsClearingEnabled; + const clearingAgent = collectsClearingAgent + ? (await this.resolveClearingAgent(dto)) ?? + (await this.ownClearingAgent(contract.companyId ?? null)) + : null; + if (collectsClearingAgent && !clearingAgent && !isGlActor) { + throw new BadRequestException( + "Pick the registered transit agent handling customs for this booking, or enter your clearing agent's name, email and phone.", + ); + } + // Bare instance: no cargo, no date, no price. Draws no contract capacity // until the customer completes it after clearance. const booking = await insertWithGeneratedReference( @@ -527,7 +552,14 @@ export class ContractBookingService { paymentCurrency: this.resolveShipmentCurrency(contract, null), contractType: 'NEW', customsClearingEnabled: contract.customsClearingEnabled, - customsClearingAgent: contract.customsClearingAgent ?? null, + customsClearingAgent: + clearingAgent?.fields.customsClearingAgent ?? + contract.customsClearingAgent ?? + null, + customsClearingAgentEmail: + clearingAgent?.fields.customsClearingAgentEmail ?? null, + customsClearingAgentPhone: + clearingAgent?.fields.customsClearingAgentPhone ?? null, equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN', originYardId: route?.originYardId ?? null, destinationYardId: route?.destinationYardId ?? null, @@ -557,10 +589,149 @@ export class ContractBookingService { } const result = await this.bookingsRepository.findByIdWithFiles(booking.id); + + // The forwarder's work list and its notice come AFTER the instance is + // committed, so it is never told about a booking that was refused above. + // From here on the forwarder can open the booking and upload its documents. + if (clearingAgent?.assigned) { + await this.transitAssignmentsService.ensureAssignment( + booking.id, + clearingAgent.assigned.id, + user?.id, + ); + void this.bookingNotifier.transitAgentAssigned( + result ?? booking, + clearingAgent.assigned, + ); + } + this.bookingNotifier.createdToStaff(result ?? booking); return { booking: result ?? booking, warnings: [] }; } + /** + * The booking company as its own clearing agent — when it holds the transit + * agent or freight forwarder role, it clears its own customs, and its own + * name and contact go on the booking. No assignment: it already owns the + * booking and uploads the documents as the customer. Null for any other + * company, so the caller falls through to requiring a named agent. + */ + private async ownClearingAgent(companyId: string | null): Promise<{ + fields: Pick< + Booking, + | 'customsClearingAgent' + | 'customsClearingAgentEmail' + | 'customsClearingAgentPhone' + >; + assigned: null; + } | null> { + if (!companyId) return null; + const [company]: Array<{ + name: string; + email: string | null; + phone: string | null; + }> = await this.dataSource.query( + `SELECT c.name, c.email, c.phone + FROM freight.companies c + WHERE c.id = $1 + AND c.deleted_at IS NULL + AND EXISTS ( + SELECT 1 + FROM freight.company_profiles cp + WHERE cp.company_id = c.id + AND cp.type IN ('transit_agent', 'freight_forwarder') + AND cp.deleted_at IS NULL + ) + LIMIT 1`, + [companyId], + ); + if (!company) return null; + return { + fields: { + customsClearingAgent: company.name, + customsClearingAgentEmail: company.email ?? null, + customsClearingAgentPhone: company.phone ?? null, + }, + assigned: null, + }; + } + + /** + * Who clears customs for a without-customs import/export booking, as the + * customer named it — one of two ways. Either a registered Ethiopian transit + * agent (a freight forwarder on the platform): the booking is assigned to it + * and the forwarder is told, and the forwarder company's own contact goes on + * the booking so the customer sees who to reach (an Ethiopian agent row + * carries none). Or the customer's own clearing agent typed in, for which + * all three of name, email and phone are required. + * + * Returns the booking columns to write plus who to assign, or null when the + * payload names nobody — the caller decides whether that is allowed. + */ + private async resolveClearingAgent( + dto: Pick< + CreateBookingUnderContractDto, + | 'transitAgentId' + | 'customsClearingAgent' + | 'customsClearingAgentEmail' + | 'customsClearingAgentPhone' + >, + ): Promise<{ + fields: Pick< + Booking, + | 'customsClearingAgent' + | 'customsClearingAgentEmail' + | 'customsClearingAgentPhone' + >; + assigned: { id: string; name: string } | null; + } | null> { + if (dto.transitAgentId) { + const agent = await this.transitAgentsRepository.findById(dto.transitAgentId); + if ( + !agent || + !agent.isActive || + agent.country !== TransitAgentCountry.Ethiopia + ) { + throw new BadRequestException( + 'The selected transit agent is not an active Ethiopian transit agent — pick another one or enter your clearing agent details.', + ); + } + const [forwarder]: Array<{ email: string | null; phone: string | null }> = + await this.dataSource.query( + `SELECT email, phone FROM freight.companies + WHERE transit_agent_id = $1 AND deleted_at IS NULL + LIMIT 1`, + [agent.id], + ); + return { + fields: { + customsClearingAgent: agent.name, + customsClearingAgentEmail: forwarder?.email ?? null, + customsClearingAgentPhone: forwarder?.phone ?? null, + }, + assigned: { id: agent.id, name: agent.name }, + }; + } + + const agentName = dto.customsClearingAgent?.trim() || null; + const agentEmail = dto.customsClearingAgentEmail?.trim() || null; + const agentPhone = dto.customsClearingAgentPhone?.trim() || null; + if (!agentName && !agentEmail && !agentPhone) return null; + if (!agentName || !agentEmail || !agentPhone) { + throw new BadRequestException( + 'Customs clearing agent name, email and phone are all required — or pick a registered transit agent.', + ); + } + return { + fields: { + customsClearingAgent: agentName, + customsClearingAgentEmail: agentEmail, + customsClearingAgentPhone: agentPhone, + }, + assigned: null, + }; + } + /** * Initiate a BARE booking instance for a GENERAL + customs shipment request * (Path B, clearance-first). Called by BookingRequestService.submit AFTER it @@ -862,65 +1033,23 @@ export class ContractBookingService { if (!dto.scheduledDate) { throw new BadRequestException('A binding shipment day is required'); } - // Without-customs import/export: the customer names who clears customs for - // this booking, one of two ways. Either a registered Ethiopian transit - // agent (a freight forwarder on the platform) — the booking is assigned to - // it and the forwarder is told — or their own clearing agent typed in - // (name, email, phone). A resubmit may omit the typed fields and keep what - // the booking already stored. Customs contracts (GL clears) and intercity - // (no border) never collect an agent. + // The clearing agent was named when the booking was initiated (see + // initiateUnderContract). A resubmit may restate it — a registered transit + // agent or typed details — and then it replaces what the booking stored; + // otherwise the stored one stands. Customs contracts (GL clears) and + // intercity (no border) never carry one. let assignedTransitAgent: { id: string; name: string } | null = null; if ( !contract.customsClearingEnabled && contract.tradeDirection !== 'DOMESTIC' ) { - if (dto.transitAgentId) { - const agent = await this.transitAgentsRepository.findById(dto.transitAgentId); - if ( - !agent || - !agent.isActive || - agent.country !== TransitAgentCountry.Ethiopia - ) { - throw new BadRequestException( - 'The selected transit agent is not an active Ethiopian transit agent — pick another one or enter your clearing agent details.', - ); - } - // The forwarder company's own contact goes on the booking, so the - // customer sees who to reach; an Ethiopian agent row carries none. - const [forwarder]: Array<{ email: string | null; phone: string | null }> = - await this.dataSource.query( - `SELECT email, phone FROM freight.companies - WHERE transit_agent_id = $1 AND deleted_at IS NULL - LIMIT 1`, - [agent.id], - ); - await this.bookingsRepository.update(booking.id, { - customsClearingAgent: agent.name, - customsClearingAgentEmail: forwarder?.email ?? null, - customsClearingAgentPhone: forwarder?.phone ?? null, - } as never); - assignedTransitAgent = { id: agent.id, name: agent.name }; - } else { - const agentName = - dto.customsClearingAgent?.trim() || booking.customsClearingAgent || null; - const agentEmail = - dto.customsClearingAgentEmail?.trim() || - booking.customsClearingAgentEmail || - null; - const agentPhone = - dto.customsClearingAgentPhone?.trim() || - booking.customsClearingAgentPhone || - null; - if (!agentName || !agentEmail || !agentPhone) { - throw new BadRequestException( - 'Customs clearing agent name, email and phone are required to complete this booking — or pick a registered transit agent.', - ); - } - await this.bookingsRepository.update(booking.id, { - customsClearingAgent: agentName, - customsClearingAgentEmail: agentEmail, - customsClearingAgentPhone: agentPhone, - } as never); + const clearingAgent = await this.resolveClearingAgent(dto); + if (clearingAgent) { + await this.bookingsRepository.update( + booking.id, + clearingAgent.fields as never, + ); + assignedTransitAgent = clearingAgent.assigned; } } // No expiry gate here on purpose: this booking was already initiated diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 1df3eea0e..a9fc77bdf 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -1183,7 +1183,7 @@ export class ContractsController { @MixedAudience(FREIGHT_PERMS.contracts.createBooking) @ApiOperation({ summary: - 'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.', + 'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). A without-customs instance names its clearing agent here (transitAgentId, or the typed agent fields). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.', }) async initiateBooking( @Param('id', ParseUUIDPipe) id: string, @@ -1198,7 +1198,16 @@ export class ContractsController { } return this.contractBookingService.initiateUnderContract( id, - { contractRouteId: dto?.contractRouteId }, + { + contractRouteId: dto?.contractRouteId, + // Who clears customs is named here, at initiation — the forwarder + // uploads the documents on the bare instance, so it must be on the + // job before that phase, not at completion. + transitAgentId: dto?.transitAgentId, + customsClearingAgent: dto?.customsClearingAgent, + customsClearingAgentEmail: dto?.customsClearingAgentEmail, + customsClearingAgentPhone: dto?.customsClearingAgentPhone, + }, { id: user?.id ?? user?.sub }, user, ); diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index 4dd8bb254..251cce7db 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -236,8 +236,9 @@ export class CreateBookingUnderContractDto { @ApiPropertyOptional({ maxLength: 200, description: - 'Customs clearing agent name. Required at completion of a without-customs ' + - 'import/export booking (the service enforces it); ignored on customs contracts.', + 'Customs clearing agent name. A without-customs import/export booking names its ' + + 'clearing agent when it is initiated (this, with email and phone, or transitAgentId); ' + + 'ignored on customs contracts.', }) @IsOptional() @IsString() diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 64468c118..1d6f28b34 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -75,7 +75,10 @@ import { TransitAgentBookingDetailPage, TransitAgentOverviewPage, } from "./pages/transit-agent"; -import { AssignedBookingsPage } from "./pages/forwarder"; +import { + AssignedBookingDetailPage, + AssignedBookingsPage, +} from "./pages/forwarder"; import FaqPage from "./pages/support/FaqPage"; import HelpPage from "./pages/support/HelpPage"; import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage"; @@ -675,6 +678,10 @@ const App = () => { path={ASSIGNED_BOOKINGS_PATH} element={} /> + } + /> } /> } /> +>; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** Field errors for the picked mode; empty when the value is complete. */ +export function validateClearingAgent( + v: ClearingAgentValue, +): ClearingAgentErrors { + const errors: ClearingAgentErrors = {}; + if (v.mode === "transit_agent") { + if (!v.transitAgentId.trim()) { + errors.transitAgentId = + "Pick the transit agent handling customs for this booking."; + } + return errors; + } + if (!v.name.trim()) errors.name = "Enter your customs clearing agent's name."; + if (!EMAIL_RE.test(v.email.trim())) { + errors.email = "Enter a valid email for your clearing agent."; + } + if (!v.phone.trim()) errors.phone = "Enter your clearing agent's phone number."; + return errors; +} + +/** The API fields for the picked mode — only ever one of the two. */ +export function toClearingAgentPayload(v: ClearingAgentValue) { + return v.mode === "transit_agent" + ? { transitAgentId: v.transitAgentId.trim() } + : { + customsClearingAgent: v.name.trim(), + customsClearingAgentEmail: v.email.trim(), + customsClearingAgentPhone: v.phone.trim(), + }; +} + +/** + * Who clears customs for a without-customs import/export booking, asked when + * the booking is initiated — that party, not the customer, uploads the + * clearance documents on it, so it has to be on the job from the start. + * A registered transit agent (freight forwarder) is the expected answer; the + * typed fallback covers an agent that is not on the platform. + */ +export default function ClearingAgentPicker({ + value, + onChange, + errors = {}, + disabled, +}: { + value: ClearingAgentValue; + onChange: (next: ClearingAgentValue) => void; + errors?: ClearingAgentErrors; + disabled?: boolean; +}) { + const set = (patch: Partial) => + onChange({ ...value, ...patch }); + + return ( + +
+ + Customs clearing agent + + + Your service does not include customs clearance — tell us who handles + customs for this booking. They will upload the clearance documents. + +
+ + // Switching clears the other option so only one is ever sent. + set( + v === "transit_agent" + ? { mode: "transit_agent", name: "", email: "", phone: "" } + : { mode: "manual", transitAgentId: "" }, + ) + } + data={[ + { value: "transit_agent", label: "Registered transit agent" }, + { value: "manual", label: "Enter agent details" }, + ]} + /> + {value.mode === "transit_agent" ? ( + set({ transitAgentId: v ?? "" })} + disabled={disabled} + label="Transit agent *" + description="Registered Ethiopian transit agents (freight forwarders). The booking is assigned to the one you pick and they are notified." + error={errors.transitAgentId} + notFoundHint="Only transit agents registered with EDR are listed. Ask your forwarder to register, or switch to entering their details instead." + /> + ) : ( + <> + set({ name: e.currentTarget.value })} + error={errors.name} + disabled={disabled} + radius={10} + /> + + set({ email: e.currentTarget.value })} + error={errors.email} + disabled={disabled} + radius={10} + /> + set({ phone: e.currentTarget.value })} + error={errors.phone} + disabled={disabled} + radius={10} + /> + + + )} +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx b/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx index 4c59c5929..bcba2411d 100644 --- a/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx +++ b/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx @@ -1,4 +1,5 @@ import { + Box, Button, Group, Modal, @@ -15,9 +16,17 @@ import { useNavigate } from "react-router-dom"; import type { Freight } from "@edr/types"; +import useAuth from "@/hooks/useAuth"; import { api } from "@/services/api"; import { contractsService } from "@/services/contracts.service"; import { bookingDocNoun } from "@/pages/bookings/clearance/bookingNextAction"; +import ClearingAgentPicker, { + emptyClearingAgent, + toClearingAgentPayload, + validateClearingAgent, + type ClearingAgentErrors, + type ClearingAgentValue, +} from "./ClearingAgentPicker"; import { deriveContractCustomerAction } from "./deriveContractCustomerAction"; interface ContractCustomerActionProps { @@ -100,9 +109,12 @@ export function ContractCustomerAction({ } /** - * One-click bare booking instance under a self-clearance import/export contract - * — ONE_TIME or GENERAL. No form, no date, no window gate: the new instance - * lands in per-booking clearance (AWAITING_DOCUMENTS) with the customer on it. + * Bare booking instance under a self-clearance import/export contract — + * ONE_TIME or GENERAL. No cargo, no date, no window gate: the new instance + * lands in per-booking clearance (AWAITING_DOCUMENTS). The one thing asked + * here is who clears customs, because that party uploads the clearance + * documents on the instance — so it is named before the instance exists, not + * at completion. A customs contract (GL clears) asks nothing. */ export function InitiateBookingButton({ contract, @@ -122,6 +134,16 @@ export function InitiateBookingButton({ const navigate = useNavigate(); const queryClient = useQueryClient(); const [confirmOpen, setConfirmOpen] = useState(false); + // Without-customs contracts name the clearing agent here; the server + // refuses the initiate without one — unless the company is itself a transit + // agent / freight forwarder, in which case it clears its own customs and the + // server records it as such. + const { clearsOwnCustoms } = useAuth(); + const asksClearingAgent = !contract.customsClearingEnabled && !clearsOwnCustoms; + const [clearingAgent, setClearingAgent] = + useState(emptyClearingAgent); + const [clearingAgentErrors, setClearingAgentErrors] = + useState({}); const mutation = useMutation({ mutationFn: () => @@ -132,6 +154,7 @@ export function InitiateBookingButton({ (contract.routes?.length ?? 0) > 1 ? contract.routes![0].id : undefined, + ...(asksClearingAgent ? toClearingAgentPayload(clearingAgent) : {}), }), onSuccess: (booking) => { queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); @@ -142,6 +165,8 @@ export function InitiateBookingButton({ `Booking initiated — upload your ${bookingDocNoun(contract)} to start the review.`, ); setConfirmOpen(false); + setClearingAgent(emptyClearingAgent); + setClearingAgentErrors({}); navigate(`/bookings/${booking.id}`); }, onError: (e: Error) => { @@ -155,6 +180,15 @@ export function InitiateBookingButton({ }, }); + const submit = () => { + if (asksClearingAgent) { + const errors = validateClearingAgent(clearingAgent); + setClearingAgentErrors(errors); + if (Object.keys(errors).length > 0) return; + } + mutation.mutate(); + }; + return ( <> { if (!mutation.isPending) setConfirmOpen(false); }} + // The button lives inside a clickable contracts-list row. The modal + // renders in a portal, but React still bubbles its clicks up the + // component tree to that row, which would navigate away mid-form. + onClick={(e) => e.stopPropagation()} centered radius="lg" - size="md" + size={asksClearingAgent ? "lg" : "md"} closeOnClickOutside={!mutation.isPending} closeOnEscape={!mutation.isPending} withCloseButton={!mutation.isPending} @@ -185,6 +223,25 @@ export function InitiateBookingButton({ . You'll upload the {bookingDocNoun(contract)} next, and the shipment quantity is drawn down from your contract's reserved capacity. + {!contract.customsClearingEnabled && clearsOwnCustoms && ( + + Your company clears its own customs, so no clearing agent is + needed for this booking. + + )} + {asksClearingAgent && ( + + { + setClearingAgent(next); + setClearingAgentErrors({}); + }} + errors={clearingAgentErrors} + disabled={mutation.isPending} + /> + + )} @@ -301,6 +358,10 @@ export function RequestExtensionButton({ onClose={() => { if (!mutation.isPending) setConfirmOpen(false); }} + // The button lives inside a clickable contracts-list row. The modal + // renders in a portal, but React still bubbles its clicks up the + // component tree to that row, which would navigate away mid-form. + onClick={(e) => e.stopPropagation()} centered radius="lg" size="md" diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index 741af49a8..11bd2562a 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -223,6 +223,9 @@ const useAuth = () => { ); const canSeeAssignedBookings = agentProfiles.length > 0 && Boolean(companyInfo?.company?.transitAgentId); + // A company holding either roster role clears customs itself, so its own + // shipments never ask it to name a clearing agent (the API assumes the same). + const clearsOwnCustoms = agentProfiles.length > 0; const assignedBookingsUnlocked = agentProfiles.some( (p) => p.status === "active", ); @@ -343,6 +346,7 @@ const useAuth = () => { hasPendingProfile, canSeeAssignedBookings, assignedBookingsUnlocked, + clearsOwnCustoms, isTransitAgentOnly, companyType, companyStatus, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx index af04db6e3..7fcbd69b9 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx @@ -41,6 +41,14 @@ interface ClearanceFlowProps { * own footer chrome. */ footer?: React.ReactNode; + /** + * Documents only — no shipment-day picker. The assigned forwarder uploads + * the customer's paperwork here but never requests the operation; that + * stays the customer's call. + */ + uploadOnly?: boolean; + /** Whose documents these are, for the section heading. Defaults to "Your". */ + ownerLabel?: string; } /** @@ -51,7 +59,13 @@ interface ClearanceFlowProps { * All state lives in the `flow` controller (see `useClearanceFlow`) so this can * be dropped into either the booking detail card or the home-page action modal. */ -export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) { +export function ClearanceFlow({ + booking, + flow, + footer, + uploadOnly = false, + ownerLabel = "Your", +}: ClearanceFlowProps) { const { clearance, customerDocs, @@ -128,7 +142,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) { - Your {bookingDocNoun(booking)} + {ownerLabel} {bookingDocNoun(booking)} Upload each required document below. Items marked * are mandatory. @@ -311,7 +325,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) { )} - {isReady && !needsCompletion && !awaitingGlCompletion && ( + {isReady && !needsCompletion && !awaitingGlCompletion && !uploadOnly && ( Choose your shipment day diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index 507c29df4..e80c5bf9c 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -10,7 +10,6 @@ import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "react-router-dom"; -import TransitAgentSelect from "@/components/onboarding/TransitAgentSelect"; import { ActionIcon, Alert, @@ -24,7 +23,6 @@ import { Loader, Modal, Paper, - SegmentedControl, Stack, Switch, Text, @@ -321,10 +319,6 @@ function mapBookingToShipmentValues( : "", withReturn: booking.equipmentReturn === "WITH_RETURN", cargoDescription: b.cargoFreeText ?? "", - // The agent entered at the first completion stays on a resubmit. - customsClearingAgent: booking.customsClearingAgent ?? "", - customsClearingAgentEmail: booking.customsClearingAgentEmail ?? "", - customsClearingAgentPhone: booking.customsClearingAgentPhone ?? "", ...(b.contractRouteId ? { contractRouteId: b.contractRouteId } : {}), }; if (contract.freightType === "CONTAINER") { @@ -462,11 +456,6 @@ function NewShipmentBookingForm({ // (mirrors the ScheduleStep picker's visibility). requiresTrain: contract.tradeDirection === "EXPORT" && Boolean(completeBookingId), - // Without-customs import/export completion collects the customer's own - // clearing agent per booking (this page never renders for a customs - // contract — see the gate above). Intercity has no border to clear. - requiresClearingAgent: - Boolean(completeBookingId) && contract.tradeDirection !== "DOMESTIC", }), ), mode: "onChange", @@ -634,22 +623,6 @@ function NewShipmentBookingForm({ ? { requestedWagons: Number(values.requestedWagons) } : {}), }), - // Who clears customs — collected at completion of a without-customs - // import/export booking. Either a registered transit agent (the booking - // is assigned to that forwarder) or the customer's own agent, for which - // the server requires all three fields. Never both. - ...(values.clearingAgentMode === "transit_agent" && - values.transitAgentId?.trim() - ? { transitAgentId: values.transitAgentId.trim() } - : values.customsClearingAgent?.trim() - ? { - customsClearingAgent: values.customsClearingAgent.trim(), - customsClearingAgentEmail: - values.customsClearingAgentEmail.trim(), - customsClearingAgentPhone: - values.customsClearingAgentPhone.trim(), - } - : {}), ...(values.notes ? { notes: values.notes } : {}), }; } @@ -799,10 +772,6 @@ function NewShipmentBookingForm({ )} - {Boolean(completeBookingId) && - contract.tradeDirection !== "DOMESTIC" && ( - - )} {/* Legacy contracts only — WITH_RETURN contracts capture per-line return quantities in the cargo step; WITHOUT_RETURN locked it off. */} {contract.freightType === "CONTAINER" && @@ -2095,114 +2064,6 @@ function EquipmentReturnStep({ form }: { form: ShipmentForm }) { * booking in its own work list and a notice — or type their own agent's name, * email and phone (all required; the schema and the server both enforce it). */ -function ClearingAgentStep({ form }: { form: ShipmentForm }) { - const mode = form.watch("clearingAgentMode"); - return ( - - } - title="Customs Clearing Agent" - description="Your service does not include customs clearance — tell us who handles customs for this booking." - /> - - ( - { - field.onChange(v); - // Switching clears the other option so only one is ever sent. - if (v === "transit_agent") { - form.setValue("customsClearingAgent", "", { shouldDirty: true }); - form.setValue("customsClearingAgentEmail", "", { shouldDirty: true }); - form.setValue("customsClearingAgentPhone", "", { shouldDirty: true }); - } else { - form.setValue("transitAgentId", "", { shouldDirty: true }); - } - }} - data={[ - { value: "transit_agent", label: "Registered transit agent" }, - { value: "manual", label: "Enter agent details" }, - ]} - /> - )} - /> - {mode === "transit_agent" ? ( - ( - field.onChange(v ?? "")} - label="Transit agent *" - description="Registered Ethiopian transit agents (freight forwarders). The booking is assigned to the one you pick and they are notified." - error={fieldState.error?.message} - notFoundHint="Only transit agents registered with EDR are listed. Ask your forwarder to register, or switch to entering their details instead." - /> - )} - /> - ) : null} - {mode === "manual" ? ( - <> - ( - - )} - /> - - ( - - )} - /> - ( - - )} - /> - - - ) : null} - - - ); -} - function NotesSection({ form }: { form: ShipmentForm }) { return ( diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts index c09f121e0..da9c9b629 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts @@ -42,12 +42,6 @@ export interface ShipmentValidationContext { * customer picks for the chosen day. Defaults to false. */ requiresTrain?: boolean; - /** - * Completion of a without-customs import/export booking: the customer's own - * clearing agent (name, email, phone) is required per booking. Defaults to - * false — direct drawdown creates and intercity never collect it. - */ - requiresClearingAgent?: boolean; } // ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit. @@ -114,15 +108,6 @@ const shipmentFormBase = z.object({ requestedWagons: z.string().default(""), bulkHazardousQuantity: z.string().default("0"), bulkReeferQuantity: z.string().default("0"), - // Customer's own customs clearing agent — collected per booking when the - // service does not bundle customs (required at completion, see superRefine). - customsClearingAgent: z.string().default(""), - customsClearingAgentEmail: z.string().default(""), - customsClearingAgentPhone: z.string().default(""), - // The other way to name who clears customs: a registered Ethiopian transit - // agent (a freight forwarder on the platform). One of the two, never both. - clearingAgentMode: z.enum(["manual", "transit_agent"]).default("manual"), - transitAgentId: z.string().default(""), notes: z.string().default(""), }); @@ -150,38 +135,6 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) { }); } - if (ctx.requiresClearingAgent && data.clearingAgentMode === "transit_agent") { - if (!data.transitAgentId.trim()) { - refineCtx.addIssue({ - code: "custom", - path: ["transitAgentId"], - message: "Pick the transit agent handling customs for this booking.", - }); - } - } else if (ctx.requiresClearingAgent) { - if (!data.customsClearingAgent.trim()) { - refineCtx.addIssue({ - code: "custom", - path: ["customsClearingAgent"], - message: "Enter your customs clearing agent's name.", - }); - } - if (!z.email().safeParse(data.customsClearingAgentEmail.trim()).success) { - refineCtx.addIssue({ - code: "custom", - path: ["customsClearingAgentEmail"], - message: "Enter a valid email for your clearing agent.", - }); - } - if (!data.customsClearingAgentPhone.trim()) { - refineCtx.addIssue({ - code: "custom", - path: ["customsClearingAgentPhone"], - message: "Enter your clearing agent's phone number.", - }); - } - } - // No default currency — the customer must pick one before submitting. if (!data.paymentCurrency) { refineCtx.addIssue({ @@ -416,11 +369,6 @@ export const initialShipmentFormValues: DeepPartial = { requestedWagons: "", bulkHazardousQuantity: "0", bulkReeferQuantity: "0", - customsClearingAgent: "", - customsClearingAgentEmail: "", - customsClearingAgentPhone: "", - clearingAgentMode: "manual", - transitAgentId: "", notes: "", }; diff --git a/apps/edr-freight-web/portal/src/pages/forwarder/AssignedBookingDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/forwarder/AssignedBookingDetailPage.tsx new file mode 100644 index 000000000..5fb57aca2 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/forwarder/AssignedBookingDetailPage.tsx @@ -0,0 +1,229 @@ +import { + Alert, + Badge, + Box, + Button, + Card, + Group, + Loader, + Stack, + Text, + Title, +} from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { + AlertCircle, + ArrowLeft, + Building2, + Clock3, + PackageCheck, +} from "lucide-react"; +import { useNavigate, useParams } from "react-router-dom"; + +import { bookingDocNounCapitalized } from "@/pages/bookings/clearance/bookingNextAction"; +import { + transitAssignmentsService, + type TransitAssignmentStatus, +} from "@/services/transit-assignments.service"; + +import { AssignedBookingDocumentsLoader } from "./AssignedBookingDocuments"; + +const LIST_PATH = "/forwarder/assigned-bookings"; + +const STATUS_META: Record< + TransitAssignmentStatus, + { label: string; color: string } +> = { + NOT_STARTED: { label: "Not started", color: "gray" }, + IN_PROGRESS: { label: "In progress", color: "blue" }, + FINISHED: { label: "Finished", color: "edr-green" }, +}; + +const prettyStatus = (s?: string | null) => + (s ?? "") + .toLowerCase() + .replace(/_/g, " ") + .replace(/^\w/, (c) => c.toUpperCase()); + +function formatDate(value?: string | null): string { + if (!value) return "—"; + const d = new Date(value); + return Number.isNaN(d.getTime()) + ? "—" + : d.toLocaleDateString(undefined, { + day: "2-digit", + month: "short", + year: "numeric", + }); +} + +/** + * One booking a customer assigned to this forwarder, with the customer's + * import/export document grid on it. + * + * The forwarder clears customs on the customer's behalf, so it uploads the + * booking's clearance documents through the same flow and endpoint the + * customer uses — the API admits the assigned agent to both. Both parties can + * upload; what the forwarder never does here is pick the shipment day, which + * stays the customer's decision (`uploadOnly`). + * + * Until the roster role is approved the API hides the booking, so the page + * shows the assignment's own facts and says why the documents are not there. + */ +export default function AssignedBookingDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const assignmentQuery = useQuery({ + queryKey: ["transit-assignments", "my", id], + queryFn: () => transitAssignmentsService.getById(id!), + enabled: Boolean(id), + }); + const assignment = assignmentQuery.data; + + if (assignmentQuery.isPending) { + return ( + + + + + Loading the assignment… + + + + ); + } + + if (assignmentQuery.isError || !assignment) { + return ( + + + navigate(LIST_PATH)} /> + }> + This assignment could not be loaded. It may have been reassigned to + another agent. + + + + ); + } + + const b = assignment.booking; + const statusMeta = STATUS_META[assignment.status]; + + return ( + + + navigate(LIST_PATH)} /> + + + +
+ +
+ + {b?.reference ?? "Assigned booking"} + + + + {assignment.customerName ?? "—"} + + + +
+ + {b?.tradeDirection ? ( + + {prettyStatus(b.tradeDirection)} + + ) : null} + {b?.status ? ( + + {prettyStatus(b.status)} + + ) : null} + + {statusMeta.label} + + +
+ + + + } + label="Assigned" + value={formatDate(assignment.assignedAt)} + /> + } + label="Started" + value={formatDate(assignment.startedAt)} + /> + } + label="Finished" + value={formatDate(assignment.finishedAt)} + /> + + {assignment.note ? ( + + {assignment.note} + + ) : null} + + + + + {b + ? bookingDocNounCapitalized({ + customsClearingEnabled: false, + tradeDirection: b.tradeDirection === "EXPORT" ? "EXPORT" : "IMPORT", + }) + : "Documents"} + + + +
+
+ ); +} + +function BackButton({ onClick }: { onClick: () => void }) { + return ( +
+ +
+ ); +} + +function Fact({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: string; +}) { + return ( + + + {label} + + + {icon} + + {value} + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/forwarder/AssignedBookingDocuments.tsx b/apps/edr-freight-web/portal/src/pages/forwarder/AssignedBookingDocuments.tsx new file mode 100644 index 000000000..da209a13e --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/forwarder/AssignedBookingDocuments.tsx @@ -0,0 +1,205 @@ +import { + Alert, + Box, + Button, + Group, + Loader, + Modal, + Stack, + Text, +} from "@mantine/core"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { AlertCircle, Upload } from "lucide-react"; + +import useAuth from "@/hooks/useAuth"; +import { ClearanceFlow } from "@/pages/bookings/clearance/ClearanceFlow"; +import { + bookingDocNoun, + bookingDocNounCapitalized, +} from "@/pages/bookings/clearance/bookingNextAction"; +import { useClearanceFlow } from "@/pages/bookings/clearance/useClearanceFlow"; +import { api } from "@/services/api"; +import type { TransitAssignment } from "@/services/transit-assignments.service"; +import type { Freight } from "@edr/types"; + +/** + * The customer's import/export document grid on a booking assigned to this + * forwarder — every document the customer already uploaded, GL's review + * status and requests, plus upload of what is still missing. + * + * Driven by the same controller the customer's own booking page uses, so what + * the forwarder can upload is exactly what the API accepts right now + * (`documentsOpen`, queried documents, GL requests), and both parties see one + * list. `uploadOnly`: the shipment day stays the customer's decision. + */ +export function AssignedBookingDocuments({ + booking, + customerName, + onUploaded, +}: { + booking: Freight.IBooking; + customerName: string | null; + onUploaded?: () => void; +}) { + const queryClient = useQueryClient(); + const flow = useClearanceFlow(booking); + const docNoun = bookingDocNoun(booking); + + const submit = () => + flow.submitDocuments({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ["transit-assignments"], + }); + onUploaded?.(); + }, + }); + + if (flow.isLoading || !flow.clearance) { + return ( + + + + Loading the document list… + + + ); + } + + return ( + + + {customerName ?? "The customer"} assigned this booking to you for + customs clearance. Upload the {docNoun} on their behalf; anything the + customer uploads shows here too, and both of you see the same list. + + + +
+ ) : ( + + Documents are closed for this booking — nothing more can be + uploaded. + + ) + } + /> + + ); +} + +/** + * Loads the assigned booking and renders {@link AssignedBookingDocuments}, or + * says why it cannot: the roster role is still under review (the API hides + * the booking until it is approved), or the booking failed to load. + */ +export function AssignedBookingDocumentsLoader({ + assignment, + onUploaded, +}: { + assignment: TransitAssignment; + onUploaded?: () => void; +}) { + const { assignedBookingsUnlocked } = useAuth(); + const bookingQuery = useQuery({ + ...api.bookings.get.queryOptions({ input: { id: assignment.bookingId } }), + enabled: assignedBookingsUnlocked, + }); + + if (!assignedBookingsUnlocked) { + return ( + + Your transit agent registration is still under review. The booking's + documents open once it is approved. + + ); + } + if (bookingQuery.isPending) { + return ( + + + + Loading the booking… + + + ); + } + if (bookingQuery.isError || !bookingQuery.data) { + return ( + }> + The booking could not be loaded. + + ); + } + return ( + + ); +} + +/** + * The document grid as a modal, so the forwarder can view and upload straight + * from the Assigned Bookings list without opening the booking. Mounted only + * while open so the staged uploads reset each time. + */ +export function AssignedBookingDocumentsModal({ + assignment, + opened, + onClose, +}: { + assignment: TransitAssignment | null; + opened: boolean; + onClose: () => void; +}) { + if (!opened || !assignment) return null; + const b = assignment.booking; + const title = b + ? bookingDocNounCapitalized({ + customsClearingEnabled: false, + tradeDirection: b.tradeDirection === "EXPORT" ? "EXPORT" : "IMPORT", + }) + : "Documents"; + return ( + + + {title} + + + {b?.reference ?? ""} + {assignment.customerName ? ` · ${assignment.customerName}` : ""} + + + } + overlayProps={{ blur: 2, backgroundOpacity: 0.55 }} + styles={{ body: { paddingTop: 8 } }} + > + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/forwarder/AssignedBookingsPage.tsx b/apps/edr-freight-web/portal/src/pages/forwarder/AssignedBookingsPage.tsx index dc68eab42..6b7db2fae 100644 --- a/apps/edr-freight-web/portal/src/pages/forwarder/AssignedBookingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/forwarder/AssignedBookingsPage.tsx @@ -34,8 +34,10 @@ import { X, } from "lucide-react"; import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; import useAuth from "@/hooks/useAuth"; +import { AssignedBookingDocumentsModal } from "./AssignedBookingDocuments"; import { transitAssignmentsService, type TransitAssignment, @@ -150,13 +152,17 @@ function TablePager({ table, pagination }: DataTableFooterProps) { * transit agent this company registered itself as, read through the same * `/transit-assignments/my` endpoint the Djibouti transit officer uses. * - * List only for now — no detail page. The forwarder sees the work from the - * moment its role is requested; documents and other actions unlock once the - * role is approved, which the banner says. + * A row opens {@link AssignedBookingDetailPage}, where the forwarder uploads + * the customer's import/export documents. The forwarder sees the work from + * the moment its role is requested; documents and other actions unlock once + * the role is approved, which the banner says. */ export default function AssignedBookingsPage() { const { assignedBookingsUnlocked } = useAuth(); + const navigate = useNavigate(); const [query, setQuery] = useState(""); + // The row whose document grid is open in the modal, if any. + const [docsFor, setDocsFor] = useState(null); const [debouncedQuery] = useDebouncedValue(query.trim(), 300); const [status, setStatus] = useState(null); const { pagination, setPagination } = usePagination({ pageSize: 10 }); @@ -276,17 +282,26 @@ export default function AssignedBookingsPage() { { id: "documents", header: () => Documents, + // Opens the customer's import/export document grid in place: what + // the customer uploaded, GL's review, and upload of what is missing. cell: ({ row }) => ( - - - - {row.original.files?.length ?? 0} - - + ), }, ], - [], + [assignedBookingsUnlocked], ); return ( @@ -434,12 +449,20 @@ export default function AssignedBookingsPage() { pageCount, }} containerClassName="border-0 shadow-none rounded-none bg-transparent" + onRowClick={(row) => + navigate(`/forwarder/assigned-bookings/${row.id}`) + } footer={(p) => } /> )} + setDocsFor(null)} + /> ); } diff --git a/apps/edr-freight-web/portal/src/pages/forwarder/index.ts b/apps/edr-freight-web/portal/src/pages/forwarder/index.ts index e606339e1..549e4a8b4 100644 --- a/apps/edr-freight-web/portal/src/pages/forwarder/index.ts +++ b/apps/edr-freight-web/portal/src/pages/forwarder/index.ts @@ -1 +1,2 @@ export { default as AssignedBookingsPage } from "./AssignedBookingsPage"; +export { default as AssignedBookingDetailPage } from "./AssignedBookingDetailPage"; diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 229097296..085cbf5b9 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -27,6 +27,7 @@ import { GenerateContractPriceResponse, SubmitContractResponse, ShipmentValidation, + type InitiateBookingUnderContractPayload, } from "./contracts.service"; import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema"; import { @@ -655,10 +656,10 @@ export const api = { ), initiateBookingUnderContract: endpoint< - { id: string; contractRouteId?: string }, + { id: string } & InitiateBookingUnderContractPayload, Freight.IBooking - >("contracts", "initiateBookingUnderContract", ({ id, contractRouteId }) => - contractsService.initiateBookingUnderContract(id, contractRouteId), + >("contracts", "initiateBookingUnderContract", ({ id, ...payload }) => + contractsService.initiateBookingUnderContract(id, payload), ), completeBookingUnderContract: endpoint< diff --git a/apps/edr-freight-web/portal/src/services/contracts.service.ts b/apps/edr-freight-web/portal/src/services/contracts.service.ts index 6aeb5b721..a5f7358ea 100644 --- a/apps/edr-freight-web/portal/src/services/contracts.service.ts +++ b/apps/edr-freight-web/portal/src/services/contracts.service.ts @@ -172,6 +172,15 @@ export function buildContractFormData( return formData; } +export type InitiateBookingUnderContractPayload = Pick< + Freight.CreateBookingUnderContractDto, + | "contractRouteId" + | "transitAgentId" + | "customsClearingAgent" + | "customsClearingAgentEmail" + | "customsClearingAgentPhone" +>; + export const contractsService = { list: async ( filter: ContractListFilter | void = {}, @@ -384,18 +393,17 @@ export const contractsService = { }, /** - * 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. + * Bare booking instance under an import/export contract — no cargo, no + * date. The instance enters per-booking clearance; the customer completes it + * (cargo + shipment day) once Operations finalizes. A without-customs + * contract names who clears customs here (`transitAgentId`, or the typed + * agent fields) — that party uploads the clearance documents. */ initiateBookingUnderContract: async ( id: string, - contractRouteId?: string, + payload: InitiateBookingUnderContractPayload = {}, ): Promise => { - const { data } = await client.post( - C.BOOKINGS_INITIATE(id), - contractRouteId ? { contractRouteId } : {}, - ); + const { data } = await client.post(C.BOOKINGS_INITIATE(id), payload); return data.data.booking ?? data.data; }, diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 15f9d3be1..8dc666070 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -1049,7 +1049,10 @@ export interface CreateBookingUnderContractDto { requestedWagons?: number; /** What the containers carry — captured per booking (container freight). */ cargoFreeText?: string; - /** Customer's own clearing agent — required at completion of a without-customs import/export booking. */ + /** + * Customer's own clearing agent — named when a without-customs import/export + * booking is initiated (all three fields, or `transitAgentId` instead). + */ customsClearingAgent?: string; customsClearingAgentEmail?: string; customsClearingAgentPhone?: string;