Refactor wagon type handling and container wagon calculations

- Removed maxWagonsPerTrain from WagonType entity and related DTOs.
- Updated containerWagonsForLines function to calculate required wagons based on container lines more accurately.
- Added unit tests for containerWagonsForLines to ensure correct calculations.
- Adjusted related services and scripts to reflect the removal of maxWagonsPerTrain.
- Enhanced booking and contract components to use new status labels for better user experience.
- Implemented validation for unique container numbers in shipment forms.
This commit is contained in:
Marshal
2026-07-09 03:36:35 +00:00
parent addee34d5d
commit ddddcfb71f
39 changed files with 753 additions and 121 deletions

View File

@@ -536,7 +536,6 @@ export function WagonTypesCrudPage() {
name: '',
capacityTons: 0,
lengthMeters: 0,
maxWagonsPerTrain: '',
supportedLoadTypes: '',
isActive: true,
});
@@ -587,7 +586,6 @@ export function WagonTypesCrudPage() {
name: '',
capacityTons: 0,
lengthMeters: 0,
maxWagonsPerTrain: '',
supportedLoadTypes: '',
isActive: true,
});
@@ -601,7 +599,6 @@ export function WagonTypesCrudPage() {
name: type.name ?? '',
capacityTons: type.capacityTons ?? 0,
lengthMeters: type.lengthMeters ?? 0,
maxWagonsPerTrain: type.maxWagonsPerTrain ?? '',
supportedLoadTypes: type.supportedLoadTypes?.join(', ') ?? '',
isActive: type.isActive,
});
@@ -814,12 +811,6 @@ export function WagonTypesCrudPage() {
error={fieldErrors.lengthMeters}
onChange={(value) => setForm((current) => ({ ...current, lengthMeters: value }))}
/>
<NumberInput
label="Max wagons per train"
min={0}
value={form.maxWagonsPerTrain === '' ? '' : Number(form.maxWagonsPerTrain)}
onChange={(value) => setForm((current) => ({ ...current, maxWagonsPerTrain: value }))}
/>
<MantineSelect
label="Status"
value={form.isActive ? 'true' : 'false'}

View File

@@ -279,7 +279,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ id: "name", header: "Name", accessorKey: "name" },
{ id: "capacityTons", header: "Capacity (t)", accessorKey: "capacityTons", format: "number" },
{ id: "lengthMeters", header: "Length (m)", accessorKey: "lengthMeters", format: "number" },
{ id: "maxWagonsPerTrain", header: "Max / train", accessorKey: "maxWagonsPerTrain", format: "number" },
{
id: "supportedLoadTypes",
header: "Load types",
@@ -291,12 +290,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "name", label: "Name", type: "text", required: true },
{ name: "capacityTons", label: "Capacity (tons)", type: "number", required: true },
{ name: "lengthMeters", label: "Length (meters)", type: "number", required: true },
{
name: "maxWagonsPerTrain",
label: "Max wagons per train",
type: "number",
optional: true,
},
{
name: "supportedLoadTypes",
label: "Supported load types",

View File

@@ -8,7 +8,6 @@ export interface WagonType {
name: string;
capacityTons: number;
lengthMeters: number;
maxWagonsPerTrain?: number | null;
supportedLoadTypes: string[];
isActive: boolean;
}

View File

@@ -334,6 +334,19 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
TRUCK_ASSIGNED: {
stage: 3,
icon: Truck,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Truck assigned · preparing for pickup",
step: "edr-green.5",
badgeLabel: "Truck assigned",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
IN_TRANSIT: {
stage: 3,
icon: Truck,

View File

@@ -16,6 +16,7 @@ import type { Freight } from "@edr/types";
import { invoicesService, type PortalInvoice } from "@/services/invoices.service";
import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui";
import { paymentStatusLabel } from "@/pages/bookings/booking-display";
import { saveBlob } from "@/utils/download";
import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils";
@@ -262,7 +263,7 @@ export function BookingPaymentPanel({
? "Paid"
: showCountdown
? "Pay window open"
: (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")}
: paymentStatusLabel(booking.paymentStatus ?? "PENDING")}
</Group>
</Group>

View File

@@ -3,6 +3,8 @@ import type { ReactNode } from "react";
import type { Freight } from "@edr/types";
import { paymentStatusLabel } from "@/pages/bookings/booking-display";
import { fmtDate, yardLabel } from "../utils";
import { SectionCard } from "./layout";
@@ -38,12 +40,7 @@ function Fact({ label, value }: { label: string; value: ReactNode }) {
export function KeyFactsStrip({ booking }: { booking: BookingLike }) {
const isContract = booking.bookingType === "GENERAL_CONTRACT";
const freight = booking.freightType === "BULK" ? "Bulk" : "Container";
const payment = booking.paymentStatus
? booking.paymentStatus
.replace(/_/g, " ")
.toLowerCase()
.replace(/^\w/, (c) => c.toUpperCase())
: "—";
const payment = paymentStatusLabel(booking.paymentStatus);
return (
<SectionCard p="lg">

View File

@@ -13,6 +13,8 @@ import type { ReactNode } from "react";
import type { Freight } from "@edr/types";
import { bookingStatusLabel } from "@/pages/bookings/booking-display";
import { bookingSubtitle, isDraftLike, isNegative } from "../utils";
export interface PageHeaderMenuActions {
@@ -60,7 +62,7 @@ export function PageHeader({
className="shrink-0 rounded-full"
style={{ width: 7, height: 7, backgroundColor: dotColor }}
/>
{status.replace(/_/g, " ").replace(/\b\w/g, (m) => m.toUpperCase())}
{bookingStatusLabel(status)}
</span>
<span className="inline-flex items-center gap-[6px] rounded-full bg-[#F1F4F7] px-[11px] py-1.5 text-xs font-bold text-[#475569]">

View File

@@ -3,6 +3,8 @@ import type { ReactNode } from "react";
import type { Freight } from "@edr/types";
import { bookingStatusLabel } from "@/pages/bookings/booking-display";
import { fmtDate, isDraftLike, isNegative } from "../utils";
import { CardTitle, SectionCard } from "./layout";
@@ -15,10 +17,7 @@ function StatusPill({ status }: { status: string }) {
const color = negative ? "#A93226" : draft ? "#475569" : "#0A6F4D";
const bg = negative ? "#FBEAE7" : draft ? "#F1F4F7" : "#ECF6F1";
const border = negative ? "#F3C8C1" : draft ? "#E1E7EE" : "#CDEBDD";
const label = status
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (m) => m.toUpperCase());
const label = bookingStatusLabel(status);
return (
<Group

View File

@@ -1,12 +1,32 @@
import { Badge, Group, Text } from "@mantine/core";
import type { Freight } from "@edr/types";
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
/**
* Shared presentation helpers for booking-like rows (one-time bookings AND
* general contracts). Kept in one place so the bookings list, contracts list,
* and detail page render type/freight/mode/payment consistently.
*/
/** Title-case an unmapped enum as a readable fallback ("FOO_BAR" → "Foo Bar"). */
export function titleCaseStatus(status: string): string {
return status
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (m) => m.toUpperCase());
}
/**
* Human label for a booking status. Single source of truth is the booking
* list's `STATUS_CONFIG` badge labels; anything not mapped there falls back to
* a readable title-cased form (never the raw SCREAMING_SNAKE enum).
*/
export function bookingStatusLabel(status?: string | null): string {
if (!status) return "—";
return STATUS_CONFIG[status]?.badgeLabel ?? titleCaseStatus(status);
}
type BookingLike = Freight.IBooking & {
bookingType?: string;
freightType?: string;
@@ -59,7 +79,12 @@ const PAYMENT_COLORS: Record<string, string> = {
PAID: "green",
PENDING: "gray",
PNR_GENERATED: "blue",
// Backend emits the long form on some flows; keep the short alias too.
VERIFICATION_IN_PROGRESS: "yellow",
PAYMENT_VERIFICATION_IN_PROGRESS: "yellow",
OVERDUE: "red",
REFUNDED: "blue",
CANCELLED: "gray",
FAILED: "red",
};
@@ -68,9 +93,19 @@ const PAYMENT_LABELS: Record<string, string> = {
PENDING: "Pending",
PNR_GENERATED: "PNR generated",
VERIFICATION_IN_PROGRESS: "Verifying",
PAYMENT_VERIFICATION_IN_PROGRESS: "Verifying",
OVERDUE: "Overdue",
REFUNDED: "Refunded",
CANCELLED: "Cancelled",
FAILED: "Failed",
};
/** Human label for a payment status (plain text, no badge). */
export function paymentStatusLabel(status?: string | null): string {
if (!status) return "—";
return PAYMENT_LABELS[status] ?? titleCaseStatus(status);
}
/** Payment status pill. */
export function PaymentBadge({ status }: { status?: string | null }) {
if (!status) return <Text fz={13} c="dimmed"></Text>;
@@ -81,7 +116,7 @@ export function PaymentBadge({ status }: { status?: string | null }) {
color={PAYMENT_COLORS[status] ?? "gray"}
styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }}
>
{PAYMENT_LABELS[status] ?? status.replace(/_/g, " ")}
{PAYMENT_LABELS[status] ?? titleCaseStatus(status)}
</Badge>
);
}

View File

@@ -32,6 +32,28 @@ const TZ = "Africa/Addis_Ababa";
/** Cards visible per carousel page. */
const PER_PAGE = 3;
/** Customer-facing labels for a booking-window phase / status. */
const WINDOW_PHASE_LABELS: Record<string, string> = {
PRE_WINDOW: "Opens soon",
OPEN: "Open now",
DOC_REVIEW: "Document review",
PAYMENT: "Payment due",
DONE: "Closed",
CLOSED_FOR_DAY: "Closed for the day",
};
/** Friendly label for a window phase/status, never the raw enum. */
function windowPhaseLabel(phase?: string | null): string {
if (!phase) return "—";
return (
WINDOW_PHASE_LABELS[phase] ??
phase
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (m) => m.toUpperCase())
);
}
function fmtDay(iso: string): string {
return new Date(iso).toLocaleDateString("en-GB", {
weekday: "short",
@@ -60,7 +82,7 @@ function windowLabel(w: MyBookingWindow): string {
if (w.windowOpensAt) {
return `Opens ${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} EAT`;
}
return (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ");
return windowPhaseLabel(w.windowPhase ?? w.bookingWindowStatus);
}
/**
@@ -163,7 +185,7 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
>
{open
? "Open now"
: (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ")}
: windowPhaseLabel(w.windowPhase ?? w.bookingWindowStatus)}
</Badge>
</Group>

View File

@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { bookingStatusLabel } from "@/pages/bookings/booking-display";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { PortalFileDropzone } from "@/components/contracts/PortalFileDropzone";
import { contractsService } from "@/services/contracts.service";
@@ -99,7 +100,7 @@ export function ContractClearanceWorkflowBanner({
<Text fz={12} c="dimmed">
Global Logistics has created your shipment booking
{clearance.linkedBookingStatus
? ` (${clearance.linkedBookingStatus.replace(/_/g, " ").toLowerCase()})`
? ` (${bookingStatusLabel(clearance.linkedBookingStatus).toLowerCase()})`
: ""}
. Track its progress from the booking.
</Text>

View File

@@ -85,6 +85,37 @@ const CLEARANCE_UPLOAD_STATUSES = [
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
// Customer-facing labels for a shipment-request status (BOOKING_REQUEST_STATUSES).
const BOOKING_REQUEST_STATUS_LABELS: Record<string, string> = {
PENDING: "Pending review",
ACCEPTED: "Accepted",
REJECTED: "Rejected",
CANCELLED: "Cancelled",
};
// Customer-facing labels for an invoice status (Freight.InvoiceStatus).
const INVOICE_STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft",
ISSUED: "Issued",
PENDING: "Due",
PARTIALLY_PAID: "Partially paid",
PAID: "Paid",
OVERDUE: "Overdue",
CANCELLED: "Cancelled",
REFUNDED: "Refunded",
EXPIRED: "Expired",
};
function invoiceStatusLabel(status: string): string {
return (
INVOICE_STATUS_LABELS[status] ??
status
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (m) => m.toUpperCase())
);
}
// Business-license document codes — surfaced as their own section so they stand
// out from the rest of the onboarding/profile set.
const BUSINESS_LICENSE_DOC_CODES = new Set([
@@ -667,7 +698,8 @@ export default function ContractDetailPage() {
variant="filled"
radius="sm"
>
{clearanceView.riskLevel}
{clearanceView.riskLevel.charAt(0) +
clearanceView.riskLevel.slice(1).toLowerCase()}
</Badge>
{clearanceView.riskAssignedAt ? (
<Text fz={12} c="dimmed">
@@ -1226,12 +1258,15 @@ export default function ContractDetailPage() {
? "teal"
: req.status === "REJECTED"
? "red"
: "yellow"
: req.status === "CANCELLED"
? "gray"
: "yellow"
}
>
{req.status === "ACCEPTED" && req.createdBookingId
? "Booking created"
: req.status}
: BOOKING_REQUEST_STATUS_LABELS[req.status] ??
req.status}
</Badge>
{req.createdBookingId ? (
<ChevronRight size={16} color={MUTED} />
@@ -1722,7 +1757,7 @@ function FinalInvoiceDueCard({
{invoice.invoiceNumber}
</Text>
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
{invoice.status}
{invoiceStatusLabel(invoice.status)}
</Badge>
</Group>
<Text fz={20} fw={800} mt={6} c={INK}>

View File

@@ -1151,6 +1151,9 @@ function ContainerLineEditor({
render={({ field, fieldState }) => (
<TextInput
{...field}
onChange={(e) =>
field.onChange(e.currentTarget.value.toUpperCase())
}
label={u === 0 ? "Container number *" : undefined}
placeholder="e.g. MSCU1234567"
error={fieldState.error?.message}

View File

@@ -149,21 +149,35 @@ export const CONTRACT_STATUS_CONFIG: Record<
DOCUMENTS_UNDER_REVIEW: { label: "Documents Under Review", ...TONE.info },
CLEARANCE_READY: { label: "Clearance Ready", ...TONE.success },
OPERATION_REQUEST_PENDING: { label: "Operation Review", ...TONE.warning },
OPERATION_REQUESTED: { label: "Operation Requested", ...TONE.info },
OPERATION_CHANGES_REQUESTED: { label: "Changes Requested", ...TONE.warning },
OPERATION_PRICE_PENDING_CONFIRM: { label: "Confirm New Price", ...TONE.warning },
ROAD_DISPATCH_PENDING: { label: "Awaiting Dispatch", ...TONE.warning },
READY_FOR_ASSIGNMENT: { label: "Assigning Wagon", ...TONE.info },
WAGON_ASSIGNED: { label: "Wagon Assigned", ...TONE.success },
SELECTED_FOR_BATCH: { label: "Awaiting Payment", ...TONE.warning },
PNR_GENERATED: { label: "Payment Reference Ready", ...TONE.warning },
PAYMENT_VERIFICATION_IN_PROGRESS: { label: "Verifying Payment", ...TONE.warning },
INVOICED: { label: "Invoiced", ...TONE.info },
PAID: { label: "Paid", ...TONE.success },
PENDING_CONSOLIDATION: { label: "Consolidating", ...TONE.info },
CONSOLIDATED: { label: "Consolidated", ...TONE.success },
TRUCK_ASSIGNED: { label: "Truck Assigned", ...TONE.success },
IN_TRANSIT: { label: "In Transit", ...TONE.info },
ARRIVED: { label: "Arrived", ...TONE.success },
DELIVERED: { label: "Delivered", ...TONE.success },
COMPLETED: { label: "Completed", ...TONE.success },
};
export function ContractStatusBadge({ status }: { status: string }) {
const cfg =
CONTRACT_STATUS_CONFIG[status] ?? {
label: status,
// Readable title-case fallback so an unmapped status never leaks the raw
// SCREAMING_SNAKE enum to the customer.
label: status
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (m) => m.toUpperCase()),
color: MUTED,
bg: "#EEF2F6",
};

View File

@@ -26,8 +26,17 @@ export interface ShipmentValidationContext {
requiresDate?: boolean;
}
// ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit.
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
const containerUnitSchema = z.object({
containerNumber: z.string().min(1, "Container number is required."),
containerNumber: z
.string()
.min(1, "Container number is required.")
.refine(
(v) => ISO_CONTAINER_NUMBER_REGEX.test(v.trim().toUpperCase()),
"Enter a valid ISO container number (e.g. ABCD1234567).",
),
sealNumber: z.string().default(""),
vgmTons: z
.string()
@@ -68,6 +77,18 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
}
if (ctx.isContainer) {
// Container numbers must be unique within this shipment (front-end only —
// the DB column is intentionally not unique). Duplicates block submit and
// price generation since both run through this same schema validation.
const numberCounts = new Map<string, number>();
data.containers.forEach((line) => {
line.units.forEach((u) => {
const key = u.containerNumber.trim().toUpperCase();
if (!key) return;
numberCounts.set(key, (numberCounts.get(key) ?? 0) + 1);
});
});
data.containers.forEach((line, i) => {
const qty = Number(line.quantity || 0);
if (qty >= 1 && line.units.length < qty) {
@@ -77,6 +98,16 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
message: `Enter details for all ${qty} container(s).`,
});
}
line.units.forEach((u, j) => {
const key = u.containerNumber.trim().toUpperCase();
if (key && (numberCounts.get(key) ?? 0) > 1) {
refineCtx.addIssue({
code: "custom",
path: ["containers", i, "units", j, "containerNumber"],
message: "Duplicate container number in this shipment.",
});
}
});
if (ctx.isHazardous) {
const h = Number(line.hazardousQuantity || 0);
if (h > qty) {