mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
add draft declaration
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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<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
|
||||
* real customs declaration.
|
||||
|
||||
@@ -301,8 +301,9 @@ export default function GlCreateBookingForm() {
|
||||
const [trainScheduleId, setTrainScheduleId] = useState("");
|
||||
const [contractRouteId, setContractRouteId] = useState<string | null>(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<ContainerLineDraft[]>([]);
|
||||
@@ -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."}
|
||||
</Text>
|
||||
<CurrencySelector
|
||||
value={isIntercity ? "ETB" : paymentCurrency}
|
||||
value={isImport ? paymentCurrency : "ETB"}
|
||||
onChange={setPaymentCurrency}
|
||||
disabled={isIntercity}
|
||||
disabled={!isImport}
|
||||
allowUsd={isImport}
|
||||
error={currencyError}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<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 (
|
||||
<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
|
||||
correction, so they lead the step. */}
|
||||
{changeRequest ? (
|
||||
|
||||
@@ -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 ? (
|
||||
<Badge
|
||||
@@ -359,7 +365,7 @@ function WagonYardBadge({
|
||||
) : null;
|
||||
}
|
||||
return (
|
||||
<Menu shadow="md" width={240} withinPortal>
|
||||
<Menu shadow="md" width={240} withinPortal onClose={() => setYardFilter("")}>
|
||||
<Menu.Target>
|
||||
<Badge
|
||||
component="button"
|
||||
@@ -380,15 +386,33 @@ function WagonYardBadge({
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Move wagon to yard</Menu.Label>
|
||||
{(yardsQuery.data ?? []).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>
|
||||
))}
|
||||
<Box px={8} pb={6}>
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder="Filter yards…"
|
||||
leftSection={<Search size={12} />}
|
||||
value={yardFilter}
|
||||
onChange={(e) => setYardFilter(e.currentTarget.value)}
|
||||
// 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>
|
||||
);
|
||||
|
||||
@@ -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<Date | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
useEffect(() => {
|
||||
@@ -47,6 +49,7 @@ export function CheckpointTimeModal({
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
fullScreen={isSmallScreen}
|
||||
radius="lg"
|
||||
title={
|
||||
<Group gap={8}>
|
||||
@@ -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"
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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<BookingDetail>(B.CLEARANCE_DRAFT_DECLARATION_SKIP(id)),
|
||||
|
||||
finalizePreClearance: (id: string) =>
|
||||
postBooking<BookingDetail>(B.CLEARANCE_FINALIZE_PRE(id)),
|
||||
|
||||
|
||||
@@ -280,7 +280,12 @@ function mapBookingToShipmentValues(
|
||||
}>;
|
||||
};
|
||||
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",
|
||||
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({
|
||||
|
||||
@@ -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<number | string>("");
|
||||
// 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<string | undefined>();
|
||||
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."}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
<CurrencySelector
|
||||
value={isIntercity || isExport ? "ETB" : paymentCurrency}
|
||||
onChange={(v) => 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}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user