From 85c2fd142882ccee88aeac25ca2011fea86740af Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 21:04:46 +0000 Subject: [PATCH] feat: enhance contract clearance process with linked booking details - Added and to for better visibility of GL-created shipment bookings. - Implemented method in to fetch the latest clearance phase for contracts, improving list responses. - Introduced property in the entity to store the latest clearance cycle's phase. - Updated to surface linked booking information in the clearance view. - Created component to display detailed container information in booking details. - Refactored booking actions to remove contract-related actions from the booking request page. - Enhanced the component to reflect the current phase of clearance actions. - Updated UI components to provide clearer messaging regarding the status of clearance and linked bookings. - Adjusted action handling in to include duty payment actions. - Improved the to show hints for each phase of the clearance process. --- .../modules/bookings/bookings.repository.ts | 2 + .../entities/booking-container.entity.ts | 7 +- .../modules/companies/companies.controller.ts | 22 +- .../contracts/contract-clearance.service.ts | 24 ++- .../modules/contracts/contracts.repository.ts | 25 +++ .../contracts/entities/contract.entity.ts | 6 + .../src/modules/files/files.service.ts | 10 + .../bookings/BookingActionsToolbar.tsx | 35 +-- .../detail/BookingContainerUnitsCard.tsx | 202 ++++++++++++++++++ .../src/components/bookings/detail/index.ts | 1 + .../bookings/booking-actions.config.ts | 40 +--- .../bookings/BookingRequestDetailPage.tsx | 65 +----- .../backoffice/src/types/booking.ts | 13 ++ .../ContractClearanceAction.tsx | 19 +- .../ContractCustomerAction.tsx | 2 + .../deriveContractCustomerAction.ts | 62 +++++- .../portal/src/pages/MyPortalPage/actions.ts | 2 +- .../components/ActionNeededSection.tsx | 37 +++- .../pages/contracts/ClearancePhaseStepper.tsx | 122 +++++++---- .../ContractClearanceWorkflowBanner.tsx | 33 ++- .../new-contract-form/step1-contract-type.tsx | 3 +- packages/types/src/freight/contracts.ts | 9 + 22 files changed, 537 insertions(+), 204 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContainerUnitsCard.tsx diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 846fd2c9a..913565f70 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -90,6 +90,7 @@ export class BookingsRepository extends BaseRepository { .createQueryBuilder('booking') .leftJoinAndSelect('booking.bookingContainers', 'bc') .leftJoinAndSelect('bc.containerType', 'ct') + .leftJoinAndSelect('bc.units', 'bcu') .leftJoinAndSelect('booking.company', 'company') // .leftJoinAndSelect('booking.customer', 'customer') .leftJoinAndSelect('booking.train', 'train') @@ -104,6 +105,7 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.reviewNotes', 'reviewNotes') .leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner') .where('booking.id = :id', { id }) + .addOrderBy('bcu.sort_order', 'ASC') .leftJoinAndMapMany( 'booking.files', FileRecord, diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts index db9746c09..182ff153d 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts @@ -1,8 +1,9 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { ContainerType } from '../../rule-engine/entities/container-type.entity'; import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity'; import { Booking } from './booking.entity'; +import { BookingContainerUnit } from './booking-container-unit.entity'; @Entity({ schema: 'freight', name: 'booking_container' }) @Index(['bookingId']) @@ -61,4 +62,8 @@ export class BookingContainer extends BaseEntity { @Column({ name: 'overweight_excess_tons', type: 'numeric', precision: 10, scale: 3, nullable: true }) overweightExcessTons?: number | null; + + /** The physical containers under this line — each with its own number + VGM. */ + @OneToMany(() => BookingContainerUnit, (u) => u.bookingContainer) + units?: BookingContainerUnit[]; } 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 4bcc3252a..b1761b35f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -334,15 +334,19 @@ export class CompaniesController { @Param("companyId", ParseUUIDPipe) companyId: string, ) { const files = await this.filesService.findByResource(companyId, "companies"); - return files.map((f) => ({ - id: f.id, - name: f.name, - code: f.code, - mimeType: f.mimeType, - size: f.size, - uploadedAt: f.createdAt, - url: f.url, - })); + return Promise.all( + files.map(async (f) => ({ + id: f.id, + name: f.name, + code: f.code, + mimeType: f.mimeType, + size: f.size, + uploadedAt: f.createdAt, + // Raw `f.url` is an un-signed MinIO path the browser can't open — sign + // it so the file previews/downloads in the client. + url: f.url ? await this.filesService.signUrl(f.url) : f.url, + })), + ); } @Post(":companyId/documents") diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 73a4fe117..532d26359 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -77,6 +77,9 @@ export interface ContractClearanceView { /** Export post-booking clearance finalized (transit permit uploaded + GL confirmed). */ exportClearanceFinalized?: boolean; linkedBookingId?: string | null; + /** Reference + status of the GL-created shipment booking, once it exists. */ + linkedBookingReference?: string | null; + linkedBookingStatus?: string | null; dutyAdvice?: { amount: number; currency: string; @@ -284,13 +287,22 @@ export class ContractClearanceService { ); let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones); - if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') { + // Once GL creates the shipment booking, surface its reference + status so the + // customer sees the concrete booking instead of a stale "will be created + // shortly" message. Reuse the export booking load; fetch for import too. + let linkedBookingReference: string | null = null; + let linkedBookingStatus: string | null = null; + if (cycle?.bookingId) { const booking = await this.bookingsService.findById(cycle.bookingId); if (booking) { - nextAction = this.workflowService.computeNextActionForBooking( - booking, - bookingMilestones, - ); + linkedBookingReference = booking.reference ?? null; + linkedBookingStatus = booking.status ?? null; + if (contract.tradeDirection === 'EXPORT') { + nextAction = this.workflowService.computeNextActionForBooking( + booking, + bookingMilestones, + ); + } } } @@ -326,6 +338,8 @@ export class ContractClearanceService { preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt), exportClearanceFinalized: Boolean(cycle?.completedAt), linkedBookingId: cycle?.bookingId ?? null, + linkedBookingReference, + linkedBookingStatus, dutyAdvice, workflowFiles, t1, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 9e464db51..34a958fd2 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -135,6 +135,7 @@ export class ContractsRepository extends BaseRepository { // Attach the generated contract PDF to each row so list/home can offer a // direct download. Loaded separately to keep pagination counts correct. await this.attachContractFiles(items); + await this.attachClearancePhases(items); const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0; return { @@ -173,6 +174,30 @@ export class ContractsRepository extends BaseRepository { } } + /** + * Attach each contract's persisted clearance phase (latest cycle's + * current_phase) so list consumers can show step-accurate customer actions + * ("Pay duty & upload slip" vs generic "Update clearance") without a + * per-contract clearance-view request. One query per page, like + * `attachContractFiles`. + */ + private async attachClearancePhases(contracts: Contract[]): Promise { + if (contracts.length === 0) return; + const ids = contracts.map((c) => c.id); + const rows: Array<{ contract_id: string; current_phase: string | null }> = + await this.dataSource.query( + `SELECT DISTINCT ON (contract_id) contract_id, current_phase + FROM freight.contract_clearance_cycles + WHERE contract_id = ANY($1) + ORDER BY contract_id, cycle_number DESC`, + [ids], + ); + const byContract = new Map(rows.map((r) => [r.contract_id, r.current_phase])); + for (const contract of contracts) { + contract.clearancePhase = byContract.get(contract.id) ?? null; + } + } + async getStatusCounts(): Promise> { const rows = await this.repository .createQueryBuilder('contract') diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index 0461d3736..07d08d3c0 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -254,4 +254,10 @@ export class Contract extends BaseEntity { createForeignKeyConstraints: false, }) files?: FileRecord[]; + + /** + * Latest clearance cycle's current_phase, attached by + * ContractsRepository.attachClearancePhases for list responses. Not a column. + */ + clearancePhase?: string | null; } 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 ec1fb6fa9..a5c641dd7 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -123,6 +123,16 @@ export class FilesService { return this.filesRepository.findByResource(resourceId, resource); } + /** + * Short-lived signed URL for a stored file's raw MinIO URL. The persisted + * `url` is an un-signed object path that a browser cannot fetch directly; + * callers that expose files for preview/download must sign them first. + */ + async signUrl(rawUrl: string, expirySeconds = 300): Promise { + const objectName = this.minioService.getObjectNameFromUrl(rawUrl); + return this.minioService.getSignedUrl(objectName, expirySeconds); + } + async findByCode( resourceId: string, resource: string, diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx index e451f2d2c..349d68f32 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx @@ -1,5 +1,5 @@ -import { Download, Zap, FileText, Clock } from "lucide-react"; -import { Stack, Text, Button } from "@mantine/core"; +import { Zap, Clock } from "lucide-react"; +import { Stack, Text } from "@mantine/core"; import type { BookingDetail } from "@/types/booking"; import { BookingActionsMenu } from "./BookingActionsMenu"; @@ -14,21 +14,11 @@ interface BookingActionsToolbarProps { mutations: Mutations; } -/** Detail-page actions: primary toolbar + downloads. */ -export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) { +/** Detail-page actions: primary staff-action toolbar. */ +export function BookingActionsToolbar({ booking }: BookingActionsToolbarProps) { const row = toBookingListRow(booking); const { status } = booking; - const downloadBlob = async (fn: () => Promise, filename: string) => { - const blob = await fn(); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); - }; - if (status === "REJECTED" || status === "CANCELLED" || status === "COMPLETED") { return null; } @@ -101,23 +91,6 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool - - {status === "CONTRACT_READY" && ( - - - - )} ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContainerUnitsCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContainerUnitsCard.tsx new file mode 100644 index 000000000..cd925c03c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContainerUnitsCard.tsx @@ -0,0 +1,202 @@ +import { useMemo } from "react"; +import { Boxes, Container as ContainerIcon, Snowflake, Flame } from "lucide-react"; +import { Badge, Box, Group, Stack, Table, Text, ThemeIcon } from "@mantine/core"; + +import type { BookingDetail } from "@/types/booking"; +import { SectionCard } from "./SectionCard"; + +export interface BookingContainerUnitsCardProps { + booking: BookingDetail; +} + +interface FlatUnit { + id: string; + containerNumber: string; + sealNumber?: string | null; + vgmTons: number; + isHazardous?: boolean; + isReefer?: boolean; + typeLabel: string; + sizeFt?: number; +} + +/** + * The physical container manifest: one row per container with its number, type, + * seal, and weight (VGM). Per-unit numbers are only captured for contract-drawdown + * bookings — when a line has no units the card falls back to the aggregate + * type/qty/weight so it still renders something for plain bookings. + */ +export function BookingContainerUnitsCard({ booking }: BookingContainerUnitsCardProps) { + const lines = booking.bookingContainers ?? []; + + const units: FlatUnit[] = useMemo( + () => + lines.flatMap((line) => + (line.units ?? []).map((u) => ({ + id: u.id, + containerNumber: u.containerNumber, + sealNumber: u.sealNumber, + vgmTons: Number(u.vgmTons) || 0, + isHazardous: u.isHazardous, + isReefer: u.isReefer, + typeLabel: line.containerType?.label ?? line.containerType?.code ?? "—", + sizeFt: line.containerType?.sizeFt, + })), + ), + [lines], + ); + + // Container bookings only — bulk has no container manifest. + if (booking.freightType === "BULK" || lines.length === 0) return null; + + const totalUnits = units.length; + const totalVgm = units.reduce((sum, u) => sum + u.vgmTons, 0); + + return ( + 0 + ? "Each physical container with its number and weight" + : "Per-container numbers were not captured for this booking" + } + accent="teal" + extra={ + totalUnits > 0 ? ( + + {totalUnits} container{totalUnits === 1 ? "" : "s"} + + ) : ( + + {lines.length} line{lines.length === 1 ? "" : "s"} + + ) + } + > + {totalUnits > 0 ? ( + + + + + + # + Container No. + Type + Seal + Weight (VGM) + + + + {units.map((u, i) => ( + + + + {i + 1} + + + + + + + + + {u.containerNumber} + + {u.isReefer ? ( + + + + ) : null} + {u.isHazardous ? ( + + + + ) : null} + + + + + {u.typeLabel} + {u.sizeFt ? ( + + {u.sizeFt}FT + + ) : null} + + + + + {u.sealNumber || "—"} + + + + + {u.vgmTons.toFixed(3)} t + + + + ))} + +
+
+ + + + Total weight (VGM) + + + {totalVgm.toFixed(3)} t + + +
+ ) : ( + // Fallback: no per-unit numbers — show the aggregate lines. + + + + + Type + Qty + VGM / unit + Total VGM + + + + {lines.map((line) => { + const perUnit = Number(line.vgmPerUnitTons) || 0; + return ( + + + + + {line.containerType?.label ?? line.containerType?.code ?? "—"} + + {line.containerType?.sizeFt ? ( + + {line.containerType.sizeFt}FT + + ) : null} + + + {line.quantity} + {perUnit.toFixed(3)} t + + + {(line.quantity * perUnit).toFixed(3)} t + + + + ); + })} + +
+
+ )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index a023fafda..003f3d4de 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -8,6 +8,7 @@ export * from "./BookingDetailHeader"; export * from "./BookingLifecycleStepper"; export * from "./BookingRouteCard"; export * from "./BookingContainersCard"; +export * from "./BookingContainerUnitsCard"; export * from "./BookingApprovalCard"; export * from "./BookingReviewNotesCard"; export * from "./BookingPaymentCard"; diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts index 66bb9b1ea..818a82c52 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts @@ -2,7 +2,6 @@ import type { LucideIcon } from "lucide-react"; import { Ban, Check, - FileSignature, MessageSquareWarning, Play, ShieldCheck, @@ -211,29 +210,6 @@ const CANCEL_ACTION: BookingActionDef = { inputPlaceholder: "Reason for cancellation…", }; -const VIEW_CONTRACT_ACTION: BookingActionDef = { - id: "viewContract", - label: "View contract", - shortLabel: "Contract", - description: "Open contract document and signatures", - confirmTitle: "", - confirmDescription: "", - variant: "outline", - icon: FileSignature, -}; - -const SIGN_CONTRACT_STAFF_ACTION: BookingActionDef = { - id: "signContractStaff", - label: "Sign contract", - shortLabel: "Sign", - description: "Open contract page and apply staff counter-signature", - confirmTitle: "", - confirmDescription: "", - variant: "default", - icon: FileSignature, - primary: true, -}; - // Opens the booking detail straight on the Clearance tab so Marketing can // review the customer's clearance documents (non-customs bookings only). const REVIEW_CLEARANCE_ACTION: BookingActionDef = { @@ -340,22 +316,14 @@ export function getBookingActions( actions = withCancel(approvalActions(approvalSteps)); break; case "APPROVED": - actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }, CANCEL_ACTION]; + actions = [CANCEL_ACTION]; break; case "CONTRACT_READY": - actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }]; - break; case "SIGNED_CUSTOMER": - actions = [SIGN_CONTRACT_STAFF_ACTION, VIEW_CONTRACT_ACTION]; - break; case "FULLY_EXECUTED": - actions = [ - { - ...VIEW_CONTRACT_ACTION, - label: "View executed contract", - primary: true, - }, - ]; + // Contract view/sign/executed buttons intentionally removed from the + // booking-request page. + actions = []; break; case "AWAITING_DOCUMENTS": case "DOCUMENTS_UNDER_REVIEW": 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 ebfe22cb7..d1830b45a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -1,7 +1,6 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { ArrowLeft, - FileSignature, Layers, LayoutGrid, Milestone, @@ -36,31 +35,19 @@ import { BookingCargoCard, BookingCompanyCard, BookingContractSummaryCard, - BookingDocumentsCard, + BookingContainerUnitsCard, ClearanceReviewSection, ContractOrdersPanel, - type BookingFileView, } from "@/components/bookings/detail"; import { WarehouseInfoCard } from "@/components/warehouses"; import { getStatusMeta } from "@/features/bookings/booking-status.config"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import type { BookingDetail } from "@/types/booking"; -import { downloadBookingFile } from "@/services/files.service"; import { useBookingDetail, useBookingMutations, } from "@/hooks/bookings/useBookings"; import { useScrollToHash } from "@/hooks/useScrollToHash"; -import toast from "react-hot-toast"; - -// Signature / generated-contract files are surfaced on the contract page, not -// in the booking's Documents list. -const SIGNATURE_FILE_CODES = new Set([ - "signature", - "signature_customer", - "signature_staff", - "contract", -]); export default function BookingRequestDetailPage() { const { id } = useParams<{ id: string }>(); @@ -77,14 +64,6 @@ export default function BookingRequestDetailPage() { } = useBookingDetail(id); const mutations = useBookingMutations(id ?? ""); - const handleDownloadFile = async (file: BookingFileView) => { - try { - await downloadBookingFile(file.id, file.name); - } catch { - toast.error("Could not download file."); - } - }; - if (isLoading) { return ( @@ -149,11 +128,6 @@ export default function BookingRequestDetailPage() { const row = toBookingListRow(booking); const statusMeta = getStatusMeta(booking.status); - const showContractButton = [ - "CONTRACT_READY", - "SIGNED_CUSTOMER", - "FULLY_EXECUTED", - ].includes(booking.status); const showApprovalCard = booking.status === "PENDING_APPROVAL" || booking.status === "APPROVED_PENDING_SIGNATURE"; @@ -246,11 +220,7 @@ export default function BookingRequestDetailPage() { - + {isGeneralContract && ( @@ -270,11 +240,7 @@ export default function BookingRequestDetailPage() { )} ) : ( - + )} @@ -306,20 +272,6 @@ export default function BookingRequestDetailPage() { View document clearance )} - {showContractButton && ( - - )} {showApprovalCard && ( )} @@ -332,15 +284,13 @@ export default function BookingRequestDetailPage() { ); } -/** The booking's primary detail cards — route, services, cargo, contract, docs. */ +/** The booking's primary detail cards — route, services, cargo, containers. */ function OverviewPanel({ booking, row, - onDownload, }: { booking: BookingDetail; row: ReturnType; - onDownload: (file: BookingFileView) => void; }) { return ( @@ -351,15 +301,10 @@ function OverviewPanel({ /> + {booking.contractSummary && ( )} - !SIGNATURE_FILE_CODES.has(f.code ?? ""), - )} - onDownload={onDownload} - /> ); } diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index 298e749c7..a89e10378 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -69,6 +69,17 @@ export interface BookingCompany { website?: string | null; } +/** One physical container under a line — its own number + verified gross mass. */ +export interface BookingContainerUnit { + id: string; + containerNumber: string; + sealNumber?: string | null; + vgmTons: number; + isHazardous?: boolean; + isReefer?: boolean; + sortOrder?: number; +} + export interface BookingContainerLine { id: string; containerTypeId: string; @@ -80,6 +91,8 @@ export interface BookingContainerLine { label?: string; sizeFt?: number; }; + /** Per-physical-container rows (number + weight). Empty when not captured. */ + units?: BookingContainerUnit[]; } export interface BookingApprovalStep { diff --git a/apps/edr-freight-web/portal/src/components/customer-actions/ContractClearanceAction.tsx b/apps/edr-freight-web/portal/src/components/customer-actions/ContractClearanceAction.tsx index 58fef65e3..4aa131c23 100644 --- a/apps/edr-freight-web/portal/src/components/customer-actions/ContractClearanceAction.tsx +++ b/apps/edr-freight-web/portal/src/components/customer-actions/ContractClearanceAction.tsx @@ -1,7 +1,7 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { Button, Modal, Text, type ButtonProps } from "@mantine/core"; -import { AlertCircle, Upload } from "lucide-react"; +import { AlertCircle, Upload, type LucideIcon } from "lucide-react"; import { api } from "@/services/api"; import { ContractClearancePanel } from "@/pages/contracts/ContractClearancePanel"; @@ -13,6 +13,10 @@ interface ContractClearanceActionProps { label?: string; size?: ButtonProps["size"]; urgent?: boolean; + /** GL's turn — render as a calm status button, not a call to action. */ + waiting?: boolean; + /** Icon override from the phase-aware action derivation. */ + icon?: LucideIcon; } export function ContractClearanceAction({ @@ -20,6 +24,8 @@ export function ContractClearanceAction({ label: labelProp, size = "xs", urgent = false, + waiting = false, + icon: iconProp, }: ContractClearanceActionProps) { const [opened, { open, close }] = useDisclosure(false); @@ -38,7 +44,13 @@ export function ContractClearanceAction({ return urgent ? "Upload clearance" : "Manage clearance"; }, [labelProp, clearance, urgent]); - const Icon = urgent || label.includes("Update") ? AlertCircle : Upload; + const Icon = + iconProp ?? (urgent || label.includes("Update") ? AlertCircle : Upload); + + // Urgent (customer's turn) = filled orange so it stands out among the green + // actions; waiting (GL's turn) = calm subtle gray; default = brand green. + const color = urgent ? "orange" : waiting ? "gray" : "edr-green"; + const variant = waiting ? "light" : "filled"; return ( @@ -47,7 +59,8 @@ export function ContractClearanceAction({ radius="md" fw={700} fz={13} - color="edr-green" + color={color} + variant={variant} leftSection={} onClick={(e) => { e.stopPropagation(); diff --git a/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx b/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx index e91f897b4..9d1b6718e 100644 --- a/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx +++ b/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx @@ -46,6 +46,8 @@ export function ContractCustomerAction({ label={action.label} size={size} urgent={action.urgent} + waiting={action.waiting} + icon={action.icon} /> ); } diff --git a/apps/edr-freight-web/portal/src/components/customer-actions/deriveContractCustomerAction.ts b/apps/edr-freight-web/portal/src/components/customer-actions/deriveContractCustomerAction.ts index 48fb19943..4de7d2545 100644 --- a/apps/edr-freight-web/portal/src/components/customer-actions/deriveContractCustomerAction.ts +++ b/apps/edr-freight-web/portal/src/components/customer-actions/deriveContractCustomerAction.ts @@ -4,8 +4,10 @@ import { CreditCard, Eye, FileSignature, + Hourglass, PackagePlus, PencilLine, + Receipt, RotateCcw, Upload, } from "lucide-react"; @@ -61,6 +63,8 @@ export type ContractCustomerAction = primary: boolean; icon: LucideIcon; urgent: boolean; + /** True when it's GL's turn — render calm/informational, not a call to action. */ + waiting?: boolean; } | { type: "pay"; @@ -126,14 +130,56 @@ export function deriveContractCustomerAction( const clr = contractNeedsClearanceAction(contract); if (clr.show) { - return { - type: "clearance", - contractId: id, - label: clr.urgent ? "Upload clearance" : "Update clearance", - primary: true, - icon: Upload, - urgent: clr.urgent, - }; + // Refine the generic clearance action by the persisted clearance phase so + // the button says what the customer actually has to do right now (e.g. + // "Pay duty & upload slip" during CUSTOMER_DUTY, not "Update clearance"). + const phase = contract.clearancePhase ?? null; + switch (phase) { + case "CUSTOMER_INTAKE": + return { + type: "clearance", + contractId: id, + label: "Upload clearance documents", + primary: true, + icon: Upload, + urgent: true, + }; + case "CUSTOMER_DUTY": + return { + type: "clearance", + contractId: id, + label: "Pay duty & upload slip", + primary: true, + icon: Receipt, + urgent: true, + }; + case "GL_ET_REVIEW": + case "GL_DJ_COLLECTION": + case "GL_ET_OUTPUT": + case "GL_ET_POST_CLEARANCE": + case "GL_DJ_LOADING": + case "POST_TRANSIT": + // GL's turn — nothing for the customer to do; show a calm status. + return { + type: "clearance", + contractId: id, + label: "Clearance in progress", + primary: false, + icon: Hourglass, + urgent: false, + waiting: true, + }; + default: + // No persisted phase (legacy / early cycles) — keep the status-derived label. + return { + type: "clearance", + contractId: id, + label: clr.urgent ? "Upload clearance" : "Update clearance", + primary: true, + icon: Upload, + urgent: clr.urgent, + }; + } } if ( diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts index 833aa6ce7..de4b387b1 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts @@ -6,7 +6,7 @@ import { contractNeedsClearanceAction } from "@/components/customer-actions/deri export interface ActionItem { id: string; /** What the customer must do — drives the icon, label and modal. */ - kind: "clearance" | "sign" | "book" | "pay"; + kind: "clearance" | "duty" | "sign" | "book" | "pay"; /** The contract/booking reference for display. */ reference: string; /** Short human description of the action. */ diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx index a68a72280..64f320602 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx @@ -8,6 +8,7 @@ import { FilePlus2, FileSignature, PackagePlus, + Receipt, Upload, } from "lucide-react"; import type { Freight } from "@edr/types"; @@ -34,6 +35,7 @@ const KIND_META: Record< { icon: typeof Upload; label: string; color: string } > = { clearance: { icon: Upload, label: "Clearance", color: "edr-green" }, + duty: { icon: Receipt, label: "Duty / tax", color: "orange" }, sign: { icon: FileSignature, label: "Sign", color: "blue" }, book: { icon: PackagePlus, label: "Book", color: "violet" }, pay: { icon: CreditCard, label: "Payment", color: "orange" }, @@ -96,6 +98,19 @@ export function ActionNeededSection({ const awaiting = c.status === "AWAITING_CLEARANCE_DOCUMENTS" || view?.clearanceStatus === "AWAITING_DOCUMENTS"; + // Duty phase: the customer's task is paying duty/tax and uploading the + // slip — a distinct, money action, not a generic document upload. + if (view?.phase === "CUSTOMER_DUTY") { + out.push({ + id: `duty-${c.id}`, + kind: "duty", + reference: c.reference, + description: "Duty / tax payment due — pay and upload the slip", + targetId: c.id, + urgent: true, + }); + return; + } // Only surface when there's something the customer can do: a query, or the // contract is awaiting their (re)upload. if (queried === 0 && !awaiting) return; @@ -162,6 +177,10 @@ export function ActionNeededSection({ case "clearance": setClearanceId(item.targetId); break; + case "duty": + // Duty advice + payment-slip upload live on the contract detail page. + navigate(`/contracts/${item.targetId}`); + break; case "pay": setPayItem(item); break; @@ -249,6 +268,8 @@ export function ActionNeededSection({ leftSection={ item.kind === "clearance" ? ( + ) : item.kind === "duty" ? ( + ) : ( ) @@ -256,13 +277,15 @@ export function ActionNeededSection({ > {item.kind === "pay" ? "Pay now" - : item.kind === "sign" - ? "Sign" - : item.kind === "book" - ? "Book" - : item.urgent - ? "Upload documents" - : "Upload"} + : item.kind === "duty" + ? "Pay duty & upload slip" + : item.kind === "sign" + ? "Sign" + : item.kind === "book" + ? "Book" + : item.urgent + ? "Upload documents" + : "Upload"} ); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ClearancePhaseStepper.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ClearancePhaseStepper.tsx index f9429a3e6..eb534f224 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ClearancePhaseStepper.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ClearancePhaseStepper.tsx @@ -15,6 +15,18 @@ const PHASE_LABELS: Record = { POST_TRANSIT: "Transit", }; +/** One-line hint under each phase label, for the vertical layout. */ +const PHASE_HINTS: Record = { + CUSTOMER_INTAKE: "You upload the required clearance documents", + GL_ET_REVIEW: "Global Logistics reviews your documents in Ethiopia", + GL_DJ_COLLECTION: "Delivery order collected in Djibouti", + GL_ET_OUTPUT: "Customs declaration prepared", + CUSTOMER_DUTY: "You pay the assessed duty / tax", + GL_ET_POST_CLEARANCE: "Transit cleared and paperwork finalised", + GL_DJ_LOADING: "Cargo loaded for departure", + POST_TRANSIT: "In transit", +}; + const IMPORT_PHASES = [ "CUSTOMER_INTAKE", "GL_ET_REVIEW", @@ -51,62 +63,92 @@ export function ClearancePhaseStepper({ const current = clearance?.phase ?? phases[0]; const activeIdx = phaseIndex(phases, current); + const dot = compact ? 26 : 30; + const rowGap = compact ? 18 : 24; + + // Vertical timeline: every phase is a row, so all steps stay visible on any + // width without horizontal scrolling. The connector runs down between dots. return ( - + {phases.map((phase, index) => { const isComplete = index < activeIdx; const isActive = index === activeIdx; const isLast = index === phases.length - 1; + const doneOrActive = isComplete || isActive; return ( - - - - - {isComplete ? : null} - - - {PHASE_LABELS[phase] ?? phase} - - + + {/* Dot + connector column */} + + + {isComplete ? ( + + ) : ( + + {index + 1} + + )} + {!isLast && ( )} - - + + + {/* Label + hint */} + + + {PHASE_LABELS[phase] ?? phase} + + {PHASE_HINTS[phase] && ( + + {PHASE_HINTS[phase]} + + )} + + ); })} - + ); } diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceWorkflowBanner.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceWorkflowBanner.tsx index 736cc67e4..cc5318cf9 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceWorkflowBanner.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceWorkflowBanner.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { Alert, Box, Button, Group, Paper, Stack, Text } from "@mantine/core"; -import { AlertTriangle, Download, Receipt, Upload } from "lucide-react"; +import { AlertTriangle, ArrowRight, Download, PackageCheck, Receipt, Upload } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import toast from "react-hot-toast"; import type { Freight } from "@edr/types"; @@ -87,7 +87,36 @@ export function ContractClearanceWorkflowBanner({ onDownload={downloadWorkflowFile} /> - {clearance.bookingReady ? ( + {clearance.linkedBookingId ? ( + }> + + + Shipment booking created + {clearance.linkedBookingReference + ? ` · ${clearance.linkedBookingReference}` + : ""} + + + Global Logistics has created your shipment booking + {clearance.linkedBookingStatus + ? ` (${clearance.linkedBookingStatus.replace(/_/g, " ").toLowerCase()})` + : ""} + . Track its progress from the booking. + + + + + ) : clearance.bookingReady ? ( Clearance is complete. Global Logistics will create your shipment booking shortly. diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx index c86e17fc5..eec7e896b 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx @@ -15,7 +15,8 @@ import { AlertBox, AsyncComboboxField, fieldStyles } from "./shared"; const CONTRACT_TYPE_OPTIONS = [ { value: "new", label: "New Contract" }, - { value: "renewal", label: "Contract Renewal" }, + // Renewal is disabled for now — not yet available to customers. + { value: "renewal", label: "Contract Renewal (coming soon)", disabled: true }, ]; type ContractForm = UseFormReturn< diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index ef893e68f..e62b410ba 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -356,6 +356,9 @@ export interface ContractClearanceView { /** Export post-booking clearance finalized after transit permit upload. */ exportClearanceFinalized?: boolean; linkedBookingId?: string | null; + /** Reference + status of the GL-created shipment booking, once it exists. */ + linkedBookingReference?: string | null; + linkedBookingStatus?: string | null; dutyAdvice?: { amount: number; currency: string; @@ -577,6 +580,12 @@ export interface IContract extends BaseEntity { status: ContractStatus; clearanceStatus: ContractClearanceStatus; clearanceCycleNumber: number; + /** + * Latest clearance cycle's current phase (list responses only). Lets list + * consumers show step-accurate customer actions without fetching the full + * clearance view per contract. + */ + clearancePhase?: ContractDocPhase | string | null; pricingBreakdown?: ContractPricingBreakdown | null; pricingDisplayMode?: "UNIT_RATES";