From a072f0445068263a5e212b238514775f4c1e85d6 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 30 Jun 2026 02:20:32 +0000 Subject: [PATCH] Enhance contract and booking request handling --- .../contracts/booking-request.repository.ts | 12 +- .../src/modules/payment/payment.service.ts | 25 +- .../contracts/detail/RequestDetailCards.tsx | 275 ++++++++++++++++ .../bookings/BookingRequestDetailPage.tsx | 22 +- .../contracts/ShipmentRequestDetailPage.tsx | 111 ++++--- .../ContractChangesRequestedView.tsx | 309 ++++++++++++++++++ .../pages/contracts/ContractDetailPage.tsx | 7 + packages/types/src/freight/contracts.ts | 36 ++ 8 files changed, 735 insertions(+), 62 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/detail/RequestDetailCards.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/ContractChangesRequestedView.tsx diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts index 9529cabbe..7a3695768 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts @@ -34,7 +34,17 @@ export class BookingRequestRepository extends BaseRepository { async findById(id: string): Promise { return this.repository.findOne({ where: { id }, - relations: { contract: true }, + // Load the contract with the bits the detail page surfaces: customer + // (company), service type (mile/customs flags), routes (with yard labels) + // and cargo scope. + relations: { + contract: { + company: true, + serviceType: true, + routes: { originYard: true, destinationYard: true }, + cargoScope: true, + }, + }, }); } diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 347fedc1e..23376820f 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -17,12 +17,14 @@ import * as fs from "fs"; import * as path from "path"; import * as Handlebars from "handlebars"; import { Booking } from "../bookings/entities/booking.entity"; +import { Invoice } from "../billing/entities/invoice.entity"; import { ClientAction, ProviderPaymentStatus, } from "@edr/payment-providers"; import { + Freight, PaymentService as PaymentServiceEnum, PaymentReferenceType, PaymentIntentSnapshot, @@ -462,30 +464,37 @@ export class PaymentService { failureCode?: string; failureMessage?: string; }): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> { - console.log(`Received payment event: ${JSON.stringify(event)}`); if (event.eventType === "payment.succeeded") { + console.log(`Payment succeeded event received for reference ${event.referenceId}`); const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); if (!intent) { + console.warn(`No local intent found for reference ${event.referenceId}`); return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; } - console.log(`Processing payment succeeded event for intent: }`,intent); + const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, { providerTxnId: event.providerTxnId, paidAt: event.paidAt ? new Date(event.paidAt) : undefined, notify: true, }); - console.log(`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); + console.log(`Payment intent ${intent.id} marked as succeeded (alreadyFinalized=${alreadyFinalized})`); - // When the intent references a booking, flip the booking itself paid. - // refId holds the booking id (the domain reference the intent opened with). - if (intent.referenceType === PaymentReferenceType.BOOKING) { + // The invoice the intent settled is the authority on what was paid for. + // Its `paymentId` links 1:1 to this intent; when its source is a booking, + // `sourceId` holds that booking id — flip the booking itself paid. + const invoice = await this.datasource.manager.findOneBy(Invoice, { + paymentId: intent.id, + }); + console.log(`Invoice lookup for payment intent ${intent.id} returned invoice ${invoice?.id} (source=${invoice?.source}, sourceId=${invoice?.sourceId})`); + if (invoice?.source === Freight.InvoiceSource.Booking) { + console.log(`Marking booking ${invoice.sourceId} as PAID due to invoice ${invoice.id} settlement`); await this.datasource.manager.update( Booking, - { id: intent.refId }, + { id: invoice.sourceId }, { status: "PAID", paymentStatus: "PAID" }, ); } - // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); + return { processed: true, alreadyFinalized }; } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/detail/RequestDetailCards.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/detail/RequestDetailCards.tsx new file mode 100644 index 000000000..595252cbf --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/detail/RequestDetailCards.tsx @@ -0,0 +1,275 @@ +import type { LucideIcon } from "lucide-react"; +import { + Building2, + FileCheck, + FileText, + Mail, + MapPin, + Package, + Phone, + Ship, + Truck, + User, + Warehouse, +} from "lucide-react"; +import { Badge, Box, Divider, Group, Stack, Text } from "@mantine/core"; +import type { Freight } from "@edr/types"; + +import { SectionCard } from "@/components/bookings/detail/SectionCard"; + +type ReqContract = NonNullable; + +interface InfoRowProps { + icon: LucideIcon; + label: string; + value?: string | null; +} + +function InfoRow({ icon: Icon, label, value }: InfoRowProps) { + return ( + + + + + {label} + + + + {value || "—"} + + + ); +} + +function InfoRows({ rows }: { rows: InfoRowProps[] }) { + const visible = rows.filter((r) => r.value); + if (visible.length === 0) { + return ( + + No details available. + + ); + } + return ( + + {visible.map((row, i) => ( +
+ {i > 0 && } + +
+ ))} +
+ ); +} + +/** Customer (company) on the request's contract. */ +export function RequestCustomerCard({ contract }: { contract?: ReqContract | null }) { + const company = contract?.company; + if (!company) { + return ( + + + No customer linked to this request. + + + ); + } + return ( + + + + ); +} + +const fmtDate = (iso?: string | null) => + iso + ? new Intl.DateTimeFormat("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + }).format(new Date(iso)) + : "—"; + +const titleCase = (s?: string | null) => + s ? s.charAt(0) + s.slice(1).toLowerCase() : "—"; + +/** Contract identity + commercial terms. */ +export function RequestContractSummaryCard({ + contract, +}: { + contract?: ReqContract | null; +}) { + if (!contract) return null; + return ( + + + + ); +} + +/** Routes + cargo scope of the contract. */ +export function RequestRouteCargoCard({ + contract, +}: { + contract?: ReqContract | null; +}) { + const routes = contract?.routes ?? []; + const cargo = contract?.cargoScope ?? []; + const isContainer = contract?.freightType === "CONTAINER"; + return ( + + + + + Routes + + {routes.length === 0 ? ( + + No routes recorded. + + ) : ( + + {routes.map((r) => ( + + + + {r.originYard?.label ?? r.originYardId} →{" "} + {r.destinationYard?.label ?? r.destinationYardId} + + + ))} + + )} + + + + Cargo scope + + {cargo.length === 0 ? ( + + No cargo scope recorded. + + ) : ( + + {cargo.map((c) => ( + } + > + {c.containerSize ?? + c.cargoFreeText ?? + (isContainer ? "Container" : "Bulk commodity")} + + ))} + + )} + + + + ); +} + +/** Service type — what the contracted service bundles (rail-only vs logistics/customs). */ +export function RequestServiceTypeCard({ + contract, +}: { + contract?: ReqContract | null; +}) { + const st = contract?.serviceType; + if (!st) return null; + + const firstMile = st.includesFirstMile ?? false; + const lastMile = st.includesLastMile ?? false; + const customs = st.includesCustoms ?? false; + const railOnly = !firstMile && !lastMile && !customs; + + const chips: Array<{ label: string; color: string; icon: LucideIcon }> = []; + if (railOnly) chips.push({ label: "Rail only", color: "blue", icon: Ship }); + if (firstMile) + chips.push({ label: "First-mile pickup", color: "teal", icon: Truck }); + if (lastMile) + chips.push({ label: "Last-mile delivery", color: "teal", icon: Warehouse }); + if (customs) + chips.push({ label: "Customs clearance (GL)", color: "grape", icon: FileCheck }); + + return ( + + + + {chips.map((c) => ( + } + > + {c.label} + + ))} + + {st.description ? ( + + {st.description} + + ) : null} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index 75badc5b3..8cc6655dd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -289,16 +289,18 @@ export default function BookingRequestDetailPage() { booking={booking} mutations={mutations} /> - + {booking.customsClearingEnabled && ( + + )} {showContractButton && ( +
+ + {contract.reference} + + + Changes requested — update your documents and resubmit. + +
+ + + } + title="A reviewer asked for changes" + > + Update the documents below — replace anything that needs to change and + attach any required document that isn't on file yet — then resubmit the + contract for review. + + + + {onFile.length > 0 && ( + + + Already on file + + {onFile.map((file) => ( + + + + + + + {labelForDocCode(file.code)} + + + {file.name} + + + + + + + On file + + + + + + ))} + + )} + + + Update documents + + + Replace any document you need to change. Documents marked required + must be on file before you can resubmit. + + + {settingQuery.isLoading ? ( + + + + ) : settingQuery.data ? ( + + ) : ( + + No document requirements are configured for your account. You can + resubmit using the documents already on file. + + )} + + {error && ( + } + mt="md" + > + {error} + + )} + {(updateMutation.isError || submitMutation.isError) && ( + } + mt="md" + > + Couldn't resubmit. Please try again. + + )} + + + + + + ); +} + +export default ContractChangesRequestedView; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 4e40313c9..bd7e1bafe 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -46,6 +46,7 @@ import { fileViewUrl } from "@/constants/apiConfig"; import { useFileViewer } from "@/hooks/useFileViewer"; import { labelForDocCode } from "@/pages/bookings/resubmit"; import { ContractClearancePanel } from "./ContractClearancePanel"; +import { ContractChangesRequestedView } from "./ContractChangesRequestedView"; import { formatRateUnit } from "./new-contract-form/unit-rates"; import { BORDER, @@ -193,6 +194,12 @@ export default function ContractDetailPage() { ); } + // Staff returned the contract for changes — show the edit-and-resubmit view + // (update documents → resubmit) instead of the read-only detail. + if (contract.status === "CHANGES_REQUESTED") { + return ; + } + const isContainer = contract.freightType === "CONTAINER"; const isGeneral = contract.contractKind === "GENERAL"; const routes = contract.routes ?? []; diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 0dbadc50f..197df32a7 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -411,7 +411,10 @@ export interface IContract extends BaseEntity { id: string; code: string; serviceName: string; + description?: string | null; canBeBookedAlone: boolean; + includesFirstMile?: boolean; + includesLastMile?: boolean; includesCustoms: boolean; } | null; paymentCurrency: string; @@ -614,6 +617,39 @@ export interface IBookingRequest extends BaseEntity { reviewedByStaffId?: string | null; reviewedAt?: string | null; reviewNote?: string | null; + /** Loaded contract relation (request detail response includes it). */ + contract?: BookingRequestContract | null; +} + +/** Customer (company) summary carried on a request's contract. */ +export interface BookingRequestCompany { + id: string; + name?: string | null; + tin?: string | null; + email?: string | null; + phone?: string | null; + address?: string | null; + contactPersonName?: string | null; + contactPersonPhone?: string | null; +} + +/** + * The slice of the contract surfaced on the shipment-request detail page: + * identity, service type (mile/customs flags), customer, routes and cargo scope. + */ +export interface BookingRequestContract { + id: string; + reference: string; + contractKind: ContractKind; + tradeDirection: ContractTradeDirection; + freightType: ContractFreightType; + customsClearingEnabled: boolean; + paymentCurrency: string; + contractValidUntil?: string | null; + company?: BookingRequestCompany | null; + serviceType?: IContract["serviceType"]; + routes?: IContractRoute[]; + cargoScope?: IContractCargoScope[]; } export interface CreateBookingRequestDto {