diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 03f13d186..04ebc2d41 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -1633,6 +1633,23 @@ export class BookingsController { 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") @PortalCustomer() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 2c069d6aa..b4cead816 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -785,6 +785,50 @@ export class BookingClearanceService { 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 { + 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 * real customs declaration. diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index e80eb9e99..dd47ae36e 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -301,8 +301,9 @@ export default function GlCreateBookingForm() { const [trainScheduleId, setTrainScheduleId] = useState(""); const [contractRouteId, setContractRouteId] = useState(null); const [notes, setNotes] = useState(""); - // ponytail: ETB-only for now — widen back to "USD" | "ETB" when multi-currency billing returns. - const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB">("ETB"); + // IMPORT bookings pick ETB or USD — starts empty so the choice is + // 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). const [cargoDescription, setCargoDescription] = useState(""); const [containerLines, setContainerLines] = useState([]); @@ -1022,8 +1023,21 @@ export default function GlCreateBookingForm() { 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 = - cargoValid && !oddBlocksSubmit && !dateError && !routeError && !partnerError; + cargoValid && + !oddBlocksSubmit && + !dateError && + !routeError && + !partnerError && + !currencyError; /** The create-booking DTO from the current form state — shared by the * authoritative price preview and the actual submit so what GL confirms is @@ -1033,7 +1047,7 @@ export default function GlCreateBookingForm() { const payload: Freight.CreateBookingUnderContractDto = { ...(contractRouteId ? { contractRouteId } : {}), - paymentCurrency, + paymentCurrency: effectiveCurrency, // Intercity bookings carry no date — staff assign a passing train later. ...(scheduledDate ? { scheduledDate: new Date(scheduledDate).toISOString() } @@ -1100,7 +1114,7 @@ export default function GlCreateBookingForm() { if (!partner || !consolidationActive) return null; const payload: Freight.CreateBookingUnderContractDto = { - paymentCurrency, + paymentCurrency: effectiveCurrency, ...(scheduledDate ? { scheduledDate: new Date(scheduledDate).toISOString() } : {}), @@ -2138,10 +2152,11 @@ export default function GlCreateBookingForm() { : "Shipments are invoiced in ETB."} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index b19c3af41..07ca7f735 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -1835,13 +1835,59 @@ function DraftDeclarationStep({ ); const [currency, setCurrency] = useState(clearance.draftDeclaration?.currency ?? "ETB"); 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 existingFiles = clearance.draftDeclaration?.files ?? []; const replaceMode = existingFiles.length > 0; + if (!sendDraft && !replaceMode) { + return ( + + setSendDraft(e.currentTarget.checked)} + /> + + + ); + } + return ( + {!replaceMode ? ( + setSendDraft(e.currentTarget.checked)} + /> + ) : null} {/* The customer sent this draft back — their words drive the correction, so they lead the step. */} {changeRequest ? ( diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/ConsistWagonList.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/ConsistWagonList.tsx index 02c4e703a..ad415e522 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/ConsistWagonList.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/ConsistWagonList.tsx @@ -15,6 +15,7 @@ import { Group, Menu, Paper, + ScrollArea, Select, Stack, Text, @@ -345,6 +346,11 @@ function WagonYardBadge({ 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) { return wagon.currentYard ? ( + setYardFilter("")}> Move wagon to yard - {(yardsQuery.data ?? []).map((y) => ( - onChange(wagon.id, y.id)} - > - {y.label ?? y.code} - - ))} + + } + value={yardFilter} + onChange={(e) => setYardFilter(e.currentTarget.value)} + // A keypress inside the menu must type, not jump menu focus. + onKeyDown={(e) => e.stopPropagation()} + /> + + + {filteredYards.map((y) => ( + onChange(wagon.id, y.id)} + > + {y.label ?? y.code} + + ))} + {filteredYards.length === 0 ? ( + + No yard matches + + ) : null} + ); diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointTimeModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointTimeModal.tsx index 6d748b559..357585656 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointTimeModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointTimeModal.tsx @@ -1,5 +1,6 @@ import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core"; import { DateTimePicker } from "@mantine/dates"; +import { useMediaQuery } from "@mantine/hooks"; import { useEffect, useState } from "react"; /** @@ -34,6 +35,7 @@ export function CheckpointTimeModal({ loading: boolean; onSubmit: (values: { occurredAt: string; note: string }) => void; }) { + const isSmallScreen = useMediaQuery("(max-width: 48em)"); const [at, setAt] = useState(null); const [note, setNote] = useState(""); useEffect(() => { @@ -47,6 +49,7 @@ export function CheckpointTimeModal({ opened={opened} onClose={onClose} centered + fullScreen={isSmallScreen} radius="lg" title={ @@ -67,6 +70,8 @@ export function CheckpointTimeModal({ value={at} onChange={(v) => setAt(v ? new Date(v) : null)} maxDate={new Date()} + dropdownType={isSmallScreen ? "modal" : "popover"} + popoverProps={{ withinPortal: true }} valueFormat="DD MMM YYYY HH:mm" clearable={false} radius="md" diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index bb3d88d2a..1fa8aa926 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -240,6 +240,8 @@ export const URL_CONSTANTS = { CLEARANCE_DUTY: (id: string) => `/bookings/${id}/clearance/duty`, CLEARANCE_DRAFT_DECLARATION: (id: string) => `/bookings/${id}/clearance/draft-declaration`, + CLEARANCE_DRAFT_DECLARATION_SKIP: (id: string) => + `/bookings/${id}/clearance/draft-declaration/skip`, CLEARANCE_TRANSIT_ASSIGNEE_REQUEST: (id: string) => `/bookings/${id}/clearance/transit-assignee/request`, CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index f6768de29..28c700529 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -705,6 +705,10 @@ export const bookingsService = { 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(B.CLEARANCE_DRAFT_DECLARATION_SKIP(id)), + finalizePreClearance: (id: string) => postBooking(B.CLEARANCE_FINALIZE_PRE(id)), diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index 783554b85..f27e75d7c 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -280,7 +280,12 @@ function mapBookingToShipmentValues( }>; }; const values: Partial = { - 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", cargoDescription: b.cargoFreeText ?? "", ...(b.contractRouteId ? { contractRouteId: b.contractRouteId } : {}), @@ -389,8 +394,9 @@ function NewShipmentBookingForm({ // Seed the equipment-return toggle from the contract; the customer can // still flip it per shipment. withReturn: contract.equipmentReturn === "WITH_RETURN", - // ponytail: ETB-only for now — preset since there is no other choice. - paymentCurrency: "ETB", + // Starts empty so the choice is deliberate (schema requires it). + // Intercity hides the field entirely, so it keeps the forced ETB. + paymentCurrency: contract.tradeDirection === "DOMESTIC" ? "ETB" : "", }, resolver: zodResolver( createShipmentFormSchema({ diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx index 76f568f5d..ce19146d2 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx @@ -9,12 +9,12 @@ import { Loader, NumberInput, Paper, - SegmentedControl, Stack, Text, Textarea, Title, } from "@mantine/core"; +import { CurrencySelector } from "@edr/ui-common"; import { AlertCircle, ArrowLeft, CalendarDays, Send } from "lucide-react"; import toast from "react-hot-toast"; import type { Freight } from "@edr/types"; @@ -36,7 +36,10 @@ export default function NewShipmentRequestPage() { const [bulkAmount, setBulkAmount] = useState(""); // 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. - 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(); const [notes, setNotes] = useState(""); const { data: contract, isLoading } = useQuery({ @@ -114,10 +117,15 @@ export default function NewShipmentRequestPage() { const hasOdd20ft = ft20Requested % 2 === 1; const handleSubmit = () => { + if (!isIntercity && !isExport && !paymentCurrency) { + setCurrencyError("Select the billing currency for this shipment."); + return; + } const dto: Freight.CreateBookingRequestDto = { contractRouteId: route?.id, scheduledDate: hasCustoms ? undefined : scheduledDate || undefined, - paymentCurrency: isIntercity || isExport ? "ETB" : paymentCurrency, + paymentCurrency: + isIntercity || isExport ? "ETB" : (paymentCurrency as "USD" | "ETB"), notes: notes.trim() || undefined, }; @@ -251,20 +259,15 @@ export default function NewShipmentRequestPage() { ? "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."} - setPaymentCurrency(v as "USD" | "ETB")} + onChange={(v) => { + setPaymentCurrency(v); + setCurrencyError(undefined); + }} disabled={isIntercity || isExport} - data={ - isExport - ? [{ label: "ETB", value: "ETB" }] - : [ - { label: "USD", value: "USD" }, - { label: "ETB", value: "ETB" }, - ] - } - color="teal" - radius={10} + allowUsd={!isIntercity && !isExport} + error={currencyError} />