mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
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:
@@ -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'}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8,7 +8,6 @@ export interface WagonType {
|
||||
name: string;
|
||||
capacityTons: number;
|
||||
lengthMeters: number;
|
||||
maxWagonsPerTrain?: number | null;
|
||||
supportedLoadTypes: string[];
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user