mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -1633,6 +1633,23 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(":id/clearance/draft-declaration/skip")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"GL ET skips the draft-declaration round: no estimate is sent to the customer, the real declaration is filed directly and duty & tax passes by default",
|
||||||
|
})
|
||||||
|
async skipBookingDraftDeclaration(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingClearanceService.skipDraftDeclaration(
|
||||||
|
id,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(":id/clearance/draft-declaration/accept")
|
@Post(":id/clearance/draft-declaration/accept")
|
||||||
@PortalCustomer()
|
@PortalCustomer()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -785,6 +785,50 @@ export class BookingClearanceService {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GL Ethiopia skips the draft-declaration round entirely: the customer is
|
||||||
|
* not sent an estimate, staff file the real customs declaration directly.
|
||||||
|
* Duty & tax passes with it by default — there is no draft price to advise
|
||||||
|
* from. Advising duty later still works and overrides the skip (a skipped
|
||||||
|
* milestone is completed normally by adviseDuty).
|
||||||
|
*/
|
||||||
|
async skipDraftDeclaration(bookingId: string, userId?: string): Promise<Booking> {
|
||||||
|
const booking = await this.loadBooking(bookingId);
|
||||||
|
if (booking.tradeDirection !== 'IMPORT') {
|
||||||
|
throw new BadRequestException('Draft declaration applies only to import bookings.');
|
||||||
|
}
|
||||||
|
await this.milestoneService.ensureBookingMilestones(bookingId, 'IMPORT');
|
||||||
|
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
|
||||||
|
const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED');
|
||||||
|
if (uploaded?.status === 'COMPLETED') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A draft declaration was already sent to the customer — it can no longer be skipped.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.workflowService.assertPriorCompleteForBooking(
|
||||||
|
bookingId,
|
||||||
|
'IMPORT',
|
||||||
|
'DRAFT_DECLARATION_UPLOADED',
|
||||||
|
);
|
||||||
|
await this.workflowService.skipMilestonesForBooking(bookingId, [
|
||||||
|
'DRAFT_DECLARATION_UPLOADED',
|
||||||
|
'DRAFT_DECLARATION_ACCEPTED',
|
||||||
|
]);
|
||||||
|
await this.workflowService.onDutySkippedForBooking(bookingId);
|
||||||
|
await this.bookingsRepository.update(bookingId, {
|
||||||
|
dutyRequired: false,
|
||||||
|
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
|
||||||
|
} as never);
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'DRAFT_DECLARATION_SKIPPED',
|
||||||
|
label:
|
||||||
|
'Skipped the draft declaration — filing the customs declaration directly (duty & tax passed by default)',
|
||||||
|
actorId: userId ?? null,
|
||||||
|
});
|
||||||
|
return this.bookingsService.findById(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The customer accepts the draft declaration — GL Ethiopia may now file the
|
* The customer accepts the draft declaration — GL Ethiopia may now file the
|
||||||
* real customs declaration.
|
* real customs declaration.
|
||||||
|
|||||||
@@ -301,8 +301,9 @@ export default function GlCreateBookingForm() {
|
|||||||
const [trainScheduleId, setTrainScheduleId] = useState("");
|
const [trainScheduleId, setTrainScheduleId] = useState("");
|
||||||
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
|
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
// ponytail: ETB-only for now — widen back to "USD" | "ETB" when multi-currency billing returns.
|
// IMPORT bookings pick ETB or USD — starts empty so the choice is
|
||||||
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB">("ETB");
|
// deliberate (required before pricing). Everything else is forced to ETB.
|
||||||
|
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "">("");
|
||||||
// What the containers carry — captured per booking (moved off the contract).
|
// What the containers carry — captured per booking (moved off the contract).
|
||||||
const [cargoDescription, setCargoDescription] = useState("");
|
const [cargoDescription, setCargoDescription] = useState("");
|
||||||
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
||||||
@@ -1022,8 +1023,21 @@ export default function GlCreateBookingForm() {
|
|||||||
partnerCargoDescription,
|
partnerCargoDescription,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Only IMPORT actually chooses — the rest bill ETB regardless of the state.
|
||||||
|
const effectiveCurrency: "USD" | "ETB" =
|
||||||
|
isImport && paymentCurrency ? paymentCurrency : "ETB";
|
||||||
|
const currencyError =
|
||||||
|
isImport && !paymentCurrency
|
||||||
|
? "Select the billing currency for this booking."
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const formValid =
|
const formValid =
|
||||||
cargoValid && !oddBlocksSubmit && !dateError && !routeError && !partnerError;
|
cargoValid &&
|
||||||
|
!oddBlocksSubmit &&
|
||||||
|
!dateError &&
|
||||||
|
!routeError &&
|
||||||
|
!partnerError &&
|
||||||
|
!currencyError;
|
||||||
|
|
||||||
/** The create-booking DTO from the current form state — shared by the
|
/** The create-booking DTO from the current form state — shared by the
|
||||||
* authoritative price preview and the actual submit so what GL confirms is
|
* authoritative price preview and the actual submit so what GL confirms is
|
||||||
@@ -1033,7 +1047,7 @@ export default function GlCreateBookingForm() {
|
|||||||
|
|
||||||
const payload: Freight.CreateBookingUnderContractDto = {
|
const payload: Freight.CreateBookingUnderContractDto = {
|
||||||
...(contractRouteId ? { contractRouteId } : {}),
|
...(contractRouteId ? { contractRouteId } : {}),
|
||||||
paymentCurrency,
|
paymentCurrency: effectiveCurrency,
|
||||||
// Intercity bookings carry no date — staff assign a passing train later.
|
// Intercity bookings carry no date — staff assign a passing train later.
|
||||||
...(scheduledDate
|
...(scheduledDate
|
||||||
? { scheduledDate: new Date(scheduledDate).toISOString() }
|
? { scheduledDate: new Date(scheduledDate).toISOString() }
|
||||||
@@ -1100,7 +1114,7 @@ export default function GlCreateBookingForm() {
|
|||||||
if (!partner || !consolidationActive) return null;
|
if (!partner || !consolidationActive) return null;
|
||||||
|
|
||||||
const payload: Freight.CreateBookingUnderContractDto = {
|
const payload: Freight.CreateBookingUnderContractDto = {
|
||||||
paymentCurrency,
|
paymentCurrency: effectiveCurrency,
|
||||||
...(scheduledDate
|
...(scheduledDate
|
||||||
? { scheduledDate: new Date(scheduledDate).toISOString() }
|
? { scheduledDate: new Date(scheduledDate).toISOString() }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -2138,10 +2152,11 @@ export default function GlCreateBookingForm() {
|
|||||||
: "Shipments are invoiced in ETB."}
|
: "Shipments are invoiced in ETB."}
|
||||||
</Text>
|
</Text>
|
||||||
<CurrencySelector
|
<CurrencySelector
|
||||||
value={isIntercity ? "ETB" : paymentCurrency}
|
value={isImport ? paymentCurrency : "ETB"}
|
||||||
onChange={setPaymentCurrency}
|
onChange={setPaymentCurrency}
|
||||||
disabled={isIntercity}
|
disabled={!isImport}
|
||||||
allowUsd={isImport}
|
allowUsd={isImport}
|
||||||
|
error={currencyError}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -1835,13 +1835,59 @@ function DraftDeclarationStep({
|
|||||||
);
|
);
|
||||||
const [currency, setCurrency] = useState(clearance.draftDeclaration?.currency ?? "ETB");
|
const [currency, setCurrency] = useState(clearance.draftDeclaration?.currency ?? "ETB");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
// OFF = don't send the customer a draft: the step is skipped, staff file the
|
||||||
|
// real declaration directly, and duty & tax passes by default with it.
|
||||||
|
const [sendDraft, setSendDraft] = useState(true);
|
||||||
|
|
||||||
const changeRequest = clearance.draftDeclarationChangeRequest;
|
const changeRequest = clearance.draftDeclarationChangeRequest;
|
||||||
const existingFiles = clearance.draftDeclaration?.files ?? [];
|
const existingFiles = clearance.draftDeclaration?.files ?? [];
|
||||||
const replaceMode = existingFiles.length > 0;
|
const replaceMode = existingFiles.length > 0;
|
||||||
|
|
||||||
|
if (!sendDraft && !replaceMode) {
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Switch
|
||||||
|
label="Send the customer a draft declaration"
|
||||||
|
description="Off: skip this step — upload the customs declaration directly. Duty & tax is passed by default."
|
||||||
|
checked={sendDraft}
|
||||||
|
onChange={(e) => setSendDraft(e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
variant="light"
|
||||||
|
loading={loading}
|
||||||
|
fullWidth
|
||||||
|
onClick={async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await bookingsService.skipDraftDeclaration(bookingId);
|
||||||
|
toast.success(
|
||||||
|
"Draft declaration skipped — upload the customs declaration next. Duty & tax passed.",
|
||||||
|
);
|
||||||
|
onChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Skip draft declaration
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
{!replaceMode ? (
|
||||||
|
<Switch
|
||||||
|
label="Send the customer a draft declaration"
|
||||||
|
description="Off: skip this step — upload the customs declaration directly. Duty & tax is passed by default."
|
||||||
|
checked={sendDraft}
|
||||||
|
onChange={(e) => setSendDraft(e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{/* The customer sent this draft back — their words drive the
|
{/* The customer sent this draft back — their words drive the
|
||||||
correction, so they lead the step. */}
|
correction, so they lead the step. */}
|
||||||
{changeRequest ? (
|
{changeRequest ? (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
Menu,
|
Menu,
|
||||||
Paper,
|
Paper,
|
||||||
|
ScrollArea,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
@@ -345,6 +346,11 @@ function WagonYardBadge({
|
|||||||
enabled: Boolean(onChange),
|
enabled: Boolean(onChange),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// The yard list is long — filter box + capped scroll keep the dropdown usable.
|
||||||
|
const [yardFilter, setYardFilter] = useState("");
|
||||||
|
const filteredYards = (yardsQuery.data ?? []).filter((y) =>
|
||||||
|
(y.label ?? y.code ?? "").toLowerCase().includes(yardFilter.trim().toLowerCase()),
|
||||||
|
);
|
||||||
if (!onChange) {
|
if (!onChange) {
|
||||||
return wagon.currentYard ? (
|
return wagon.currentYard ? (
|
||||||
<Badge
|
<Badge
|
||||||
@@ -359,7 +365,7 @@ function WagonYardBadge({
|
|||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Menu shadow="md" width={240} withinPortal>
|
<Menu shadow="md" width={240} withinPortal onClose={() => setYardFilter("")}>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<Badge
|
<Badge
|
||||||
component="button"
|
component="button"
|
||||||
@@ -380,15 +386,33 @@ function WagonYardBadge({
|
|||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Label>Move wagon to yard</Menu.Label>
|
<Menu.Label>Move wagon to yard</Menu.Label>
|
||||||
{(yardsQuery.data ?? []).map((y) => (
|
<Box px={8} pb={6}>
|
||||||
<Menu.Item
|
<TextInput
|
||||||
key={y.id}
|
size="xs"
|
||||||
disabled={y.id === wagon.currentYard?.id}
|
placeholder="Filter yards…"
|
||||||
onClick={() => onChange(wagon.id, y.id)}
|
leftSection={<Search size={12} />}
|
||||||
>
|
value={yardFilter}
|
||||||
{y.label ?? y.code}
|
onChange={(e) => setYardFilter(e.currentTarget.value)}
|
||||||
</Menu.Item>
|
// A keypress inside the menu must type, not jump menu focus.
|
||||||
))}
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<ScrollArea.Autosize mah={350} type="auto">
|
||||||
|
{filteredYards.map((y) => (
|
||||||
|
<Menu.Item
|
||||||
|
key={y.id}
|
||||||
|
disabled={y.id === wagon.currentYard?.id}
|
||||||
|
onClick={() => onChange(wagon.id, y.id)}
|
||||||
|
>
|
||||||
|
{y.label ?? y.code}
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
{filteredYards.length === 0 ? (
|
||||||
|
<Text size="xs" c="dimmed" px={12} py={6}>
|
||||||
|
No yard matches
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</ScrollArea.Autosize>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
|
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
|
||||||
import { DateTimePicker } from "@mantine/dates";
|
import { DateTimePicker } from "@mantine/dates";
|
||||||
|
import { useMediaQuery } from "@mantine/hooks";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +35,7 @@ export function CheckpointTimeModal({
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
onSubmit: (values: { occurredAt: string; note: string }) => void;
|
onSubmit: (values: { occurredAt: string; note: string }) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const isSmallScreen = useMediaQuery("(max-width: 48em)");
|
||||||
const [at, setAt] = useState<Date | null>(null);
|
const [at, setAt] = useState<Date | null>(null);
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -47,6 +49,7 @@ export function CheckpointTimeModal({
|
|||||||
opened={opened}
|
opened={opened}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
centered
|
centered
|
||||||
|
fullScreen={isSmallScreen}
|
||||||
radius="lg"
|
radius="lg"
|
||||||
title={
|
title={
|
||||||
<Group gap={8}>
|
<Group gap={8}>
|
||||||
@@ -67,6 +70,8 @@ export function CheckpointTimeModal({
|
|||||||
value={at}
|
value={at}
|
||||||
onChange={(v) => setAt(v ? new Date(v) : null)}
|
onChange={(v) => setAt(v ? new Date(v) : null)}
|
||||||
maxDate={new Date()}
|
maxDate={new Date()}
|
||||||
|
dropdownType={isSmallScreen ? "modal" : "popover"}
|
||||||
|
popoverProps={{ withinPortal: true }}
|
||||||
valueFormat="DD MMM YYYY HH:mm"
|
valueFormat="DD MMM YYYY HH:mm"
|
||||||
clearable={false}
|
clearable={false}
|
||||||
radius="md"
|
radius="md"
|
||||||
|
|||||||
@@ -240,6 +240,8 @@ export const URL_CONSTANTS = {
|
|||||||
CLEARANCE_DUTY: (id: string) => `/bookings/${id}/clearance/duty`,
|
CLEARANCE_DUTY: (id: string) => `/bookings/${id}/clearance/duty`,
|
||||||
CLEARANCE_DRAFT_DECLARATION: (id: string) =>
|
CLEARANCE_DRAFT_DECLARATION: (id: string) =>
|
||||||
`/bookings/${id}/clearance/draft-declaration`,
|
`/bookings/${id}/clearance/draft-declaration`,
|
||||||
|
CLEARANCE_DRAFT_DECLARATION_SKIP: (id: string) =>
|
||||||
|
`/bookings/${id}/clearance/draft-declaration/skip`,
|
||||||
CLEARANCE_TRANSIT_ASSIGNEE_REQUEST: (id: string) =>
|
CLEARANCE_TRANSIT_ASSIGNEE_REQUEST: (id: string) =>
|
||||||
`/bookings/${id}/clearance/transit-assignee/request`,
|
`/bookings/${id}/clearance/transit-assignee/request`,
|
||||||
CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN: (id: string) =>
|
CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN: (id: string) =>
|
||||||
|
|||||||
@@ -705,6 +705,10 @@ export const bookingsService = {
|
|||||||
return unwrap(response.data) as BookingDetail;
|
return unwrap(response.data) as BookingDetail;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Skip the draft-declaration round — file the real declaration directly; duty & tax passes by default. */
|
||||||
|
skipDraftDeclaration: (id: string) =>
|
||||||
|
postBooking<BookingDetail>(B.CLEARANCE_DRAFT_DECLARATION_SKIP(id)),
|
||||||
|
|
||||||
finalizePreClearance: (id: string) =>
|
finalizePreClearance: (id: string) =>
|
||||||
postBooking<BookingDetail>(B.CLEARANCE_FINALIZE_PRE(id)),
|
postBooking<BookingDetail>(B.CLEARANCE_FINALIZE_PRE(id)),
|
||||||
|
|
||||||
|
|||||||
@@ -280,7 +280,12 @@ function mapBookingToShipmentValues(
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
const values: Partial<ShipmentFormInputValues> = {
|
const values: Partial<ShipmentFormInputValues> = {
|
||||||
paymentCurrency: "ETB",
|
// Resubmit keeps the currency the customer already chose on this booking;
|
||||||
|
// a missing value falls back to empty so the choice is made deliberately.
|
||||||
|
paymentCurrency:
|
||||||
|
booking.paymentCurrency === "USD" || booking.paymentCurrency === "ETB"
|
||||||
|
? booking.paymentCurrency
|
||||||
|
: "",
|
||||||
withReturn: booking.equipmentReturn === "WITH_RETURN",
|
withReturn: booking.equipmentReturn === "WITH_RETURN",
|
||||||
cargoDescription: b.cargoFreeText ?? "",
|
cargoDescription: b.cargoFreeText ?? "",
|
||||||
...(b.contractRouteId ? { contractRouteId: b.contractRouteId } : {}),
|
...(b.contractRouteId ? { contractRouteId: b.contractRouteId } : {}),
|
||||||
@@ -389,8 +394,9 @@ function NewShipmentBookingForm({
|
|||||||
// Seed the equipment-return toggle from the contract; the customer can
|
// Seed the equipment-return toggle from the contract; the customer can
|
||||||
// still flip it per shipment.
|
// still flip it per shipment.
|
||||||
withReturn: contract.equipmentReturn === "WITH_RETURN",
|
withReturn: contract.equipmentReturn === "WITH_RETURN",
|
||||||
// ponytail: ETB-only for now — preset since there is no other choice.
|
// Starts empty so the choice is deliberate (schema requires it).
|
||||||
paymentCurrency: "ETB",
|
// Intercity hides the field entirely, so it keeps the forced ETB.
|
||||||
|
paymentCurrency: contract.tradeDirection === "DOMESTIC" ? "ETB" : "",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(
|
resolver: zodResolver(
|
||||||
createShipmentFormSchema({
|
createShipmentFormSchema({
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import {
|
|||||||
Loader,
|
Loader,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
Paper,
|
Paper,
|
||||||
SegmentedControl,
|
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { CurrencySelector } from "@edr/ui-common";
|
||||||
import { AlertCircle, ArrowLeft, CalendarDays, Send } from "lucide-react";
|
import { AlertCircle, ArrowLeft, CalendarDays, Send } from "lucide-react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
@@ -36,7 +36,10 @@ export default function NewShipmentRequestPage() {
|
|||||||
const [bulkAmount, setBulkAmount] = useState<number | string>("");
|
const [bulkAmount, setBulkAmount] = useState<number | string>("");
|
||||||
// GL books this shipment on the customer's behalf, so the currency they want
|
// GL books this shipment on the customer's behalf, so the currency they want
|
||||||
// to be invoiced in has to be stated here — the contract itself quotes USD.
|
// to be invoiced in has to be stated here — the contract itself quotes USD.
|
||||||
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB">("USD");
|
// Starts empty so the billing-currency choice is deliberate — required at
|
||||||
|
// submit. Intercity/export are forced to ETB (server-enforced too).
|
||||||
|
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "">("");
|
||||||
|
const [currencyError, setCurrencyError] = useState<string | undefined>();
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
const { data: contract, isLoading } = useQuery({
|
const { data: contract, isLoading } = useQuery({
|
||||||
@@ -114,10 +117,15 @@ export default function NewShipmentRequestPage() {
|
|||||||
const hasOdd20ft = ft20Requested % 2 === 1;
|
const hasOdd20ft = ft20Requested % 2 === 1;
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
|
if (!isIntercity && !isExport && !paymentCurrency) {
|
||||||
|
setCurrencyError("Select the billing currency for this shipment.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const dto: Freight.CreateBookingRequestDto = {
|
const dto: Freight.CreateBookingRequestDto = {
|
||||||
contractRouteId: route?.id,
|
contractRouteId: route?.id,
|
||||||
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
|
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
|
||||||
paymentCurrency: isIntercity || isExport ? "ETB" : paymentCurrency,
|
paymentCurrency:
|
||||||
|
isIntercity || isExport ? "ETB" : (paymentCurrency as "USD" | "ETB"),
|
||||||
notes: notes.trim() || undefined,
|
notes: notes.trim() || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -251,20 +259,15 @@ export default function NewShipmentRequestPage() {
|
|||||||
? "Export shipments are invoiced in ETB."
|
? "Export shipments are invoiced in ETB."
|
||||||
: "Your contract is quoted in USD. Global Logistics will book this shipment and invoice you in the currency you pick here."}
|
: "Your contract is quoted in USD. Global Logistics will book this shipment and invoice you in the currency you pick here."}
|
||||||
</Text>
|
</Text>
|
||||||
<SegmentedControl
|
<CurrencySelector
|
||||||
value={isIntercity || isExport ? "ETB" : paymentCurrency}
|
value={isIntercity || isExport ? "ETB" : paymentCurrency}
|
||||||
onChange={(v) => setPaymentCurrency(v as "USD" | "ETB")}
|
onChange={(v) => {
|
||||||
|
setPaymentCurrency(v);
|
||||||
|
setCurrencyError(undefined);
|
||||||
|
}}
|
||||||
disabled={isIntercity || isExport}
|
disabled={isIntercity || isExport}
|
||||||
data={
|
allowUsd={!isIntercity && !isExport}
|
||||||
isExport
|
error={currencyError}
|
||||||
? [{ label: "ETB", value: "ETB" }]
|
|
||||||
: [
|
|
||||||
{ label: "USD", value: "USD" },
|
|
||||||
{ label: "ETB", value: "ETB" },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
color="teal"
|
|
||||||
radius={10}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user