From 1d77f377f3bf7418b1c1e7e6c34a75d80f6ae4ff Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 23 Jun 2026 07:54:36 +0000 Subject: [PATCH 01/19] enhance service filtering and dashboard functionality with company profile support --- .../modules/bookings/bookings.controller.ts | 13 +- .../src/modules/bookings/bookings.service.ts | 18 +-- .../bookings/dto/filter-booking.dto.ts | 9 ++ .../modules/companies/companies.controller.ts | 7 +- .../modules/companies/companies.service.ts | 28 ++-- .../companies/dto/dashboard-query.dto.ts | 13 ++ apps/edr-freight-web/portal/src/App.tsx | 11 +- .../portal/src/components/AppLayout.tsx | 144 ++++++------------ .../portal/src/components/ModeIndicator.tsx | 55 ------- .../portal/src/constants/profileMode.ts | 22 +-- .../src/pages/MyPortalPage/MyPortalPage.tsx | 31 +++- .../MyPortalPage/components/HelloSection.tsx | 2 - .../portal/src/pages/MyPortalPage/hooks.ts | 17 ++- .../portal/src/pages/bookings/MyBookings.tsx | 58 ++++++- .../pages/contracts/ContractDetailPage.tsx | 2 - .../src/pages/contracts/ContractsList.tsx | 2 - .../portal/src/services/api.ts | 2 +- .../portal/src/services/bookings.service.ts | 2 + .../portal/src/services/companies.service.ts | 5 +- 19 files changed, 206 insertions(+), 235 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/companies/dto/dashboard-query.dto.ts delete mode 100644 apps/edr-freight-web/portal/src/components/ModeIndicator.tsx 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 f3afb5722..0545f1f94 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -148,15 +148,10 @@ export class BookingsController { }, }; } - // Scope to the active operational profile (importer/exporter) when one - // resolves; otherwise fall back to company-level scoping. - const companyProfileId = - await this.bookingsService.resolveActiveCompanyProfileId(userId); - return this.bookingsService.findAll( - filter, - companyId, - companyProfileId ?? undefined, - ); + // Company-wide by default; the optional filter.companyProfileId (per-page + // service filter) narrows within the company. The company guard always + // applies, so a customer can only ever see their own company's bookings. + return this.bookingsService.findAll(filter, companyId); } @Get('by-company/:companyId/customer-view') diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index b6f3ee220..fddc1cf61 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -656,10 +656,11 @@ export class BookingsService { assignedToSchedule: filter.assignedToSchedule, // A forced company scope (portal/customer) overrides any caller-provided // companyId so a customer can only ever see their own company's bookings. - // When an active profile resolves, scope to it; otherwise fall back to the - // company so nothing breaks for not-yet-onboarded customers. - companyId: forceCompanyProfileId ? undefined : forceCompanyId ?? filter.companyId, - companyProfileId: forceCompanyProfileId, + // The company guard always applies; the optional companyProfileId filter + // (from the per-page service filter) narrows WITHIN the company — the repo + // ANDs both, so cross-company access is impossible. + companyId: forceCompanyId ?? filter.companyId, + companyProfileId: forceCompanyProfileId ?? filter.companyProfileId, contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, @@ -694,18 +695,15 @@ export class BookingsService { filter: FilterBookingDto, ): Promise { const { company } = await this.companiesService.getCompanyInfoByUserId(userId); - // Scope to the active operational profile when one resolves; fall back to - // company-level so not-yet-onboarded customers still see their payables. - const companyProfileId = - await this.companiesService.resolveActiveCompanyProfileId(userId); return this.bookingsRepository.findAllPaginated({ page: filter.page ?? 1, pageSize: filter.pageSize ?? 20, statuses: BookingsService.PAYABLE_STATUSES, excludePaymentStatus: 'PAID', - companyId: companyProfileId ? undefined : company.id, - companyProfileId: companyProfileId ?? undefined, + // Company-wide: payables span all of the customer's services. + companyId: company.id, + companyProfileId: filter.companyProfileId, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index d52473813..ee5099c52 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -38,6 +38,15 @@ export class FilterBookingDto { @IsUUID() companyId?: string; + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Narrow to a single operational profile (importer/exporter/freight_forwarder) within the company.', + }) + @IsOptional() + @IsUUID() + companyProfileId?: string; + @ApiPropertyOptional() @IsOptional() contractType?: string; diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index fff8fc7e5..2fba1e878 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -28,6 +28,7 @@ import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto"; import { SetActiveModeDto } from "./dto/set-active-mode.dto"; import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto"; import { StartOnboardingDto } from "./dto/start-onboarding.dto"; +import { DashboardQueryDto } from "./dto/dashboard-query.dto"; import { ResponseCompanyDto, ResponseCompanyProfileDto, @@ -86,8 +87,12 @@ export class CompaniesController { }) async getDashboard( @CurrentUser() user: CurrentIamUser, + @Query() query: DashboardQueryDto, ): Promise { - return this.companiesService.getDashboardSummary(user.id); + return this.companiesService.getDashboardSummary( + user.id, + query.companyProfileId, + ); } @Post("fetch-etrade-info") diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index a4466efb2..939803abf 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -7,7 +7,10 @@ import { import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; import { ExternalProfileRepository } from "./external-profile.repository"; -import { CompanyDashboardRepository } from "./company-dashboard.repository"; +import { + CompanyDashboardRepository, + DashboardScope, +} from "./company-dashboard.repository"; import { MinioService } from "../minio/minio.service"; import { ETradeService } from "./services/etrade.service"; import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; @@ -320,6 +323,7 @@ export class CompaniesService { */ async getDashboardSummary( userId: string, + companyProfileId?: string, ): Promise { // A user without a company profile has no bookings — return an empty summary // rather than 404, so the portal home still renders. @@ -327,17 +331,17 @@ export class CompaniesService { const companyId = profile?.company?.id ?? profile?.companyId ?? null; if (!companyId) return this.emptyDashboardSummary(); - // Scope KPIs to the active operational profile (importer/exporter mode) when - // one resolves; otherwise aggregate across the whole company. - const companyProfileId = profile?.activeProfileType - ? ((await this.companyProfilesRepo.findByType( - companyId, - profile.activeProfileType, - )) ?? null) - : null; - const scope = companyProfileId - ? { companyProfileId: companyProfileId.id } - : { companyId }; + // Company-wide by default (all services' data). An optional companyProfileId + // (from the per-page service filter) narrows to one operational profile — + // but only after we confirm it belongs to this user's company, since the + // dashboard scope has no company guard at the repository layer. + let scope: DashboardScope = { companyId }; + if (companyProfileId) { + const owned = await this.companyProfilesRepo.findByCompanyId(companyId); + if (owned.some((p) => p.id === companyProfileId)) { + scope = { companyProfileId }; + } + } const now = new Date(); const yearStart = new Date(now.getFullYear(), 0, 1); diff --git a/apps/edr-freight-api/src/modules/companies/dto/dashboard-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/dashboard-query.dto.ts new file mode 100644 index 000000000..c41c3279a --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/dashboard-query.dto.ts @@ -0,0 +1,13 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsOptional, IsUUID } from "class-validator"; + +export class DashboardQueryDto { + @ApiPropertyOptional({ + format: "uuid", + description: + "Narrow dashboard KPIs to a single operational profile (importer/exporter/freight_forwarder) of the user's company. Omit for company-wide totals.", + }) + @IsOptional() + @IsUUID() + companyProfileId?: string; +} diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 15137cb26..e72178452 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -228,14 +228,7 @@ const sidebarItems: SidebarItem[] = [ const App = () => { const navigate = useNavigate(); const location = useLocation(); - const { - user, - company, - activeProfileType, - companyType, - switchMode, - createProfileAndSwitch, - } = useAuth(); + const { user, company, companyType, createProfileAndSwitch } = useAuth(); const displayName = user?.name?.en || user?.username || user?.email || "User"; const userEmail = user?.email; @@ -278,8 +271,6 @@ const App = () => { userEmail={userEmail} companyProfiles={companyProfiles} companyType={companyType} - activeProfileType={activeProfileType} - onSwitchMode={switchMode} onCreateProfile={createProfileAndSwitch} > diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index 7f86ead43..284497af2 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -19,9 +19,7 @@ import { } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { - ArrowLeftRight, Bell, - Check, ChevronDown, FileSignature, LogOut, @@ -61,13 +59,9 @@ export interface AppLayoutProps { userEmail?: string; /** Operational profiles for the company — surfaced as reference chips in the account menu. */ companyProfiles?: { type: string; reference: string; status?: string }[]; - /** Company type (e.g. "customer", "forwarder") — gates the importer/exporter switch. */ + /** Company type (e.g. "customer", "forwarder") — gates the "Add service" control. */ companyType?: string | null; - /** The active operational mode (importer/exporter/...). */ - activeProfileType?: string | null; - /** Switch to an existing profile of the given type. */ - onSwitchMode?: (type: ServiceType) => Promise | void; - /** Create the profile of the given type (with business license) then switch. */ + /** Create a new service profile of the given type (with business license). */ onCreateProfile?: ( type: ServiceType, licenseFiles: File[], @@ -146,8 +140,6 @@ export function AppLayout({ userEmail, companyProfiles = [], companyType, - activeProfileType, - onSwitchMode, onCreateProfile, children, }: AppLayoutProps) { @@ -174,16 +166,16 @@ export function AppLayout({ const initials = getInitials(userName); const activePage = getActivePage(sidebarItems, activePath); - // ── Service selection (customer companies only) ── - // A customer can operate as importer, exporter and/or freight forwarder, - // and switch between whichever service profiles their company has. + // ── Add a service (customer companies only) ── + // A customer can operate as importer, exporter and/or freight forwarder. The + // header lets them ADD a service they don't have yet (creating a profile with + // its business license). Data is no longer scoped by an "active" service — + // every page shows all the company's data, with an optional per-page filter. const isCustomer = companyType === "customer"; - const canSwitch = - isCustomer && - CUSTOMER_SERVICES.includes(activeProfileType as ServiceType); - const profileExists = (type: ServiceType) => companyProfiles.some((p) => p.type === type); + const addableServices = CUSTOMER_SERVICES.filter((t) => !profileExists(t)); + const canAddService = isCustomer && addableServices.length > 0; const [switching, setSwitching] = useState(false); const [createOpen, setCreateOpen] = useState(false); @@ -191,22 +183,12 @@ export function AppLayout({ const [licenseFiles, setLicenseFiles] = useState([]); const [createError, setCreateError] = useState(null); - const handleSelectService = async (type: ServiceType) => { - if (type === activeProfileType) return; - if (profileExists(type)) { - setSwitching(true); - try { - await onSwitchMode?.(type); - } finally { - setSwitching(false); - } - } else { - // No profile yet — collect a business license, then create + switch. - setCreateTarget(type); - setLicenseFiles([]); - setCreateError(null); - setCreateOpen(true); - } + const handleAddService = (type: ServiceType) => { + // Collect a business license, then create the profile. + setCreateTarget(type); + setLicenseFiles([]); + setCreateError(null); + setCreateOpen(true); }; const handleCreateConfirm = async () => { @@ -302,8 +284,8 @@ export function AppLayout({ {/* Right: switch + search + bell + avatar */} - {/* Service selector (customer companies only) */} - {canSwitch && ( + {/* Add a service (customer companies that don't yet have all three) */} + {canAddService && ( } + leftSection={} rightSection={} styles={{ root: { height: 36 } }} visibleFrom="xs" > - {serviceLabel(activeProfileType as ServiceType)} + Add service - Select service - {CUSTOMER_SERVICES.map((type) => { - const isActive = type === activeProfileType; - const exists = profileExists(type); - return ( - handleSelectService(type)} - leftSection={ - isActive ? ( - - ) : exists ? ( - - ) : ( - - ) - } - disabled={isActive} - > - {serviceLabel(type)} - {!exists && ( - - (set up) - - )} - - ); - })} + Add a service + {addableServices.map((type) => ( + handleAddService(type)} + leftSection={} + > + {serviceLabel(type)} + + ))} )} @@ -468,39 +432,25 @@ export function AppLayout({ - {companyProfiles.map((p) => { - const isActive = p.type === activeProfileType; - return ( - ( + + - - {isActive && ( - - )} - - {PROFILE_TYPE_LABELS[p.type] ?? p.type} - - - - {p.reference} - - - ); - })} + {PROFILE_TYPE_LABELS[p.type] ?? p.type} + + + {p.reference} + + + ))} diff --git a/apps/edr-freight-web/portal/src/components/ModeIndicator.tsx b/apps/edr-freight-web/portal/src/components/ModeIndicator.tsx deleted file mode 100644 index ff8f33bfb..000000000 --- a/apps/edr-freight-web/portal/src/components/ModeIndicator.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Badge, Tooltip } from "@mantine/core"; -import { ArrowDownToLine, ArrowUpFromLine } from "lucide-react"; - -import useAuth from "@/hooks/useAuth"; -import { modeDataDescription, modeDataLabel } from "@/constants/profileMode"; - -interface ModeIndicatorProps { - /** Mantine size token for the badge. */ - size?: "sm" | "md" | "lg"; -} - -/** - * Small pill showing which operational mode's data is currently on screen - * (Import / Export). The data itself is scoped server-side by the active - * profile; this just makes the scope visible. Switching is done via the header - * button — this is read-only. - * - * Renders nothing for non-customer companies or when no import/export mode is - * active, so it never interferes with forwarders or not-yet-onboarded users. - */ -export function ModeIndicator({ size = "md" }: ModeIndicatorProps) { - const { companyType, activeProfileType } = useAuth(); - - if (companyType !== "customer") return null; - - const label = modeDataLabel(activeProfileType); - if (!label) return null; - - const isImport = activeProfileType === "importer"; - - return ( - - - ) : ( - - ) - } - styles={{ - root: { textTransform: "none", letterSpacing: 0, fontWeight: 600 }, - }} - > - Viewing: {label} - - - ); -} - -export default ModeIndicator; diff --git a/apps/edr-freight-web/portal/src/constants/profileMode.ts b/apps/edr-freight-web/portal/src/constants/profileMode.ts index a73901ccb..7629ad34b 100644 --- a/apps/edr-freight-web/portal/src/constants/profileMode.ts +++ b/apps/edr-freight-web/portal/src/constants/profileMode.ts @@ -1,6 +1,6 @@ /** - * Operational-mode (importer/exporter/…) labels and helpers, shared by the app - * header and the per-page mode indicator so there is a single source of truth. + * Operational-service (importer/exporter/…) display labels, shared by the app + * header and the per-page service filters so there is a single source of truth. */ export const PROFILE_TYPE_LABELS: Record = { @@ -10,21 +10,3 @@ export const PROFILE_TYPE_LABELS: Record = { dj_freight_forwarder: "DJ Freight Forwarder", transporter: "Transporter", }; - -/** The data-scope label shown to the user (importer ⇒ "Import", exporter ⇒ "Export"). */ -export function modeDataLabel( - activeProfileType?: string | null, -): string | null { - if (activeProfileType === "importer") return "Import"; - if (activeProfileType === "exporter") return "Export"; - return null; -} - -/** Short helper sentence describing what the active mode scopes. */ -export function modeDataDescription( - activeProfileType?: string | null, -): string { - const label = modeDataLabel(activeProfileType); - if (!label) return ""; - return `Showing your ${label.toLowerCase()} data — switch in the header.`; -} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx index 0a4dcb6ce..4da182283 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx @@ -1,7 +1,9 @@ import type { Currency } from "@/pages/billing/invoices.mock"; import { formatCurrency } from "@/pages/billing/invoices.mock"; -import { Grid, Stack } from "@mantine/core"; +import { Group, Grid, Select, Stack } from "@mantine/core"; +import { useState } from "react"; import { useNavigate } from "react-router-dom"; +import { PROFILE_TYPE_LABELS } from "@/constants/profileMode"; import { FreightVolumeSection, HelloSection, @@ -15,8 +17,12 @@ import { useMyPortalData } from "./hooks"; export default function MyPortalPage() { const navigate = useNavigate(); + const [selectedProfileId, setSelectedProfileId] = useState( + null, + ); const { customer, + companyProfiles, bookingsQuery, dashboardQuery, allBookings, @@ -30,7 +36,12 @@ export default function MyPortalPage() { dashboard, volumePoints, maxVolume, - } = useMyPortalData(); + } = useMyPortalData(selectedProfileId ?? undefined); + + const serviceOptions = companyProfiles.map((p) => ({ + value: p.id, + label: `${PROFILE_TYPE_LABELS[p.type] ?? p.type} · ${p.reference}`, + })); const handleBookingClick = (id: string) => { navigate(`/bookings/${id}`); @@ -40,6 +51,22 @@ export default function MyPortalPage() { + {serviceOptions.length > 1 && ( + + { + setServiceFilter(v); + resetPage(); + }} + clearable + radius="md" + comboboxProps={{ withinPortal: true }} + style={{ width: 200 }} + aria-label="Filter by service" + /> + )} - General contract · {isContainer ? "Containerised" : "Bulk"} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx index 40ebd4f45..34bcc37a3 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx @@ -25,7 +25,6 @@ import { type ColumnDef, usePagination, } from "@edr/ui-common"; -import { ModeIndicator } from "@/components/ModeIndicator"; import { CargoModeCell, PaymentBadge } from "../bookings/booking-display"; import { ContractStatusBadge, GREEN, INK, MUTED } from "./contract-ui"; @@ -177,7 +176,6 @@ export default function ContractsList() { General Contracts - Reserve a quantity once, then place orders against it until the diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 9ebd1cc01..bf170cfac 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -128,7 +128,7 @@ export const api = { companiesService.updateProfile, ), - getDashboard: endpoint( + getDashboard: endpoint( "companies", "getDashboard", companiesService.getDashboard, diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index a95026f93..0631a49aa 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -75,6 +75,8 @@ export interface BookingListFilter { freightType?: string; /** IMPORT / EXPORT / DOMESTIC. */ tradeDirection?: string; + /** Narrow to a single operational profile (importer/exporter/freight_forwarder). */ + companyProfileId?: string; /** Created-date range (ISO). */ createdFrom?: string; createdTo?: string; diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index 791c0f8de..a5905589b 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -162,9 +162,12 @@ export const companiesService = { return unwrap(response.data); }, - getDashboard: async (): Promise => { + getDashboard: async ( + companyProfileId?: string, + ): Promise => { const response = await client.get>( URL_CONSTANTS.COMPANIES_API.DASHBOARD, + { params: companyProfileId ? { companyProfileId } : undefined }, ); return unwrap(response.data); }, From 6fd1b68389d25bc401c7e45778a7d4683e8a484f Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 23 Jun 2026 08:37:15 +0000 Subject: [PATCH 02/19] enhance company approval workflow and TIN validation; add pending approval handling in booking and onboarding forms --- .../src/modules/bookings/bookings.service.ts | 7 + .../modules/companies/companies.service.ts | 3 +- .../companies/dto/create-company.dto.ts | 4 +- .../companies/dto/update-profile.dto.ts | 4 +- .../pages/customers/CustomerDetailPage.tsx | 20 +- .../backoffice/src/services/api.ts | 12 ++ .../src/services/customers.service.ts | 7 + apps/edr-freight-web/portal/src/App.tsx | 19 +- .../src/components/onboarding/ETradeInfo.tsx | 4 +- .../portal/src/hooks/useAuth.ts | 5 + .../src/pages/accounts/CompanyProfileForm.tsx | 194 +++++++++++++----- .../src/pages/bookings/NewBookingPage.tsx | 34 +++ .../new-booking-form/step5-cargo-details.tsx | 22 ++ 13 files changed, 276 insertions(+), 59 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index fddc1cf61..cac19f111 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -11,6 +11,7 @@ import { Freight, SchedulingStatus } from '@edr/types'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; import { ProfileType } from '../companies/entities/company-profile.entity'; +import { CompanyStatus } from '../companies/entities/company.entity'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { eatDay } from '../train-scheduling/batch-window.util'; import { FilesService } from '../files/files.service'; @@ -287,6 +288,12 @@ export class BookingsService { ); } const { company } = await this.companiesService.getCompanyInfoByUserId(userId); + // A customer can only book once their company has been approved. + if (company.status !== CompanyStatus.Active) { + throw new ForbiddenException( + "Your company is awaiting approval — you can't create bookings yet.", + ); + } companyId = company.id; } diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 939803abf..e57d3b5f8 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -844,8 +844,9 @@ export class CompaniesService { onboardingCompleted: true, onboardingStep: "done", }); + // Awaiting backoffice approval — stays Pending until an admin activates it. await this.companiesRepo.update(companyId, { - status: CompanyStatus.Active, + status: CompanyStatus.Pending, }); return this.getCompanyInfoByUserId(userId); } diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts index 0a699fe5e..e5b686d11 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -18,7 +18,9 @@ export class CreateCompanyDto { @IsString() @IsNotEmpty() @Length(10, 10) - @Matches(/^\d+$/, { message: 'TIN must contain only digits' }) + @Matches(/^00\d{8}$/, { + message: 'TIN must be 10 digits starting with 00', + }) tin!: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index d94cb5f35..c99ef9d3c 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -35,7 +35,9 @@ export class UpdateProfileDto { @IsOptional() @IsString() @Length(10, 10) - @Matches(/^\d+$/, { message: 'TIN must contain only digits' }) + @Matches(/^00\d{8}$/, { + message: 'TIN must be 10 digits starting with 00', + }) tin?: string; @IsOptional() diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index f38e8c38b..b1e05ab3d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -22,7 +22,7 @@ import { LayoutGrid, Package, } from "lucide-react"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import { useNavigate, useParams } from "react-router-dom"; @@ -84,6 +84,9 @@ export default function CustomerDetailPage() { enabled: Boolean(id), }), ); + const approveMutation = useMutation( + api.customers.setCompanyStatus.mutationOptions(), + ); const bookingsQuery = useQuery( api.customers.bookings.queryOptions({ input: { id: id ?? "" }, @@ -398,6 +401,21 @@ export default function CustomerDetailPage() { + {company.status === "pending" && ( + + )} } /> diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 86d14ef7d..fdaa1a6cb 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -1947,6 +1947,18 @@ export const api = { QUERY_KEYS.CUSTOMERS.ROOT, ], ), + + setCompanyStatus: endpoint<{ companyId: string; status: string }, unknown>( + "customers", + "setCompanyStatus", + ({ companyId, status }) => + customersService.setCompanyStatus(companyId, status), + undefined, + (input) => [ + QUERY_KEYS.CUSTOMERS.byId(input.companyId), + QUERY_KEYS.CUSTOMERS.ROOT, + ], + ), }, overview: { diff --git a/apps/edr-freight-web/backoffice/src/services/customers.service.ts b/apps/edr-freight-web/backoffice/src/services/customers.service.ts index d375ad7a7..8a8124ecd 100644 --- a/apps/edr-freight-web/backoffice/src/services/customers.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/customers.service.ts @@ -87,4 +87,11 @@ export const customersService = { ) .then((r) => r.data); }, + + /** Approve / change a company's status (e.g. pending → active). */ + setCompanyStatus(companyId: string, status: string): Promise { + return apiClient + .patch(URL_CONSTANTS.COMPANIES.BY_ID(companyId), { status }) + .then((r) => r.data); + }, }; diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index e72178452..fe93865c7 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -1,6 +1,7 @@ import { AppLayout, type SidebarItem } from "@/components/AppLayout"; import { CalendarCheck, + Clock, Home, Layers, Loader2, @@ -109,11 +110,13 @@ function isOnboardingAllowedPath(pathname: string): boolean { * as users who haven't completed onboarding. */ function OnboardingGate() { - const { company, onboardingCompleted } = useAuth(); + const { company, onboardingCompleted, companyStatus } = useAuth(); const location = useLocation(); const needsOnboarding = !company || !onboardingCompleted; const allowedHere = isOnboardingAllowedPath(location.pathname); + // Onboarding done but not yet approved by an admin → awaiting-approval state. + const awaitingApproval = !needsOnboarding && companyStatus === "pending"; // Open by default while onboarding is pending (covers the login case). const [wizardOpen, { open: openWizard, close: closeWizard }] = @@ -146,6 +149,7 @@ function OnboardingGate() { {needsOnboarding && !wizardOpen && ( )} + {awaitingApproval && } void }) { ); } +/** Shown after onboarding while the company awaits backoffice approval. */ +function PendingApprovalBanner() { + return ( +
+ + + Your company is awaiting EDR approval. You can browse, but creating + bookings is disabled until your company is approved. + +
+ ); +} + /** Keeps authenticated users off the login/signup pages. */ function RedirectIfAuthed() { const { isPending, isAuthenticated } = useAuth(); diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx index 5fcfadaa1..41dc33886 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -33,7 +33,7 @@ export default function ETradeInfo({ const hasData = mutation.data; const handleFetch = async () => { - if (!tin || tin.length !== 10) return; + if (!tin || tin.length !== 10 || !tin.startsWith("00")) return; const result = await mutation.mutateAsync(tin); if (result) { onDataLoaded(result); @@ -60,7 +60,7 @@ export default function ETradeInfo({ variant="filled" color="edr-green" onClick={handleFetch} - disabled={!tin || tin.length !== 10 || isLoading} + disabled={!tin || tin.length !== 10 || !tin.startsWith("00") || isLoading} leftSection={ isLoading ? : } diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index 0f4f4e6a1..ec54a9a30 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -157,6 +157,9 @@ const useAuth = () => { const activeCompanyProfileId = companyInfo?.profile?.activeCompanyProfileId ?? null; const companyType = companyInfo?.company?.type ?? null; + const companyStatus = companyInfo?.company?.status ?? null; + // A company can create bookings only once an admin has approved it (active). + const isCompanyApproved = companyStatus === "active"; const onboardingCompleted = companyInfo?.profile?.onboardingCompleted ?? false; const onboardingStep = companyInfo?.profile?.onboardingStep ?? null; @@ -230,6 +233,8 @@ const useAuth = () => { activeProfileType, activeCompanyProfileId, companyType, + companyStatus, + isCompanyApproved, onboardingCompleted, onboardingStep, switchMode, diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 29055a2e8..69aabae53 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -55,7 +55,8 @@ type CompanyStep = | "additional"; const onboardingSchema = z.object({ - companyName: z.string().min(1, "Company name is required"), + companyFirstName: z.string().min(1, "First name is required"), + companyLastName: z.string().min(1, "Last name is required"), companyEmail: z.string().email("Invalid email address"), companyPhone: z .string() @@ -65,7 +66,10 @@ const onboardingSchema = z.object({ // Derived from the eTrade address parts (kebele/woreda/zone/region); no // standalone input — the granular fields live in the registration section. companyAddress: z.string().optional(), - tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), + tinNumber: z + .string() + .length(10, "TIN must be exactly 10 digits") + .regex(/^00\d{8}$/, "TIN must be 10 digits starting with 00"), vatNumber: z .string() .min(1, "VAT number is required") @@ -83,7 +87,12 @@ const onboardingSchema = z.object({ kebele: z.string().optional(), houseNo: z.string().optional(), etradePhone: z.string().optional(), - contactPersonName: z.string().min(1, "Contact person name is required"), + contactPersonFirstName: z + .string() + .min(1, "Contact person first name is required"), + contactPersonLastName: z + .string() + .min(1, "Contact person last name is required"), contactPersonPosition: z.string().optional(), contactPersonEmail: z .string() @@ -94,13 +103,15 @@ const onboardingSchema = z.object({ .string() .min(1, "Contact person phone is required") .refine(isValidPhone, "Enter a valid phone number"), - generalManagerName: z.string().min(1, "GM name is required"), + generalManagerFirstName: z.string().min(1, "GM first name is required"), + generalManagerLastName: z.string().min(1, "GM last name is required"), generalManagerEmail: z.string().email("Invalid GM email"), generalManagerPhone: z .string() .min(1, "GM phone is required") .refine(isValidPhone, "Enter a valid phone number"), - poaName: z.string().optional(), + poaFirstName: z.string().optional(), + poaLastName: z.string().optional(), poaPhone: z .string() .optional() @@ -114,7 +125,8 @@ type FormData = z.infer; const stepFields: Record = { company: [ - "companyName", + "companyFirstName", + "companyLastName", "companyEmail", "companyPhone", "companyLocation", @@ -136,12 +148,14 @@ const stepFields: Record = { "etradePhone", ], personnel: [ - "generalManagerName", + "generalManagerFirstName", + "generalManagerLastName", "generalManagerEmail", "generalManagerPhone", ], contact: [ - "contactPersonName", + "contactPersonFirstName", + "contactPersonLastName", "contactPersonPosition", "contactPersonEmail", "contactPersonPhone", @@ -151,9 +165,23 @@ const stepFields: Record = { additional: [], }; +/** Join first + last into the single name the API stores. */ +function joinName(first?: string, last?: string): string { + return [first?.trim(), last?.trim()].filter(Boolean).join(" "); +} + +/** Split a stored single name into first (first token) + last (the rest). */ +function splitName(full?: string | null): { first: string; last: string } { + const trimmed = (full ?? "").trim(); + if (!trimmed) return { first: "", last: "" }; + const idx = trimmed.indexOf(" "); + if (idx === -1) return { first: trimmed, last: "" }; + return { first: trimmed.slice(0, idx), last: trimmed.slice(idx + 1).trim() }; +} + function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { return { - companyName: data.companyName, + companyName: joinName(data.companyFirstName, data.companyLastName), companyEmail: data.companyEmail, companyPhone: data.companyPhone, companyLocation: data.companyLocation, @@ -162,14 +190,20 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { vatNumber: data.vatNumber, fanNumber: data.fanNumber, attributes: { - contactPersonName: data.contactPersonName, + contactPersonName: joinName( + data.contactPersonFirstName, + data.contactPersonLastName, + ), contactPersonPosition: data.contactPersonPosition || undefined, contactPersonEmail: data.contactPersonEmail || undefined, contactPersonPhone: data.contactPersonPhone, - generalManagerName: data.generalManagerName, + generalManagerName: joinName( + data.generalManagerFirstName, + data.generalManagerLastName, + ), generalManagerEmail: data.generalManagerEmail, generalManagerPhone: data.generalManagerPhone, - poaName: data.poaName || undefined, + poaName: joinName(data.poaFirstName, data.poaLastName) || undefined, poaPhone: data.poaPhone || undefined, poaAddress: data.poaAddress || undefined, poaEmail: data.poaEmail || undefined, @@ -183,7 +217,7 @@ function stepPayload(step: CompanyStep, d: FormData): Partial({ resolver: zodResolver(onboardingSchema), defaultValues: { - companyName: "", + companyFirstName: "", + companyLastName: "", companyEmail: "", companyPhone: "", companyLocation: "", @@ -380,14 +429,17 @@ export default function CompanyProfileForm({ kebele: "", houseNo: "", etradePhone: "", - contactPersonName: "", + contactPersonFirstName: "", + contactPersonLastName: "", contactPersonPosition: "", contactPersonEmail: "", contactPersonPhone: "", - generalManagerName: "", + generalManagerFirstName: "", + generalManagerLastName: "", generalManagerEmail: "", generalManagerPhone: "", - poaName: "", + poaFirstName: "", + poaLastName: "", poaPhone: "", poaAddress: "", poaEmail: "", @@ -412,7 +464,9 @@ export default function CompanyProfileForm({ const handleETradeDataLoaded = (data: CompanyRegistrationData) => { // Company name comes from the eTrade manager/owner name on the license. if (data.managerName) { - setValue("companyName", data.managerName, { shouldValidate: true }); + const { first, last } = splitName(data.managerName); + setValue("companyFirstName", first, { shouldValidate: true }); + setValue("companyLastName", last, { shouldValidate: true }); } setValue("licenceNumber", data.licenceNumber); setValue("statusDescription", data.statusDescription); @@ -459,7 +513,9 @@ export default function CompanyProfileForm({ /** Fill the General Manager from the eTrade business owner. */ const useOwnerAsManager = () => { if (!etradeOwner) return; - setValue("generalManagerName", etradeOwner.name); + const { first, last } = splitName(etradeOwner.name); + setValue("generalManagerFirstName", first, { shouldValidate: true }); + setValue("generalManagerLastName", last, { shouldValidate: true }); setValue("generalManagerPhone", etradeOwner.phone ?? "", { shouldValidate: true, }); @@ -469,7 +525,8 @@ export default function CompanyProfileForm({ const toggleGmAsContact = (checked: boolean) => { setGmIsContact(checked); if (!checked) return; - setValue("contactPersonName", watch("generalManagerName")); + setValue("contactPersonFirstName", watch("generalManagerFirstName")); + setValue("contactPersonLastName", watch("generalManagerLastName")); setValue("contactPersonEmail", watch("generalManagerEmail")); setValue("contactPersonPhone", watch("generalManagerPhone")); }; @@ -478,7 +535,8 @@ export default function CompanyProfileForm({ const toggleContactAsPoa = (checked: boolean) => { setContactIsPoa(checked); if (!checked) return; - setValue("poaName", watch("contactPersonName")); + setValue("poaFirstName", watch("contactPersonFirstName")); + setValue("poaLastName", watch("contactPersonLastName")); setValue("poaEmail", watch("contactPersonEmail")); setValue("poaPhone", watch("contactPersonPhone")); }; @@ -642,12 +700,20 @@ export default function CompanyProfileForm({ - + + + + )} - + + + + + + + - - + + toggleContactAsPoa(e.currentTarget.checked)} /> - + + + + + } + radius="md" + style={{ maxWidth: "500px" }} + mb="lg" + > + + Awaiting Approval + + + Your company is awaiting EDR approval. Creating bookings is disabled + until your company has been approved. + + + + + ); + } + const persistAndPriceMutation = useMutation({ mutationFn: async ({ payload, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index eae62a92a..7a21ef3d0 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -66,6 +66,21 @@ export function Step5CargoDetails({ } }, [parentId]); + // For containerised cargo, the total weight is derived from the containers + // (Σ qty × vgm) rather than typed by hand — keep cargoWeight in sync. + useEffect(() => { + if (cargoType !== "container") return; + const total = (containers ?? []).reduce( + (sum, c) => sum + (Number(c?.qty) || 0) * (Number(c?.vgm) || 0), + 0, + ); + form.setValue("cargoWeight", total ? String(total) : "", { + shouldValidate: true, + }); + // form is stable; re-run when the containers or cargo type change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [containers, cargoType]); + const selectedCommodity = useMemo(() => { if (!referenceData?.cargo_type || !parentId || !childId) return null; const group = referenceData.cargo_type.find((g) => g.id === parentId); @@ -207,6 +222,13 @@ export function Step5CargoDetails({ placeholder={isPerItem ? "0" : "0.00"} leftSection={} error={fieldState.error?.message} + // Container total is auto-summed from the containers below. + readOnly={cargoType === "container"} + description={ + cargoType === "container" + ? "Auto-calculated from the containers below." + : undefined + } radius={10} styles={fieldStyles} min={0} From 6a63ae2fa6390398fdf31470c540de2c60cd1604 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 23 Jun 2026 11:33:04 +0000 Subject: [PATCH 03/19] refactor login page to simplify identifier normalization and remove unused login methods --- .../backoffice/src/pages/auth/LoginPage.tsx | 101 +++--------------- .../src/components/onboarding/ETradeInfo.tsx | 4 +- .../src/pages/accounts/CompanyProfileForm.tsx | 100 +++++++++-------- .../portal/src/pages/accounts/LoginPage.tsx | 89 ++++----------- 4 files changed, 94 insertions(+), 200 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx index e689ef9f8..89e8557c3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx @@ -1,11 +1,7 @@ import { type FormEvent, useState } from "react"; -import { parsePhoneNumberFromString } from "libphonenumber-js"; import { Eye, EyeOff, - Mail, - Smartphone, - UserRound, ArrowUpRight, Globe, ChevronDown, @@ -14,65 +10,15 @@ import { useNavigate } from "react-router-dom"; import { useAuth } from "@/auth/useAuth"; -type LoginMode = "email" | "phone" | "username"; - -const loginModes: Array<{ - value: LoginMode; - label: string; - icon: typeof Mail; - placeholder: string; -}> = [ - { - value: "email", - label: "Email", - icon: Mail, - placeholder: "name@company.com", - }, - { - value: "phone", - label: "Phone", - icon: Smartphone, - placeholder: "09XXXXXXXX", - }, - { - value: "username", - label: "Username", - icon: UserRound, - placeholder: "username", - }, - ]; - -const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -const usernamePattern = /^[a-zA-Z0-9._-]{3,32}$/; - -const normalizeIdentifier = (mode: LoginMode, value: string) => { - const trimmed = value.trim(); - - if (mode === "email") { - if (!emailPattern.test(trimmed.toLowerCase())) { - throw new Error("Enter a valid email address."); - } - - return trimmed.toLowerCase(); +/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */ +const normaliseIdentifier = (raw: string): string => { + const v = raw.trim(); + const digits = v.replace(/\D/g, ""); + if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) { + const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, ""); + return `+251${local}`; } - - if (mode === "phone") { - const parsed = parsePhoneNumberFromString(trimmed, "ET"); - - if (!parsed?.isValid()) { - throw new Error("Enter a valid Ethiopian phone number."); - } - - return parsed.number; - } - - if (!usernamePattern.test(trimmed)) { - throw new Error( - "Username must be 3-32 characters and use letters, numbers, ., _, or -.", - ); - } - - return trimmed; + return v.toLowerCase(); }; const LOGIN_IMAGE = "/assets/login.png"; @@ -214,7 +160,6 @@ const FormFooter = () => ( const LoginPage = () => { const navigate = useNavigate(); const { login, verifyMfa } = useAuth(); - const [mode, setMode] = useState("email"); const [identifier, setIdentifier] = useState(""); const [password, setPassword] = useState(""); const [otp, setOtp] = useState(""); @@ -224,15 +169,13 @@ const LoginPage = () => { const [normalizedIdentifier, setNormalizedIdentifier] = useState(""); const [error, setError] = useState(null); - const currentMode = loginModes.find((item) => item.value === mode)!; - const handleSubmit = async (event: FormEvent) => { event.preventDefault(); setSubmitting(true); setError(null); try { - const normalized = normalizeIdentifier(mode, identifier); + const normalized = normaliseIdentifier(identifier); setNormalizedIdentifier(normalized); const result = await login({ email: normalized, password }); @@ -284,32 +227,14 @@ const LoginPage = () => {
-
- - -
-
- -
- setIdentifier(event.target.value)} - placeholder={currentMode.placeholder} + placeholder="name@company.com or 09XXXXXXXX" + autoComplete="username" className={fieldClass} />
diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx index 41dc33886..98efc2613 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -50,8 +50,8 @@ export default function ETradeInfo({ TIN Number (10 digits) *} + placeholder="0012345678" maxLength={10} error={error} {...register} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 69aabae53..6822124fc 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -450,14 +450,15 @@ export default function CompanyProfileForm({ }); // The business owner/manager pulled from eTrade — powers "Use owner as - // manager" on the General Manager step. Null until a TIN lookup succeeds. + // manager" on the General Manager step. const [etradeOwner, setEtradeOwner] = useState<{ name: string; phone: string; + email?: string; } | null>(null); - // Mirror the two "copy from previous person" checkboxes so they can be - // re-toggled (re-checking re-pulls the latest values). + // Mirror the three "copy from previous person" checkboxes. + const [ownerIsGm, setOwnerIsGm] = useState(false); const [gmIsContact, setGmIsContact] = useState(false); const [contactIsPoa, setContactIsPoa] = useState(false); @@ -507,15 +508,20 @@ export default function CompanyProfileForm({ phone: toEthiopianE164( data.managerPhone || data.regularPhone || data.mobilePhone, ), + email: data.managerEmail || undefined, }); }; /** Fill the General Manager from the eTrade business owner. */ - const useOwnerAsManager = () => { - if (!etradeOwner) return; + const toggleOwnerAsGm = (checked: boolean) => { + setOwnerIsGm(checked); + if (!checked || !etradeOwner) return; const { first, last } = splitName(etradeOwner.name); setValue("generalManagerFirstName", first, { shouldValidate: true }); setValue("generalManagerLastName", last, { shouldValidate: true }); + setValue("generalManagerEmail", etradeOwner.email ?? "", { + shouldValidate: true, + }); setValue("generalManagerPhone", etradeOwner.phone ?? "", { shouldValidate: true, }); @@ -702,13 +708,13 @@ export default function CompanyProfileForm({ First Name *} placeholder="Global" error={errors.companyFirstName?.message} {...register("companyFirstName")} /> Last Name *} placeholder="Logistics Ltd" error={errors.companyLastName?.message} {...register("companyLastName")} @@ -716,7 +722,7 @@ export default function CompanyProfileForm({ Company Email *} type="email" placeholder="ops@company.com" error={errors.companyEmail?.message} @@ -730,21 +736,21 @@ export default function CompanyProfileForm({ /> Location *} placeholder="Addis Ababa, Ethiopia" error={errors.companyLocation?.message} {...register("companyLocation")} /> VAT Number *} placeholder="VAT-12345" maxLength={10} error={errors.vatNumber?.message} {...register("vatNumber")} /> FAN Number (16 digits) *} placeholder="1234567890123456" maxLength={16} error={errors.fanNumber?.message} @@ -757,16 +763,21 @@ export default function CompanyProfileForm({ Registration Details + + Auto-filled from eTrade — these fields cannot be edited. + @@ -774,13 +785,15 @@ export default function CompanyProfileForm({ @@ -788,13 +801,15 @@ export default function CompanyProfileForm({ @@ -806,13 +821,15 @@ export default function CompanyProfileForm({ @@ -820,13 +837,15 @@ export default function CompanyProfileForm({ @@ -834,7 +853,8 @@ export default function CompanyProfileForm({ @@ -850,31 +870,25 @@ export default function CompanyProfileForm({ {step === "personnel" && ( <> - - - General Manager - - {etradeOwner && ( - - )} - + + General Manager + + toggleOwnerAsGm(e.currentTarget.checked)} + /> First Name *} placeholder="Abebe" error={errors.generalManagerFirstName?.message} {...register("generalManagerFirstName")} /> Last Name *} placeholder="Bikila" error={errors.generalManagerLastName?.message} {...register("generalManagerLastName")} @@ -882,7 +896,7 @@ export default function CompanyProfileForm({ Email *} type="email" placeholder="gm@company.com" error={errors.generalManagerEmail?.message} @@ -911,13 +925,13 @@ export default function CompanyProfileForm({ /> First Name *} placeholder="Jane" error={errors.contactPersonFirstName?.message} {...register("contactPersonFirstName")} /> Last Name *} placeholder="Smith" error={errors.contactPersonLastName?.message} {...register("contactPersonLastName")} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx index ac13f4c59..bdd10871b 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -1,48 +1,39 @@ import { type FormEvent, useState } from "react"; -import { ChevronDown, Eye, EyeOff, Mail, Smartphone } from "lucide-react"; +import { Eye, EyeOff } from "lucide-react"; import { useLocation, useNavigate } from "react-router-dom"; -import RPNInput from "react-phone-number-input"; -import "react-phone-number-input/style.css"; import useAuth from "@/hooks/useAuth"; import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell"; -import "@/components/phone-field.css"; const EDR_LOGO = "/assets/edr-logo.png"; -type LoginMethod = "email" | "phone"; - -const loginMethods: Array<{ - value: LoginMethod; - label: string; - icon: typeof Mail; - placeholder: string; -}> = [ - { value: "email", label: "Email", icon: Mail, placeholder: "name@company.com" }, - { value: "phone", label: "Phone", icon: Smartphone, placeholder: "09XXXXXXXX" }, -]; +/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */ +function normaliseIdentifier(raw: string): string { + const v = raw.trim(); + const digits = v.replace(/\D/g, ""); + if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) { + const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, ""); + return `+251${local}`; + } + return v.toLowerCase(); +} export default function LoginPage() { const navigate = useNavigate(); const location = useLocation(); const { login } = useAuth(); - const [method, setMethod] = useState("email"); const [identifier, setIdentifier] = useState(""); const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); - const currentMethod = loginMethods.find((item) => item.value === method)!; - const handleSubmit = async (event: FormEvent) => { event.preventDefault(); setError(null); setLoading(true); try { - // In phone mode the identifier is already a canonical E.164 string - // (e.g. +251912345678) from the phone field; email mode passes through. - const result = await login({ email: identifier, password }); + const result = await login({ email: normaliseIdentifier(identifier), password }); if (result.success) { const from = (location.state as { from?: { pathname: string } } | null)?.from ?.pathname; @@ -74,55 +65,19 @@ export default function LoginPage() {
-
- -
- - -
-
-
- {method === "phone" ? ( -
- setIdentifier(v ?? "")} - /> -
- ) : ( - setIdentifier(event.target.value)} - placeholder={currentMethod.placeholder} - disabled={loading} - className={fieldClass} - /> - )} + setIdentifier(event.target.value)} + placeholder="name@company.com or 09XXXXXXXX" + disabled={loading} + autoComplete="username" + className={fieldClass} + />
From eb85f8d32dc4fe328e943b7fb0466aace7940eb7 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 23 Jun 2026 14:25:54 +0000 Subject: [PATCH 04/19] refactor booking priority logic and constants; adjust scoring thresholds and remove unused service types --- .../bookings/booking-pricing.service.spec.ts | 1 - .../bookings/booking-pricing.service.ts | 29 ++++++------------- .../modules/overview/overview.constants.ts | 2 +- .../dto/create-priority-config.dto.ts | 10 +++++-- .../dto/create-service-type.dto.ts | 9 ++++-- .../src/seed/pricing-data.seeder.ts | 14 +++++---- .../bookings/BookingPriorityBadge.tsx | 4 +-- 7 files changed, 35 insertions(+), 34 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 471fcb6f2..1c1b490dd 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -44,7 +44,6 @@ describe('BookingPricingService — domestic corridor', () => { {} as never, {} as never, ratesService as never, - {} as never, exchangeService as never, ); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index fbafaa911..746e7d4f3 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -2,7 +2,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RatesService } from '../rule-engine/services/rates.service'; -import { ServiceTypesService } from '../rule-engine/services/service-types.service'; import { Rate } from '../rule-engine/entities/rate.entity'; import { ExchangeService } from '@edr/api-common'; import { @@ -40,7 +39,6 @@ export class BookingPricingService { private readonly ruleEngineService: RuleEngineService, private readonly containerTypesService: ContainerTypesService, private readonly ratesService: RatesService, - private readonly serviceTypesService: ServiceTypesService, private readonly exchangeService: ExchangeService, ) {} @@ -255,27 +253,18 @@ export class BookingPricingService { }; } - /** Recompute priority on submit (USD + service tier). */ + /** + * Recompute priority on submit. + * + * The full priority model is additive and capped at 100: + * service-type bonus (≤ 15) + wagon block (≤ 50) + currency block (≤ 35). + * All three components are produced by RuleEngineService.evaluate, so submit + * simply re-runs the engine — there is no extra submit-time inflation. + */ async computeSubmitPriorityScore(booking: Booking): Promise { const evalInput = await this.buildEvalInputForBooking(booking); const ruleResult = await this.ruleEngineService.evaluate(evalInput); - let score = ruleResult.priorityScore; - - const serviceType = await this.serviceTypesService.findById(booking.serviceTypeId); - if (booking.paymentCurrency === 'USD' && serviceType) { - const code = (serviceType.code ?? '').toUpperCase(); - const hasForwarding = - serviceType.includesFirstMile || - serviceType.includesLastMile || - code.includes('FORWARD') || - code.includes('Y'); - const railOnly = code.includes('RAIL') && !hasForwarding; - - if (hasForwarding) score += 1000; - else if (railOnly || code.includes('X')) score += 500; - } - - return score; + return ruleResult.priorityScore; } private async computeBaseRailLinesWithRates( diff --git a/apps/edr-freight-api/src/modules/overview/overview.constants.ts b/apps/edr-freight-api/src/modules/overview/overview.constants.ts index fed9a76c7..6181d446e 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.constants.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.constants.ts @@ -1,4 +1,4 @@ -export const OVERVIEW_URGENT_PRIORITY_THRESHOLD = 1000; +export const OVERVIEW_URGENT_PRIORITY_THRESHOLD = 70; export const OVERVIEW_NEEDS_ACTION_STATUSES = [ 'SUBMITTED', diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-config.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-config.dto.ts index 140954e83..d2ca44d93 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-config.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-config.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsIn, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; +import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; export class CreatePriorityConfigDto { @ApiProperty({ description: 'Config type: WAGON or CURRENCY', enum: ['WAGON', 'CURRENCY'] }) @@ -30,9 +30,15 @@ export class CreatePriorityConfigDto { @Min(0) maxWagonCount!: number; - @ApiProperty({ description: 'Points awarded when booking matches this rule', default: 0 }) + @ApiProperty({ + description: + 'Points awarded when booking matches this rule. Capped so the priority blocks sum to ≤ 100 alongside the service-type bonus (service ≤ 15 + wagon ≤ 50 + currency ≤ 35). WAGON configs should not exceed 50; CURRENCY configs should not exceed 35.', + default: 0, + maximum: 50, + }) @IsInt() @Min(0) + @Max(50) scorePoints!: number; @ApiPropertyOptional({ default: false, description: 'Feature flag — toggle without code deploy' }) diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts index 4683d448d..d68625fdc 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; export class CreateServiceTypeDto { @ApiProperty({ description: 'Service type display name', maxLength: 255 }) @@ -32,10 +32,15 @@ export class CreateServiceTypeDto { @IsBoolean() includesCustoms?: boolean; - @ApiPropertyOptional({ description: 'Priority bonus points awarded when this service is used', default: 0 }) + @ApiPropertyOptional({ + description: 'Priority bonus points awarded when this service is used (0–15)', + default: 0, + maximum: 15, + }) @IsOptional() @IsInt() @Min(0) + @Max(15) priorityBonusPoints?: number; @ApiPropertyOptional({ default: true }) diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 75d17be98..28cebc9e7 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -180,7 +180,7 @@ export class PricingDataSeeder { includesFirstMile: true, includesLastMile: true, includesCustoms: true, - priorityBonusPoints: 100, + priorityBonusPoints: 15, isActive: true, displayOrder: 2, }, @@ -192,7 +192,7 @@ export class PricingDataSeeder { includesFirstMile: false, includesLastMile: false, includesCustoms: false, - priorityBonusPoints: 50, + priorityBonusPoints: 10, isActive: true, displayOrder: 3, }, @@ -382,9 +382,11 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { this.logger.log("Seeded weight limit rules"); } private async seedPriorityConfigs(prRepo: any): Promise { - // Wagon Count Block — independent, applies regardless of currency. - // Currency Block — applies only to the matching payment currency, within the wagon range. - // Both blocks are additive (see RuleEngineService.evaluate). + // Priority rule = Wagon Block + Currency Block (both additive; see RuleEngineService.evaluate). + // Combined with the service-type bonus the total priority score caps at 100: + // service-type bonus (≤ 15) + wagon block (≤ 50) + currency block (≤ 35) = 100. + // Wagon Count Block — independent, applies regardless of currency. Max 50. + // Currency Block — applies only to the matching payment currency, within the wagon range. Max 35. const rows = [ // ── Wagon Count Block ─────────────────────────────────────────────── { type: "WAGON", label: "Wagons 1–20", currency: null, minWagonCount: 1, maxWagonCount: 20, scorePoints: 0, displayOrder: 1 }, @@ -392,7 +394,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { { type: "WAGON", label: "Wagons 31–40", currency: null, minWagonCount: 31, maxWagonCount: 40, scorePoints: 30, displayOrder: 3 }, { type: "WAGON", label: "Wagons 41–50", currency: null, minWagonCount: 41, maxWagonCount: 50, scorePoints: 50, displayOrder: 4 }, // ── Payment Currency Block ────────────────────────────────────────── - { type: "CURRENCY", label: "USD · Wagons 1–25", currency: "USD", minWagonCount: 1, maxWagonCount: 25, scorePoints: 17, displayOrder: 5 }, + { type: "CURRENCY", label: "USD · Wagons 1–25", currency: "USD", minWagonCount: 1, maxWagonCount: 25, scorePoints: 15, displayOrder: 5 }, { type: "CURRENCY", label: "USD · Wagons 26–50", currency: "USD", minWagonCount: 26, maxWagonCount: 50, scorePoints: 35, displayOrder: 6 }, { type: "CURRENCY", label: "ETB · Wagons 1–50", currency: "ETB", minWagonCount: 1, maxWagonCount: 50, scorePoints: 0, displayOrder: 7 }, ]; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingPriorityBadge.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPriorityBadge.tsx index 5a17e4568..460e9b334 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingPriorityBadge.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPriorityBadge.tsx @@ -1,14 +1,14 @@ import { Badge } from "@mantine/core"; export function BookingPriorityBadge({ score }: { score: number }) { - if (score >= 1000) { + if (score >= 70) { return ( Urgent ); } - if (score >= 500) { + if (score >= 40) { return ( High From 080a08229d7e2f49784667c0b836649322b17ab0 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 23 Jun 2026 20:40:48 +0000 Subject: [PATCH 05/19] feat: enhance booking flow with multi-route support and onboarding document integration - Introduced multi-route functionality in the booking process, allowing users to add additional routes for general contracts. - Updated the StepDocuments component to display onboarding documents automatically attached to bookings. - Refactored Step4Route to manage extra routes and quantities dynamically. - Improved Step8Review to reflect the new onboarding document handling and updated submission readiness checks. - Added new API endpoints and services for managing contract route lines and rejecting bookings. - Removed the allowConsolidation field from the booking model as it is now managed by the system. - Created migrations for the new contract route lines table and updated related entities. --- .../1820000000000-DropAllowConsolidation.ts | 23 +++ .../1820000000001-CreateContractRouteLines.ts | 61 +++++++ .../booking-orders.controller.ts | 9 + .../booking-orders/booking-orders.module.ts | 3 +- .../booking-orders/booking-orders.service.ts | 101 ++++++++--- .../booking-orders/dto/contract-view.dto.ts | 33 ++++ .../dto/create-booking-order.dto.ts | 10 ++ .../entities/booking-order.entity.ts | 8 + .../entities/contract-route-line.entity.ts | 53 ++++++ .../general-contract.service.ts | 75 +++++++- .../bookings/booking-pricing.service.ts | 115 +++++++++++-- .../bookings/booking-transition.service.ts | 26 +++ .../modules/bookings/bookings.controller.ts | 15 ++ .../modules/bookings/bookings.repository.ts | 7 - .../src/modules/bookings/bookings.service.ts | 124 ++++++++------ .../bookings/dto/create-booking.dto.ts | 45 ++++- .../bookings/dto/filter-booking.dto.ts | 5 - .../dto/generate-price-response.dto.ts | 13 ++ .../bookings/dto/request-changes.dto.ts | 13 +- .../bookings/entities/booking.entity.ts | 3 - .../modules/companies/companies.service.ts | 12 ++ .../src/modules/files/files.service.ts | 32 ++++ .../src/seed/demo-bookings.seeder.ts | 8 +- .../src/seed/pricing-data.seeder.ts | 28 +-- .../backoffice/src/types/booking.ts | 1 - .../src/pages/bookings/EditBookingPage.tsx | 8 +- .../src/pages/bookings/NewBookingPage.tsx | 160 ++++++++++++++---- .../pages/bookings/new-booking-form/schema.ts | 69 +++++++- .../new-booking-form/step-documents.tsx | 136 +++++++++------ .../new-booking-form/step0-operation-type.tsx | 119 +++++++++++++ .../bookings/new-booking-form/step4-route.tsx | 133 ++++++++++++++- .../new-booking-form/step8-review.tsx | 70 ++++---- .../pages/bookings/new-booking-form/steps.tsx | 1 + .../src/pages/contracts/PlaceOrderDialog.tsx | 154 +++++++++++++++-- .../portal/src/services/api.ts | 13 ++ .../src/services/booking-orders.service.ts | 10 ++ .../portal/src/services/bookings.service.ts | 12 ++ packages/types/src/freight/index.ts | 35 +++- 38 files changed, 1457 insertions(+), 286 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1820000000000-DropAllowConsolidation.ts create mode 100644 apps/edr-freight-api/src/migrations/1820000000001-CreateContractRouteLines.ts create mode 100644 apps/edr-freight-api/src/modules/booking-orders/entities/contract-route-line.entity.ts create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step0-operation-type.tsx diff --git a/apps/edr-freight-api/src/migrations/1820000000000-DropAllowConsolidation.ts b/apps/edr-freight-api/src/migrations/1820000000000-DropAllowConsolidation.ts new file mode 100644 index 000000000..dd232b28a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000000-DropAllowConsolidation.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Consolidation is now system-managed: the backend consolidates partial-wagon + * container bookings automatically, derived from the container quantities. The + * `allow_consolidation` opt-in flag is therefore redundant and is dropped. + * `consolidation_partner_id` (the actual pairing link) is unaffected. + */ +export class DropAllowConsolidation1820000000000 implements MigrationInterface { + name = 'DropAllowConsolidation1820000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS allow_consolidation;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS allow_consolidation BOOLEAN NOT NULL DEFAULT false;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1820000000001-CreateContractRouteLines.ts b/apps/edr-freight-api/src/migrations/1820000000001-CreateContractRouteLines.ts new file mode 100644 index 000000000..23eda1730 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000001-CreateContractRouteLines.ts @@ -0,0 +1,61 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +/** + * Multi-route general contracts: a contract may reserve quantity across several + * routes. Each (contract, route, container type) is a row here; drawdown orders + * reference the route line they drew from via booking_orders.route_line_id. + */ +export class CreateContractRouteLines1820000000001 + implements MigrationInterface +{ + name = 'CreateContractRouteLines1820000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'contract_route_lines', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'contract_booking_id', type: 'uuid' }, + { name: 'origin_yard_id', type: 'uuid' }, + { name: 'destination_yard_id', type: 'uuid' }, + { name: 'container_type_id', type: 'uuid', isNullable: true }, + { name: 'quantity', type: 'numeric', precision: 12, scale: 3 }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + foreignKeys: [ + { + columnNames: ['contract_booking_id'], + referencedSchema: 'freight', + referencedTableName: 'bookings', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.contract_route_lines', + new TableIndex({ + name: 'idx_contract_route_lines_contract', + columnNames: ['contract_booking_id'], + }), + ); + + await queryRunner.query( + `ALTER TABLE freight.booking_orders ADD COLUMN IF NOT EXISTS route_line_id uuid;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.booking_orders DROP COLUMN IF EXISTS route_line_id;`, + ); + await queryRunner.dropTable('freight.contract_route_lines', true); + } +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts index 6821c4e7d..b05895407 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts @@ -45,6 +45,15 @@ export class BookingOrdersController { return this.generalContractService.getQuantityLines(id); } + @Get('contract/:id/routes') + @ApiOperation({ + summary: + 'Per-route contracted / ordered / remaining quantities (multi-route contracts). Empty for single-route.', + }) + async routes(@Param('id', ParseUUIDPipe) id: string) { + return this.generalContractService.getRouteLines(id); + } + @Get(':id') @ApiOperation({ summary: 'Get a single booking order' }) async findOne(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts index c8ea869be..3347bef97 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts @@ -9,11 +9,12 @@ import { BookingOrdersRepository } from './booking-orders.repository'; import { BookingOrdersService } from './booking-orders.service'; import { BookingOrder } from './entities/booking-order.entity'; import { BookingOrderLine } from './entities/booking-order-line.entity'; +import { ContractRouteLine } from './entities/contract-route-line.entity'; import { GeneralContractService } from './general-contract.service'; @Module({ imports: [ - TypeOrmModule.forFeature([BookingOrder, BookingOrderLine]), + TypeOrmModule.forFeature([BookingOrder, BookingOrderLine, ContractRouteLine]), BookingsModule, CompaniesModule, DropdownSettingsModule, diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts index 1a75c7914..c306d420b 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts @@ -79,12 +79,38 @@ export class BookingOrdersService { throw new BadRequestException('You do not have access to this contract'); } + // Resolve the route the order ships on: a chosen contract route line for a + // multi-route contract, else the contract's own origin/destination. + const routeLines = await this.generalContractService.getRouteLines( + contract.id, + ); + let originYardId = contract.originYardId; + let destinationYardId = contract.destinationYardId; + let routeLineId: string | null = null; + + if (routeLines.length > 0) { + if (!dto.routeLineId) { + throw new BadRequestException( + 'This contract has multiple routes — select a route to draw from', + ); + } + const chosen = routeLines.find((r) => r.routeLineId === dto.routeLineId); + if (!chosen) { + throw new BadRequestException( + 'Selected route is not part of this contract', + ); + } + originYardId = chosen.originYardId; + destinationYardId = chosen.destinationYardId; + routeLineId = chosen.routeLineId; + } + // Validate the route has a departure on the chosen day. const day = eatDay(new Date(dto.scheduledDate)); const hasDeparture = await this.trainSchedulingService.existsOpenScheduleOnRouteDay( - contract.originYardId, - contract.destinationYardId, + originYardId, + destinationYardId, day, ); if (!hasDeparture) { @@ -93,41 +119,64 @@ export class BookingOrdersService { ); } - // Validate each line against the remaining pool. - const poolLines = await this.generalContractService.getQuantityLines( - contract.id, - ); const isContainer = contract.freightType === 'CONTAINER'; - for (const line of dto.lines) { - if (line.quantity <= 0) { - throw new BadRequestException('Order quantities must be greater than zero'); + const orderTotal = dto.lines.reduce((sum, l) => sum + l.quantity, 0); + + if (routeLineId) { + // Multi-route: validate against the chosen route line's remaining pool. + for (const line of dto.lines) { + if (line.quantity <= 0) { + throw new BadRequestException('Order quantities must be greater than zero'); + } } - const key = isContainer ? (line.containerTypeId ?? '') : ''; - const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key); - if (!poolLine) { + const chosen = routeLines.find((r) => r.routeLineId === routeLineId)!; + if (orderTotal > chosen.remainingQuantity) { throw new BadRequestException( - isContainer - ? `Container type ${line.containerTypeId} is not part of this contract` - : 'This contract has no matching quantity pool', + `Requested ${orderTotal} exceeds remaining ${chosen.remainingQuantity} for this route`, ); } - if (line.quantity > poolLine.remainingQuantity) { - throw new BadRequestException( - `Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` + - (poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''), - ); + } else { + // Single-route: validate each line against the per-container-type pool. + const poolLines = await this.generalContractService.getQuantityLines( + contract.id, + ); + for (const line of dto.lines) { + if (line.quantity <= 0) { + throw new BadRequestException('Order quantities must be greater than zero'); + } + const key = isContainer ? (line.containerTypeId ?? '') : ''; + const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key); + if (!poolLine) { + throw new BadRequestException( + isContainer + ? `Container type ${line.containerTypeId} is not part of this contract` + : 'This contract has no matching quantity pool', + ); + } + if (line.quantity > poolLine.remainingQuantity) { + throw new BadRequestException( + `Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` + + (poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''), + ); + } } } // Persist the order + its child shipment booking atomically. const order = await this.dataSource.transaction(async (manager) => { - const childBooking = await this.spawnChildBooking(contract, dto, manager); + const childBooking = await this.spawnChildBooking( + contract, + dto, + { originYardId, destinationYardId }, + manager, + ); const reference = await this.generateReference(); const orderRow = manager.create(BookingOrder, { reference, contractBookingId: contract.id, bookingId: childBooking.id, + routeLineId, companyId: contract.companyId ?? null, scheduledDate: new Date(dto.scheduledDate), status: 'PAID', @@ -150,8 +199,8 @@ export class BookingOrdersService { // Feed the child booking into the day-pool batch so it allocates to a train. try { await this.bookingBatchService.processRouteDay({ - originYardId: contract.originYardId, - destinationYardId: contract.destinationYardId, + originYardId, + destinationYardId, day, }); } catch (err) { @@ -180,6 +229,7 @@ export class BookingOrdersService { private async spawnChildBooking( contract: Booking, dto: CreateBookingOrderDto, + route: { originYardId: string; destinationYardId: string }, manager: import('typeorm').EntityManager, ): Promise { const reference = await this.generateChildBookingReference(); @@ -213,8 +263,8 @@ export class BookingOrdersService { firstMilePickupAddress: contract.firstMilePickupAddress ?? null, lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, equipmentReturn: contract.equipmentReturn, - originYardId: contract.originYardId, - destinationYardId: contract.destinationYardId, + originYardId: route.originYardId, + destinationYardId: route.destinationYardId, tradeDirection: contract.tradeDirection, freightType: contract.freightType, cargoTypeId: contract.cargoTypeId ?? null, @@ -233,7 +283,6 @@ export class BookingOrdersService { customerSignedAt: now, priorityScore: contract.priorityScore, totalAmount: 0, - allowConsolidation: false, schedulingStatus: 'NOT_SCHEDULED', }); const savedChild = await manager.save(child); diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts index 6c0c86669..fc2cc0325 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts @@ -21,3 +21,36 @@ export class ContractQuantityLineView { @ApiProperty() remainingQuantity!: number; } + +/** A contracted/ordered/remaining pool line for one route of a general contract. */ +export class ContractRouteLineView { + @ApiProperty({ description: 'Contract route line id' }) + routeLineId!: string; + + @ApiProperty() + originYardId!: string; + + @ApiProperty({ nullable: true }) + originYardName!: string | null; + + @ApiProperty() + destinationYardId!: string; + + @ApiProperty({ nullable: true }) + destinationYardName!: string | null; + + @ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' }) + containerTypeId!: string | null; + + @ApiProperty({ nullable: true }) + containerTypeName!: string | null; + + @ApiProperty() + contractedQuantity!: number; + + @ApiProperty() + orderedQuantity!: number; + + @ApiProperty() + remainingQuantity!: number; +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts index 7043e9704..c7712b5b9 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts @@ -32,6 +32,16 @@ export class CreateBookingOrderDto { @IsUUID() contractBookingId!: string; + @ApiPropertyOptional({ + format: 'uuid', + description: + 'For multi-route contracts: the contract route line being drawn from. ' + + 'Determines the shipment origin/destination. Omit for single-route contracts.', + }) + @IsOptional() + @IsUUID() + routeLineId?: string; + @ApiProperty({ example: '2026-07-01T00:00:00.000Z', description: 'Shipment day for this order' }) @IsDateString() scheduledDate!: string; diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts index 5d6857051..610496d79 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts @@ -40,6 +40,14 @@ export class BookingOrder extends BaseEntity { @JoinColumn({ name: 'company_id' }) company?: Company | null; + /** + * The contract route line this order drew down (multi-route general contracts). + * Null for legacy/single-route contracts that have no route lines — the order + * then uses the contract's own origin/destination. + */ + @Column({ name: 'route_line_id', type: 'uuid', nullable: true }) + routeLineId?: string | null; + @Column({ name: 'scheduled_date', type: 'timestamptz' }) scheduledDate!: Date; diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/contract-route-line.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/contract-route-line.entity.ts new file mode 100644 index 000000000..0bac5bbd6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/entities/contract-route-line.entity.ts @@ -0,0 +1,53 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { ContainerType } from '../../rule-engine/entities/container-type.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; + +/** + * One contracted route+quantity line of a GENERAL contract. A general contract + * may span several routes (e.g. Addis→Dire Dawa: 10, Modjo→Djibouti: 5); each + * route reserves its own quantity pool. Drawdown orders pick one of these routes + * and decrement that route's pool. One-time bookings do not use this — they keep + * the single origin/destination on the booking itself. + */ +@Entity({ schema: 'freight', name: 'contract_route_lines' }) +@Index(['contractBookingId']) +export class ContractRouteLine extends BaseEntity { + /** The general contract (a Booking with bookingType = GENERAL_CONTRACT). */ + @Column({ name: 'contract_booking_id', type: 'uuid' }) + contractBookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: 'contract_booking_id' }) + contractBooking?: Booking; + + @Column({ name: 'origin_yard_id', type: 'uuid' }) + originYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'origin_yard_id' }) + originYard?: Yard; + + @Column({ name: 'destination_yard_id', type: 'uuid' }) + destinationYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'destination_yard_id' }) + destinationYard?: Yard; + + /** + * Container type this route line reserves (CONTAINER contracts); null for + * BULK/BREAK_BULK, where the quantity is tons/items. + */ + @Column({ name: 'container_type_id', type: 'uuid', nullable: true }) + containerTypeId?: string | null; + + @ManyToOne(() => ContainerType, { nullable: true }) + @JoinColumn({ name: 'container_type_id' }) + containerType?: ContainerType | null; + + /** Contracted quantity for this (route, container type): containers, tons, or items. */ + @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 }) + quantity!: number; +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts b/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts index ba382d085..107addc9c 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts @@ -4,7 +4,11 @@ import { DataSource } from 'typeorm'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingOrder } from './entities/booking-order.entity'; -import { ContractQuantityLineView } from './dto/contract-view.dto'; +import { ContractRouteLine } from './entities/contract-route-line.entity'; +import { + ContractQuantityLineView, + ContractRouteLineView, +} from './dto/contract-view.dto'; /** Setting code holding the global ordering window (in months) for general contracts. */ export const CONTRACT_PERIOD_SETTING_CODE = 'general_contract_period'; @@ -120,6 +124,69 @@ export class GeneralContractService { ]; } + /** + * Per-route drawdown pool for a multi-route general contract: contracted vs. + * ordered vs. remaining, one entry per contracted route line. Returns [] for + * single-route contracts (no route lines) — callers fall back to + * {@link getQuantityLines}. + */ + async getRouteLines( + contractBookingId: string, + ): Promise { + const routeLines = await this.dataSource + .getRepository(ContractRouteLine) + .find({ + where: { contractBookingId }, + relations: { + originYard: true, + destinationYard: true, + containerType: true, + }, + order: { createdAt: 'ASC' }, + }); + if (routeLines.length === 0) return []; + + const ordered = await this.orderedByRouteLine(contractBookingId); + + return routeLines.map((rl) => { + const orderedQty = ordered.get(rl.id) ?? 0; + const contracted = Number(rl.quantity); + return { + routeLineId: rl.id, + originYardId: rl.originYardId, + originYardName: rl.originYard?.label ?? null, + destinationYardId: rl.destinationYardId, + destinationYardName: rl.destinationYard?.label ?? null, + containerTypeId: rl.containerTypeId ?? null, + containerTypeName: rl.containerType?.label ?? null, + contractedQuantity: contracted, + orderedQuantity: orderedQty, + remainingQuantity: Math.max(0, contracted - orderedQty), + }; + }); + } + + /** Sum of non-cancelled order quantities, keyed by route_line_id. */ + private async orderedByRouteLine( + contractBookingId: string, + ): Promise> { + const rows = await this.dataSource + .getRepository(BookingOrder) + .createQueryBuilder('o') + .innerJoin('o.lines', 'line') + .select('o.route_line_id', 'key') + .addSelect('SUM(line.quantity)', 'total') + .where('o.contract_booking_id = :contractBookingId', { contractBookingId }) + .andWhere('o.route_line_id IS NOT NULL') + .andWhere(`o.status NOT IN ('CANCELLED', 'REJECTED')`) + .groupBy('o.route_line_id') + .getRawMany<{ key: string; total: string }>(); + + const map = new Map(); + for (const row of rows) if (row.key) map.set(row.key, Number(row.total)); + return map; + } + /** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */ private async orderedByContainerType( contractBookingId: string, @@ -155,6 +222,12 @@ export class GeneralContractService { /** True once every contracted line is fully drawn down. */ async isExhausted(contractBookingId: string): Promise { + // Multi-route contracts are exhausted when every route line is drawn down; + // single-route contracts fall back to the per-container-type pool. + const routeLines = await this.getRouteLines(contractBookingId); + if (routeLines.length > 0) { + return routeLines.every((l) => l.remainingQuantity <= 0); + } const lines = await this.getQuantityLines(contractBookingId); return lines.every((l) => l.remainingQuantity <= 0); } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 746e7d4f3..c06981cce 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -10,6 +10,10 @@ import { RuleEngineService, } from '../rule-engine/rule-engine.service'; import { BookingsRepository } from './bookings.repository'; +import { + containersPerWagon, + wagonRemainder, +} from './consolidation.service'; import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; @@ -32,6 +36,29 @@ type StoredPricingBreakdown = { generatedAt?: string; } | null; +/** Friendly labels for the per-unit rate card shown at the confirm step. */ +const SURCHARGE_LABELS: Record = { + HAZARD_SURCHARGE: 'Hazardous cargo', + HAZARDOUS_CARGO: 'Hazardous cargo', + REEFER_SURCHARGE: 'Refrigerated (reefer)', + REEFER_CARGO: 'Refrigerated (reefer)', + OVERWEIGHT_PER_TON: 'Overweight excess', + DOUBLE_HANDLING: 'Double handling', + LASHING: 'Lashing', + PIL_EXTRA_FEE: 'Shipping line fee', +}; + +function surchargeLabel(code: string): string { + return ( + SURCHARGE_LABELS[code] ?? + code + .toLowerCase() + .split('_') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' ') + ); +} + @Injectable() export class BookingPricingService { constructor( @@ -101,16 +128,32 @@ export class BookingPricingService { for (const mod of ruleResult.appliedModifiers) { const usdAmount = mod.calculatedAmount; const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; + + const rate = rateById.get(mod.rateId); + const unit = rate?.rateUnit ?? 'FLAT'; + const unitUsd = rate ? Number(rate.rateValue) : usdAmount; + const unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; + // Per-unit count: explicit trigger (e.g. overweight tons) when present, + // otherwise derived from total ÷ unit price (FLAT surcharges → 1). + const quantity = + mod.triggerValue != null && mod.triggerValue > 0 + ? mod.triggerValue + : unitUsd > 0 + ? Math.max(1, Math.round(usdAmount / unitUsd)) + : 1; + const item: PriceLineItemDto = { code: mod.surchargeTypeCode, - description: `Surcharge: ${mod.surchargeTypeCode}`, + description: surchargeLabel(mod.surchargeTypeCode), amount: convertedAmount, + unitAmount, + unit, + quantity, currency: paymentCurrency, }; lineItems.push(item); total += convertedAmount; - const rate = rateById.get(mod.rateId); if (rate) usedRatesMap.set(rate.id, rate); } @@ -164,7 +207,7 @@ export class BookingPricingService { } async buildEvalInputForBooking(booking: Booking): Promise { - const containers = await Promise.all( + const lines = await Promise.all( (booking.bookingContainers ?? []) .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null) .map(async (bc) => { @@ -172,14 +215,19 @@ export class BookingPricingService { const vgm = Number(bc.vgmPerUnitTons); const qty = bc.quantity; return { - containerTypeId: bc.containerTypeId, + container: { + containerTypeId: bc.containerTypeId, + quantity: qty, + vgmPerUnitTons: vgm, + totalVgmTons: qty * vgm, + isReefer: ct.isReefer, + }, + perWagon: containersPerWagon(Number(ct.wagonsPerUnit)), quantity: qty, - vgmPerUnitTons: vgm, - totalVgmTons: qty * vgm, - isReefer: ct.isReefer, }; }), ); + const containers = lines.map((l) => l.container); // Wagon count is persisted per container line at booking creation; sum it. const totalWagons = booking.freightType === 'CONTAINER' @@ -191,6 +239,13 @@ export class BookingPricingService { ) : 0; + // Consolidation is system-managed: the CONSOLIDATION_ENABLED surcharge fires + // whenever any container line leaves a wagon partially filled. Derived from + // the container quantities — there is no persisted opt-in flag. + const allowConsolidation = + booking.freightType === 'CONTAINER' && + lines.some((l) => wagonRemainder(l.quantity, l.perWagon) > 0); + return { freightType: booking.freightType as 'CONTAINER' | 'BULK', cargoTypeId: booking.cargoTypeId ?? null, @@ -199,7 +254,7 @@ export class BookingPricingService { tradeDirection: booking.tradeDirection, isHazardous: booking.isHazardous, isGovernment: booking.isGovernment, - allowConsolidation: booking.allowConsolidation, + allowConsolidation, shippingLineId: booking.shippingLineId, totalWagons, containers, @@ -238,6 +293,9 @@ export class BookingPricingService { code: 'TOTAL', description: 'Contract total', amount: total, + unitAmount: total, + unit: 'FLAT', + quantity: 1, currency: booking.paymentCurrency, }, ], @@ -301,10 +359,15 @@ export class BookingPricingService { usedRatesMap.set(rate.id, rate); const usdAmount = this.amountForRate(rate, container.quantity, wagonCount); const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; + const unitUsd = Number(rate.rateValue); + const label = await this.containerTypeLabel(container.containerTypeId); lines.push({ code: rateType, - description: `Base rail (${rateType})`, + description: `${label} rail freight`, amount, + unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd, + unit: rate.rateUnit, + quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount), currency: paymentCurrency, }); } @@ -320,10 +383,14 @@ export class BookingPricingService { isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1; const usdAmount = this.amountForRate(fallback, quantity, wagonCount); const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; + const unitUsd = Number(fallback.rateValue); lines.push({ code: rateType, - description: `Base rail (${rateType})`, + description: isBulk ? 'Bulk rail freight' : 'Container rail freight', amount, + unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd, + unit: fallback.rateUnit, + quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount), currency: paymentCurrency, }); } @@ -332,6 +399,34 @@ export class BookingPricingService { return { lineItems: lines, usedRates: [...usedRatesMap.values()] }; } + /** Friendly container-type label for the per-unit card; degrades to "Container". */ + private async containerTypeLabel(containerTypeId: string): Promise { + try { + const ct = await this.containerTypesService?.findById?.(containerTypeId); + return ct?.label ?? 'Container'; + } catch { + return 'Container'; + } + } + + /** How many units a rate's total is divided into, by rate unit (for the per-unit card). */ + private effectiveUnitQuantity( + rateUnit: string, + quantity: number, + wagonCount: number, + ): number { + switch (rateUnit) { + case 'PER_WAGON': + return wagonCount; + case 'FLAT': + return 1; + case 'PER_CONTAINER': + case 'PER_TON': + default: + return quantity; + } + } + private pickRate( rates: Rate[], rateType: string, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index c41013ddb..38cf0fca6 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -420,6 +420,32 @@ export class BookingTransitionService { return this.bookingsService.findById(updated!.id); } + /** + * Customer rejects the priced booking at the confirm step. The booking becomes + * REJECTED (terminal) — the customer starts a new booking rather than editing + * this one. Only a not-yet-committed booking can be rejected this way. + */ + async reject(bookingId: string, reason?: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, [ + 'DRAFT', + 'SUBMITTED', + 'PRICE_CHANGED_PENDING_CONFIRM', + 'PENDING_CONSOLIDATION', + ]); + + await this.bookingsRepository.createReviewNote( + bookingId, + reason?.trim() || 'Customer rejected the price estimate.', + 'REJECTION', + ); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'REJECTED', + } as never); + return this.bookingsService.findById(updated!.id); + } + async enrichBookingResponse(booking: Booking): Promise { .innerJoinAndSelect('b.bookingContainers', 'bc') .innerJoin('bc.containerType', 'ct') .where('b.id != :bookingId', { bookingId: booking.id }) - .andWhere('b.allowConsolidation = true') .andWhere('b.consolidationPartnerId IS NULL') // Only pair bookings the customer has committed (SUBMITTED) or that are // already waiting (PENDING_CONSOLIDATION). DRAFT bookings are excluded so @@ -650,11 +648,6 @@ export class BookingsRepository extends BaseRepository { excludePaymentStatus: options.excludePaymentStatus, }); } - if (options.allowConsolidation !== undefined) { - qb.andWhere('booking.allow_consolidation = :allowConsolidation', { - allowConsolidation: options.allowConsolidation, - }); - } if (options.consolidationPaired === 'true') { qb.andWhere('booking.consolidation_partner_id IS NOT NULL'); } else if (options.consolidationPaired === 'false') { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index cac19f111..21b145774 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -26,6 +26,7 @@ import { DataSource, In } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Yard } from '../rule-engine/entities/yard.entity'; +import { ContractRouteLine } from '../booking-orders/entities/contract-route-line.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; @@ -126,7 +127,6 @@ export class BookingsService { tradeDirection: string; isHazardous?: boolean; isGovernment?: boolean; - allowConsolidation?: boolean; shippingLineId?: string | null; containers: CreateBookingContainerDto[]; }): Promise { @@ -151,6 +151,14 @@ export class BookingsService { containers.reduce((sum, c) => sum + c.wagonsRequired, 0), ); + // Consolidation is system-managed: the CONSOLIDATION_ENABLED rule trigger + // fires whenever a container line leaves a wagon partially filled. There is + // no customer opt-in — partial-wagon cargo always consolidates. + const allowConsolidation = + dto.freightType === 'CONTAINER' + ? await this.needsConsolidation(dto.containers) + : false; + return { freightType: dto.freightType, cargoTypeId: dto.cargoTypeId ?? null, @@ -159,8 +167,7 @@ export class BookingsService { tradeDirection: dto.tradeDirection, isHazardous: dto.isHazardous ?? false, isGovernment: dto.isGovernment ?? false, - allowConsolidation: - dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false, + allowConsolidation, shippingLineId: dto.shippingLineId, totalWagons, containers, @@ -168,26 +175,20 @@ export class BookingsService { } /** - * Enable consolidation when any container line leaves a wagon partially filled - * (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon). - * - * Partial-wagon cargo ALWAYS consolidates — the customer cannot opt out of a - * half-empty wagon, so `explicit === false` is ignored when consolidation is - * actually needed. The opt-in flag only matters for cargo that already fills - * whole wagons (where consolidation is moot anyway). + * True when any container line leaves a wagon partially filled (e.g. 1×20ft on + * a 2-slot wagon). Partial-wagon cargo must consolidate before it can finalize; + * cargo that already fills whole wagons never does. This is computed from the + * container quantities alone — there is no customer-facing opt-in flag. */ - private async resolveConsolidation( + private async needsConsolidation( containers: CreateBookingContainerDto[], - explicit?: boolean, ): Promise { - const needs = await this.consolidationService.needsConsolidation( + return this.consolidationService.needsConsolidation( containers.map((c) => ({ containerTypeId: c.containerTypeId, quantity: c.quantity, })), ); - if (needs) return true; - return explicit ?? false; } /** Search for a complementary partner; pair or queue as PENDING_CONSOLIDATION. */ @@ -197,10 +198,12 @@ export class BookingsService { }> { const messages: string[] = []; - if (!booking.allowConsolidation || booking.consolidationPartnerId) { + if (booking.consolidationPartnerId) { return { booking, messages }; } + // Only partial-wagon container lines produce slots; full-wagon (and bulk) + // bookings return none and need no consolidation. const slots = await this.consolidationService.slotsFromBooking(booking); if (slots.length === 0) { return { booking, messages }; @@ -370,9 +373,9 @@ export class BookingsService { ); } - const allowConsolidation = + const needsConsolidation = dto.freightType === 'CONTAINER' - ? await this.resolveConsolidation(containers, dto.allowConsolidation) + ? await this.needsConsolidation(containers) : false; const evalInput = await this.buildEvalInput({ @@ -383,7 +386,6 @@ export class BookingsService { tradeDirection, isHazardous: dto.isHazardous, isGovernment, - allowConsolidation, shippingLineId: dto.shippingLineId, containers, }); @@ -423,7 +425,6 @@ export class BookingsService { startDate: dto.startDate ? new Date(dto.startDate) : undefined, endDate: dto.endDate ? new Date(dto.endDate) : undefined, status: 'DRAFT', - allowConsolidation, priorityScore: ruleResult.priorityScore, totalAmount: 0, paymentStatus: 'PENDING', @@ -443,6 +444,24 @@ export class BookingsService { warnings.push(`Estimated wagons required: ${wagonCount}`); } + // Multi-route general contracts: persist the contracted routes + quantities. + // Each drawdown order later draws from one of these route lines. + if (isGeneralContract && dto.routes?.length) { + const routeRepo = this.dataSource.getRepository(ContractRouteLine); + await routeRepo.save( + dto.routes.map((r) => + routeRepo.create({ + contractBookingId: booking.id, + originYardId: r.originYardId, + destinationYardId: r.destinationYardId, + containerTypeId: + dto.freightType === 'CONTAINER' ? (r.containerTypeId ?? null) : null, + quantity: r.quantity, + }), + ), + ); + } + if (files.length > 0) { try { await this.filesService.uploadMany(booking.id, 'bookings', files); @@ -451,9 +470,36 @@ export class BookingsService { } } + // Reuse the booking profile's onboarding documents instead of asking the + // customer to re-upload. Snapshot them onto the booking now (by reference), + // so a later active-profile switch never changes this booking's documents. + if (companyProfileId) { + try { + const onboardingFiles = + await this.companiesService.getProfileOnboardingFiles(companyProfileId); + if (onboardingFiles.length > 0) { + await this.filesService.attachExistingFiles( + booking.id, + 'bookings', + onboardingFiles.map((f, i) => ({ + code: `onboarding_document_${i + 1}`, + name: f.name, + url: f.url, + size: f.size, + mimeType: f.mimeType, + })), + ); + } + } catch { + warnings.push( + 'Could not attach onboarding documents — they can be added from the booking page.', + ); + } + } + let full = await this.findById(booking.id); - if (allowConsolidation) { + if (needsConsolidation) { const consolidation = await this.tryAutoConsolidate(full); full = consolidation.booking; warnings.push(...consolidation.messages); @@ -512,12 +558,9 @@ export class BookingsService { dto.tradeDirection, ); - const allowConsolidation = + const needsConsolidation = freightType === 'CONTAINER' - ? await this.resolveConsolidation( - containers, - dto.allowConsolidation ?? existing.allowConsolidation, - ) + ? await this.needsConsolidation(containers) : false; const evalInput = await this.buildEvalInput({ @@ -527,7 +570,6 @@ export class BookingsService { paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency, tradeDirection, isHazardous: dto.isHazardous ?? existing.isHazardous, - allowConsolidation, shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined, containers, }); @@ -536,12 +578,11 @@ export class BookingsService { this.ruleEngineService.assertNoHardBlocks(ruleResult); warnings.push(...ruleResult.warnings); - const pricingFieldsChanged = this.pricingRelevantFieldsChanged( + const pricingFieldsChanged = await this.pricingRelevantFieldsChanged( existing, dto, freightType, cargoTypeId, - allowConsolidation, containers, ); @@ -549,7 +590,6 @@ export class BookingsService { ...dto, freightType, cargoTypeId: freightType === 'BULK' ? cargoTypeId : null, - allowConsolidation, priorityScore: ruleResult.priorityScore, tradeDirection, }; @@ -599,7 +639,7 @@ export class BookingsService { let booking = await this.findById(id); - if (allowConsolidation && !booking.consolidationPartnerId) { + if (needsConsolidation && !booking.consolidationPartnerId) { const consolidation = await this.tryAutoConsolidate(booking); booking = consolidation.booking; warnings.push(...consolidation.messages); @@ -678,7 +718,6 @@ export class BookingsService { paymentStatus: filter.paymentStatus, createdFrom: filter.createdFrom, createdTo: filter.createdTo, - allowConsolidation: filter.allowConsolidation, consolidationPaired: filter.consolidationPaired, sortBy: filter.sortBy, sortOrder: filter.sortOrder, @@ -846,7 +885,6 @@ export class BookingsService { paymentStatus: filter.paymentStatus, createdFrom: filter.createdFrom, createdTo: filter.createdTo, - allowConsolidation: filter.allowConsolidation, consolidationPaired: filter.consolidationPaired, }; @@ -955,10 +993,6 @@ export class BookingsService { }> { const booking = await this.findById(id); - if (!booking.allowConsolidation) { - throw new BadRequestException('Booking is not eligible for consolidation'); - } - const needs = await this.consolidationService.needsConsolidationFromBooking( booking, ); @@ -1042,14 +1076,13 @@ export class BookingsService { }; } - private pricingRelevantFieldsChanged( + private async pricingRelevantFieldsChanged( existing: Booking, dto: UpdateBookingDto, freightType: FreightType, cargoTypeId: string | null | undefined, - allowConsolidation: boolean, containers: CreateBookingContainerDto[], - ): boolean { + ): Promise { if (dto.freightType !== undefined && dto.freightType !== existing.freightType) { return true; } @@ -1062,18 +1095,14 @@ export class BookingsService { if (dto.isHazardous !== undefined && dto.isHazardous !== existing.isHazardous) { return true; } - if ( - dto.allowConsolidation !== undefined && - dto.allowConsolidation !== existing.allowConsolidation - ) { - return true; - } if (dto.shippingLineId !== undefined && dto.shippingLineId !== existing.shippingLineId) { return true; } if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== existing.cargoTypeId) { return true; } + // Container lines drive both the base price and the consolidation surcharge + // (CONSOLIDATION_ENABLED fires on partial wagons), so any line change re-prices. if (dto.containers !== undefined) { const existingContainers = (existing.bookingContainers ?? []) .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null) @@ -1088,8 +1117,7 @@ export class BookingsService { } if ( freightType !== existing.freightType || - (cargoTypeId ?? null) !== (existing.cargoTypeId ?? null) || - allowConsolidation !== existing.allowConsolidation + (cargoTypeId ?? null) !== (existing.cargoTypeId ?? null) ) { return true; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index b7d5ea14d..7a8468dcb 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -53,6 +53,30 @@ export class CreateBookingContainerDto { vgmPerUnitTons!: number; } +export class CreateContractRouteDto { + @ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' }) + @IsUUID() + originYardId!: string; + + @ApiProperty({ format: 'uuid', description: 'FK to yards.id (destination)' }) + @IsUUID() + destinationYardId!: string; + + @ApiPropertyOptional({ + format: 'uuid', + description: 'Container type for CONTAINER contracts; omit for BULK', + }) + @IsOptional() + @IsUUID() + containerTypeId?: string; + + @ApiProperty({ description: 'Contracted quantity for this route', minimum: 1 }) + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + quantity!: number; +} + export class CreateBookingDto { /** Class-level freight shape check (not a request field). */ @Validate(BookingFreightShapeConstraint) @@ -161,6 +185,21 @@ export class CreateBookingDto { @IsUUID() destinationYardId!: string; + /** + * GENERAL_CONTRACT only: the routes this contract reserves quantity across. + * Each entry has its own origin/destination and quantity; the first entry also + * matches the booking's originYardId/destinationYardId. Omitted for one-time + * bookings, which use the single origin/destination above. + */ + @ApiPropertyOptional({ type: [CreateContractRouteDto] }) + @ValidateIf((o) => o.bookingType === 'GENERAL_CONTRACT') + @IsOptional() + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CreateContractRouteDto) + routes?: CreateContractRouteDto[]; + @ApiProperty({ enum: TRADE_DIRECTIONS }) @IsIn([...TRADE_DIRECTIONS]) tradeDirection!: string; @@ -233,10 +272,4 @@ export class CreateBookingDto { @ValidateNested({ each: true }) @Type(() => CreateBookingContainerDto) containers?: CreateBookingContainerDto[]; - - @ApiPropertyOptional({ default: false }) - @IsOptional() - @IsBoolean() - @Transform(({ value }) => value === 'true' || value === true) - allowConsolidation?: boolean; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index ee5099c52..d189f5448 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -96,11 +96,6 @@ export class FilterBookingDto { @IsIn([...PAYMENT_STATUSES]) paymentStatus?: string; - @ApiPropertyOptional() - @IsOptional() - @Transform(({ value }) => value === 'true' || value === true) - allowConsolidation?: boolean; - @ApiPropertyOptional({ description: 'true | false — filter paired consolidation' }) @IsOptional() consolidationPaired?: string; diff --git a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts index 3474bec74..532d6b1a7 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts @@ -7,9 +7,22 @@ export class PriceLineItemDto { @ApiProperty() description!: string; + /** Computed line total (unitAmount × quantity). Retained for totals elsewhere. */ @ApiProperty() amount!: number; + /** Price for a single unit of this charge (e.g. one 20ft container, one ton). */ + @ApiProperty() + unitAmount!: number; + + /** Unit the rate is charged per: PER_CONTAINER | PER_TON | PER_WAGON | PER_KM | FLAT. */ + @ApiProperty() + unit!: string; + + /** How many units this charge applies to (containers, tons, wagons; 1 for FLAT). */ + @ApiProperty() + quantity!: number; + @ApiProperty() currency!: string; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts index 99855d49f..698c5f98c 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts @@ -1,5 +1,5 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsString, MinLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MinLength } from 'class-validator'; export class RequestChangesDto { @ApiProperty({ description: 'Staff note explaining what the customer must fix' }) @@ -34,3 +34,12 @@ export class CancelBookingDto { @MinLength(1) reason!: string; } + +export class RejectBookingDto { + @ApiPropertyOptional({ + description: 'Optional reason the customer rejected the price estimate', + }) + @IsOptional() + @IsString() + reason?: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index d5f358e5f..1b5a9acae 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -294,9 +294,6 @@ export class Booking extends BaseEntity { @Column({ name: 'priority_score', type: 'int', default: 0 }) priorityScore!: number; - @Column({ name: 'allow_consolidation', type: 'boolean', default: false }) - allowConsolidation!: boolean; - @Column({ name: 'consolidation_partner_id', type: 'uuid', nullable: true }) consolidationPartnerId?: string | null; diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index e57d3b5f8..77973d357 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -914,6 +914,18 @@ export class CompaniesService { return profile.businessLicenseFiles ?? []; } + /** + * Onboarding documents stored on a company profile, fetched by profile id. + * Internal helper (no ownership check) used when a booking reuses the active + * profile's onboarding documents. Returns [] when the profile is unknown. + */ + async getProfileOnboardingFiles( + profileId: string, + ): Promise { + const profile = await this.companyProfilesRepo.findById(profileId); + return profile?.businessLicenseFiles ?? []; + } + /** * Resolve which company_profile a new booking belongs to, from the company * and the booking's trade direction. IMPORT → importer profile, EXPORT → diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index 97a5e9e34..31cf87dd7 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -54,6 +54,38 @@ export class FilesService { ); } + /** + * Attach already-stored files (e.g. a company profile's onboarding documents) + * to a resource by reference — creates FileRecord rows pointing at the existing + * object-storage URLs, without re-uploading bytes. The snapshot is fixed at call + * time, so later changes to the source documents never alter what was attached. + */ + async attachExistingFiles( + resourceId: string, + resource: string, + files: Array<{ + code: string; + name: string; + url: string; + size: number; + mimeType?: string; + }>, + ): Promise { + return Promise.all( + files.map((f) => + this.filesRepository.create({ + resourceId, + resource, + code: f.code, + name: f.name, + url: f.url, + size: f.size, + mimeType: f.mimeType ?? "application/octet-stream", + }), + ), + ); + } + async findById(id: string): Promise { const record = await this.filesRepository.findById(id); if (!record) throw new NotFoundException(`File ${id} not found`); diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index 736c1fbf9..9670243c9 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -386,9 +386,7 @@ export class DemoBookingsSeeder { shippingLineId: null, cargoTotalWeightVgm: demoBooking.totalWeightTons, isHazardous: false, - paymentCurrency: "ETB", - allowConsolidation: false, - priorityScore: 0, + paymentCurrency: "ETB", priorityScore: 0, versionNumber: 1, }, { conflictPaths: { reference: true } }, @@ -459,9 +457,7 @@ export class DemoBookingsSeeder { shippingLineId: null, cargoTotalWeightVgm: demoBulk.totalWeightTons, isHazardous: false, - paymentCurrency: "USD", - allowConsolidation: false, - priorityScore: 10, + paymentCurrency: "USD", priorityScore: 10, schedulingStatus: "HOLDING", versionNumber: 1, }, diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 28cebc9e7..cddc34757 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -643,9 +643,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, - isHazardous: false, - allowConsolidation: false, - shippingLineId: null, + isHazardous: false, shippingLineId: null, cargoTypeId: null, cargoTotalWeightVgm: 250, containers: [ @@ -663,9 +661,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, - isHazardous: true, - allowConsolidation: false, - shippingLineId: null, + isHazardous: true, shippingLineId: null, cargoTypeId: null, cargoTotalWeightVgm: 135, containers: [ @@ -683,9 +679,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, - isHazardous: false, - allowConsolidation: false, - shippingLineId: maersk.id, + isHazardous: false, shippingLineId: maersk.id, cargoTypeId: null, cargoTotalWeightVgm: 480, containers: [ @@ -703,9 +697,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, - isHazardous: false, - allowConsolidation: true, - shippingLineId: null, + isHazardous: false, shippingLineId: null, cargoTypeId: null, cargoTotalWeightVgm: 224, containers: [ @@ -723,9 +715,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { serviceTypeId: railBulk.id, originYardId: djibouti.id, destinationYardId: addis.id, - isHazardous: false, - allowConsolidation: false, - shippingLineId: null, + isHazardous: false, shippingLineId: null, cargoTypeId: grain.id, cargoTotalWeightVgm: 500, containers: [], @@ -741,9 +731,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, - isHazardous: false, - allowConsolidation: false, - shippingLineId: null, + isHazardous: false, shippingLineId: null, cargoTypeId: null, cargoTotalWeightVgm: 75, containers: [ @@ -761,9 +749,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, - isHazardous: false, - allowConsolidation: false, - shippingLineId: null, + isHazardous: false, shippingLineId: null, cargoTypeId: null, cargoTotalWeightVgm: 300, containers: [ diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index 660ca7e74..da5d37cec 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -126,7 +126,6 @@ export interface BookingDetail { tradeDirection: string; cargoTotalWeightVgm: number; isHazardous: boolean; - allowConsolidation: boolean; consolidationPartnerId?: string | null; consolidationPartner?: BookingNamedRef & { reference?: string } | null; priorityScore: number; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx index e7dbdd54c..da39659d2 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx @@ -113,6 +113,12 @@ function mapBookingToFormValues( ): BookingFormInputValues { const vals = { ...initialBookingFormValues, + operationType: + booking.tradeDirection === "IMPORT" + ? "import" + : booking.tradeDirection === "EXPORT" + ? "export" + : "intercity", contractType: (booking.contractType?.toLowerCase() as "new" | "renewal") ?? "new", previousContractRef: booking.previousContractId ?? "", @@ -137,7 +143,6 @@ function mapBookingToFormValues( isHazardous: booking.isHazardous ?? false, isRefrigerated: booking.isRefrigerated ?? false, shippingLine: (booking as any).shippingLine?.id ?? "", - consolidationEnabled: booking.allowConsolidation ?? false, paymentCurrency: booking.paymentCurrency === "ETB" ? "ETB" : "USD", scheduledDate: booking.scheduledDate @@ -432,7 +437,6 @@ export default function EditBookingPage() { cargoTotalWeightVgm: totalWeight, isHazardous: data.isHazardous, paymentCurrency: data.paymentCurrency, - allowConsolidation: data.consolidationEnabled, freightType: data.cargoType === "container" ? ("CONTAINER" as const) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 55fa6d0c2..2e2cc3e85 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -1,6 +1,5 @@ import { api } from "@/services/api"; import { Freight } from "@edr/types"; -import { hasAllRequiredDocuments } from "@/services/booking-form-data"; import type { CreateBookingPayload, GeneratePriceResponse, @@ -34,15 +33,19 @@ import useAuth from "@/hooks/useAuth"; import { BookingFormInputValues, STEPS, + allowedOperationsForProfiles, bookingFormSchema, getRouteDirection, initialBookingFormValues, + operationToProfileType, stepFields, type BookingDocuments, type BookingFormValues, + type OperationType, } from "./new-booking-form/schema"; import { StepIndicator } from "./new-booking-form/StepIndicator"; import { + Step0OperationType, Step1ContractType, Step2ServiceType, Step4Route, @@ -54,10 +57,26 @@ import { type PriceModalMode = "submit" | "draft"; +/** Suffix for a per-unit rate, e.g. "each" for a per-container price. */ +function unitRateLabel(unit?: string): string { + switch (unit) { + case "PER_CONTAINER": + return "each"; + case "PER_TON": + return "per ton"; + case "PER_WAGON": + return "per wagon"; + case "PER_KM": + return "per km"; + default: + return ""; + } +} + export default function NewBookingPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); - const [step, setStep] = useState(1); + const [step, setStep] = useState(0); const auth = useAuth(); const { data: referenceData, isLoading: refDataLoading } = useQuery( api.bookings.referenceData.queryOptions(), @@ -191,6 +210,20 @@ export default function NewBookingPage() { }, }); + // Customer rejects the priced booking → it becomes REJECTED (terminal) and the + // customer starts a fresh booking. + const rejectMutation = useMutation({ + mutationFn: async () => { + if (!priceBookingId) throw new Error("No booking to reject"); + return api.bookings.reject.call({ id: priceBookingId }); + }, + onSuccess: () => { + setPriceModalMode(null); + queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); + navigate("/bookings"); + }, + }); + const abortMutation = useMutation({ mutationFn: async (reason: string) => { if (!priceBookingId) throw new Error("No booking to abort"); @@ -245,6 +278,34 @@ export default function NewBookingPage() { return route; }, [originYard, destinationYard]); + // Operations the customer may book, gated by the company's onboarded profiles. + const allowedOperations = useMemo(() => { + const profileTypes = (auth.company?.company?.companyProfiles ?? []).map( + (p) => p.type, + ); + return allowedOperationsForProfiles(profileTypes); + }, [auth.company]); + + // Stamp the booking to the right operational profile. Import/Export switch the + // active mode so the matching onboarding documents are attached; Intercity uses + // whatever profile is already active. + const handleOperationSelect = (op: OperationType) => { + if (op === "intercity") return; + const target = operationToProfileType(op); + if (auth.activeProfileType !== target) { + void auth.switchMode(target as never); + } + }; + + // Onboarding documents for the active profile — shown read-only in the + // Documents step and attached to the booking on submit by the backend. + const onboardingDocs = useMemo(() => { + const profiles = auth.company?.company?.companyProfiles ?? []; + const active = + profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0]; + return active?.licenseFiles ?? []; + }, [auth.company, auth.activeCompanyProfileId]); + const [pricingData, setPricingData] = useState( null, ); @@ -261,14 +322,8 @@ export default function NewBookingPage() { const valid = await form.trigger(stepFields[step], { shouldFocus: true }); if (!valid) return; - if (step === 6 && !hasAllRequiredDocuments(form.getValues("documents"))) { - form.setError("documents", { - type: "manual", - message: "Upload all four required documents.", - }); - return; - } - + // Documents (step 6) is read-only — the active profile's onboarding files are + // attached automatically, so there is nothing to validate here. goToStep(1); } @@ -348,7 +403,6 @@ export default function NewBookingPage() { // engine assigns the train, so no trainScheduleId is sent. cargoTotalWeightVgm: totalWeight, isHazardous: data.isHazardous, - allowConsolidation: data.consolidationEnabled, freightType: data.cargoType === "container" ? ("CONTAINER" as const) @@ -377,6 +431,37 @@ export default function NewBookingPage() { ? { shippingLineId: data.shippingLine } : {}), ...(cargoFreeText ? { cargoFreeText } : {}), + // Multi-route general contracts: route #1 is the primary origin/destination + // carrying the full contracted quantity; each extra route reserves its own. + ...(isContract + ? { + routes: [ + { + originYardId: data.originYard, + destinationYardId: data.destinationYard, + quantity: + data.cargoType === "container" + ? data.containers.reduce( + (sum, c) => sum + Number(c.qty || 0), + 0, + ) + : totalWeight, + }, + ...(data.extraRoutes ?? []) + .filter( + (r) => + r.originYard && + r.destinationYard && + Number(r.quantity) > 0, + ) + .map((r) => ({ + originYardId: r.originYard, + destinationYardId: r.destinationYard, + quantity: Number(r.quantity), + })), + ], + } + : {}), }; } @@ -394,14 +479,8 @@ export default function NewBookingPage() { }); const handleSubmitBooking = form.handleSubmit((data) => { - if (!hasAllRequiredDocuments(data.documents)) { - form.setError("documents", { - type: "manual", - message: "Upload all four required documents.", - }); - setStep(6); - return; - } + // Documents are reused from onboarding and attached by the backend, so there + // is no upload requirement to enforce here. try { const apiPayload = buildApiPayload(data); persistAndPriceMutation.mutate({ @@ -498,6 +577,13 @@ export default function NewBookingPage() { )} + {step === 0 && ( + + )} {step === 1 && ( )} @@ -522,13 +608,14 @@ export default function NewBookingPage() { {step === 5 && ( )} - {step === 6 && } + {step === 6 && } {step === 7 && ( {priceModalMode === "submit" - ? "Review the price estimate below. Confirm to submit your booking for EDR staff review." - : "Your booking has been saved as a draft. Here is the estimated price."} + ? "These are the unit rates that apply to your booking. Confirm to submit for EDR staff review, or reject to discard this booking." + : "Your booking has been saved as a draft. These are the unit rates that apply."} {pricingData.lineItems.map((item) => ( - + {item.description} + {item.quantity && item.quantity > 1 ? ( + + {" "} + × {item.quantity.toLocaleString()} + + ) : null} - {item.amount.toLocaleString()} {item.currency} + {(item.unitAmount ?? item.amount).toLocaleString()}{" "} + {item.currency} {unitRateLabel(item.unit)} ))} - - - Total - - - {pricingData.totalAmount.toLocaleString()} {pricingData.currency} - - {pricingData.warnings.length > 0 && ( {pricingData.warnings.join(", ")} @@ -645,12 +731,15 @@ export default function NewBookingPage() { {priceModalMode === "submit" ? ( <> diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index da8f1837e..11291b3aa 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -3,6 +3,7 @@ import { DeepPartial, Path } from "react-hook-form"; import * as z from "zod"; export const STEPS = [ + { id: 0, label: "Operation Type", short: "Operation" }, { id: 1, label: "Contract Type", short: "Contract" }, { id: 2, label: "Service Type & Mile", short: "Service" }, { id: 3, label: "Route", short: "Route" }, @@ -12,6 +13,9 @@ export const STEPS = [ { id: 7, label: "Review & Submit", short: "Submit" }, ] as const; +export const OPERATION_TYPES = ["import", "export", "intercity"] as const; +export type OperationType = (typeof OPERATION_TYPES)[number]; + /** * Shipment documents collected during booking creation. The fileKeys mirror * `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts. @@ -86,6 +90,10 @@ export type BookingTypeOption = (typeof BOOKING_TYPES)[number]; export const bookingFormSchema = z .object({ + // Operation the booking is for, gated by the company's onboarded profiles. + // Drives trade direction (import/export → IMPORT/EXPORT; intercity → DOMESTIC) + // and the active company profile the booking is stamped to. + operationType: z.enum(OPERATION_TYPES, "Select an operation type."), // One-time booking vs. a general contract (umbrella, drawn down by orders). bookingType: z.enum(BOOKING_TYPES).default("one_time"), contractType: z.enum(["new", "renewal"], "Select a contract type."), @@ -117,6 +125,18 @@ export const bookingFormSchema = z customsClearingEnabled: z.boolean().default(false), originYard: z.string().min(1, "Select an origin yard."), destinationYard: z.string().min(1, "Select a destination yard."), + // Additional routes for a GENERAL contract (the primary origin/destination + // above is route #1). Each adds another (origin, destination, quantity) pool. + // Ignored for one-time bookings. + extraRoutes: z + .array( + z.object({ + originYard: z.string(), + destinationYard: z.string(), + quantity: z.string(), + }), + ) + .default([]), shippingLine: z.string(), // Day-level pool: the customer selects only a DAY. The batch engine assigns // the specific train later, so no trainScheduleId is collected here. @@ -145,10 +165,9 @@ export const bookingFormSchema = z .refine((vgm) => Number(vgm) >= 0, "Must be greater than 0"), }), ), - // Consolidation is system-managed, not a customer choice. The backend only - // consolidates partial-wagon bookings, so this is always allowed; the - // customer neither sees nor toggles it. - consolidationEnabled: z.boolean().default(true), + // Consolidation is system-managed, not a customer choice: the backend + // consolidates partial-wagon container bookings automatically, derived from + // the container quantities. The customer neither sees nor toggles it. documents: z.record(z.string(), z.any()).default({}), notes: z.string(), }) @@ -254,6 +273,7 @@ export const initialBookingFormValues: DeepPartial = { customsClearingEnabled: false, originYard: "", destinationYard: "", + extraRoutes: [], shippingLine: "", scheduledDate: "", cargoWeight: "", @@ -262,12 +282,12 @@ export const initialBookingFormValues: DeepPartial = { isHazardous: false, isRefrigerated: false, containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }], - consolidationEnabled: true, documents: {}, notes: "", }; export const stepFields: Record>> = { + 0: ["operationType"], 1: ["bookingType", "contractType", "previousContractRef"], 2: [ "serviceTypeId", @@ -280,6 +300,7 @@ export const stepFields: Record>> = { 3: [ "originYard", "destinationYard", + "extraRoutes", "isHazardous", "isRefrigerated", "shippingLine", @@ -330,6 +351,44 @@ export function getRouteDirection( return "DOMESTIC"; } +/** + * Operations a company may book, derived from its onboarded profile types. + * - freight forwarder (or DJ forwarder) → import, export, intercity + * - importer → import, intercity + * - exporter → export, intercity + * - importer + exporter → import, export, intercity + * Intercity (DOMESTIC) is always available to any customer-side profile. + */ +export function allowedOperationsForProfiles( + profileTypes: string[], +): OperationType[] { + const has = (t: string) => profileTypes.includes(t); + const isForwarder = has("freight_forwarder") || has("dj_freight_forwarder"); + const ops = new Set(); + if (isForwarder || has("importer")) ops.add("import"); + if (isForwarder || has("exporter")) ops.add("export"); + // Any importer/exporter/forwarder profile can also run domestic (intercity). + if (isForwarder || has("importer") || has("exporter")) ops.add("intercity"); + // Preserve a stable display order. + return OPERATION_TYPES.filter((o) => ops.has(o)); +} + +/** Trade direction the backend will derive for a given operation type. */ +export function operationToTradeDirection( + op: OperationType, +): Freight.ScheduleTradeDirection { + if (op === "import") return "IMPORT"; + if (op === "export") return "EXPORT"; + return "DOMESTIC"; +} + +/** The company_profile type a booking for this operation should be stamped to. */ +export function operationToProfileType(op: OperationType): string { + if (op === "import") return "importer"; + if (op === "export") return "exporter"; + return "freight_forwarder"; +} + export function calcWagons(containers: ContainerConfig[]) { const Ft20Wagons = containers .filter((c) => c.type === "20ft") diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx index 07a6d197c..7cb091d68 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx @@ -1,40 +1,36 @@ -import { Box, Group, Text } from "@mantine/core"; -import { SmartFileInput } from "@edr/ui-common"; -import { CheckCircle2, FileUp } from "lucide-react"; -import { Controller, type UseFormReturn } from "react-hook-form"; +import { Box, Group, Stack, Text } from "@mantine/core"; +import { CheckCircle2, FileText, FileUp } from "lucide-react"; -import { - BOOKING_DOCS_SETTING, - BookingFormInputValues, - type BookingDocuments, - type BookingFormValues, -} from "./schema"; import { StepCard, StepHeader } from "./shared"; -type BookingForm = UseFormReturn< - BookingFormInputValues, - any, - BookingFormValues ->; - -function countAttached(documents: BookingDocuments): number { - return BOOKING_DOCS_SETTING.fields.filter((f) => { - const value = documents[f.fileKey]; - return Array.isArray(value) ? value.length > 0 : Boolean(value); - }).length; +export interface OnboardingDoc { + name: string; + url: string; + size: number; + mimeType?: string; } -export function StepDocuments({ form }: { form: BookingForm }) { - const documents = (form.watch("documents") ?? {}) as BookingDocuments; - const attached = countAttached(documents); - const total = BOOKING_DOCS_SETTING.fields.length; +function formatSize(bytes: number): string { + if (!bytes) return ""; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +/** + * Read-only documents step: lists the documents the company uploaded during + * onboarding for the active operational profile. These are attached to the + * booking automatically at submission — the customer is never asked to re-upload. + */ +export function StepDocuments({ documents }: { documents: OnboardingDoc[] }) { + const total = documents.length; return ( } - title="Shipment Documents" - description="Attach your shipment documents now, or skip and upload them later from the booking page." + title="Documents" + description="The documents from your onboarding will be attached to this booking automatically. No re-upload is needed." /> 0 ? "#ECF6F1" : "#FBECEC", + color: total > 0 ? "#0A6F4D" : "#B42318", }} > - {attached === total ? ( - - ) : ( - `${attached}/${total}` - )} + {total > 0 ? : } - {attached === 0 - ? "All documents are optional here — you can upload them later from the booking page." - : `${attached} of ${total} attached. You can finish the rest later from the booking page.`} + {total > 0 + ? `${total} onboarding ${total === 1 ? "document" : "documents"} will be attached to this booking.` + : "No onboarding documents found on your active profile. You can add documents later from the booking page."} - ( - field.onChange(value)} - /> - )} - /> + {total > 0 && ( + + {documents.map((doc, i) => ( + + + + + + + {doc.name} + + {doc.size ? ( + + {formatSize(doc.size)} + + ) : null} + + + + + Uploaded + + + + ))} + + )} ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step0-operation-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step0-operation-type.tsx new file mode 100644 index 000000000..6bb1643ec --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step0-operation-type.tsx @@ -0,0 +1,119 @@ +import { Controller, type UseFormReturn } from "react-hook-form"; +import { ArrowDownToLine, ArrowUpFromLine, Truck } from "lucide-react"; +import { Text } from "@mantine/core"; +import { + BookingFormInputValues, + type BookingFormValues, + type OperationType, +} from "./schema"; +import { + AlertBox, + OptionCard, + OptionFieldError, + StepCard, + StepHeader, +} from "./shared"; + +type BookingForm = UseFormReturn< + BookingFormInputValues, + any, + BookingFormValues +>; + +const OPTIONS: Array<{ + value: OperationType; + title: string; + description: string; + icon: React.ReactNode; + iconBg: string; + iconColor: string; +}> = [ + { + value: "import", + title: "Import", + description: "Cargo arriving into Ethiopia via Djibouti.", + icon: , + iconBg: "#ECF6F1", + iconColor: "#0A6F4D", + }, + { + value: "export", + title: "Export", + description: "Cargo leaving Ethiopia bound for Djibouti.", + icon: , + iconBg: "#EAF1FB", + iconColor: "#2E5B96", + }, + { + value: "intercity", + title: "Intercity", + description: "Domestic movement between Ethiopian yards.", + icon: , + iconBg: "#F1ECFB", + iconColor: "#6A40B8", + }, +]; + +export function Step0OperationType({ + form, + allowedOperations, + onSelect, +}: { + form: BookingForm; + allowedOperations: OperationType[]; + onSelect?: (op: OperationType) => void; +}) { + return ( + + } + title="Operation Type" + description="Choose what this booking is for. The options available reflect the operations your company is registered for." + /> + + {allowedOperations.length === 0 && ( + + Your company has no operational profile yet. Complete onboarding to + register as an importer, exporter, or freight forwarder. + + )} + + ( +
+
+ {OPTIONS.map((opt) => { + const enabled = allowedOperations.includes(opt.value); + return ( + { + if (!enabled) return; + field.onChange(opt.value); + onSelect?.(opt.value); + }} + /> + ); + })} +
+ +
+ )} + /> + + + Import and Export are stamped to your matching company profile; their + documents are attached automatically at submission. + +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx index 40fc1f061..410c2b48f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -1,8 +1,29 @@ import type { Freight } from "@edr/types"; -import { Box, Divider, Group, Skeleton, Stack, Switch, Text } from "@mantine/core"; -import { Flame, MapPin, Route as RouteIcon, Snowflake } from "lucide-react"; +import { + Box, + Button, + Divider, + Group, + NumberInput, + Skeleton, + Stack, + Switch, + Text, +} from "@mantine/core"; +import { + Flame, + MapPin, + Plus, + Route as RouteIcon, + Snowflake, + Trash2, +} from "lucide-react"; import { useEffect, useMemo } from "react"; -import { Controller, type UseFormReturn } from "react-hook-form"; +import { + Controller, + useFieldArray, + type UseFormReturn, +} from "react-hook-form"; import { BookingFormInputValues, type BookingFormValues, @@ -27,6 +48,13 @@ export function Step4Route({ }) { const originYard = form.watch("originYard"); const destinationYard = form.watch("destinationYard"); + const isGeneralContract = form.watch("bookingType") === "general_contract"; + + const { + fields: extraRoutes, + append: appendRoute, + remove: removeRoute, + } = useFieldArray({ control: form.control, name: "extraRoutes" }); const yardOptions = useMemo(() => { if (!referenceData?.yard) return []; @@ -144,6 +172,105 @@ export function Step4Route({
)} + {isGeneralContract && !isLoading && ( + + + Additional contract routes + + + + A general contract can reserve quantity across several routes. The + route above is your primary route; add more routes and the quantity + reserved for each. + + + {extraRoutes.map((rf, i) => ( + + + ( + + )} + /> + + + ( + + )} + /> + + + ( + field.onChange(String(v ?? ""))} + radius="md" + /> + )} + /> + + + + ))} + + + )} + {direction && direction !== "DOMESTIC" && ( void; direction: Freight.ScheduleTradeDirection; referenceData?: Freight.BookingReferenceData; + onboardingDocs?: Array<{ name: string; size?: number }>; onSaveDraft?: () => void; onSubmit?: () => void; saveDraftPending?: boolean; @@ -171,12 +170,8 @@ export function Step8Review({ ) : Number(values.cargoWeight || 0); - const documents = (values.documents ?? {}) as BookingDocuments; - const docsAttached = BOOKING_DOCS_SETTING.fields.filter((f) => { - const value = documents[f.fileKey]; - return Array.isArray(value) ? value.length > 0 : Boolean(value); - }).length; - const allDocsReady = hasAllRequiredDocuments(documents); + // Documents are reused from onboarding (read-only) and attached on submit. + const onboardingDocsCount = onboardingDocs.length; const cargoValue = (() => { if (values.cargoType === "container") return "Container freight"; @@ -374,35 +369,35 @@ export function Step8Review({ onEdit={() => setStep(REVIEW_STEP_TARGETS.documents)} > - {BOOKING_DOCS_SETTING.fields.map((field) => { - const file = documents[field.fileKey]; - const attached = Array.isArray(file) - ? file.length > 0 - : Boolean(file); - const fileName = attached - ? Array.isArray(file) - ? file[0]?.name - : (file as File)?.name - : null; - return ( - + {onboardingDocsCount > 0 ? ( + onboardingDocs.map((doc, i) => ( + - {attached ? ( - - ) : ( - - )} - {field.fileLabel} + + + {doc.name} + - - {fileName ?? "Missing"} + + Uploaded - ); - })} + )) + ) : ( + + + + No onboarding documents found on your active profile. + + + )} - {docsAttached} of {BOOKING_DOCS_SETTING.fields.length} attached + Documents from your onboarding will be attached to this booking. @@ -451,17 +446,16 @@ export function Step8Review({ label="Cargo details complete" /> 0} + label="Onboarding documents attached" /> - {allDocsReady - ? "Ready to submit. You'll review the price estimate before final submission." - : "Upload all four documents to enable submission."} + Ready to submit. You'll review the unit rates before final + submission. diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/steps.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/steps.tsx index 2532da237..7ed47c1dc 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/steps.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/steps.tsx @@ -1,3 +1,4 @@ +export { Step0OperationType } from "./step0-operation-type"; export { Step1ContractType } from "./step1-contract-type"; export { Step2ServiceType } from "./step2-service-type"; export { Step4Route } from "./step4-route"; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx b/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx index 7ad77f6eb..730b31b98 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx @@ -41,15 +41,33 @@ export function PlaceOrderDialog({ const [scheduledDate, setScheduledDate] = useState(null); const [quantities, setQuantities] = useState>({}); + const [routeLineId, setRouteLineId] = useState(null); + + // Multi-route contracts expose route lines; single-route contracts return []. + const { data: routeLines = [] } = useQuery({ + ...api.bookingOrders.routes.queryOptions({ + input: { contractBookingId: contract.id }, + }), + enabled: opened, + }); + const isMultiRoute = routeLines.length > 0; + const selectedRoute = routeLines.find((r) => r.routeLineId === routeLineId); + + // The route the order ships on drives both the available-days query and the + // remaining-quantity check: the chosen route line for multi-route contracts, + // else the contract's own origin/destination. + const originYardId = isMultiRoute + ? selectedRoute?.originYardId + : contract.originYard?.id; + const destinationYardId = isMultiRoute + ? selectedRoute?.destinationYardId + : contract.destinationYard?.id; const { data: availableDays, isLoading: daysLoading } = useQuery({ ...api.bookings.getAvailableDays.queryOptions({ - input: { - originYardId: contract.originYard?.id, - destinationYardId: contract.destinationYard?.id, - }, + input: { originYardId, destinationYardId }, }), - enabled: opened && !!contract.originYard?.id && !!contract.destinationYard?.id, + enabled: opened && !!originYardId && !!destinationYardId, }); const dayOptions = useMemo( @@ -82,6 +100,11 @@ export function PlaceOrderDialog({ contractBookingId: contract.id, }), }); + queryClient.invalidateQueries({ + queryKey: api.bookingOrders.routes.queryKey({ + contractBookingId: contract.id, + }), + }); queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: contract.id }), }); @@ -93,6 +116,7 @@ export function PlaceOrderDialog({ function reset() { setScheduledDate(null); setQuantities({}); + setRouteLineId(null); } function handleClose() { @@ -103,6 +127,28 @@ export function PlaceOrderDialog({ function handleSubmit() { if (!scheduledDate) return; + + if (isMultiRoute) { + if (!selectedRoute) return; + const raw = quantities["__route__"]; + const qty = typeof raw === "number" ? raw : 0; + if (qty <= 0) return; + createMutation.mutate({ + contractBookingId: contract.id, + routeLineId: selectedRoute.routeLineId, + scheduledDate: new Date(scheduledDate).toISOString(), + lines: [ + { + containerTypeId: isContainer + ? (selectedRoute.containerTypeId ?? null) + : null, + quantity: qty, + }, + ], + }); + return; + } + const lines: Freight.CreateBookingOrderLineDto[] = pool .map((line) => { const raw = quantities[lineKey(line)]; @@ -124,11 +170,27 @@ export function PlaceOrderDialog({ } const orderableLines = pool.filter((l) => l.remainingQuantity > 0); - const hasQuantity = pool.some((l) => { - const raw = quantities[lineKey(l)]; - return typeof raw === "number" && raw > 0; - }); - const canSubmit = !!scheduledDate && hasQuantity && !createMutation.isPending; + const routeQtyRaw = quantities["__route__"]; + const hasQuantity = isMultiRoute + ? typeof routeQtyRaw === "number" && routeQtyRaw > 0 + : pool.some((l) => { + const raw = quantities[lineKey(l)]; + return typeof raw === "number" && raw > 0; + }); + const canSubmit = + !!scheduledDate && + hasQuantity && + (!isMultiRoute || !!selectedRoute) && + !createMutation.isPending; + + const routeOptions = routeLines.map((r) => ({ + value: r.routeLineId, + label: `${r.originYardName ?? r.originYardId} → ${r.destinationYardName ?? r.destinationYardId} · ${formatQuantity( + r.remainingQuantity, + null, + isContainer, + )} remaining`, + })); return ( - Draw down from contract {contract.reference}. Route, - cargo and service are inherited — just pick a shipment date and - quantity. + Draw down from contract {contract.reference}. Cargo + and service are inherited — pick {isMultiRoute ? "a route, " : ""}a + shipment date and quantity. + {isMultiRoute && ( + } nothingFoundMessage="No departures on this route" @@ -168,6 +247,52 @@ export function PlaceOrderDialog({ styles={{ input: { height: 44 } }} /> + {isMultiRoute ? ( + + + Quantity + + {!selectedRoute ? ( + + Select a route to draw down from. + + ) : selectedRoute.remainingQuantity <= 0 ? ( + }> + This route is fully drawn down — no quantity remains. + + ) : ( + +
+ + {selectedRoute.containerTypeName ?? + (isContainer ? "Containers" : "Tons")} + + + {formatQuantity( + selectedRoute.remainingQuantity, + null, + isContainer, + )}{" "} + remaining + +
+ + setQuantities({ __route__: v === "" ? "" : Number(v) }) + } + min={0} + max={selectedRoute.remainingQuantity} + step={isContainer ? 1 : 0.5} + clampBehavior="strict" + radius="md" + w={130} + placeholder="0" + /> +
+ )} +
+ ) : ( Quantity @@ -219,6 +344,7 @@ export function PlaceOrderDialog({ ); })} + )} {createMutation.isError && ( }> diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index bf170cfac..6048be28b 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -225,6 +225,12 @@ export const api = { ({ id, reason }) => bookingsService.cancel(id, reason), ), + reject: endpoint<{ id: string; reason?: string }, Freight.IBooking>( + "bookings", + "reject", + ({ id, reason }) => bookingsService.reject(id, reason), + ), + generatePrice: endpoint<{ id: string }, GeneratePriceResponse>( "bookings", "generatePrice", @@ -292,6 +298,13 @@ export const api = { bookingOrdersService.pool(contractBookingId), ), + routes: endpoint< + { contractBookingId: string }, + Freight.ContractRouteLine[] + >("booking-orders", "routes", ({ contractBookingId }) => + bookingOrdersService.routes(contractBookingId), + ), + create: endpoint( "booking-orders", "create", diff --git a/apps/edr-freight-web/portal/src/services/booking-orders.service.ts b/apps/edr-freight-web/portal/src/services/booking-orders.service.ts index fc1490531..a920414a7 100644 --- a/apps/edr-freight-web/portal/src/services/booking-orders.service.ts +++ b/apps/edr-freight-web/portal/src/services/booking-orders.service.ts @@ -24,6 +24,16 @@ export const bookingOrdersService = { return data.data ?? data; }, + /** Per-route contracted / ordered / remaining quantities (multi-route contracts). */ + routes: async ( + contractBookingId: string, + ): Promise => { + const { data } = await client.get( + `/api/booking-orders/contract/${contractBookingId}/routes`, + ); + return data.data ?? data; + }, + /** Place a drawdown order against a contract. */ create: async ( payload: CreateBookingOrderPayload, diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 0631a49aa..e74f8281c 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -35,7 +35,14 @@ export interface ContractView { export interface PriceLineItem { code: string; description: string; + /** Computed line total (unitAmount × quantity). */ amount: number; + /** Price for a single unit of this charge (e.g. one 20ft container, one ton). */ + unitAmount?: number; + /** Unit the rate is charged per: PER_CONTAINER | PER_TON | PER_WAGON | PER_KM | FLAT. */ + unit?: string; + /** How many units this charge applies to (containers, tons, wagons; 1 for FLAT). */ + quantity?: number; currency: string; } @@ -136,6 +143,11 @@ export const bookingsService = { return data.data; }, + reject: async (id: string, reason?: string): Promise => { + const { data } = await client.post(`/api/bookings/${id}/reject`, { reason }); + return data.data; + }, + generatePrice: async (id: string): Promise => { const { data } = await client.post(`/api/bookings/${id}/generate-price`); return data.data; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 4b1ef3ebd..b86e64dd8 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -387,7 +387,6 @@ export interface IBooking extends BaseEntity { tradeDirection: "IMPORT" | "EXPORT"; paymentCurrency: string; - allowConsolidation: boolean; consolidationPartnerId?: string | null; startDate?: string | null; @@ -430,7 +429,14 @@ export interface IBooking extends BaseEntity { export interface PricingBreakdownLineItem { code: string; + /** Computed line total (unitAmount × quantity). */ amount: number; + /** Price for a single unit of this charge (e.g. one 20ft container, one ton). */ + unitAmount?: number; + /** Unit the rate is charged per: PER_CONTAINER | PER_TON | PER_WAGON | PER_KM | FLAT. */ + unit?: string; + /** How many units this charge applies to (containers, tons, wagons; 1 for FLAT). */ + quantity?: number; currency: string; description: string; } @@ -577,6 +583,14 @@ export interface CreateBookingContainerDto { vgmPerUnitTons: number; } +/** A contracted route+quantity line for a GENERAL contract. */ +export interface CreateContractRouteDto { + originYardId: string; + destinationYardId: string; + containerTypeId?: string | undefined; + quantity: number; +} + export interface CreateBookingDto { freightShapeValidation?: boolean | undefined; reference?: string | undefined; @@ -610,7 +624,8 @@ export interface CreateBookingDto { endDate?: string | undefined; financialTerms?: string | undefined; containers?: CreateBookingContainerDto[]; - allowConsolidation?: boolean; + /** GENERAL_CONTRACT only: routes the contract reserves quantity across. */ + routes?: CreateContractRouteDto[]; } // ── General Contracts & Booking Orders ────────────────────────────────────────── @@ -651,9 +666,25 @@ export interface CreateBookingOrderLineDto { quantity: number; } +/** Per-route contracted / ordered / remaining pool line (multi-route contracts). */ +export interface ContractRouteLine { + routeLineId: string; + originYardId: string; + originYardName?: string | null; + destinationYardId: string; + destinationYardName?: string | null; + containerTypeId?: string | null; + containerTypeName?: string | null; + contractedQuantity: number; + orderedQuantity: number; + remainingQuantity: number; +} + export interface CreateBookingOrderDto { /** The general contract (booking) this order draws down from. */ contractBookingId: string; + /** For multi-route contracts: the route line being drawn from. */ + routeLineId?: string; /** The shipment day the customer wants for this order. */ scheduledDate: string; lines: CreateBookingOrderLineDto[]; From 29f6928059c6e6e543ece3cc0c13ed3b3d2304ed Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 23 Jun 2026 21:00:51 +0000 Subject: [PATCH 06/19] feat: add unit of measure selection for cargo types and update related calculations in booking forms --- .../src/pages/ruleEngine/CargoTypesPage.tsx | 25 ++++ .../src/pages/bookings/NewBookingPage.tsx | 24 ++-- .../new-booking-form/step5-cargo-details.tsx | 113 ++++++++++++------ .../new-booking-form/step8-review.tsx | 23 +++- 4 files changed, 136 insertions(+), 49 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx index 43dcac46a..890796758 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx @@ -35,6 +35,7 @@ import { canAccessRuleEngineResource } from "@/lib/permissions"; import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog"; import { getRuleEngineResource, + RULE_ENGINE_SELECT_NONE, type FormFieldDef, } from "@/pages/ruleEngine/config/resources"; import { @@ -52,6 +53,8 @@ interface CargoNode extends RuleEngineRecord { parentGroupId?: string | null; showFreeTextBox?: boolean; requiresDirectorApproval?: boolean; + /** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */ + unitOfMeasure?: string | null; isActive?: boolean; displayOrder?: number; } @@ -62,6 +65,21 @@ const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0); /** Create/edit form fields. Parent is set from the current page, never picked. */ const FORM_FIELDS: FormFieldDef[] = [ { name: "cargoTypeName", label: "Cargo type name", type: "text", required: true }, + { + // How this cargo is measured. Optional — leave "None" for grouping + // categories; set it on the actual commodities so bookings ask for the + // right amount (estimated tons vs. total item count). + name: "unitOfMeasure", + label: "Unit of measure", + type: "select", + optional: true, + placeholder: "Select unit (optional)", + options: [ + { label: "None", value: RULE_ENGINE_SELECT_NONE }, + { label: "Per ton (bulk)", value: "PER_TON" }, + { label: "Per item (break-bulk)", value: "PER_ITEM" }, + ], + }, { name: "showFreeTextBox", label: "Show free text box", type: "boolean" }, { name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" }, { name: "isActive", label: "Active", type: "boolean" }, @@ -469,6 +487,13 @@ function CargoRow({ ) : null} + {node.unitOfMeasure ? ( + + + {node.unitOfMeasure === "PER_ITEM" ? "Per item" : "Per ton"} + + + ) : null} {inactive ? ( Inactive diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 2e2cc3e85..5a4e67d0d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -337,14 +337,6 @@ export default function NewBookingPage() { throw new Error("Validation failed"); } - const totalWeight = - data.cargoType === "container" - ? data.containers.reduce( - (acc, c) => acc + Number(c.vgm || 0) * Number(c.qty || 0), - 0, - ) - : Number(data.cargoWeight || 0); - const cargoTree = referenceData?.cargo_type ?? []; const containerGroups = referenceData?.containers ?? []; @@ -363,6 +355,22 @@ export default function NewBookingPage() { .flatMap((g) => g.children ?? []) .find((c) => c.id === childId); + // Bulk amount lives in cargoTotalWeightVgm — tons (estimated) or a whole + // item count, depending on the commodity's unit_of_measure. Item counts are + // rounded since fractional items are meaningless. Container totals are the + // summed VGM of all container lines. + const isPerItem = + bulkChild?.unit_of_measure === Freight.CargoUnitOfMeasure.PerItem; + const totalWeight = + data.cargoType === "container" + ? data.containers.reduce( + (acc, c) => acc + Number(c.vgm || 0) * Number(c.qty || 0), + 0, + ) + : isPerItem + ? Math.round(Number(data.cargoWeight || 0)) + : Number(data.cargoWeight || 0); + const cargoTypeId = data.cargoType === "bulk" ? childId : undefined; const cargoFreeText = bulkChild?.show_free_text_box diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index 7a21ef3d0..b31a83174 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -87,10 +87,10 @@ export function Step5CargoDetails({ return group?.children?.find((c) => c.id === childId) ?? null; }, [referenceData, parentId, childId]); - // Unit of measure for bulk/break-bulk cargo: PER_ITEM → "Items", else "Tons". - // Drives the weight/quantity label so customers enter the right unit. + // Unit of measure for bulk/break-bulk cargo: PER_ITEM → ask for a total item + // count; otherwise ask for estimated tons. Drives the amount field's label, + // icon, and step so customers enter the right unit. const isPerItem = selectedCommodity?.unit_of_measure === "PER_ITEM"; - const bulkUnitLabel = isPerItem ? "Items" : "Tons"; const freightTypeGroups = useMemo(() => { if (!referenceData?.cargo_type) return []; @@ -204,41 +204,36 @@ export function Step5CargoDetails({ />
- {/* Weight */} -
- ( - } - error={fieldState.error?.message} - // Container total is auto-summed from the containers below. - readOnly={cargoType === "container"} - description={ - cargoType === "container" - ? "Auto-calculated from the containers below." - : undefined - } - radius={10} - styles={fieldStyles} - min={0} - step={isPerItem ? 1 : 0.01} - /> - )} - /> -
+ {/* Containerised cargo: total weight is auto-summed from the containers + below, so we show it here up-front as a read-only running total. */} + {cargoType === "container" && ( +
+ ( + } + error={fieldState.error?.message} + readOnly + description="Auto-calculated from the containers below." + radius={10} + styles={fieldStyles} + min={0} + step={0.01} + /> + )} + /> +
+ )} - {/* Bulk freight type */} + {/* Bulk freight type — pick the commodity FIRST so we know whether the + cargo is measured in tons or items before asking for the amount. */} {cargoType === "bulk" && (
{freightTypeOptions.length > 0 ? ( @@ -269,8 +264,8 @@ export function Step5CargoDetails({ )} @@ -292,6 +287,46 @@ export function Step5CargoDetails({ )} /> )} + + {/* Amount — only once a commodity is chosen, so the unit (tons vs + items) is known. PER_TON asks for estimated tons to ship; + PER_ITEM asks for the total item count to import/export. */} + {selectedCommodity && ( + ( + + ) : ( + + ) + } + error={fieldState.error?.message} + description={ + isPerItem + ? "Total count of items you plan to import or export." + : "Your best estimate of the total weight to ship, in tons." + } + radius={10} + styles={fieldStyles} + min={0} + step={isPerItem ? 1 : 0.01} + /> + )} + /> + )}
)} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx index 9cae7d510..5acbc0213 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx @@ -173,6 +173,13 @@ export function Step8Review({ // Documents are reused from onboarding (read-only) and attached on submit. const onboardingDocsCount = onboardingDocs.length; + const selectedCommodity = (() => { + if (values.cargoType !== "bulk" || !referenceData) return null; + const path = values.cargoTypePath ?? []; + const group = referenceData.cargo_type.find((g) => g.id === path[0]); + return group?.children?.find((c) => c.id === path[1]) ?? null; + })(); + const cargoValue = (() => { if (values.cargoType === "container") return "Container freight"; if (!referenceData) return ""; @@ -183,6 +190,18 @@ export function Step8Review({ return child ? `${group.name} — ${child.name}` : group.name; })(); + // Bulk PER_ITEM cargo is a whole item count, not tons — label it accordingly. + const isPerItem = selectedCommodity?.unit_of_measure === "PER_ITEM"; + const totalQuantityRow = isPerItem + ? { + label: "Total quantity", + value: totalVgm > 0 ? `${Math.round(totalVgm)} items` : "—", + } + : { + label: "Total VGM", + value: totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—", + }; + const originYardName = referenceData?.yard.find((y) => y.id === values.originYard)?.name ?? values.originYard; @@ -333,8 +352,8 @@ export function Step8Review({ > 0 ? `${totalVgm.toFixed(1)} tons` : "—"} + label={totalQuantityRow.label} + value={totalQuantityRow.value} /> {values.cargoType === "container" && values.containers.length > 0 && ( From 6485cbdd711bcdde5901a0e395c803244156da74 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 23 Jun 2026 21:33:34 +0000 Subject: [PATCH 07/19] feat: implement document clearance workflow for bookings - Added ClearanceCard component to display and manage clearance documents in ReadonlyBookingView. - Introduced new API endpoints for clearance operations: getClearance, submitClearanceDocuments, and proceedToOperation. - Created BookingDocumentReview entity and migration for document review status tracking. - Developed GlClearancePage for Global Logistics to review and manage document submissions. - Implemented utility functions for determining clearance setting codes based on trade direction and freight type. - Added tests for booking transition clearance logic and clearance utility functions. --- ...20000000002-CreateBookingDocumentReview.ts | 66 +++ .../bookings/booking-contract.service.ts | 17 +- .../bookings/booking-next-step.util.ts | 20 + .../booking-transition.clearance.spec.ts | 76 ++++ .../bookings/booking-transition.service.ts | 288 +++++++++++++ .../modules/bookings/bookings.controller.ts | 81 ++++ .../src/modules/bookings/bookings.module.ts | 4 + .../modules/bookings/bookings.repository.ts | 80 ++++ .../modules/bookings/clearance.util.spec.ts | 49 +++ .../src/modules/bookings/clearance.util.ts | 70 +++ .../bookings/dto/request-changes.dto.ts | 18 +- .../booking-document-review.entity.ts | 51 +++ .../bookings/entities/booking.entity.ts | 5 + .../src/seed/file-upload-settings.seeder.ts | 182 +++++++- .../src/seed/freight-permissions.registry.ts | 14 + apps/edr-freight-web/backoffice/src/App.tsx | 16 + .../backoffice/src/lib/permissions.ts | 3 + .../src/pages/bookings/GlClearancePage.tsx | 399 ++++++++++++++++++ .../src/services/bookings.service.ts | 31 ++ .../BookingDetailPage/ReadonlyBookingView.tsx | 9 + .../components/ClearanceCard.tsx | 377 +++++++++++++++++ .../portal/src/services/api.ts | 19 + .../portal/src/services/bookings.service.ts | 27 ++ packages/types/src/freight/index.ts | 28 ++ 24 files changed, 1924 insertions(+), 6 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1820000000002-CreateBookingDocumentReview.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/clearance.util.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/entities/booking-document-review.entity.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/bookings/GlClearancePage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx diff --git a/apps/edr-freight-api/src/migrations/1820000000002-CreateBookingDocumentReview.ts b/apps/edr-freight-api/src/migrations/1820000000002-CreateBookingDocumentReview.ts new file mode 100644 index 000000000..0237648f7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000002-CreateBookingDocumentReview.ts @@ -0,0 +1,66 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +/** + * Per-document GL review for the post-counter-sign clearance gate. One row per + * required clearance document; GL marks each APPROVED or QUERIED before the + * booking can proceed to operations. + */ +export class CreateBookingDocumentReview1820000000002 + implements MigrationInterface +{ + name = 'CreateBookingDocumentReview1820000000002'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'booking_document_review', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'booking_id', type: 'uuid' }, + { name: 'setting_code', type: 'varchar', length: '128' }, + { name: 'file_key', type: 'varchar', length: '128' }, + { name: 'file_record_id', type: 'uuid', isNullable: true }, + { name: 'status', type: 'varchar', length: '20', default: "'PENDING'" }, + { name: 'note', type: 'text', isNullable: true }, + { name: 'reviewed_by_staff_id', type: 'uuid', isNullable: true }, + { name: 'reviewed_at', type: 'timestamptz', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + foreignKeys: [ + { + columnNames: ['booking_id'], + referencedSchema: 'freight', + referencedTableName: 'bookings', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.booking_document_review', + new TableIndex({ name: 'idx_booking_document_review_booking', columnNames: ['booking_id'] }), + ); + await queryRunner.createIndex( + 'freight.booking_document_review', + new TableIndex({ name: 'idx_booking_document_review_status', columnNames: ['status'] }), + ); + await queryRunner.createIndex( + 'freight.booking_document_review', + new TableIndex({ + name: 'uq_booking_document_review_doc', + columnNames: ['booking_id', 'setting_code', 'file_key'], + isUnique: true, + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.booking_document_review', true); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts index 6601ca704..abd6377db 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts @@ -19,6 +19,7 @@ import { FileRecord } from '../files/entities/file.entity'; import { BookingsRepository } from './bookings.repository'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; +import { clearanceSettingCode } from './clearance.util'; import { ContractViewDto } from './dto/contract-view.dto'; import { SignContractDto } from './dto/sign-contract.dto'; import { ContractSignerRole } from './entities/booking-contract-signature.entity'; @@ -222,19 +223,31 @@ export class BookingContractService { const updates: Record = {}; + // Whether a document-clearance gate applies (IMPORT/EXPORT bookings). When it + // does, the counter-signed booking goes to AWAITING_DOCUMENTS for the customer + // to upload clearance documents instead of straight into the batch pipeline. + const includesCustoms = booking.serviceType?.includesCustoms ?? false; + const clearanceCode = clearanceSettingCode( + booking.tradeDirection, + booking.freightType, + includesCustoms, + ); + if (role === 'CUSTOMER') { updates.status = 'SIGNED_CUSTOMER'; updates.customerSignedAt = now; } else { - updates.status = 'FULLY_EXECUTED'; updates.fullyExecutedAt = now; updates.marketingApprovedAt = now; updates.marketingApprovedById = options.signerUserId ?? null; updates.lockedAt = now; + updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED'; } const updated = await this.bookingsRepository.update(bookingId, updates as never); - if (role === 'STAFF' && updated?.trainScheduleId) { + // Only the non-clearance (legacy/domestic) path enters the batch pipeline now; + // clearance bookings enter operations after the GL document gate. + if (role === 'STAFF' && !clearanceCode && updated?.trainScheduleId) { this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId); } try { diff --git a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts index b79c4ef20..43e70e5c9 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts @@ -57,6 +57,26 @@ export function computeNextStep( action: 'AWAIT_PAYMENT', description: 'Awaiting customer payment', }; + case 'AWAITING_DOCUMENTS': + return { + action: 'UPLOAD_DOCUMENTS', + description: 'Upload the clearance documents for your shipment', + }; + case 'DOCUMENTS_UNDER_REVIEW': + return { + action: 'AWAIT_DOCUMENT_REVIEW', + description: 'Global Logistics is reviewing your documents', + }; + case 'CLEARANCE_READY': + return { + action: 'PROCEED_TO_OPERATION', + description: 'Clearance is ready — proceed to operation', + }; + case 'OPERATION_REQUESTED': + return { + action: 'AWAIT_OPERATION', + description: 'Operation requested; an operator will take it forward', + }; case 'PAID': return { action: 'START_TRANSIT', diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts new file mode 100644 index 000000000..af81b888a --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -0,0 +1,76 @@ +import { BadRequestException } from '@nestjs/common'; +import { BookingTransitionService } from './booking-transition.service'; + +/** + * Focused tests for the clearance 100%-approved gate in finalizeClearance. + * Uses minimal stubs for the service's collaborators. + */ +describe('BookingTransitionService — finalizeClearance gate', () => { + const booking = { + id: 'b-1', + status: 'DOCUMENTS_UNDER_REVIEW', + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + serviceType: { includesCustoms: false }, // no output set → only the input gate + }; + + // Input set has two required docs. + const inputSetting = { + code: 'clearance_import_container_without_customs', + fields: [ + { fileKey: 'commercial_invoice', isRequired: true }, + { fileKey: 'packing_list', isRequired: true }, + ], + }; + + function makeService(reviews: Array<{ settingCode: string; fileKey: string; status: string }>) { + const bookingsRepository = { + findDocumentReviews: jest.fn().mockResolvedValue(reviews), + update: jest.fn().mockResolvedValue({ id: 'b-1' }), + }; + const bookingsService = { + findById: jest.fn().mockResolvedValue(booking), + }; + const fileUploadSettingsService = { + getByCode: jest.fn().mockResolvedValue(inputSetting), + }; + const filesService = { findByResource: jest.fn().mockResolvedValue([]) }; + + const service = new BookingTransitionService( + bookingsRepository as never, + {} as never, // ruleEngineService + {} as never, // pricingService + {} as never, // contractService + filesService as never, + fileUploadSettingsService as never, + bookingsService as never, + ); + return { service, bookingsRepository }; + } + + it('rejects when a required document is not APPROVED', async () => { + const { service } = makeService([ + { + settingCode: inputSetting.code, + fileKey: 'commercial_invoice', + status: 'APPROVED', + }, + // packing_list is still PENDING (missing approval) + ]); + await expect(service.finalizeClearance('b-1')).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('moves to CLEARANCE_READY when all required documents are APPROVED', async () => { + const { service, bookingsRepository } = makeService([ + { settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED' }, + { settingCode: inputSetting.code, fileKey: 'packing_list', status: 'APPROVED' }, + ]); + await service.finalizeClearance('b-1'); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-1', + expect.objectContaining({ status: 'CLEARANCE_READY' }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 38cf0fca6..7adbc56f4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -8,10 +8,13 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; +import { FilesService } from '../files/files.service'; +import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { BookingContractService } from './booking-contract.service'; import { BookingPricingService } from './booking-pricing.service'; import { BookingsRepository } from './bookings.repository'; import { assertBookingStatus } from './booking-status.util'; +import { clearanceCodesForBooking } from './clearance.util'; import { computeNextStep, type BookingNextStep } from './booking-next-step.util'; import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { PriceLineItemDto } from './dto/generate-price-response.dto'; @@ -25,6 +28,8 @@ export class BookingTransitionService { private readonly ruleEngineService: RuleEngineService, private readonly pricingService: BookingPricingService, private readonly contractService: BookingContractService, + private readonly filesService: FilesService, + private readonly fileUploadSettingsService: FileUploadSettingsService, @Inject(forwardRef(() => BookingsService)) private readonly bookingsService: BookingsService, ) {} @@ -446,6 +451,289 @@ export class BookingTransitionService { return this.bookingsService.findById(updated!.id); } + // ── Document clearance gate (post counter-sign) ─────────────────────────── + + /** + * The clearance document grid for a booking: each required field from the + * resolved customer-input set (and the GL-output set for customs) with its + * uploaded file and GL review status. Drives both portals' clearance UI. + */ + async getClearanceView(bookingId: string): Promise<{ + status: string; + includesCustoms: boolean; + inputCode: string | null; + outputCode: string | null; + documents: Array<{ + fileKey: string; + label: string; + required: boolean; + uploadedBy: 'customer' | 'gl'; + settingCode: string; + file: { id: string; name: string; url: string } | null; + reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null; + note: string | null; + }>; + allApproved: boolean; + }> { + const booking = await this.bookingsService.findById(bookingId); + const { inputCode, outputCode, includesCustoms } = + clearanceCodesForBooking(booking); + + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const fileByCode = new Map(files.map((f) => [f.code, f])); + const reviews = await this.bookingsRepository.findDocumentReviews(bookingId); + const reviewByKey = new Map( + reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]), + ); + + const documents: Awaited< + ReturnType + >['documents'] = []; + + const pushSetting = async ( + code: string | null, + uploadedBy: 'customer' | 'gl', + ) => { + if (!code) return; + let setting; + try { + setting = await this.fileUploadSettingsService.getByCode(code); + } catch { + return; // setting not seeded — skip gracefully + } + for (const field of setting.fields ?? []) { + const file = fileByCode.get(field.fileKey) ?? null; + const review = reviewByKey.get(`${code}:${field.fileKey}`) ?? null; + documents.push({ + fileKey: field.fileKey, + label: field.fileLabel, + required: field.isRequired, + uploadedBy, + settingCode: code, + file: file + ? { id: file.id, name: file.name, url: file.url } + : null, + reviewStatus: review?.status ?? null, + note: review?.note ?? null, + }); + } + }; + + await pushSetting(inputCode, 'customer'); + await pushSetting(outputCode, 'gl'); + + // Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set. + for (const f of files) { + if (!f.code?.startsWith('custom_')) continue; + const review = reviewByKey.get(`custom:${f.code}`) ?? null; + documents.push({ + fileKey: f.code, + label: f.name, + required: false, + uploadedBy: 'customer', + settingCode: 'custom', + file: { id: f.id, name: f.name, url: f.url }, + reviewStatus: review?.status ?? null, + note: review?.note ?? null, + }); + } + + const allApproved = await this.isClearanceFullyApproved(booking); + + return { + status: booking.status, + includesCustoms, + inputCode, + outputCode, + documents, + allApproved, + }; + } + + /** + * True when every REQUIRED field of the booking's customer-input clearance set + * has an APPROVED review row. The 100% gate before clearance can be finalized. + */ + private async isClearanceFullyApproved(booking: Booking): Promise { + const { inputCode } = clearanceCodesForBooking(booking); + if (!inputCode) return true; // no gate applies (e.g. domestic) + let setting; + try { + setting = await this.fileUploadSettingsService.getByCode(inputCode); + } catch { + return false; + } + const required = (setting.fields ?? []).filter((f) => f.isRequired); + if (required.length === 0) return true; + const reviews = await this.bookingsRepository.findDocumentReviews(booking.id); + return required.every((field) => + reviews.some( + (r) => + r.settingCode === inputCode && + r.fileKey === field.fileKey && + r.status === 'APPROVED', + ), + ); + } + + /** + * Customer uploads clearance documents. Each multipart file's fieldname is the + * field's fileKey (or custom_ for ad-hoc). Saves FileRecords, refreshes the + * per-document review rows to PENDING, and moves the booking into review. + */ + async submitClearanceDocuments( + bookingId: string, + files: Express.Multer.File[], + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW']); + const { inputCode } = clearanceCodesForBooking(booking); + if (!inputCode) { + throw new BadRequestException('This booking has no document-clearance step'); + } + if (files.length === 0) { + throw new BadRequestException('No documents uploaded'); + } + + for (const file of files) { + const record = await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: file.fieldname, + file, + }); + // Ad-hoc docs (custom_*) are not part of the required gate; still tracked. + const settingCode = file.fieldname.startsWith('custom_') + ? 'custom' + : inputCode; + await this.bookingsRepository.upsertDocumentReviewPending({ + bookingId, + settingCode, + fileKey: file.fieldname, + fileRecordId: record.id, + }); + } + + await this.bookingsRepository.update(bookingId, { + status: 'DOCUMENTS_UNDER_REVIEW', + } as never); + return this.bookingsService.findById(bookingId); + } + + /** GL reviews a single document: APPROVED or QUERIED (with a note). */ + async reviewDocument( + bookingId: string, + fileKey: string, + status: 'APPROVED' | 'QUERIED', + staffId: string, + note?: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); + const { inputCode, outputCode } = clearanceCodesForBooking(booking); + + const existing = await this.bookingsRepository.findDocumentReviews(bookingId); + const match = existing.find((r) => r.fileKey === fileKey); + const settingCode = + match?.settingCode ?? + (fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom')); + + if (status === 'QUERIED' && !note?.trim()) { + throw new BadRequestException('A note is required when querying a document'); + } + + await this.bookingsRepository.setDocumentReviewStatus( + bookingId, + settingCode, + fileKey, + status, + staffId, + note, + ); + if (status === 'QUERIED') { + await this.bookingsRepository.createReviewNote( + bookingId, + `Document "${fileKey}" queried: ${note}`, + 'CHANGES_REQUESTED', + staffId, + ); + } + return this.bookingsService.findById(bookingId); + } + + /** GL uploads the customs output documents (IM4/IM5/EX3/etc.). */ + async uploadClearanceOutputDocuments( + bookingId: string, + files: Express.Multer.File[], + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); + const { outputCode } = clearanceCodesForBooking(booking); + if (!outputCode) { + throw new BadRequestException('This booking has no customs output documents'); + } + if (files.length === 0) { + throw new BadRequestException('No documents uploaded'); + } + for (const file of files) { + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: file.fieldname, + file, + }); + } + return this.bookingsService.findById(bookingId); + } + + /** + * GL confirms clearance: requires every customer document APPROVED (100% gate) + * and, for customs, the required output documents present → CLEARANCE_READY. + */ + async finalizeClearance(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); + + const approved = await this.isClearanceFullyApproved(booking); + if (!approved) { + throw new BadRequestException( + 'All required documents must be approved before clearance can be finalized', + ); + } + + const { outputCode } = clearanceCodesForBooking(booking); + if (outputCode) { + const setting = await this.fileUploadSettingsService.getByCode(outputCode); + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const uploaded = new Set(files.map((f) => f.code)); + const missing = (setting.fields ?? []).filter( + (f) => f.isRequired && !uploaded.has(f.fileKey), + ); + if (missing.length > 0) { + throw new BadRequestException( + `Upload all required customs output documents first: ${missing + .map((m) => m.fileLabel) + .join(', ')}`, + ); + } + } + + await this.bookingsRepository.update(bookingId, { + status: 'CLEARANCE_READY', + } as never); + return this.bookingsService.findById(bookingId); + } + + /** Customer proceeds to operation once clearance is ready → OPERATION_REQUESTED. */ + async requestOperation(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['CLEARANCE_READY']); + await this.bookingsRepository.update(bookingId, { + status: 'OPERATION_REQUESTED', + } as never); + return this.bookingsService.findById(bookingId); + } + async enrichBookingResponse(booking: Booking): Promise { return pending === 0; } + // ── Clearance document reviews ──────────────────────────────────────────── + + findDocumentReviews(bookingId: string): Promise { + return this.dataSource.getRepository(BookingDocumentReview).find({ + where: { bookingId }, + order: { createdAt: 'ASC' }, + }); + } + + findDocumentReview( + bookingId: string, + settingCode: string, + fileKey: string, + ): Promise { + return this.dataSource.getRepository(BookingDocumentReview).findOne({ + where: { bookingId, settingCode, fileKey }, + }); + } + + /** + * Upsert a document-review row to PENDING for a freshly uploaded file. Resets + * any prior QUERIED/APPROVED state so the GL re-reviews the new upload. + */ + async upsertDocumentReviewPending(input: { + bookingId: string; + settingCode: string; + fileKey: string; + fileRecordId: string; + }): Promise { + const repo = this.dataSource.getRepository(BookingDocumentReview); + const existing = await repo.findOne({ + where: { + bookingId: input.bookingId, + settingCode: input.settingCode, + fileKey: input.fileKey, + }, + }); + if (existing) { + await repo.update(existing.id, { + fileRecordId: input.fileRecordId, + status: 'PENDING', + note: null, + reviewedByStaffId: null, + reviewedAt: null, + }); + return; + } + await repo.save(repo.create({ ...input, status: 'PENDING' })); + } + + /** GL marks a document APPROVED or QUERIED (with an optional note). */ + async setDocumentReviewStatus( + bookingId: string, + settingCode: string, + fileKey: string, + status: DocumentReviewStatus, + staffId: string, + note?: string, + ): Promise { + const repo = this.dataSource.getRepository(BookingDocumentReview); + const existing = await repo.findOne({ + where: { bookingId, settingCode, fileKey }, + }); + const patch = { + status, + note: note ?? null, + reviewedByStaffId: staffId, + reviewedAt: new Date(), + }; + if (existing) { + await repo.update(existing.id, patch); + return; + } + await repo.save(repo.create({ bookingId, settingCode, fileKey, ...patch })); + } + /** Persist cargo modifiers linked to rate snapshots. */ async createCargoModifiers( rows: Array<{ diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts new file mode 100644 index 000000000..a7bd13c28 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts @@ -0,0 +1,49 @@ +import { + clearanceSettingCode, + clearanceOutputSettingCode, +} from './clearance.util'; + +describe('clearance.util — clearanceSettingCode', () => { + it('resolves import container with/without customs', () => { + expect(clearanceSettingCode('IMPORT', 'CONTAINER', true)).toBe( + 'clearance_import_container_with_customs', + ); + expect(clearanceSettingCode('IMPORT', 'CONTAINER', false)).toBe( + 'clearance_import_container_without_customs', + ); + }); + + it('resolves export bulk with/without customs', () => { + expect(clearanceSettingCode('EXPORT', 'BULK', true)).toBe( + 'clearance_export_bulk_with_customs', + ); + expect(clearanceSettingCode('EXPORT', 'BULK', false)).toBe( + 'clearance_export_bulk_without_customs', + ); + }); + + it('returns null for DOMESTIC (no clearance gate)', () => { + expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull(); + expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBeNull(); + }); +}); + +describe('clearance.util — clearanceOutputSettingCode', () => { + it('returns a container output code only for customs container bookings', () => { + expect(clearanceOutputSettingCode('IMPORT', 'CONTAINER', true)).toBe( + 'clearance_output_import_container', + ); + expect(clearanceOutputSettingCode('EXPORT', 'CONTAINER', true)).toBe( + 'clearance_output_export_container', + ); + }); + + it('returns null without customs', () => { + expect(clearanceOutputSettingCode('IMPORT', 'CONTAINER', false)).toBeNull(); + }); + + it('returns null for bulk (no container output set) and domestic', () => { + expect(clearanceOutputSettingCode('IMPORT', 'BULK', true)).toBeNull(); + expect(clearanceOutputSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts new file mode 100644 index 000000000..69a8232d7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts @@ -0,0 +1,70 @@ +import { Booking } from './entities/booking.entity'; + +/** + * Resolves which seeded clearance FileUploadSetting applies to a booking, from + * its trade direction, freight type and whether its service includes customs. + * Mirrors the codes seeded in file-upload-settings.seeder.ts. + */ + +type Op = 'import' | 'export'; +type Freight = 'container' | 'bulk'; + +/** Trade direction → clearance operation. DOMESTIC has no customs clearance. */ +function operationFor(tradeDirection: string): Op | null { + if (tradeDirection === 'IMPORT') return 'import'; + if (tradeDirection === 'EXPORT') return 'export'; + return null; // DOMESTIC / intercity — no clearance gate +} + +function freightFor(freightType: string): Freight { + return freightType === 'BULK' ? 'bulk' : 'container'; +} + +/** The customer-input clearance setting code, or null when no gate applies. */ +export function clearanceSettingCode( + tradeDirection: string, + freightType: string, + includesCustoms: boolean, +): string | null { + const op = operationFor(tradeDirection); + if (!op) return null; + const freight = freightFor(freightType); + const customs = includesCustoms ? 'with_customs' : 'without_customs'; + return `clearance_${op}_${freight}_${customs}`; +} + +/** The GL-output (customs output) setting code; only container customs sets exist. */ +export function clearanceOutputSettingCode( + tradeDirection: string, + freightType: string, + includesCustoms: boolean, +): string | null { + if (!includesCustoms) return null; + const op = operationFor(tradeDirection); + if (!op) return null; + // Only container customs output sets are seeded for this phase. + if (freightFor(freightType) !== 'container') return null; + return `clearance_output_${op}_container`; +} + +/** Convenience: resolve both codes for a loaded booking (with its serviceType). */ +export function clearanceCodesForBooking(booking: Booking): { + inputCode: string | null; + outputCode: string | null; + includesCustoms: boolean; +} { + const includesCustoms = booking.serviceType?.includesCustoms ?? false; + return { + inputCode: clearanceSettingCode( + booking.tradeDirection, + booking.freightType, + includesCustoms, + ), + outputCode: clearanceOutputSettingCode( + booking.tradeDirection, + booking.freightType, + includesCustoms, + ), + includesCustoms, + }; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts index 698c5f98c..6e6a52b03 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsOptional, IsString, MinLength } from 'class-validator'; +import { IsIn, IsOptional, IsString, MinLength } from 'class-validator'; export class RequestChangesDto { @ApiProperty({ description: 'Staff note explaining what the customer must fix' }) @@ -43,3 +43,19 @@ export class RejectBookingDto { @IsString() reason?: string; } + +export class ReviewDocumentDto { + @ApiProperty({ description: 'The document fileKey being reviewed' }) + @IsString() + @MinLength(1) + fileKey!: string; + + @ApiProperty({ enum: ['APPROVED', 'QUERIED'] }) + @IsIn(['APPROVED', 'QUERIED']) + status!: 'APPROVED' | 'QUERIED'; + + @ApiPropertyOptional({ description: 'Required when querying a document' }) + @IsOptional() + @IsString() + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-document-review.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-document-review.entity.ts new file mode 100644 index 000000000..532e46610 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-document-review.entity.ts @@ -0,0 +1,51 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Booking } from './booking.entity'; + +export const DOCUMENT_REVIEW_STATUSES = ['PENDING', 'APPROVED', 'QUERIED'] as const; +export type DocumentReviewStatus = (typeof DOCUMENT_REVIEW_STATUSES)[number]; + +/** + * Per-document GL review for the post-counter-sign clearance gate. One row per + * required clearance document (keyed by fileKey within a setting). GL marks each + * APPROVED or QUERIED (with a note); the booking can only proceed once every + * required customer document is APPROVED. A QUERIED row returns to PENDING when + * the customer re-uploads that file. + */ +@Entity({ schema: 'freight', name: 'booking_document_review' }) +@Index(['bookingId']) +@Index(['status']) +@Index(['bookingId', 'settingCode', 'fileKey'], { unique: true }) +export class BookingDocumentReview extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + /** The clearance setting this document belongs to (e.g. clearance_import_container_with_customs). */ + @Column({ name: 'setting_code', type: 'varchar', length: 128 }) + settingCode!: string; + + /** The required document's stable key within the setting (e.g. commercial_invoice). */ + @Column({ name: 'file_key', type: 'varchar', length: 128 }) + fileKey!: string; + + /** The uploaded FileRecord backing this review row (null until uploaded). */ + @Column({ name: 'file_record_id', type: 'uuid', nullable: true }) + fileRecordId?: string | null; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' }) + status!: DocumentReviewStatus; + + /** GL note explaining a QUERIED status. */ + @Column({ name: 'note', type: 'text', nullable: true }) + note?: string | null; + + @Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true }) + reviewedByStaffId?: string | null; + + @Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true }) + reviewedAt?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 1b5a9acae..3ec08eee2 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -43,6 +43,11 @@ export const BOOKING_STATUSES = [ 'CONSOLIDATED', 'CONTRACT_ACTIVE', 'CONTRACT_CLOSED', + // Post counter-sign document-clearance gate (GL workflow). + 'AWAITING_DOCUMENTS', + 'DOCUMENTS_UNDER_REVIEW', + 'CLEARANCE_READY', + 'OPERATION_REQUESTED', ] as const; export type BookingStatus = (typeof BOOKING_STATUSES)[number]; diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 340a291db..bb497f8f8 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -192,6 +192,169 @@ const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [ const COMPANY_ONBOARDING_DESCRIPTION = "Required documents for external company onboarding, by company nationality."; +// ── Clearance document settings ──────────────────────────────────────────── +// Operation/clearance documents collected after contract counter-sign, resolved +// at runtime from (operationType, freightType, includesCustoms). The `entity` +// is "booking_clearance" so the backoffice file-settings editor can filter them. +// Two kinds of set per customs category: a CUSTOMER-INPUT set (the customer +// uploads) and a GL-OUTPUT set (Global Logistics uploads the customs outputs). + +const JPG_EXTENSIONS = ["jpg", "jpeg", "png", "pdf"]; +const CLEARANCE_ENTITY = "booking_clearance"; + +/** Build a clearance field with sensible defaults; `critical` marks isRequired. */ +function clearanceField( + fileKey: string, + fileLabel: string, + displayOrder: number, + opts?: { required?: boolean; help?: string; extensions?: string[] }, +): OnboardingField { + return { + fileKey, + fileLabel, + helpText: opts?.help ?? "", + isRequired: opts?.required ?? true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: opts?.extensions ?? DOC_EXTENSIONS, + maxSizeMb: 10, + displayOrder, + }; +} + +/** Documents shared by every container import category (with/without customs). */ +const IMPORT_CONTAINER_FIELDS: OnboardingField[] = [ + clearanceField("commercial_invoice", "Commercial Invoice", 1), + clearanceField("packing_list", "Packing List", 2), + clearanceField("import_license", "Import License", 3), + clearanceField("certificate_of_origin", "Certificate of Origin", 4), + clearanceField( + "external_freight_cost", + "External Freight Cost / Checkup Documentation", + 5, + ), + clearanceField("bill_of_lading", "Bill of Lading / Railway Bill", 6), + clearanceField("vgm", "Verified Gross Mass (VGM)", 7, { required: true }), + clearanceField("release_order", "Release Order", 8, { required: true }), +]; + +/** Documents shared by every container export category (with/without customs). */ +const EXPORT_CONTAINER_FIELDS: OnboardingField[] = [ + clearanceField("booking_confirmation", "Booking Confirmation", 1), + clearanceField("commercial_invoice", "Commercial Invoice", 2), + clearanceField("packing_list", "Packing List", 3), + clearanceField("shipping_instruction", "Shipping Instruction", 4), + clearanceField("bank_permit", "Bank Permit", 5), + clearanceField("export_license", "Export License", 6), + clearanceField("vgm_letter", "VGM Letter", 7, { required: true }), + clearanceField("railway_bill", "Railway Bill", 8), + clearanceField("delegation_letter", "Delegation Letter / POA", 9, { + required: false, + help: "Required only if EDR manages all transit activity.", + }), +]; + +/** Bulk import documents (shorter, transit-focused set). */ +const IMPORT_BULK_FIELDS: OnboardingField[] = [ + clearanceField("packing_list", "Packing List", 1, { required: true }), + clearanceField("bill_of_loading", "Bill of Loading", 2, { required: true }), + clearanceField("port_invoice", "Port Invoice", 3), +]; + +/** Bulk export documents (transit/customs corridor docs). */ +const EXPORT_BULK_FIELDS: OnboardingField[] = [ + clearanceField("release_order_djibouti", "Release Order (Djibouti)", 1), + clearanceField("port_gate_pass", "Port Gate Pass", 2), + clearanceField("port_invoice", "Port Invoice", 3), +]; + +/** GL-uploaded customs output documents (import container). */ +const IMPORT_CONTAINER_OUTPUT_FIELDS: OnboardingField[] = [ + clearanceField("im4", "IM4 — Permanent Import Document", 1), + clearanceField("im5", "IM5 — Temporary Import Document", 2, { + required: false, + }), + clearanceField("transit_permitted", "Transit Permitted Screenshot", 3, { + extensions: JPG_EXTENSIONS, + }), +]; + +/** GL-uploaded customs output documents (export container). */ +const EXPORT_CONTAINER_OUTPUT_FIELDS: OnboardingField[] = [ + clearanceField("ex3", "EX3 — Permanent Export Document", 1), + clearanceField("ex8", "EX8 — Export Transit Document", 2), + clearanceField("export_release", "Export Release", 3), + clearanceField("t1", "T1 — Transport Document", 4), +]; + +const CLEARANCE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ + // ── Customer-input sets ── + { + code: "clearance_import_container_with_customs", + label: "Import container clearance documents (with customs)", + entity: CLEARANCE_ENTITY, + fields: IMPORT_CONTAINER_FIELDS, + }, + { + code: "clearance_import_container_without_customs", + label: "Import container documents (without customs)", + entity: CLEARANCE_ENTITY, + fields: IMPORT_CONTAINER_FIELDS, + }, + { + code: "clearance_export_container_with_customs", + label: "Export container clearance documents (with customs)", + entity: CLEARANCE_ENTITY, + fields: EXPORT_CONTAINER_FIELDS, + }, + { + code: "clearance_export_container_without_customs", + label: "Export container documents (without customs)", + entity: CLEARANCE_ENTITY, + fields: EXPORT_CONTAINER_FIELDS, + }, + { + code: "clearance_import_bulk_with_customs", + label: "Import bulk clearance documents (with customs)", + entity: CLEARANCE_ENTITY, + fields: IMPORT_BULK_FIELDS, + }, + { + code: "clearance_import_bulk_without_customs", + label: "Import bulk documents (without customs)", + entity: CLEARANCE_ENTITY, + fields: IMPORT_BULK_FIELDS, + }, + { + code: "clearance_export_bulk_with_customs", + label: "Export bulk clearance documents (with customs)", + entity: CLEARANCE_ENTITY, + fields: EXPORT_BULK_FIELDS, + }, + { + code: "clearance_export_bulk_without_customs", + label: "Export bulk documents (without customs)", + entity: CLEARANCE_ENTITY, + fields: EXPORT_BULK_FIELDS, + }, + // ── GL-output sets (customs only) ── + { + code: "clearance_output_import_container", + label: "Customs output documents (import container)", + entity: CLEARANCE_ENTITY, + fields: IMPORT_CONTAINER_OUTPUT_FIELDS, + }, + { + code: "clearance_output_export_container", + label: "Customs output documents (export container)", + entity: CLEARANCE_ENTITY, + fields: EXPORT_CONTAINER_OUTPUT_FIELDS, + }, +]; + +const CLEARANCE_DESCRIPTION = + "Operation/clearance documents collected after contract execution, by operation, freight type and customs."; + @Injectable() export class FileUploadSettingsSeeder { private readonly logger = new Logger(FileUploadSettingsSeeder.name); @@ -203,12 +366,25 @@ export class FileUploadSettingsSeeder { const settingRepository = manager.getRepository(FileUploadSetting); const fieldRepository = manager.getRepository(FileUploadField); - for (const documentSetting of COMPANY_ONBOARDING_DOCUMENTS) { + const allSettings: Array< + OnboardingDocumentSetting & { description: string } + > = [ + ...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({ + ...s, + description: COMPANY_ONBOARDING_DESCRIPTION, + })), + ...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({ + ...s, + description: CLEARANCE_DESCRIPTION, + })), + ]; + + for (const documentSetting of allSettings) { await settingRepository.upsert( { code: documentSetting.code, label: documentSetting.label, - description: COMPANY_ONBOARDING_DESCRIPTION, + description: documentSetting.description, entity: documentSetting.entity, }, { @@ -245,7 +421,7 @@ export class FileUploadSettingsSeeder { }); this.logger.log( - "Ensured company onboarding file upload settings for external companies", + "Ensured company onboarding + booking clearance file upload settings", ); } } diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index ed0a494ab..b7027a487 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -52,6 +52,9 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ perm('a1000001-0001-4000-8000-00000000000c', 'edr_freight_app:bookings:payment_verify', 'Verify payment'), perm('a1000001-0001-4000-8000-00000000000d', 'edr_freight_app:bookings:operations', 'Booking operations'), perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'), + perm('a1000001-0001-4000-8000-000000000020', 'edr_freight_app:bookings:review_documents', 'Review clearance documents'), + perm('a1000001-0001-4000-8000-000000000021', 'edr_freight_app:bookings:upload_clearance_output', 'Upload customs output documents'), + perm('a1000001-0001-4000-8000-000000000022', 'edr_freight_app:bookings:finalize_clearance', 'Finalize document clearance'), perm('a1000001-0001-4000-8000-00000000000f', 'edr_freight_app:train_scheduling:view', 'View train scheduling'), perm('a1000001-0001-4000-8000-000000000010', 'edr_freight_app:train_scheduling:manage', 'Manage train scheduling'), perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'), @@ -107,6 +110,9 @@ export const FREIGHT_PERMS = { signStaff: 'edr_freight_app:bookings:sign_staff', operations: 'edr_freight_app:bookings:operations', cancel: 'edr_freight_app:bookings:cancel', + reviewDocuments: 'edr_freight_app:bookings:review_documents', + uploadClearanceOutput: 'edr_freight_app:bookings:upload_clearance_output', + finalizeClearance: 'edr_freight_app:bookings:finalize_clearance', }, trainScheduling: { view: 'edr_freight_app:train_scheduling:view', @@ -167,6 +173,14 @@ export const ROLE_PERMISSION_PRESETS = { ...allRuleEngineViewKeys(), ], finance: [FREIGHT_PERMS.bookings.view], + // Global Logistics: reviews post-counter-sign clearance documents, uploads + // customs output documents, and finalizes the clearance gate. + globalLogistics: [ + FREIGHT_PERMS.bookings.view, + FREIGHT_PERMS.bookings.reviewDocuments, + FREIGHT_PERMS.bookings.uploadClearanceOutput, + FREIGHT_PERMS.bookings.finalizeClearance, + ], // Marketing handles intake through contract (same as line staff here). marketing: [ FREIGHT_PERMS.bookings.view, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index e91a8c810..462c97f9a 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -12,6 +12,7 @@ import { Paperclip, Send, Settings, + ShieldCheck, SlidersHorizontal, Train, Truck, @@ -27,6 +28,7 @@ import LoginPage from "./pages/auth/LoginPage"; import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; +import GlClearancePage from "./pages/bookings/GlClearancePage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; @@ -109,6 +111,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { title: "Operations", items: [ + { + label: "Document Clearance", + href: "/dashboard/clearance", + icon: , + permission: FREIGHT_PERMS.bookings.reviewDocuments, + }, { label: "Train Schedules", href: "/dashboard/operations/train-scheduling-v2", @@ -376,6 +384,14 @@ const App = () => { path="booking-requests/:id/contract" element={} /> + + + + } + /> } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 7f3b2ac90..cd127fa61 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -15,6 +15,9 @@ export const FREIGHT_PERMS = { signStaff: "edr_freight_app:bookings:sign_staff", operations: "edr_freight_app:bookings:operations", cancel: "edr_freight_app:bookings:cancel", + reviewDocuments: "edr_freight_app:bookings:review_documents", + uploadClearanceOutput: "edr_freight_app:bookings:upload_clearance_output", + finalizeClearance: "edr_freight_app:bookings:finalize_clearance", }, trainScheduling: { view: "edr_freight_app:train_scheduling:view", diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/GlClearancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/GlClearancePage.tsx new file mode 100644 index 000000000..6888f71c6 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/GlClearancePage.tsx @@ -0,0 +1,399 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Alert, + Box, + Button, + Card, + FileButton, + Group, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { + AlertCircle, + CheckCircle2, + Clock, + Download, + FileText, + ShieldCheck, + Upload, +} from "lucide-react"; +import toast from "react-hot-toast"; +import type { Freight } from "@edr/types"; + +import { bookingsService } from "@/services/bookings.service"; + +const REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW"; + +export default function GlClearancePage() { + const qc = useQueryClient(); + const [selectedId, setSelectedId] = useState(null); + + // Bookings currently awaiting GL document review. + const { data: list, isLoading } = useQuery({ + queryKey: ["gl-clearance", "list"], + queryFn: () => bookingsService.list({ status: REVIEW_STATUS, pageSize: 100 }), + }); + + const bookings = list?.items ?? []; + const activeId = selectedId ?? bookings[0]?.id ?? null; + + return ( + + + + + Document Clearance + + + +
+ + + Awaiting review ({bookings.length}) + + {isLoading && ( + + Loading… + + )} + {!isLoading && bookings.length === 0 && ( + + No bookings awaiting document review. + + )} + + {bookings.map((b) => ( + + ))} + + + + + {activeId ? ( + + qc.invalidateQueries({ queryKey: ["gl-clearance", "list"] }) + } + /> + ) : ( + + Select a booking to review its documents. + + )} + +
+
+ ); +} + +function ClearanceReviewPanel({ + bookingId, + onChanged, +}: { + bookingId: string; + onChanged: () => void; +}) { + const qc = useQueryClient(); + const [queryNotes, setQueryNotes] = useState>({}); + const [outputFiles, setOutputFiles] = useState>({}); + + const { data: clearance, isLoading } = useQuery({ + queryKey: ["gl-clearance", bookingId], + queryFn: () => bookingsService.getClearance(bookingId), + }); + + const refresh = () => { + qc.invalidateQueries({ queryKey: ["gl-clearance", bookingId] }); + onChanged(); + }; + + const reviewMutation = useMutation({ + mutationFn: (p: { + fileKey: string; + status: "APPROVED" | "QUERIED"; + note?: string; + }) => bookingsService.reviewClearanceDocument(bookingId, p), + onSuccess: () => { + toast.success("Document updated"); + refresh(); + }, + onError: () => toast.error("Could not update document"), + }); + + const outputMutation = useMutation({ + mutationFn: () => bookingsService.uploadClearanceOutput(bookingId, outputFiles), + onSuccess: () => { + toast.success("Output documents uploaded"); + setOutputFiles({}); + refresh(); + }, + onError: () => toast.error("Upload failed"), + }); + + const finalizeMutation = useMutation({ + mutationFn: () => bookingsService.finalizeClearance(bookingId), + onSuccess: () => { + toast.success("Clearance finalized"); + refresh(); + }, + onError: (e) => + toast.error(e instanceof Error ? e.message : "Could not finalize clearance"), + }); + + const customerDocs = useMemo( + () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"), + [clearance], + ); + const glDocs = useMemo( + () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"), + [clearance], + ); + + if (isLoading || !clearance) { + return ( + + Loading clearance… + + ); + } + + return ( + + + + + Customer documents + + {clearance.allApproved ? ( + + + + All approved + + + ) : ( + + + + Review pending + + + )} + + + + {customerDocs.map((doc) => ( + + setQueryNotes((n) => ({ ...n, [doc.fileKey]: v })) + } + onApprove={() => + reviewMutation.mutate({ fileKey: doc.fileKey, status: "APPROVED" }) + } + onQuery={() => + reviewMutation.mutate({ + fileKey: doc.fileKey, + status: "QUERIED", + note: queryNotes[doc.fileKey], + }) + } + busy={reviewMutation.isPending} + /> + ))} + + + + {clearance.outputCode && ( + + + Customs output documents + + + {glDocs.map((doc) => ( + + + + + {doc.label} + {doc.required ? " *" : ""} + + + + {doc.file ? ( + + + + ) : ( + + Not uploaded + + )} + + f && setOutputFiles((o) => ({ ...o, [doc.fileKey]: f })) + } + accept="application/pdf,image/*" + > + {(props) => ( + + )} + + + + ))} + + + + + + )} + + {finalizeMutation.isError && ( + }> + {finalizeMutation.error instanceof Error + ? finalizeMutation.error.message + : "Could not finalize clearance."} + + )} + + + + + + ); +} + +function DocReviewRow({ + doc, + note, + onNote, + onApprove, + onQuery, + busy, +}: { + doc: Freight.ClearanceDocument; + note: string; + onNote: (v: string) => void; + onApprove: () => void; + onQuery: () => void; + busy: boolean; +}) { + return ( + + + + + + + {doc.label} + {doc.required ? " *" : ""} + + + {doc.file ? doc.file.name : "Not uploaded"} + + + + + {doc.reviewStatus === "APPROVED" && ( + + Approved + + )} + {doc.reviewStatus === "QUERIED" && ( + + Queried + + )} + {doc.file && ( + + + + )} + + + + {doc.file && ( + + onNote(e.currentTarget.value)} + style={{ flex: 1 }} + radius="md" + size="xs" + /> + + + + )} + + ); +} 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 06dbb638b..ad71655bd 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -2,6 +2,7 @@ import { api as client } from "../auth/http"; import { unwrap } from "@/utils/endpoint"; import { URL_CONSTANTS } from "@/constants/URLS"; import type { BookingDetail } from "@/types/booking"; +import type { Freight } from "@edr/types"; const B = URL_CONSTANTS.BOOKINGS; @@ -169,6 +170,36 @@ export const bookingsService = { await client.delete(B.BY_ID(id)); }, + // ── Document clearance (GL workflow) ── + getClearance: async (id: string): Promise => { + const response = await client.get(`/bookings/${id}/clearance`); + return unwrap(response.data) as Freight.ClearanceView; + }, + + reviewClearanceDocument: ( + id: string, + payload: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string }, + ) => postBooking(`/bookings/${id}/clearance/review`, payload), + + uploadClearanceOutput: async ( + id: string, + files: Record, + ): Promise => { + const form = new FormData(); + for (const [key, file] of Object.entries(files)) { + if (file) form.append(key, file); + } + const response = await client.post( + `/bookings/${id}/clearance/output-documents`, + form, + { headers: { "Content-Type": "multipart/form-data" } }, + ); + return unwrap(response.data) as BookingDetail; + }, + + finalizeClearance: (id: string) => + postBooking(`/bookings/${id}/clearance/finalize`), + staffAccept: (id: string) => postBooking(B.STAFF_ACCEPT(id)), requestChanges: (id: string, note: string) => diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index ca7346a83..43f9a1c00 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -9,6 +9,7 @@ import { paymentsService, type PaymentMethod } from "@/services/payments.service import type { Freight } from "@edr/types"; import { ActivityCard } from "./components/ActivityCard"; +import { ClearanceCard } from "./components/ClearanceCard"; import { ContainersCard } from "./components/ContainersCard"; import { ContractCard } from "./components/ContractCard"; import { DocRow, IconSquare } from "./components/Documents"; @@ -62,6 +63,12 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) const showCountdown = canPay && !!booking.paymentDeadline; const isExpired = status === "EXPIRED"; const isPendingConsolidation = status === "PENDING_CONSOLIDATION"; + const isClearance = [ + "AWAITING_DOCUMENTS", + "DOCUMENTS_UNDER_REVIEW", + "CLEARANCE_READY", + "OPERATION_REQUESTED", + ].includes(status); // Paired: a consolidation partner was found and the booking resumed the normal // flow. Surface the "partner found" reassurance only in the early stages, // before approval, so it doesn't linger for the rest of the booking's life. @@ -126,6 +133,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) + {isClearance && } + diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx new file mode 100644 index 000000000..59ab71ff9 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx @@ -0,0 +1,377 @@ +import { + Alert, + Box, + Button, + FileButton, + Group, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + AlertCircle, + CheckCircle2, + Clock, + Download, + FileText, + Plus, + Upload, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { api } from "@/services/api"; +import type { Freight } from "@edr/types"; + +import { CardTitle, SectionCard } from "./layout"; +import { IconSquare } from "./Documents"; + +const GREEN = "#0A6F4D"; + +function StatusPill({ doc }: { doc: Freight.ClearanceDocument }) { + if (doc.reviewStatus === "APPROVED") { + return ( + + + + Approved + + + ); + } + if (doc.reviewStatus === "QUERIED") { + return ( + + + + Queried + + + ); + } + if (doc.file) { + return ( + + + + Pending review + + + ); + } + return ( + + Not uploaded + + ); +} + +/** + * Customer-facing clearance section: shows the resolved document grid, lets the + * customer (re)upload pending/queried documents plus ad-hoc named documents, and + * proceed to operation once Global Logistics marks the booking CLEARANCE_READY. + */ +export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const status = booking.status as string; + + const { data: clearance, isLoading } = useQuery( + api.bookings.getClearance.queryOptions({ input: { id: booking.id } }), + ); + + // Pending uploads keyed by fileKey, plus ad-hoc rows (label + file). + const [pending, setPending] = useState>({}); + const [adHoc, setAdHoc] = useState>( + [], + ); + + const refresh = () => { + queryClient.invalidateQueries({ + queryKey: api.bookings.getClearance.queryKey({ id: booking.id }), + }); + queryClient.invalidateQueries({ + queryKey: api.bookings.get.queryKey({ id: booking.id }), + }); + }; + + const uploadMutation = useMutation({ + ...api.bookings.submitClearanceDocuments.mutationOptions(), + onSuccess: () => { + setPending({}); + setAdHoc([]); + refresh(); + }, + }); + + const proceedMutation = useMutation({ + ...api.bookings.proceedToOperation.mutationOptions(), + onSuccess: () => refresh(), + }); + + // Only the customer-input documents are uploadable here; GL output docs are + // shown read-only. + const customerDocs = useMemo( + () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"), + [clearance], + ); + const glDocs = useMemo( + () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"), + [clearance], + ); + + if (status === "OPERATION_REQUESTED") { + return ( + + Operation + } mt="sm"> + Operation requested. An operator will take your shipment forward. + + + ); + } + + if (isLoading || !clearance) { + return ( + + Clearance documents + + Loading clearance… + + + ); + } + + const isReady = status === "CLEARANCE_READY"; + const canUpload = + status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW"; + + function handleSubmit() { + const files: Record = { ...pending }; + adHoc.forEach((row, i) => { + if (row.file) files[`custom_${Date.now()}_${i}`] = row.file; + }); + if (Object.keys(files).length === 0) return; + uploadMutation.mutate({ id: booking.id, files }); + } + + return ( + + + Clearance documents + {clearance.includesCustoms && ( + + Customs clearance + + )} + + + {isReady ? ( + } mb="md"> + Clearance is ready. You can now proceed to operation. + + ) : status === "DOCUMENTS_UNDER_REVIEW" ? ( + } mb="md"> + Global Logistics is reviewing your documents. Queried documents below + need to be re-uploaded. + + ) : ( + } mb="md"> + Upload the documents below to start the clearance review. + + )} + + + {customerDocs.map((doc) => ( + + + + + + + + + {doc.label} + {doc.required ? " *" : ""} + + {doc.file && ( + + {doc.file.name} + + )} + + + + + {doc.file && ( + } /> + )} + {canUpload && doc.reviewStatus !== "APPROVED" && ( + + f && setPending((p) => ({ ...p, [doc.fileKey]: f })) + } + accept="application/pdf,image/*" + > + {(props) => ( + + )} + + )} + + + {doc.reviewStatus === "QUERIED" && doc.note && ( + + Query: {doc.note} + + )} + {pending[doc.fileKey] && ( + + Ready to upload: {pending[doc.fileKey].name} + + )} + + ))} + + + {/* GL output documents (read-only to the customer). */} + {glDocs.length > 0 && ( + <> + + Customs output documents + + + {glDocs.map((doc) => ( + + + {doc.label} + + {doc.file ? ( + } /> + ) : ( + + Pending + + )} + + ))} + + + )} + + {/* Ad-hoc / additional documents. */} + {canUpload && ( + + + + Additional documents + + + + + {adHoc.map((row, i) => ( + + + setAdHoc((rows) => + rows.map((r, j) => + j === i ? { ...r, name: e.currentTarget.value } : r, + ), + ) + } + style={{ flex: 1 }} + radius="md" + /> + + setAdHoc((rows) => + rows.map((r, j) => (j === i ? { ...r, file: f } : r)), + ) + } + accept="application/pdf,image/*" + > + {(props) => ( + + )} + + + ))} + + + )} + + {uploadMutation.isError && ( + } mt="md"> + {uploadMutation.error instanceof Error + ? uploadMutation.error.message + : "Upload failed. Please try again."} + + )} + + + {canUpload && ( + + )} + {isReady && ( + + )} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 6048be28b..d00208d7b 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -256,6 +256,25 @@ export const api = { bookingsService.uploadDocuments(id, files), ), + getClearance: endpoint<{ id: string }, Freight.ClearanceView>( + "bookings", + "getClearance", + ({ id }) => bookingsService.getClearance(id), + ), + + submitClearanceDocuments: endpoint< + { id: string; files: Record }, + Freight.IBooking + >("bookings", "submitClearanceDocuments", ({ id, files }) => + bookingsService.submitClearanceDocuments(id, files), + ), + + proceedToOperation: endpoint<{ id: string }, Freight.IBooking>( + "bookings", + "proceedToOperation", + ({ id }) => bookingsService.proceedToOperation(id), + ), + checkPayment: endpoint<{ orderId: string }, { status: string }>( "bookings", "checkPayment", diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index e74f8281c..2cebf6fea 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -184,6 +184,33 @@ export const bookingsService = { return data.data; }, + // ── Document clearance ── + getClearance: async (id: string): Promise => { + const { data } = await client.get(`/api/bookings/${id}/clearance`); + return data.data ?? data; + }, + + submitClearanceDocuments: async ( + id: string, + files: Record, + ): Promise => { + const formData = new FormData(); + for (const [key, file] of Object.entries(files)) { + if (file) formData.append(key, file); + } + const { data } = await client.post( + `/api/bookings/${id}/clearance/documents`, + formData, + { headers: { "Content-Type": "multipart/form-data" } }, + ); + return data.data; + }, + + proceedToOperation: async (id: string): Promise => { + const { data } = await client.post(`/api/bookings/${id}/clearance/proceed`); + return data.data; + }, + getContractView: async (id: string): Promise => { const { data } = await client.get(B.CONTRACT_VIEW(id)); return data.data ?? data; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index b86e64dd8..62509f6e7 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -448,6 +448,34 @@ export interface PricingBreakdown { totalAmount: number; } +// ── Document clearance (post counter-sign GL workflow) ────────────────────── + +export type DocumentReviewStatus = "PENDING" | "APPROVED" | "QUERIED"; + +/** One row of the clearance document grid (a required doc + its file + review). */ +export interface ClearanceDocument { + fileKey: string; + label: string; + required: boolean; + /** Who supplies this document: the customer, or Global Logistics staff. */ + uploadedBy: "customer" | "gl"; + settingCode: string; + file: { id: string; name: string; url: string } | null; + reviewStatus: DocumentReviewStatus | null; + note: string | null; +} + +/** The clearance view for a booking, driving both portals' clearance UI. */ +export interface ClearanceView { + status: string; + includesCustoms: boolean; + inputCode: string | null; + outputCode: string | null; + documents: ClearanceDocument[]; + /** True once every required customer document is APPROVED (the 100% gate). */ + allApproved: boolean; +} + export interface IInvoice extends BaseEntity { bookingId: string; invoiceNumber: string; From 733bfd1e5e116bc478a5bedb7540f07c98617064 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 23 Jun 2026 22:15:29 +0000 Subject: [PATCH 08/19] feat: reorder cargo and route steps in booking form; update related logic and UI components --- .../src/pages/bookings/EditBookingPage.tsx | 1 - .../src/pages/bookings/NewBookingPage.tsx | 18 +- .../pages/bookings/new-booking-form/schema.ts | 34 +- .../new-booking-form/step5-cargo-details.tsx | 397 +++++++----------- .../new-booking-form/step8-review.tsx | 17 +- 5 files changed, 170 insertions(+), 297 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx index da39659d2..07a778d6d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx @@ -821,7 +821,6 @@ export default function EditBookingPage() { diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 5a4e67d0d..17d3eef81 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -357,16 +357,14 @@ export default function NewBookingPage() { // Bulk amount lives in cargoTotalWeightVgm — tons (estimated) or a whole // item count, depending on the commodity's unit_of_measure. Item counts are - // rounded since fractional items are meaningless. Container totals are the - // summed VGM of all container lines. + // rounded since fractional items are meaningless. Containers carry NO weight + // at the wizard — VGM is captured later in operations — so container bookings + // send 0. const isPerItem = bulkChild?.unit_of_measure === Freight.CargoUnitOfMeasure.PerItem; const totalWeight = data.cargoType === "container" - ? data.containers.reduce( - (acc, c) => acc + Number(c.vgm || 0) * Number(c.qty || 0), - 0, - ) + ? 0 : isPerItem ? Math.round(Number(data.cargoWeight || 0)) : Number(data.cargoWeight || 0); @@ -420,7 +418,8 @@ export default function NewBookingPage() { ? data.containers.map((c) => ({ containerTypeId: findContainerTypeId(c.containerType), quantity: Number(c.qty || 1), - vgmPerUnitTons: Number(c.vgm || 0), + // Weight (VGM) is not collected at the wizard — captured in operations. + vgmPerUnitTons: 0, })) : [], ...(data.previousContractRef @@ -599,16 +598,15 @@ export default function NewBookingPage() { )} {step === 3 && ( - )} {step === 4 && ( - diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index 11291b3aa..c3085fb57 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -6,8 +6,8 @@ export const STEPS = [ { id: 0, label: "Operation Type", short: "Operation" }, { id: 1, label: "Contract Type", short: "Contract" }, { id: 2, label: "Service Type & Mile", short: "Service" }, - { id: 3, label: "Route", short: "Route" }, - { id: 4, label: "Cargo Details", short: "Cargo" }, + { id: 3, label: "Cargo Details", short: "Cargo" }, + { id: 4, label: "Route", short: "Route" }, { id: 5, label: "Shipment Date", short: "Schedule" }, { id: 6, label: "Documents", short: "Documents" }, { id: 7, label: "Review & Submit", short: "Submit" }, @@ -158,11 +158,9 @@ export const bookingFormSchema = z .refine((q) => q.length !== 0, "Quantity is required.") .refine((q) => !isNaN(+q), "Enter a valid Number") .refine((qty) => Number(qty) >= 1, "Must be greater than 0"), - vgm: z - .string() - .refine((vgm) => vgm.length !== 0, "VGM is required.") - .refine((vgm) => !isNaN(+vgm), "Enter a valid Number") - .refine((vgm) => Number(vgm) >= 0, "Must be greater than 0"), + // Weight (VGM) is NOT collected at the wizard — it is captured later in + // operations. Kept optional so existing payload code stays valid. + vgm: z.string().default("0"), }), ), // Consolidation is system-managed, not a customer choice: the backend @@ -202,12 +200,10 @@ export const bookingFormSchema = z .refine( (data) => { if (data.cargoType !== "bulk") return true; - const cargoWeight = Number(data.cargoWeight); - return ( - !!data.cargoWeight && !Number.isNaN(cargoWeight) && cargoWeight > 0 - ); + const quantity = Number(data.cargoWeight); + return !!data.cargoWeight && !Number.isNaN(quantity) && quantity > 0; }, - { message: "Enter a cargo weight greater than 0.", path: ["cargoWeight"] }, + { message: "Enter a quantity greater than 0.", path: ["cargoWeight"] }, ) .refine( (data) => !(data.cargoType === "container" && data.containers.length === 0), @@ -240,14 +236,6 @@ export const bookingFormSchema = z message: "Enter at least 1 container.", }); } - - if (!c.vgm || +c.vgm <= 0) { - ctx.addIssue({ - code: "custom", - path: ["containers", i, "vgm"], - message: "Enter VGM greater than 0.", - }); - } }); } }); @@ -281,7 +269,7 @@ export const initialBookingFormValues: DeepPartial = { cargoFreeText: "", isHazardous: false, isRefrigerated: false, - containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }], + containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "0" }], documents: {}, notes: "", }; @@ -297,7 +285,8 @@ export const stepFields: Record>> = { "equipmentReturn", "customsClearingEnabled", ], - 3: [ + 3: ["cargoType", "cargoWeight", "cargoTypePath", "containers"], + 4: [ "originYard", "destinationYard", "extraRoutes", @@ -305,7 +294,6 @@ export const stepFields: Record>> = { "isRefrigerated", "shippingLine", ], - 4: ["cargoType", "cargoWeight", "cargoTypePath", "containers"], 5: ["scheduledDate"], 6: ["documents"], 7: ["notes"], diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index b31a83174..9765d1950 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -1,17 +1,11 @@ import { useEffect, useMemo } from "react"; import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form"; import { Package, Plus, Trash2, Weight } from "lucide-react"; -import { - ActionIcon, - Button, - Skeleton, - Text, - TextInput, -} from "@mantine/core"; +import { ActionIcon, Button, Skeleton, Text, TextInput } from "@mantine/core"; import type { Freight } from "@edr/types"; import { BookingFormInputValues, - calcWagons, + calcWagons, type BookingFormValues, } from "./schema"; import { @@ -33,12 +27,10 @@ type BookingForm = UseFormReturn< export function Step5CargoDetails({ form, - direction, referenceData, isLoading, }: { form: BookingForm; - direction: Freight.ScheduleTradeDirection; referenceData?: Freight.BookingReferenceData; isLoading?: boolean; }) { @@ -66,21 +58,6 @@ export function Step5CargoDetails({ } }, [parentId]); - // For containerised cargo, the total weight is derived from the containers - // (Σ qty × vgm) rather than typed by hand — keep cargoWeight in sync. - useEffect(() => { - if (cargoType !== "container") return; - const total = (containers ?? []).reduce( - (sum, c) => sum + (Number(c?.qty) || 0) * (Number(c?.vgm) || 0), - 0, - ); - form.setValue("cargoWeight", total ? String(total) : "", { - shouldValidate: true, - }); - // form is stable; re-run when the containers or cargo type change. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [containers, cargoType]); - const selectedCommodity = useMemo(() => { if (!referenceData?.cargo_type || !parentId || !childId) return null; const group = referenceData.cargo_type.find((g) => g.id === parentId); @@ -88,8 +65,8 @@ export function Step5CargoDetails({ }, [referenceData, parentId, childId]); // Unit of measure for bulk/break-bulk cargo: PER_ITEM → ask for a total item - // count; otherwise ask for estimated tons. Drives the amount field's label, - // icon, and step so customers enter the right unit. + // count; otherwise ask for an estimated tonnage. Drives the quantity field's + // label, icon, and step so customers enter the right unit. const isPerItem = selectedCommodity?.unit_of_measure === "PER_ITEM"; const freightTypeGroups = useMemo(() => { @@ -119,29 +96,13 @@ export function Step5CargoDetails({ ); }, [referenceData, parentId]); - function getOverweightAlert( - type: "20ft" | "40ft", - vgm: number, - ): string | null { - if (type === "20ft" && vgm > 0) { - const limit = direction === "EXPORT" ? 25 : 20; - if (vgm > limit) { - return `VGM ${vgm}t exceeds the ${limit}t ${direction ?? "standard"} limit for a 20ft container. An Overweight Surcharge will apply.`; - } - } - if (type === "40ft" && vgm > 32.5) { - return `VGM ${vgm}t exceeds the 32.5t global limit for a 40ft container. An Overweight Surcharge will apply.`; - } - return null; - } - if (isLoading) { return ( } title="Cargo Details" - description="Define your cargo type, weight, and container configuration." + description="Choose your cargo type and configuration." />
@@ -161,7 +122,7 @@ export function Step5CargoDetails({ } title="Cargo Details" - description="Define your cargo type, weight, and container configuration." + description="Choose your cargo type and configuration. Container weight is captured later in operations." /> {/* Cargo Type */} @@ -204,36 +165,8 @@ export function Step5CargoDetails({ />
- {/* Containerised cargo: total weight is auto-summed from the containers - below, so we show it here up-front as a read-only running total. */} - {cargoType === "container" && ( -
- ( - } - error={fieldState.error?.message} - readOnly - description="Auto-calculated from the containers below." - radius={10} - styles={fieldStyles} - min={0} - step={0.01} - /> - )} - /> -
- )} - {/* Bulk freight type — pick the commodity FIRST so we know whether the - cargo is measured in tons or items before asking for the amount. */} + cargo is measured in tons or items before asking for the quantity. */} {cargoType === "bulk" && (
{freightTypeOptions.length > 0 ? ( @@ -288,9 +221,9 @@ export function Step5CargoDetails({ /> )} - {/* Amount — only once a commodity is chosen, so the unit (tons vs - items) is known. PER_TON asks for estimated tons to ship; - PER_ITEM asks for the total item count to import/export. */} + {/* Quantity — only once a commodity is chosen, so the unit (tons vs + items) is known. PER_TON asks for estimated tons; PER_ITEM asks + for the total item count. */} {selectedCommodity && ( )} - {/* Container list */} + {/* Container list — type + quantity only; no weight is collected here. */} {cargoType === "container" && ( <>
@@ -343,190 +272,148 @@ export function Step5CargoDetails({ radius="md" leftSection={} onClick={() => - append({ type: "20ft", containerType: "", qty: "1", vgm: "" }) + append({ + type: "20ft", + containerType: "", + qty: "1", + vgm: "0", + }) } > Add Container
- {fields.map((field, index) => { - const containerType = containers[index]?.type; - const vgm = containers[index]?.vgm ?? 0; - const alert = getOverweightAlert(containerType, +vgm); - - return ( -
-
- ( +
+
+ + Container {index + 1} + + {fields.length > 1 && ( + remove(index)} + aria-label="Remove container" > - Container {index + 1} - - {fields.length > 1 && ( - remove(index)} - aria-label="Remove container" - > - - - )} -
+ + + )} +
- {/* Container size */} + {/* Container size */} + ( +
+
+ {[ + { val: "20ft" as const, label: "20ft Container (TEU)" }, + { val: "40ft" as const, label: "40ft Container (FEU)" }, + ].map((ct) => ( + typeField.onChange(ct.val)} + > +
+ +

{ct.label}

+
+
+ ))} +
+ +
+ )} + /> + + {/* Quantity + Container Type */} +
( + render={({ field: qtyField, fieldState }) => (
-
- {[ - { - val: "20ft" as const, - label: "20ft Container (TEU)", - limit: - direction === "EXPORT" - ? "Max 25t per container" - : "Max 20t per container", - }, - { - val: "40ft" as const, - label: "40ft Container (FEU)", - limit: "Max 32.5t per container", - }, - ].map((ct) => ( - typeField.onChange(ct.val)} - > -
- -

{ct.label}

-
-

- {ct.limit} -

-
- ))} + + Quantity * + +
+ + qtyField.onChange(e.target.value)} + onBlur={qtyField.onBlur} + type="number" + min={1} + className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-center text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500" + /> +
- + {fieldState.error?.message && ( + + {fieldState.error.message} + + )}
)} /> - {/* Qty + VGM + Type */} -
- ( -
- - Quantity * - -
- - - qtyField.onChange(e.target.value) - } - onBlur={qtyField.onBlur} - type="number" - min={1} - className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-center text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500" - /> - -
- {fieldState.error?.message && ( - - {fieldState.error.message} - - )} -
- )} - /> - - ( - vgmField.onChange(e.target.value)} - onBlur={vgmField.onBlur} - type="number" - label="Tons *" - placeholder="e.g. 18.5" - error={fieldState.error?.message} - radius="md" - min={0} - step={0.1} - /> - )} - /> - - ( - - )} - /> -
- - {alert && ( - - Overweight Alert: {alert} - - )} + ( + + )} + />
- ); - })} +
+ ))}
{(() => { @@ -536,10 +423,10 @@ export function Step5CargoDetails({

Unpaired 20ft Container

- One 20ft container occupies only half a wagon. The wagon - will depart once a co-loader is found to fill the remaining - slot, which may delay departure beyond the - standard lead time. + One 20ft container occupies only half a wagon. The wagon will + depart once a co-loader is found to fill the remaining slot, + which may delay departure beyond the standard + lead time.

); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx index 5acbc0213..d7a57d7d4 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx @@ -33,8 +33,8 @@ import { StepHeader } from "./shared"; export const REVIEW_STEP_TARGETS = { contract: 1, service: 2, - route: 3, - cargo: 4, + cargo: 3, + route: 4, schedule: 5, documents: 6, } as const; @@ -351,17 +351,19 @@ export function Step8Review({ onEdit={() => setStep(REVIEW_STEP_TARGETS.cargo)} > - + {/* Containers carry no weight at the wizard — only bulk shows a quantity row. */} + {values.cargoType === "bulk" && ( + + )} {values.cargoType === "container" && values.containers.length > 0 && (
Type Qty - VGM (t) @@ -371,7 +373,6 @@ export function Step8Review({ {c.containerType || c.type} {c.qty} - {c.vgm} ))} From 196e296275b714bcc1eeae9a8c671b4201178096 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 23 Jun 2026 22:21:12 +0000 Subject: [PATCH 09/19] feat: update terminology for shipment date to estimated shipment date in booking forms --- .../src/pages/bookings/new-booking-form/schema.ts | 2 +- .../bookings/new-booking-form/step-scheduling.tsx | 14 ++++++++------ .../bookings/new-booking-form/step8-review.tsx | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index c3085fb57..b7252946c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -8,7 +8,7 @@ export const STEPS = [ { id: 2, label: "Service Type & Mile", short: "Service" }, { id: 3, label: "Cargo Details", short: "Cargo" }, { id: 4, label: "Route", short: "Route" }, - { id: 5, label: "Shipment Date", short: "Schedule" }, + { id: 5, label: "Estimated Date", short: "Schedule" }, { id: 6, label: "Documents", short: "Documents" }, { id: 7, label: "Review & Submit", short: "Submit" }, ] as const; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-scheduling.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-scheduling.tsx index 5b47a4dbf..7db864739 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-scheduling.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-scheduling.tsx @@ -152,10 +152,11 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) { > - Select a shipment date + Estimated shipment date - Confirmed train departures · {originName} → {destinationName} + Planning only · pick from scheduled departures · {originName} →{" "} + {destinationName} @@ -189,8 +190,8 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) { {originYardId && destinationYardId - ? `${availableCount} day${availableCount !== 1 ? "s" : ""} with a departure in ${format(currentDate, "MMMM")} — pick one to continue` - : "Select origin and destination to see available departures"} + ? `${availableCount} scheduled departure day${availableCount !== 1 ? "s" : ""} in ${format(currentDate, "MMMM")} — pick your estimated date` + : "Select origin and destination to see scheduled departures"} {/* Weekday headers */} @@ -284,14 +285,15 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) { c="edr-green.7" style={{ letterSpacing: "0.08em" }} > - SELECTED DAY + ESTIMATED DATE {format(new Date(selectedDate + "T00:00:00"), "EEE, MMM d yyyy")} - Your train is confirmed by our freight desk after booking. + A planning estimate. You'll confirm the actual shipment date + later when you request the operation. diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx index d7a57d7d4..dc544dcb8 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx @@ -342,7 +342,7 @@ export function Step8Review({ title="Schedule" onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)} > - + Date: Wed, 24 Jun 2026 01:22:53 +0300 Subject: [PATCH 10/19] feat: add showFreeTextBox field to cargo type seeder and improve reference data validation in draft bookings seed --- .../src/seed/pricing-data.seeder.ts | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index cddc34757..bd3e8f112 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -246,6 +246,7 @@ export class PricingDataSeeder { { code: "GRAIN", cargoTypeName: "Grain / Cereals", + showFreeTextBox: false, requiresDirectorApproval: false, isActive: true, displayOrder: 1, @@ -253,6 +254,7 @@ export class PricingDataSeeder { { code: "FERTILIZER", cargoTypeName: "Fertilizer", + showFreeTextBox: false, requiresDirectorApproval: false, isActive: true, displayOrder: 2, @@ -260,6 +262,7 @@ export class PricingDataSeeder { { code: "CEMENT", cargoTypeName: "Cement / Clinker", + showFreeTextBox: false, requiresDirectorApproval: false, isActive: true, displayOrder: 3, @@ -267,6 +270,7 @@ export class PricingDataSeeder { { code: "STEEL", cargoTypeName: "Steel / Rebar", + showFreeTextBox: false, requiresDirectorApproval: true, isActive: true, displayOrder: 4, @@ -274,6 +278,7 @@ export class PricingDataSeeder { { code: "MACHINERY", cargoTypeName: "Heavy Machinery", + showFreeTextBox: false, requiresDirectorApproval: true, isActive: true, displayOrder: 5, @@ -281,6 +286,7 @@ export class PricingDataSeeder { { code: "OTHER_BULK", cargoTypeName: "Other Bulk Cargo", + showFreeTextBox: false, requiresDirectorApproval: false, isActive: true, displayOrder: 6, @@ -623,15 +629,29 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { slByCode: Map, cargoByCode: Map, ): Promise { - const djibouti = yardByCode.get("DJIBOUTI")!; - const addis = yardByCode.get("ADDIS_ABABA")!; - const railContainer = stByCode.get("RAIL_CONTAINER")!; - const railBulk = stByCode.get("RAIL_BULK")!; - const maersk = slByCode.get("MAERSK")!; - const grain = cargoByCode.get("GRAIN")!; - const twenty = ctByCode.get("20FT")!; - const forty = ctByCode.get("40FT")!; - const twentyReefer = ctByCode.get("20FT_REEFER")!; + const djibouti = yardByCode.get("DJIBOUTI"); + const addis = yardByCode.get("ADDIS_ABABA"); + const railContainer = stByCode.get("RAIL_CONTAINER"); + const railBulk = stByCode.get("RAIL_BULK"); + const maersk = slByCode.get("MAERSK"); + const grain = cargoByCode.get("GRAIN"); + const twenty = ctByCode.get("20FT"); + const forty = ctByCode.get("40FT"); + const twentyReefer = ctByCode.get("20FT_REEFER"); + + const missing: string[] = []; + if (!djibouti) missing.push("yard:DJIBOUTI"); + if (!addis) missing.push("yard:ADDIS_ABABA"); + if (!railContainer) missing.push("serviceType:RAIL_CONTAINER"); + if (!railBulk) missing.push("serviceType:RAIL_BULK"); + if (!grain) missing.push("cargoType:GRAIN"); + if (!twenty) missing.push("containerType:20FT"); + if (!forty) missing.push("containerType:40FT"); + if (!twentyReefer) missing.push("containerType:20FT_REEFER"); + if (missing.length > 0) { + this.logger.warn(`seedDraftBookings: skipping — missing reference data: ${missing.join(", ")}`); + return; + } const drafts = [ { From 2b4dfc64906c098e4ed6f125f8b300819816b550 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 23 Jun 2026 22:54:32 +0000 Subject: [PATCH 11/19] feat: implement staff price adjustment feature for bookings --- .../1820000000003-AddPriceAdjustment.ts | 39 ++++ .../bookings/booking-pricing.service.ts | 3 +- .../bookings/booking-transition.service.ts | 24 +++ .../modules/bookings/bookings.controller.ts | 20 +++ .../bookings/dto/request-changes.dto.ts | 18 +- .../bookings/entities/booking.entity.ts | 16 ++ .../rule-engine/rule-engine.service.ts | 30 +++- .../bookings/BookingPricingSummary.tsx | 167 +++++++++++++++--- .../src/services/bookings.service.ts | 7 + .../backoffice/src/types/booking.ts | 14 ++ .../BookingDetailPage/components/pricing.tsx | 52 +++--- .../src/pages/bookings/NewBookingPage.tsx | 58 +++--- packages/types/src/freight/index.ts | 5 + 13 files changed, 364 insertions(+), 89 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1820000000003-AddPriceAdjustment.ts diff --git a/apps/edr-freight-api/src/migrations/1820000000003-AddPriceAdjustment.ts b/apps/edr-freight-api/src/migrations/1820000000003-AddPriceAdjustment.ts new file mode 100644 index 000000000..7ad97e7bf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000003-AddPriceAdjustment.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Staff price adjustment: an optional override of a booking's computed total, + * with who/when/why. When set, the customer sees the adjusted total + a badge. + */ +export class AddPriceAdjustment1820000000003 implements MigrationInterface { + name = 'AddPriceAdjustment1820000000003'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS adjusted_total_amount numeric(14,2);`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS adjusted_by_staff_id uuid;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS adjusted_at timestamptz;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS adjustment_reason text;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS adjustment_reason;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS adjusted_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS adjusted_by_staff_id;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS adjusted_total_amount;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index c06981cce..8502aa7ad 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -252,7 +252,8 @@ export class BookingPricingService { serviceTypeId: booking.serviceTypeId, paymentCurrency: booking.paymentCurrency, tradeDirection: booking.tradeDirection, - isHazardous: booking.isHazardous, + // Coerce defensively in case the stored flag is a string ("true"/"false"). + isHazardous: booking.isHazardous === true || (booking.isHazardous as unknown) === 'true', isGovernment: booking.isGovernment, allowConsolidation, shippingLineId: booking.shippingLineId, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 7adbc56f4..82b13c4bc 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -451,6 +451,30 @@ export class BookingTransitionService { return this.bookingsService.findById(updated!.id); } + /** + * Staff adjusts a booking's total price. Stores an override (with who/when/why) + * that supersedes the computed total for the customer, who sees an + * "Adjusted by EDR" badge. Passing null clears the adjustment. + */ + async adjustPrice( + bookingId: string, + amount: number | null, + staffId: string, + reason?: string, + ): Promise { + await this.bookingsService.findById(bookingId); + if (amount != null && amount < 0) { + throw new BadRequestException('Adjusted amount cannot be negative'); + } + await this.bookingsRepository.update(bookingId, { + adjustedTotalAmount: amount, + adjustedByStaffId: amount == null ? null : staffId, + adjustedAt: amount == null ? null : new Date(), + adjustmentReason: amount == null ? null : (reason ?? null), + } as never); + return this.bookingsService.findById(bookingId); + } + // ── Document clearance gate (post counter-sign) ─────────────────────────── /** 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 8c0268154..d650098f9 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -42,6 +42,7 @@ import { FilterBookingDto } from './dto/filter-booking.dto'; import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { + AdjustPriceDto, ApproveStepDto, CancelBookingDto, RejectBookingDto, @@ -455,6 +456,25 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Post(':id/adjust-price') + @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) + @ApiOperation({ + summary: 'Staff adjust booking total price (override; null clears it)', + }) + async adjustPrice( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AdjustPriceDto, + @CurrentUser() user: AuthUserPayload, + ) { + const booking = await this.transitionService.adjustPrice( + id, + dto.amount ?? null, + resolveAuthUserId(user), + dto.reason, + ); + return this.transitionService.enrichBookingResponse(booking); + } + @Post(':id/government-expedite') @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) @ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' }) diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts index 6e6a52b03..d598b948e 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsOptional, IsString, MinLength } from 'class-validator'; +import { IsIn, IsNumber, IsOptional, IsString, Min, MinLength } from 'class-validator'; export class RequestChangesDto { @ApiProperty({ description: 'Staff note explaining what the customer must fix' }) @@ -44,6 +44,22 @@ export class RejectBookingDto { reason?: string; } +export class AdjustPriceDto { + @ApiPropertyOptional({ + description: + 'New total price. Omit or send null to clear a previous adjustment.', + }) + @IsOptional() + @IsNumber() + @Min(0) + amount?: number | null; + + @ApiPropertyOptional({ description: 'Reason for the adjustment' }) + @IsOptional() + @IsString() + reason?: string; +} + export class ReviewDocumentDto { @ApiProperty({ description: 'The document fileKey being reviewed' }) @IsString() diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 3ec08eee2..9586e4bb8 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -161,6 +161,22 @@ export class Booking extends BaseEntity { @Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) totalAmount!: number; + /** + * Staff-adjusted total price. When set, it overrides the computed totalAmount + * for the customer, who is shown an "Adjusted by EDR" badge. + */ + @Column({ name: 'adjusted_total_amount', type: 'numeric', precision: 14, scale: 2, nullable: true }) + adjustedTotalAmount?: number | null; + + @Column({ name: 'adjusted_by_staff_id', type: 'uuid', nullable: true }) + adjustedByStaffId?: string | null; + + @Column({ name: 'adjusted_at', type: 'timestamptz', nullable: true }) + adjustedAt?: Date | null; + + @Column({ name: 'adjustment_reason', type: 'text', nullable: true }) + adjustmentReason?: string | null; + @Column({ name: 'payment_status', type: 'varchar', length: 20, default: 'PENDING' }) paymentStatus!: string; diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 35dbef868..9884ee854 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, BadRequestException } from '@nestjs/common'; +import { Inject, Injectable, Logger, BadRequestException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity'; import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity'; @@ -89,6 +89,8 @@ export interface RuleEvaluationResult { @Injectable() export class RuleEngineService { + private readonly logger = new Logger(RuleEngineService.name); + constructor( @Inject(CARGO_TYPES_REPOSITORY) private readonly cargoTypesRepo: ICargoTypesRepository, @@ -207,6 +209,14 @@ export class RuleEngineService { const liveRates = await this.ratesRepo.findLiveRates(); const rateById = new Map(liveRates.map((r) => [r.id, r])); + // TEMP diagnostic — trace the surcharge trigger state so we can confirm + // whether a "Hazardous" line is firing for a non-hazardous booking. + this.logger.debug( + `surcharge eval: isHazardous=${input.isHazardous} (type ${typeof input.isHazardous}) ` + + `hasReefer=${hasReefer} hasOverweight=${hasOverweight} ` + + `shippingLineMapped=${shippingLineMapped}`, + ); + for (const st of surchargeTypes) { const triggered = this.matchesTrigger(st.triggerCondition, { isHazardous: input.isHazardous, @@ -233,6 +243,11 @@ export class RuleEngineService { } } + // Safety guard: never include a surcharge with a non-positive amount (a + // zero-rate or zero-trigger line would otherwise show as a confusing + // "free" surcharge on the breakdown). + if (!(calculatedAmount > 0)) continue; + appliedModifiers.push({ surchargeTypeId: st.id, surchargeTypeCode: st.code, @@ -382,17 +397,20 @@ export class RuleEngineService { allowConsolidation: boolean; }, ): boolean { + // Coerce defensively: a flag may arrive as the string "true"/"false" (e.g. + // from multipart form-data) and a non-empty "false" string is truthy. + const truthy = (v: unknown): boolean => v === true || v === 'true'; switch (condition) { case 'CARGO_FLAG_HAZARDOUS': - return state.isHazardous; + return truthy(state.isHazardous); case 'CARGO_FLAG_REEFER': - return state.hasReefer; + return truthy(state.hasReefer); case 'VGM_EXCEEDS_LIMIT': - return state.hasOverweight; + return truthy(state.hasOverweight); case 'SHIPPING_LINE_MAPPED': - return state.shippingLineMapped; + return truthy(state.shippingLineMapped); case 'CONSOLIDATION_ENABLED': - return state.allowConsolidation; + return truthy(state.allowConsolidation); default: return false; } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx index 1342df085..c0dd56f74 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx @@ -1,50 +1,171 @@ -import { Banknote, Receipt } from "lucide-react"; -import { Paper, Stack, Group, Text, Divider } from "@mantine/core"; +import { useState } from "react"; +import { Banknote, Pencil, Receipt } from "lucide-react"; +import { + Button, + Divider, + Group, + NumberInput, + Paper, + Stack, + Text, + Textarea, +} from "@mantine/core"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import toast from "react-hot-toast"; import type { BookingDetail } from "@/types/booking"; +import { bookingsService } from "@/services/bookings.service"; import { SectionCard } from "./detail/SectionCard"; import { detailStyles } from "./detail/booking-detail.styles"; export function BookingPricingSummary({ booking }: { booking: BookingDetail }) { - const amount = Number(booking.totalAmount); - const modifiers = booking.cargoModifiers ?? []; + const qc = useQueryClient(); + const computed = Number(booking.totalAmount); + const isAdjusted = + booking.adjustedTotalAmount !== null && + booking.adjustedTotalAmount !== undefined; + const effective = isAdjusted ? Number(booking.adjustedTotalAmount) : computed; + + const lineItems = booking.pricingBreakdown?.lineItems ?? []; + + const [editing, setEditing] = useState(false); + const [amount, setAmount] = useState(effective); + const [reason, setReason] = useState(""); + + const adjustMutation = useMutation({ + mutationFn: (payload: { amount: number | null; reason?: string }) => + bookingsService.adjustPrice(booking.id, payload.amount, payload.reason), + onSuccess: () => { + toast.success("Price updated"); + setEditing(false); + qc.invalidateQueries({ queryKey: ["bookings"] }); + }, + onError: () => toast.error("Could not update price"), + }); + + const fmt = (n: number) => + `${booking.paymentCurrency} ${n.toLocaleString(undefined, { minimumFractionDigits: 2 })}`; return ( - - Total amount - - - {booking.paymentCurrency}{" "} - {amount.toLocaleString(undefined, { minimumFractionDigits: 2 })} - + +
+ + {isAdjusted ? "Adjusted total" : "Total amount"} + + + {fmt(effective)} + + {isAdjusted && ( + + Computed: {fmt(computed)} + {booking.adjustmentReason ? ` · ${booking.adjustmentReason}` : ""} + + )} +
+ {!editing && ( + + )} +
+ + {editing && ( + + setAmount(v === "" ? "" : Number(v))} + min={0} + radius="md" + prefix={`${booking.paymentCurrency} `} + thousandSeparator="," + /> +