mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
changes
This commit is contained in:
@@ -16,7 +16,7 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{
|
||||
statuses: readonly string[] | null;
|
||||
}> = [
|
||||
{ key: 'all', statuses: null },
|
||||
{ key: 'intake', statuses: ['SUBMITTED'] },
|
||||
{ key: 'intake', statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'] },
|
||||
{
|
||||
key: 'in_approval',
|
||||
statuses: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'],
|
||||
@@ -28,7 +28,7 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{
|
||||
{ key: 'payment', statuses: ['FULLY_EXECUTED'] },
|
||||
{
|
||||
key: 'operations',
|
||||
statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED','PAID'],
|
||||
statuses: ['IN_TRANSIT', 'PAID'],
|
||||
},
|
||||
{ key: 'completed', statuses: ['COMPLETED'] },
|
||||
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||
@@ -184,6 +190,16 @@ export class BookingTransitionService {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['SUBMITTED']);
|
||||
|
||||
// Consolidation gate: a booking whose containers don't fill whole wagons
|
||||
// cannot be accepted until it is paired with a complementary booking.
|
||||
const gate = await this.bookingsService.resolveConsolidationGate(bookingId);
|
||||
if (gate.blocked) {
|
||||
throw new ConflictException(
|
||||
gate.message ??
|
||||
'Booking requires consolidation and cannot be accepted until a partner is found.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
|
||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId: booking.cargoTypeId,
|
||||
|
||||
@@ -93,6 +93,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
|
||||
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
|
||||
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
|
||||
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
|
||||
.where('booking.id = :id', { id })
|
||||
.leftJoinAndMapMany(
|
||||
'booking.files',
|
||||
@@ -177,7 +178,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.andWhere('b.allowConsolidation = true')
|
||||
.andWhere('b.consolidationPartnerId IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', {
|
||||
statuses: ['DRAFT', 'PENDING_CONSOLIDATION'],
|
||||
statuses: ['DRAFT', 'SUBMITTED', 'PENDING_CONSOLIDATION'],
|
||||
})
|
||||
.andWhere('b.originYardId = :originYardId', {
|
||||
originYardId: booking.originYardId,
|
||||
@@ -214,15 +215,27 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Pair two bookings for consolidation. */
|
||||
/**
|
||||
* Pair two bookings for consolidation. Both return to SUBMITTED so staff can
|
||||
* accept them into the approval chain; the link itself (consolidationPartnerId)
|
||||
* marks them as consolidated in the UI.
|
||||
*/
|
||||
async pairConsolidation(bookingId: string, partnerId: string): Promise<void> {
|
||||
await this.repository.update(bookingId, {
|
||||
consolidationPartnerId: partnerId,
|
||||
status: 'CONSOLIDATED',
|
||||
status: 'SUBMITTED',
|
||||
} as never);
|
||||
await this.repository.update(partnerId, {
|
||||
consolidationPartnerId: bookingId,
|
||||
status: 'CONSOLIDATED',
|
||||
status: 'SUBMITTED',
|
||||
} as never);
|
||||
}
|
||||
|
||||
/** Park a booking that needs consolidation but has no partner yet. */
|
||||
async parkForConsolidation(bookingId: string): Promise<void> {
|
||||
await this.repository.update(bookingId, {
|
||||
consolidationPartnerId: null,
|
||||
status: 'PENDING_CONSOLIDATION',
|
||||
} as never);
|
||||
}
|
||||
|
||||
@@ -429,6 +442,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
||||
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
|
||||
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
|
||||
.where('booking.deleted_at IS NULL');
|
||||
|
||||
this.applyListFilters(qb, options);
|
||||
|
||||
@@ -204,6 +204,48 @@ export class BookingsService {
|
||||
return { booking: pending, messages };
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidation gate used at staff-accept time. Returns the (possibly newly
|
||||
* paired) booking plus whether it still needs a consolidation partner.
|
||||
* When a booking needs consolidation and none is found, it is parked in
|
||||
* PENDING_CONSOLIDATION and `blocked` is true so the caller refuses the accept.
|
||||
*/
|
||||
async resolveConsolidationGate(bookingId: string): Promise<{
|
||||
booking: Booking;
|
||||
blocked: boolean;
|
||||
message?: string;
|
||||
}> {
|
||||
let booking = await this.findById(bookingId);
|
||||
|
||||
// Already paired — passes the gate.
|
||||
if (booking.consolidationPartnerId) {
|
||||
return { booking, blocked: false };
|
||||
}
|
||||
|
||||
const needs =
|
||||
await this.consolidationService.needsConsolidationFromBooking(booking);
|
||||
if (!needs) {
|
||||
return { booking, blocked: false };
|
||||
}
|
||||
|
||||
// A partner may have appeared since submission — try to pair now.
|
||||
const result = await this.tryAutoConsolidate(booking);
|
||||
booking = result.booking;
|
||||
if (booking.consolidationPartnerId) {
|
||||
return { booking, blocked: false, message: result.messages.join(' ') };
|
||||
}
|
||||
|
||||
// Still no partner — park it and block the accept.
|
||||
await this.bookingsRepository.parkForConsolidation(booking.id);
|
||||
booking = await this.findById(booking.id);
|
||||
const slots = await this.consolidationService.slotsFromBooking(booking);
|
||||
return {
|
||||
booking,
|
||||
blocked: true,
|
||||
message: this.consolidationService.describePending(booking, slots),
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a new freight booking. */
|
||||
async create(
|
||||
dto: CreateBookingDto,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Badge } from "@mantine/core";
|
||||
import { Badge, Group } from "@mantine/core";
|
||||
import { Link2 } from "lucide-react";
|
||||
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
|
||||
|
||||
const statusColorMap: Record<string, string> = {
|
||||
@@ -24,14 +25,26 @@ const statusColorMap: Record<string, string> = {
|
||||
CONSOLIDATED: "indigo",
|
||||
};
|
||||
|
||||
export function BookingStatusBadge({ status }: { status: string }) {
|
||||
interface BookingStatusBadgeProps {
|
||||
status: string;
|
||||
/** When the booking is part of a consolidation, show a sibling badge. */
|
||||
consolidated?: boolean;
|
||||
/** Partner booking reference for the consolidated badge tooltip. */
|
||||
partnerReference?: string | null;
|
||||
}
|
||||
|
||||
export function BookingStatusBadge({
|
||||
status,
|
||||
consolidated,
|
||||
partnerReference,
|
||||
}: BookingStatusBadgeProps) {
|
||||
const style = BOOKING_STATUS_STYLES[status] ?? {
|
||||
label: status,
|
||||
color: "gray",
|
||||
};
|
||||
const color = statusColorMap[status] ?? "gray";
|
||||
|
||||
return (
|
||||
const statusBadge = (
|
||||
<Badge
|
||||
color={color}
|
||||
variant="light"
|
||||
@@ -51,4 +64,34 @@ export function BookingStatusBadge({ status }: { status: string }) {
|
||||
{style.label}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
if (!consolidated) return statusBadge;
|
||||
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{statusBadge}
|
||||
<Badge
|
||||
color="indigo"
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
tt="uppercase"
|
||||
fw={600}
|
||||
leftSection={<Link2 size={12} />}
|
||||
title={
|
||||
partnerReference
|
||||
? `Consolidated with ${partnerReference}`
|
||||
: "Part of a consolidation"
|
||||
}
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
letterSpacing: "0.05em",
|
||||
display: "inline-flex",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
Consolidated
|
||||
</Badge>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,11 @@ export function BookingDetailHeader({
|
||||
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
|
||||
{booking.reference}
|
||||
</Title>
|
||||
<BookingStatusBadge status={booking.status} />
|
||||
<BookingStatusBadge
|
||||
status={booking.status}
|
||||
consolidated={Boolean(booking.consolidationPartnerId)}
|
||||
partnerReference={booking.consolidationPartner?.reference}
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Building2 size={14} color="var(--mantine-color-gray-5)" />
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Alert, List, Text } from "@mantine/core";
|
||||
import { Link2 } from "lucide-react";
|
||||
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
|
||||
/**
|
||||
* Shown on a booking parked in PENDING_CONSOLIDATION. Explains that the booking
|
||||
* cannot be approved until a complementary booking fills the wagon, and lists
|
||||
* the partial-wagon container lines that are waiting for a partner.
|
||||
*/
|
||||
export function ConsolidationWaitingBanner({ bookingId }: { bookingId: string }) {
|
||||
const { data } = useQuery({
|
||||
queryKey: [...QUERY_KEYS.BOOKINGS.byId(bookingId), "consolidation"],
|
||||
queryFn: () => bookingsService.getConsolidationDetails(bookingId),
|
||||
enabled: Boolean(bookingId),
|
||||
});
|
||||
|
||||
return (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
icon={<Link2 size={18} />}
|
||||
title="Waiting for a consolidation partner"
|
||||
>
|
||||
<Text size="sm">
|
||||
This booking cannot be approved until a matching booking fills the
|
||||
wagon. It will return to the approval queue automatically once a partner
|
||||
is found.
|
||||
</Text>
|
||||
{data?.wagonSlots?.length ? (
|
||||
<List size="sm" mt="xs" spacing={2}>
|
||||
{data.wagonSlots.map((slot) => (
|
||||
<List.Item key={slot.containerTypeCode}>
|
||||
{slot.slotsNeeded} more × {slot.containerTypeCode} (
|
||||
{slot.containersPerWagon} per wagon; you have {slot.quantity})
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
) : null}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -347,6 +347,10 @@ export function getBookingActions(
|
||||
case "CHANGES_REQUESTED":
|
||||
actions = [CANCEL_ACTION];
|
||||
break;
|
||||
case "PENDING_CONSOLIDATION":
|
||||
// View-only while waiting for a consolidation partner; cancel still allowed.
|
||||
actions = [CANCEL_ACTION];
|
||||
break;
|
||||
default:
|
||||
actions = [];
|
||||
}
|
||||
|
||||
@@ -212,21 +212,25 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
|
||||
},
|
||||
PENDING_CONSOLIDATION: {
|
||||
title: "Pending Consolidation",
|
||||
description: "Waiting for consolidation partner.",
|
||||
description: "Blocked — waiting for a consolidation partner before approval.",
|
||||
color: "text-amber-600",
|
||||
stage: 4,
|
||||
stage: 0,
|
||||
},
|
||||
CONSOLIDATED: {
|
||||
title: "Consolidated",
|
||||
description: "Paired with another booking.",
|
||||
color: "text-indigo-600",
|
||||
stage: 4,
|
||||
stage: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export const BOOKING_LIST_TABS = [
|
||||
{ key: "all", label: "All bookings", statuses: null as string[] | null },
|
||||
{ key: "intake", label: "Submitted", statuses: ["SUBMITTED"] },
|
||||
{
|
||||
key: "intake",
|
||||
label: "Submitted",
|
||||
statuses: ["SUBMITTED", "PENDING_CONSOLIDATION"],
|
||||
},
|
||||
{
|
||||
key: "in_approval",
|
||||
label: "In approval",
|
||||
@@ -256,7 +260,7 @@ export const BOOKING_LIST_TABS = [
|
||||
{
|
||||
key: "operations",
|
||||
label: "Operations",
|
||||
statuses: ["PAID", "IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"],
|
||||
statuses: ["PAID", "IN_TRANSIT"],
|
||||
},
|
||||
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
|
||||
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
|
||||
@@ -265,7 +269,15 @@ export const BOOKING_LIST_TABS = [
|
||||
export type BookingStatusTabKey = (typeof BOOKING_LIST_TABS)[number]["key"];
|
||||
|
||||
export const WORKFLOW_STAGES = [
|
||||
{ label: "Submission", statuses: ["DRAFT", "SUBMITTED", "CHANGES_REQUESTED"] },
|
||||
{
|
||||
label: "Submission",
|
||||
statuses: [
|
||||
"DRAFT",
|
||||
"SUBMITTED",
|
||||
"CHANGES_REQUESTED",
|
||||
"PENDING_CONSOLIDATION",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Approval",
|
||||
statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE"],
|
||||
@@ -286,7 +298,7 @@ export const WORKFLOW_STAGES = [
|
||||
},
|
||||
{
|
||||
label: "Operations",
|
||||
statuses: ["PAID", "IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"],
|
||||
statuses: ["PAID", "IN_TRANSIT"],
|
||||
},
|
||||
{ label: "Done", statuses: ["COMPLETED"] },
|
||||
] as const;
|
||||
|
||||
@@ -42,6 +42,8 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
|
||||
trainScheduleId: booking.trainScheduleId ?? null,
|
||||
isGovernment: booking.isGovernment ?? false,
|
||||
governmentInstitution: booking.governmentInstitution ?? null,
|
||||
consolidationPartnerId: booking.consolidationPartnerId ?? null,
|
||||
consolidationPartnerReference: booking.consolidationPartner?.reference ?? null,
|
||||
createdAt: booking.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
|
||||
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
|
||||
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
|
||||
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
|
||||
import { ConsolidationWaitingBanner } from "@/components/bookings/detail/ConsolidationWaitingBanner";
|
||||
import {
|
||||
detailStyles,
|
||||
BookingRequestHero,
|
||||
@@ -128,6 +129,10 @@ export default function BookingRequestDetailPage() {
|
||||
titleColor={statusMeta.color}
|
||||
/>
|
||||
|
||||
{booking.status === "PENDING_CONSOLIDATION" && (
|
||||
<ConsolidationWaitingBanner bookingId={booking.id} />
|
||||
)}
|
||||
|
||||
<Grid gutter="lg">
|
||||
{/* LEFT — primary content */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
|
||||
@@ -227,7 +227,11 @@ export default function BookingRequestsPage() {
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="py-1">
|
||||
<BookingStatusBadge status={row.original.status} />
|
||||
<BookingStatusBadge
|
||||
status={row.original.status}
|
||||
consolidated={Boolean(row.original.consolidationPartnerId)}
|
||||
partnerReference={row.original.consolidationPartnerReference}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
|
||||
@@ -87,6 +87,21 @@ export interface ContractView {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ConsolidationWagonSlot {
|
||||
containerTypeCode: string;
|
||||
quantity: number;
|
||||
containersPerWagon: number;
|
||||
remainder: number;
|
||||
slotsNeeded: number;
|
||||
}
|
||||
|
||||
export interface ConsolidationDetails {
|
||||
statusMessage: string;
|
||||
wagonSlots: ConsolidationWagonSlot[];
|
||||
partner: { id: string; reference: string } | null;
|
||||
splitBilling: { bookingShare: number; partnerShare: number } | null;
|
||||
}
|
||||
|
||||
export interface SignContractPayload {
|
||||
role: "CUSTOMER" | "STAFF";
|
||||
signatureImageBase64: string;
|
||||
@@ -198,6 +213,13 @@ export const bookingsService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getConsolidationDetails: async (
|
||||
id: string,
|
||||
): Promise<ConsolidationDetails> => {
|
||||
const response = await client.get<ConsolidationDetails>(B.CONSOLIDATION(id));
|
||||
return unwrap(response.data) as ConsolidationDetails;
|
||||
},
|
||||
|
||||
customerSign: (id: string, payload: SignContractPayload) =>
|
||||
postBooking<BookingDetail>(B.CUSTOMER_SIGN(id), {
|
||||
...payload,
|
||||
|
||||
@@ -101,6 +101,8 @@ export interface BookingDetail {
|
||||
cargoTotalWeightVgm: number;
|
||||
isHazardous: boolean;
|
||||
allowConsolidation: boolean;
|
||||
consolidationPartnerId?: string | null;
|
||||
consolidationPartner?: BookingNamedRef & { reference?: string } | null;
|
||||
priorityScore: number;
|
||||
schedulingStatus?: string;
|
||||
holdExpiresAt?: string | null;
|
||||
@@ -157,5 +159,7 @@ export interface BookingListRow {
|
||||
trainScheduleId?: string | null;
|
||||
isGovernment?: boolean;
|
||||
governmentInstitution?: string | null;
|
||||
consolidationPartnerId?: string | null;
|
||||
consolidationPartnerReference?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user