mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 10:08:21 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'll upload the {bookingDocNoun(contract)} next, and the shipment
|
||||
quantity is drawn down from your contract'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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: "",
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { default as AssignedBookingsPage } from "./AssignedBookingsPage";
|
||||
export { default as AssignedBookingDetailPage } from "./AssignedBookingDetailPage";
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user