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.
This commit is contained in:
marshal
2026-09-08 03:54:48 +00:00
parent 7c422ff600
commit 8314b3bd45
19 changed files with 977 additions and 286 deletions

View File

@@ -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,

View File

@@ -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],
);

View File

@@ -458,7 +458,14 @@ export class ContractBookingService {
*/
async initiateUnderContract(
contractId: string,
dto: Pick<CreateBookingUnderContractDto, 'contractRouteId'>,
dto: Pick<
CreateBookingUnderContractDto,
| 'contractRouteId'
| 'transitAgentId'
| 'customsClearingAgent'
| 'customsClearingAgentEmail'
| 'customsClearingAgentPhone'
>,
user?: { id?: string } | null,
actorPermissions?: unknown,
): Promise<CreateBookingUnderContractResult> {
@@ -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

View File

@@ -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,
);

View File

@@ -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()

View File

@@ -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={<AssignedBookingsPage />}
/>
<Route
path={`${ASSIGNED_BOOKINGS_PATH}/:id`}
element={<AssignedBookingDetailPage />}
/>
<Route path="/contracts" element={<ContractsList />} />
<Route path="/contracts/new" element={<NewContractPage />} />
<Route

View File

@@ -0,0 +1,158 @@
import { Group, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
import TransitAgentSelect from "@/components/onboarding/TransitAgentSelect";
export type ClearingAgentMode = "transit_agent" | "manual";
export interface ClearingAgentValue {
mode: ClearingAgentMode;
transitAgentId: string;
name: string;
email: string;
phone: string;
}
export const emptyClearingAgent: ClearingAgentValue = {
mode: "transit_agent",
transitAgentId: "",
name: "",
email: "",
phone: "",
};
export type ClearingAgentErrors = Partial<
Record<"transitAgentId" | "name" | "email" | "phone", string>
>;
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<ClearingAgentValue>) =>
onChange({ ...value, ...patch });
return (
<Stack gap="sm">
<div>
<Text fw={600} size="sm">
Customs clearing agent
</Text>
<Text size="xs" c="dimmed">
Your service does not include customs clearance tell us who handles
customs for this booking. They will upload the clearance documents.
</Text>
</div>
<SegmentedControl
fullWidth
radius={10}
color="edr-green"
disabled={disabled}
value={value.mode}
onChange={(v) =>
// 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" ? (
<TransitAgentSelect
value={value.transitAgentId || null}
onChange={(v) => 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."
/>
) : (
<>
<TextInput
label="Agent name *"
placeholder="Customs clearing agent name"
value={value.name}
onChange={(e) => set({ name: e.currentTarget.value })}
error={errors.name}
disabled={disabled}
radius={10}
/>
<Group grow align="flex-start">
<TextInput
type="email"
label="Agent email *"
placeholder="agent@example.com"
value={value.email}
onChange={(e) => set({ email: e.currentTarget.value })}
error={errors.email}
disabled={disabled}
radius={10}
/>
<TextInput
type="tel"
label="Agent phone *"
placeholder="+251 9…"
value={value.phone}
onChange={(e) => set({ phone: e.currentTarget.value })}
error={errors.phone}
disabled={disabled}
radius={10}
/>
</Group>
</>
)}
</Stack>
);
}

View File

@@ -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<ClearingAgentValue>(emptyClearingAgent);
const [clearingAgentErrors, setClearingAgentErrors] =
useState<ClearingAgentErrors>({});
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 (
<>
<Modal
@@ -162,9 +196,13 @@ export function InitiateBookingButton({
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"
size={asksClearingAgent ? "lg" : "md"}
closeOnClickOutside={!mutation.isPending}
closeOnEscape={!mutation.isPending}
withCloseButton={!mutation.isPending}
@@ -185,6 +223,25 @@ export function InitiateBookingButton({
. You&apos;ll upload the {bookingDocNoun(contract)} next, and the shipment
quantity is drawn down from your contract&apos;s reserved capacity.
</Text>
{!contract.customsClearingEnabled && clearsOwnCustoms && (
<Text size="sm" c="dimmed" mt="sm">
Your company clears its own customs, so no clearing agent is
needed for this booking.
</Text>
)}
{asksClearingAgent && (
<Box mt="md">
<ClearingAgentPicker
value={clearingAgent}
onChange={(next) => {
setClearingAgent(next);
setClearingAgentErrors({});
}}
errors={clearingAgentErrors}
disabled={mutation.isPending}
/>
</Box>
)}
<Group justify="flex-end" gap="sm" mt="lg">
<Button
variant="default"
@@ -199,7 +256,7 @@ export function InitiateBookingButton({
radius="md"
leftSection={<Icon size={16} />}
loading={mutation.isPending}
onClick={() => mutation.mutate()}
onClick={submit}
>
Yes, initiate booking
</Button>
@@ -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"

View File

@@ -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,

View File

@@ -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) {
<Stack gap="md">
<Box>
<Text fz={13} fw={700} c="#10202F">
Your {bookingDocNoun(booking)}
{ownerLabel} {bookingDocNoun(booking)}
</Text>
<Text fz={12} c="dimmed" mt={4}>
Upload each required document below. Items marked * are mandatory.
@@ -311,7 +325,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
</Alert>
)}
{isReady && !needsCompletion && !awaitingGlCompletion && (
{isReady && !needsCompletion && !awaitingGlCompletion && !uploadOnly && (
<Box mt="lg">
<Text fz="13px" fw={700} c="#10202F" mb={6}>
Choose your shipment day

View File

@@ -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({
)}
<RouteStep form={form} contract={contract} routes={routes} />
<CargoStep form={form} contract={contract} />
{Boolean(completeBookingId) &&
contract.tradeDirection !== "DOMESTIC" && (
<ClearingAgentStep form={form} />
)}
{/* 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 (
<StepCard>
<StepHeader
icon={<FileText size={22} />}
title="Customs Clearing Agent"
description="Your service does not include customs clearance — tell us who handles customs for this booking."
/>
<Stack gap="sm">
<Controller
name="clearingAgentMode"
control={form.control}
render={({ field }) => (
<SegmentedControl
fullWidth
radius={10}
color="edr-green"
value={field.value}
onChange={(v) => {
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" ? (
<Controller
name="transitAgentId"
control={form.control}
render={({ field, fieldState }) => (
<TransitAgentSelect
value={field.value || null}
onChange={(v) => 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" ? (
<>
<Controller
name="customsClearingAgent"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
label="Agent name *"
placeholder="Customs clearing agent name"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
<Group grow align="flex-start">
<Controller
name="customsClearingAgentEmail"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="email"
label="Agent email *"
placeholder="agent@example.com"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
<Controller
name="customsClearingAgentPhone"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="tel"
label="Agent phone *"
placeholder="+251 9…"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
</Group>
</>
) : null}
</Stack>
</StepCard>
);
}
function NotesSection({ form }: { form: ShipmentForm }) {
return (
<StepCard>

View File

@@ -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<ShipmentFormValues> = {
requestedWagons: "",
bulkHazardousQuantity: "0",
bulkReeferQuantity: "0",
customsClearingAgent: "",
customsClearingAgentEmail: "",
customsClearingAgentPhone: "",
clearingAgentMode: "manual",
transitAgentId: "",
notes: "",
};

View File

@@ -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 (
<Box p={{ base: 16, sm: 24, lg: 32 }}>
<Group gap="sm">
<Loader size="sm" color="edr-green" />
<Text c="edr-muted" size="sm">
Loading the assignment
</Text>
</Group>
</Box>
);
}
if (assignmentQuery.isError || !assignment) {
return (
<Box p={{ base: 16, sm: 24, lg: 32 }}>
<Stack gap="md">
<BackButton onClick={() => navigate(LIST_PATH)} />
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
This assignment could not be loaded. It may have been reassigned to
another agent.
</Alert>
</Stack>
</Box>
);
}
const b = assignment.booking;
const statusMeta = STATUS_META[assignment.status];
return (
<Box p={{ base: 16, sm: 24, lg: 32 }}>
<Stack gap="lg">
<BackButton onClick={() => navigate(LIST_PATH)} />
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="sm" align="center">
<div className="flex size-10 items-center justify-center rounded-xl bg-edr-soft text-edr-green-7">
<PackageCheck size={20} />
</div>
<Box>
<Title order={2}>{b?.reference ?? "Assigned booking"}</Title>
<Group gap={4} wrap="nowrap" align="center">
<Building2 size={12} className="shrink-0 text-edr-muted" />
<Text c="edr-muted" size="sm">
{assignment.customerName ?? "—"}
</Text>
</Group>
</Box>
</Group>
<Group gap="xs">
{b?.tradeDirection ? (
<Badge size="sm" variant="light" radius="sm" color="blue">
{prettyStatus(b.tradeDirection)}
</Badge>
) : null}
{b?.status ? (
<Badge size="sm" variant="light" radius="sm" color="gray">
{prettyStatus(b.status)}
</Badge>
) : null}
<Badge size="sm" variant="light" radius="sm" color={statusMeta.color}>
{statusMeta.label}
</Badge>
</Group>
</Group>
<Card withBorder shadow="sm" radius="lg" p="md">
<Group gap="xl" wrap="wrap">
<Fact
icon={<Clock3 size={14} />}
label="Assigned"
value={formatDate(assignment.assignedAt)}
/>
<Fact
icon={<Clock3 size={14} />}
label="Started"
value={formatDate(assignment.startedAt)}
/>
<Fact
icon={<Clock3 size={14} />}
label="Finished"
value={formatDate(assignment.finishedAt)}
/>
</Group>
{assignment.note ? (
<Text fz={13} c="edr-text" mt="sm" style={{ whiteSpace: "pre-wrap" }}>
{assignment.note}
</Text>
) : null}
</Card>
<Card withBorder shadow="sm" radius="lg" p="md">
<Title order={4} mb="md">
{b
? bookingDocNounCapitalized({
customsClearingEnabled: false,
tradeDirection: b.tradeDirection === "EXPORT" ? "EXPORT" : "IMPORT",
})
: "Documents"}
</Title>
<AssignedBookingDocumentsLoader assignment={assignment} />
</Card>
</Stack>
</Box>
);
}
function BackButton({ onClick }: { onClick: () => void }) {
return (
<div>
<Button
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<ArrowLeft size={14} />}
onClick={onClick}
>
Assigned bookings
</Button>
</div>
);
}
function Fact({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<Box>
<Text fz={10} fw={600} tt="uppercase" c="edr-muted" style={{ letterSpacing: "0.08em" }}>
{label}
</Text>
<Group gap={6} wrap="nowrap">
<span className="text-edr-muted">{icon}</span>
<Text fz={13} c="edr-text">
{value}
</Text>
</Group>
</Box>
);
}

View File

@@ -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 (
<Group gap="sm">
<Loader size="sm" color="edr-green" />
<Text c="edr-muted" size="sm">
Loading the document list
</Text>
</Group>
);
}
return (
<Stack gap="md">
<Text c="edr-muted" size="sm">
{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.
</Text>
<ClearanceFlow
booking={booking}
flow={flow}
uploadOnly
ownerLabel="The customer's"
footer={
flow.canUpload ? (
<Group justify="flex-end" mt="xl" gap="sm">
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={16} />}
onClick={submit}
loading={flow.uploadMutation.isPending}
disabled={!flow.canSubmit}
>
Submit documents
</Button>
</Group>
) : (
<Alert color="gray" variant="light" radius="md" mt="lg">
Documents are closed for this booking nothing more can be
uploaded.
</Alert>
)
}
/>
</Stack>
);
}
/**
* 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 (
<Alert color="yellow" variant="light" radius="md">
Your transit agent registration is still under review. The booking's
documents open once it is approved.
</Alert>
);
}
if (bookingQuery.isPending) {
return (
<Group gap="sm">
<Loader size="sm" color="edr-green" />
<Text c="edr-muted" size="sm">
Loading the booking
</Text>
</Group>
);
}
if (bookingQuery.isError || !bookingQuery.data) {
return (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
The booking could not be loaded.
</Alert>
);
}
return (
<AssignedBookingDocuments
booking={bookingQuery.data}
customerName={assignment.customerName}
onUploaded={onUploaded}
/>
);
}
/**
* 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 (
<Modal
opened
onClose={onClose}
centered
size="xl"
radius="md"
title={
<Box>
<Text fw={700} fz={16}>
{title}
</Text>
<Text fz={12} c="dimmed" ff="monospace">
{b?.reference ?? ""}
{assignment.customerName ? ` · ${assignment.customerName}` : ""}
</Text>
</Box>
}
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
styles={{ body: { paddingTop: 8 } }}
>
<AssignedBookingDocumentsLoader assignment={assignment} />
</Modal>
);
}

View File

@@ -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<T>({ table, pagination }: DataTableFooterProps<T>) {
* 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<TransitAssignment | null>(null);
const [debouncedQuery] = useDebouncedValue(query.trim(), 300);
const [status, setStatus] = useState<TransitAssignmentStatus | null>(null);
const { pagination, setPagination } = usePagination({ pageSize: 10 });
@@ -276,17 +282,26 @@ export default function AssignedBookingsPage() {
{
id: "documents",
header: () => <span className={headerCell}>Documents</span>,
// 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 }) => (
<Group gap={6} wrap="nowrap">
<Paperclip size={12} className="shrink-0 text-edr-muted" />
<Text fz={12} c="edr-text">
{row.original.files?.length ?? 0}
</Text>
</Group>
<Button
variant="light"
color="edr-green"
size="compact-xs"
radius="md"
leftSection={<Paperclip size={12} />}
onClick={(e) => {
e.stopPropagation();
setDocsFor(row.original);
}}
>
{assignedBookingsUnlocked ? "View / upload" : "View"}
</Button>
),
},
],
[],
[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) => <TablePager {...p} />}
/>
</Box>
)}
</Card>
</Stack>
<AssignedBookingDocumentsModal
assignment={docsFor}
opened={docsFor !== null}
onClose={() => setDocsFor(null)}
/>
</Box>
);
}

View File

@@ -1 +1,2 @@
export { default as AssignedBookingsPage } from "./AssignedBookingsPage";
export { default as AssignedBookingDetailPage } from "./AssignedBookingDetailPage";

View File

@@ -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<

View File

@@ -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<Freight.IBooking> => {
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;
},

View File

@@ -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;