Merge pull request #1402 from Tria-plc/freight_feature/usermanagement

add draft declaration
This commit is contained in:
marshal
2026-08-23 22:03:22 +03:00
committed by GitHub
10 changed files with 201 additions and 35 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,7 +386,19 @@ function WagonYardBadge({
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Move wagon to yard</Menu.Label>
{(yardsQuery.data ?? []).map((y) => (
<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}
@@ -389,6 +407,12 @@ function WagonYardBadge({
{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>
);

View File

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

View File

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

View File

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

View File

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

View File

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