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 3c9c7db14..533b51b3b 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -204,17 +204,27 @@ export class ContractsRepository extends BaseRepository { 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])); + const rows: Array<{ + contract_id: string; + current_phase: string | null; + booking_id: string | null; + booking_status: string | null; + }> = await this.dataSource.query( + `SELECT DISTINCT ON (ccc.contract_id) + ccc.contract_id, ccc.current_phase, + b.id AS booking_id, b.status AS booking_status + FROM freight.contract_clearance_cycles ccc + LEFT JOIN freight.bookings b ON b.id = ccc.booking_id + WHERE ccc.contract_id = ANY($1) + ORDER BY ccc.contract_id, ccc.cycle_number DESC`, + [ids], + ); + const byContract = new Map(rows.map((r) => [r.contract_id, r])); for (const contract of contracts) { - contract.clearancePhase = byContract.get(contract.id) ?? null; + const row = byContract.get(contract.id); + contract.clearancePhase = row?.current_phase ?? null; + contract.latestCycleBookingId = row?.booking_id ?? null; + contract.latestCycleBookingStatus = row?.booking_status ?? null; } } 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 a526d632f..b5e0b8fb1 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 @@ -312,6 +312,14 @@ export class Contract extends BaseEntity { */ clearancePhase?: string | null; + /** + * Latest clearance cycle's linked booking (id + status), attached alongside + * clearancePhase. Lets the GL queue tell an expired (unpaid) booking apart + * from a live one so it can offer a rebook. Not columns. + */ + latestCycleBookingId?: string | null; + latestCycleBookingStatus?: string | null; + /** * Body of the most recent CHANGES_REQUESTED review note, attached by * ContractsService.findById so the portal can show the customer what staff diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index 842276815..dc52533da 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -13,3 +13,6 @@ export function fileViewUrl(fileId: string, download = false): string { const base = `${API_BASE_URL}/api/files/${fileId}`; return download ? `${base}?download=1` : base; } + + + diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx index 619ce59ad..6c8565d03 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx @@ -5,6 +5,7 @@ import { Alert, Badge, Box, + Button, Grid, Group, Loader, @@ -21,10 +22,18 @@ import { CheckCircle2, Clock, PackageCheck, + RefreshCw, ShieldCheck, } from "lucide-react"; import { Link } from "react-router-dom"; +import { useAuth } from "@/auth/useAuth"; +import { + FREIGHT_PERMS, + hasPermission, + isDjiboutiGl, +} from "@/lib/permissions"; + import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs"; import { PageContainer } from "@/components/page/PageContainer"; import { PageHeader } from "@/components/page/PageHeader"; @@ -44,6 +53,7 @@ import { export default function ContractClearanceDetailPage() { const { id } = useParams<{ id: string }>(); const { view, viewer } = useFileViewer(); + const { user } = useAuth(); const { data: contract, refetch: refetchContract } = useContractDetail(id); const { @@ -106,6 +116,16 @@ export default function ContractClearanceDetailPage() { const reviewReadOnly = shipmentLocked; const queriesLocked = Boolean(clearance?.preClearanceFinalized); const bookingHref = `/dashboard/contracts/${id}/create-booking`; + // The GL-created booking expired unpaid — the slot is free again and GL + // rebooks on the customer's behalf (customs bookings are never self-booked). + const bookingExpired = clearance?.linkedBookingStatus === "EXPIRED"; + const canRebook = + bookingExpired && + hasPermission(user, FREIGHT_PERMS.contracts.createBooking) && + !isDjiboutiGl(user); + const rebookHref = linkedBookingId + ? `${bookingHref}?copyFrom=${linkedBookingId}` + : bookingHref; const { data: bookingMilestones, refetch: refetchBookingMilestones } = useBookingMilestones(linkedBookingId); @@ -165,7 +185,16 @@ export default function ContractClearanceDetailPage() { { label: reference }, ]} meta={ - bookingAlreadyCreated ? ( + bookingExpired ? ( + } + > + Payment expired — rebook + + ) : bookingAlreadyCreated ? ( : null} - {bookingAlreadyCreated ? ( + {bookingExpired ? ( + } + title="Booking expired — payment not received" + > + + + The customer did not pay before the deadline, so the booking + expired and its train slot was released. The contract slot is + free again — GL Ethiopia can rebook on the customer's + behalf without re-running clearance. + {linkedBookingId ? ( + <> + {" "} + + View expired booking → + + + ) : null} + + {canRebook ? ( + + ) : null} + + + ) : bookingAlreadyCreated ? ( + } + > + Payment expired + + + ); + } if (row.bookingCreated) { return ( @@ -519,6 +548,27 @@ export default function ContractClearanceListPage() { Create booking + ) : row.original.paymentExpired && canCreateBooking ? ( + + + ) : ( diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 28d503faf..9aa36374a 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -620,6 +620,12 @@ export interface IContract extends BaseEntity { * clearance view per contract. */ clearancePhase?: ContractDocPhase | string | null; + /** + * Latest clearance cycle's linked booking id + status (list responses only). + * An EXPIRED status means the customer never paid — GL may rebook. + */ + latestCycleBookingId?: string | null; + latestCycleBookingStatus?: string | null; pricingBreakdown?: ContractPricingBreakdown | null; pricingDisplayMode?: "UNIT_RATES";