Merge pull request #923 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-23 00:02:55 +03:00
committed by GitHub
39 changed files with 3644 additions and 362 deletions

View File

@@ -548,11 +548,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <Boxes />,
children: [
...getCategorySidebarChildren("configuration"),
{
label: "Contract validity",
href: "/dashboard/configuration/contract-validity-periods",
permission: FREIGHT_PERMS.config.contractValidity.view,
},
{
label: "Train scheduling rules",
href: "/dashboard/configuration/train-scheduling-rules",
@@ -1465,14 +1460,14 @@ const App = () => {
</RequirePermission>
}
/>
<Route
{/* <Route
path="configuration/contract-validity-periods"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<ContractValidityPeriodsPage />
</RequirePermission>
}
/>
/> */}
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
<Route
path="configuration/cargo-types/:id"

View File

@@ -16,6 +16,8 @@ import type { Freight } from "@edr/types";
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import type { useContractMutations } from "@/hooks/contracts/useContracts";
import { useAuth } from "@/auth/useAuth";
import { canApproveContractStep } from "@/lib/permissions";
type Mutations = ReturnType<typeof useContractMutations>;
@@ -29,6 +31,7 @@ export function ContractApprovalStepsCard({
contract,
mutations,
}: ContractApprovalStepsCardProps) {
const { user } = useAuth();
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingStep, setPendingStep] =
useState<Freight.IContractApprovalStep | null>(null);
@@ -166,6 +169,10 @@ export function ContractApprovalStepsCard({
key={step.id}
step={step}
isNext={actionable && nextPending?.id === step.id}
// Buttons show only to the step's actual approver (matching
// position type): a chief step never offers Approve/Reject to a
// marketing officer. Everyone still sees the "next" highlight.
canAct={canApproveContractStep(user, step.requiredRole)}
isPending={
mutations.approveStep.isPending ||
mutations.rejectStep.isPending
@@ -306,12 +313,14 @@ export function ContractApprovalStepsCard({
function StepRow({
step,
isNext,
canAct,
isPending,
onApprove,
onReject,
}: {
step: Freight.IContractApprovalStep;
isNext: boolean;
canAct: boolean;
isPending: boolean;
onApprove: () => void;
onReject: () => void;
@@ -372,8 +381,11 @@ function StepRow({
)}
</Box>
</Group>
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
{isNext && step.status === "PENDING" && (
{/* One element type per row: action buttons on the active step (they
already imply "pending & actionable"), a status badge otherwise.
Mixing compact buttons + a badge here made them read as misaligned. */}
<Group gap="xs" wrap="nowrap" align="center" style={{ flexShrink: 0 }}>
{isNext && canAct && step.status === "PENDING" ? (
<>
<Button
size="compact-sm"
@@ -395,16 +407,17 @@ function StepRow({
Reject
</Button>
</>
) : (
<Badge
variant="light"
color={statusColor}
size="sm"
radius="sm"
tt="uppercase"
>
{step.status}
</Badge>
)}
<Badge
variant="light"
color={statusColor}
size="sm"
radius="sm"
tt="uppercase"
>
{step.status}
</Badge>
</Group>
</Group>
);

View File

@@ -269,6 +269,8 @@ export default function GlCreateBookingForm() {
const [scheduledDate, setScheduledDate] = useState("");
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
const [notes, setNotes] = useState("");
// What the containers carry — captured per booking (moved off the contract).
const [cargoDescription, setCargoDescription] = useState("");
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
const [bulk, setBulk] = useState<BulkDraft>({
cargoWeightTons: "",
@@ -459,6 +461,10 @@ export default function GlCreateBookingForm() {
);
};
setPrefilled(true);
// Rebook carries the expired booking's cargo description forward.
if (copyFromBooking.cargoFreeText) {
setCargoDescription(copyFromBooking.cargoFreeText);
}
setContainerLines(
lines.map((c) => {
const qty = Math.max(1, c.quantity);
@@ -705,14 +711,23 @@ export default function GlCreateBookingForm() {
const lineErrors = useMemo<LineErrors[]>(() => {
if (!isContainer || !contract) return [];
return containerLines.map((line) => {
// A line can be 0 (the contract covers both sizes; a booking may only need
// one) but the booking as a whole needs at least one container — anchor
// that error on the first line's quantity so it renders in the field.
const totalQty = containerLines.reduce(
(sum, l) => sum + Math.max(0, Number(l.quantity) || 0),
0,
);
return containerLines.map((line, idx) => {
const errs: LineErrors = {};
const qty = Number(line.quantity || 0);
if (line.quantity.trim() === "") {
errs.quantity = "Quantity is required.";
} else if (Number.isNaN(qty) || qty < 1) {
errs.quantity = "At least 1.";
} else if (line.units.length < qty) {
} else if (Number.isNaN(qty) || qty < 0) {
errs.quantity = "Enter 0 or more.";
} else if (idx === 0 && totalQty < 1) {
errs.quantity = "Book at least one container (either size).";
} else if (qty >= 1 && line.units.length < qty) {
errs.units = `Enter details for all ${qty} container(s).`;
}
if (contract.isHazardous) {
@@ -789,6 +804,11 @@ export default function GlCreateBookingForm() {
const routeError =
multiRoute && !contractRouteId ? "Select a route." : undefined;
const cargoDescriptionError =
isContainer && !cargoDescription.trim()
? "Describe the cargo carried in the containers."
: undefined;
const cargoValid = isContainer
? lineErrors.every(
(e) =>
@@ -800,7 +820,8 @@ export default function GlCreateBookingForm() {
) &&
unitErrors.every((line) =>
line.every((e) => !e.containerNumber && !e.vgmTons),
)
) &&
!cargoDescriptionError
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError;
@@ -827,6 +848,8 @@ export default function GlCreateBookingForm() {
};
if (isContainer) {
// What the containers carry — captured per booking, not on the contract.
if (cargoDescription.trim()) payload.cargoFreeText = cargoDescription.trim();
payload.containers = containerLines
.filter((l) => Number(l.quantity) >= 1)
.map((l) => ({
@@ -1245,6 +1268,19 @@ export default function GlCreateBookingForm() {
)}
{remainderNotice}
<ContractCapacityNotice contractId={contract.id} isContainer />
<Textarea
label="Cargo description *"
description="What do the containers carry on this shipment?"
placeholder="e.g. Electronics, garments, machinery spare parts…"
value={cargoDescription}
onChange={(e) => setCargoDescription(e.currentTarget.value)}
error={showErrors ? cargoDescriptionError : undefined}
radius={10}
autosize
minRows={2}
maxRows={4}
styles={fieldStyles}
/>
{containerLines.length === 0 ? (
<Text fz="sm" c="dimmed">
This contract has no container sizes in scope.
@@ -1264,7 +1300,7 @@ export default function GlCreateBookingForm() {
type="number"
onKeyDown={blockNegative}
label="Quantity *"
min={1}
min={0}
value={line.quantity}
error={
showErrors

View File

@@ -287,6 +287,8 @@ export type SegmentStripBooking = {
destinationYardId?: string | null;
tradeDirection?: string | null;
wagonsRequired?: number | null;
/** GROSS tons (cargo + tare of the booking's wagons), as the API sends it. */
weightTons?: number | null;
};
/**
@@ -300,10 +302,13 @@ export function SegmentOccupancyStrip({
stops,
bookings,
maxWagons,
maxGrossTons,
}: {
stops: Array<{ yardId: string; label: string }>;
bookings: SegmentStripBooking[];
maxWagons?: number | null;
/** Loco pull ceiling incl. tolerance — per-leg gross is measured against it. */
maxGrossTons?: number | null;
}) {
if (stops.length < 2) return null;
const lastIdx = stops.length - 1;
@@ -312,6 +317,7 @@ export function SegmentOccupancyStrip({
const segments = stops.slice(0, -1).map((stop, edge) => {
let cargo = 0;
let intercity = 0;
let grossTons = 0;
for (const b of bookings) {
const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
const to =
@@ -322,8 +328,15 @@ export function SegmentOccupancyStrip({
const wagons = Number(b.wagonsRequired) || 1;
if (b.tradeDirection === "DOMESTIC") intercity += wagons;
else cargo += wagons;
grossTons += Number(b.weightTons) || 0;
}
return { from: stop, to: stops[edge + 1], cargo, intercity };
return {
from: stop,
to: stops[edge + 1],
cargo,
intercity,
grossTons: Math.round(grossTons * 10) / 10,
};
});
const cap = Number(maxWagons) || null;
@@ -393,6 +406,22 @@ export function SegmentOccupancyStrip({
</Text>
) : null}
</Text>
{seg.grossTons > 0 ? (
<Text
size="xs"
ta="center"
fw={600}
c={
maxGrossTons != null && seg.grossTons > maxGrossTons
? "red.7"
: "dimmed"
}
style={{ whiteSpace: "nowrap" }}
>
{seg.grossTons}
{maxGrossTons != null ? ` / ${maxGrossTons}` : ""} T gross
</Text>
) : null}
</Stack>
{i === segments.length - 1 ? (
<Stack gap={2} align="center" justify="flex-end" style={{ minWidth: 0 }}>

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import type { AuthUser } from "@/auth/types";
import { canApproveContractStep } from "./permissions";
const withPositionType = (typeKey: string): AuthUser => ({
employee: [{ positions: [{ positionType: { key: typeKey } }] }],
});
const withRole = (roleKey: string): AuthUser => ({ roles: [{ key: roleKey }] });
const withPermission = (permKey: string): AuthUser => ({
permissionKeys: [permKey],
});
describe("canApproveContractStep", () => {
it("shows to the matching position type only", () => {
const chief = withPositionType("-marketing-chief");
expect(canApproveContractStep(chief, "-marketing-chief")).toBe(true);
// a marketing officer must NOT see the chief step's buttons
expect(canApproveContractStep(chief, "-marketing-director-")).toBe(false);
});
it("lets super/org admins action any step", () => {
expect(canApproveContractStep(withRole("super_admin"), "anything")).toBe(
true,
);
expect(
canApproveContractStep(withRole("organization_admin"), "-marketing-chief"),
).toBe(true);
});
it("resolves legacy chain roles via their position-type aliases", () => {
const director = withPositionType("operation-director");
expect(canApproveContractStep(director, "DIRECTOR")).toBe(true);
expect(canApproveContractStep(director, "CEO")).toBe(false);
});
it("honours the role's own legacy approve permission", () => {
const staff = withPermission(
"edr_freight_app:contracts:approve_director",
);
expect(canApproveContractStep(staff, "DIRECTOR")).toBe(true);
});
it("does NOT show to holders of an unrelated approve permission", () => {
// the dropped blanket fallback: a line-staff approver is not a chief
const lineStaff = withPermission(
"edr_freight_app:contracts:approve_line_staff",
);
expect(canApproveContractStep(lineStaff, "-marketing-chief")).toBe(false);
});
it("returns false without a user or role", () => {
expect(canApproveContractStep(null, "-marketing-chief")).toBe(false);
expect(canApproveContractStep(withPositionType("x"), null)).toBe(false);
});
});

View File

@@ -240,12 +240,6 @@ export const FREIGHT_PERMS = {
cancel: "edr_freight_app:warehouse_fee_invoices:cancel",
pay: "edr_freight_app:warehouse_fee_invoices:pay",
},
config: {
contractValidity: {
view: "edr_freight_app:config:contract_validity:view",
manage: "edr_freight_app:config:contract_validity:manage",
},
},
settings: {
fileUpload: {
view: "edr_freight_app:settings:file_upload:view",
@@ -420,6 +414,53 @@ export function hasPermission(
return getPermissionKeys(user).includes(key);
}
// Legacy chain roles predate position types; map each to the position types
// that stand in for it. Mirror of the API's LEGACY_ROLE_POSITION_TYPES so the
// button visibility matches what the approve/reject endpoint will accept.
const LEGACY_ROLE_POSITION_TYPES: Record<string, string[]> = {
LINE_STAFF: ["employee", "teamLeader", "officeHead", "recordOfficer"],
DIRECTOR: ["director", "operation-director"],
CEO: ["chief", "deputy"],
};
const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
CEO: FREIGHT_PERMS.contracts.approveCeo,
};
/**
* Can this user action a contract approval step requiring `requiredRole`?
*
* `requiredRole` is an `iam.position_types.key` (the role vocabulary approval
* chains are configured in), or a legacy LINE_STAFF/DIRECTOR/CEO string. Used
* to show Approve/Reject only to the step's actual approver — a chief step
* shows only to a chief, a marketing-officer step only to that officer.
*
* Deliberately STRICTER than the API's `assertCanApproveContractStep`, which
* also lets through anyone holding any contract-approve permission (a fallback
* for delegates whose token omits the position type). That blanket is what made
* every approver see the button, so it is dropped here: the visibility rule is
* admin OR the matching position type (direct / legacy alias) OR the role's own
* legacy approve permission. The server still guards the mutation.
*/
export function canApproveContractStep(
user: AuthUser | null | undefined,
requiredRole: string | null | undefined,
): boolean {
if (!user || !requiredRole) return false;
if (isFreightApprovalAdmin(user)) return true;
const positionTypes = getPositionTypeKeys(user);
if (positionTypes.includes(requiredRole)) return true;
const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? [];
if (aliases.some((alias) => positionTypes.includes(alias))) return true;
const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole];
return Boolean(legacyPermission && hasPermission(user, legacyPermission));
}
export function canAccessBookings(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.bookings.view);
}

View File

@@ -935,6 +935,7 @@ export default function TrainScheduleV2DetailPage() {
stops={schedule.stops ?? []}
bookings={schedule.bookings ?? []}
maxWagons={schedule.maxWagons}
maxGrossTons={schedule.maxGrossWeightTons}
/>
) : (
<Box maw={340}>

View File

@@ -169,6 +169,8 @@ export interface BookingDetail {
contractType: string;
freightType: "CONTAINER" | "BULK";
tradeDirection: string;
/** What the containers carry / bulk commodity label — entered at booking time. */
cargoFreeText?: string | null;
cargoTotalWeightVgm: number;
isHazardous: boolean;
consolidationPartnerId?: string | null;

View File

@@ -640,6 +640,8 @@ export interface TrainScheduleDetail {
}>;
/** Ordered corridor stops (route milestones) — for per-segment occupancy. */
stops?: Array<{ yardId: string; label: string }>;
/** Loco pull ceiling incl. overage tolerance — per-leg gross is held to it. */
maxGrossWeightTons?: number | null;
warnings?: string[];
}

View File

@@ -38,6 +38,7 @@ import {
} from "react-router-dom";
import useAuth from "@/hooks/useAuth";
import {
CONTAINER_SIZES,
CONTRACT_STEPS,
ContractFormInputValues,
contractFormSchema,
@@ -537,15 +538,15 @@ export default function NewContractPage({
const isContainer = data.cargoType === "container";
const isGeneral = data.contractKind === "general_contract";
// Cargo scope rows — no quantities (doc §5.4). Container: one row per enabled
// size; bulk: a single commodity row. Both GENERAL and ONE_TIME are uncapped
// (quantityCap omitted → NULL): the customer books repeatedly against a
// GENERAL contract until its validity expires.
// Cargo scope rows — no quantities (doc §5.4). Container: ALWAYS both sizes
// (rates quoted for both; per-booking quantities can zero a size out) and no
// description — that moved to booking time. Bulk: a single commodity row.
// Both GENERAL and ONE_TIME are uncapped (quantityCap omitted → NULL): the
// customer books repeatedly against a GENERAL contract until its validity
// expires.
const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer
? data.enabledContainerSizes.map((size) => ({
? CONTAINER_SIZES.map((size) => ({
containerSize: size,
// Required cargo description — what the containers carry.
cargoFreeText: data.cargoFreeText.trim() || undefined,
}))
: [
{

View File

@@ -346,6 +346,10 @@ function NewShipmentBookingForm({
// Equipment return is a container concern — bulk keeps the contract default.
...(isContainer
? {
// What the containers carry — captured per booking, not on the contract.
...(values.cargoDescription?.trim()
? { cargoFreeText: values.cargoDescription.trim() }
: {}),
containers: values.containers
.filter((l) => Number(l.quantity) >= 1)
.map((l) => ({
@@ -1295,6 +1299,26 @@ function CargoStep({
)}
{remainderNotice}
<ContractCapacityNotice contractId={contract.id} isContainer />
<Controller
name="cargoDescription"
control={form.control}
render={({ field, fieldState }) => (
<Textarea
label="Cargo description *"
description="What do the containers carry on this shipment?"
placeholder="e.g. Electronics, garments, machinery spare parts…"
value={field.value ?? ""}
onChange={(e) => field.onChange(e.currentTarget.value)}
onBlur={field.onBlur}
error={fieldState.error?.message}
radius={10}
autosize
minRows={2}
maxRows={4}
styles={fieldStyles}
/>
)}
/>
{lines.map((line, index) => (
<ContainerLineEditor
key={line.containerSize}
@@ -1648,7 +1672,7 @@ function ContainerLineEditor({
type="number"
onKeyDown={blockNegative}
label="Quantity *"
min={1}
min={0}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}

View File

@@ -58,12 +58,9 @@ export function contractToFormValues(
const scope = contract.cargoScope ?? [];
// Container scope: one row per enabled size, with per-size caps for GENERAL.
const enabledContainerSizes = isContainer
? scope
.map((s) => s.containerSize)
.filter((s): s is string => Boolean(s))
: [];
// Container scope: contracts now always cover both sizes — force both even
// for older single-size drafts so resubmitting upgrades them.
const enabledContainerSizes = isContainer ? ["20ft", "40ft"] : [];
const containerSizeCaps: Record<string, number> = {};
if (isContainer && isGeneral) {
for (const s of scope) {
@@ -119,10 +116,9 @@ export function contractToFormValues(
enabledContainerSizes as ContractFormInputValues["enabledContainerSizes"],
containerSizeCaps,
cargoTypePath,
// Bulk: the commodity free-text; container: the required cargo
// description (stored on every size row — read the first).
cargoFreeText:
(isContainer ? scope[0]?.cargoFreeText : bulkRow?.cargoFreeText) ?? "",
// Bulk commodity free-text only — the container cargo description is
// captured per booking now, not on the contract.
cargoFreeText: (isContainer ? "" : bulkRow?.cargoFreeText) ?? "",
bulkQuantityCap:
isGeneral && bulkRow?.quantityCap != null ? bulkRow.quantityCap : 0,
isHazardous: contract.isHazardous,

View File

@@ -160,8 +160,9 @@ export const contractFormSchema = z
// ── Cargo SCOPE (no quantities) ──
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
// Container scope: the enabled sizes (min 1). Each becomes a
// contract_cargo_scope row.
// Container scope: ALWAYS both sizes — the contract covers 20ft and 40ft
// (both rates shown); the customer picks quantities per booking, where a
// size can be 0. No picker in the UI; kept for review display/prefill.
enabledContainerSizes: z.array(z.enum(CONTAINER_SIZES)).default([]),
// GENERAL only: per-size container quantity cap (total bookable over the
// validity window). Keyed by size; must be > 0 for every enabled size
@@ -222,24 +223,8 @@ export const contractFormSchema = z
message: "Intercity contracts are priced in ETB.",
});
}
if (data.cargoType === "container") {
// Container scope: at least one enabled size.
if (data.enabledContainerSizes.length === 0) {
ctx.addIssue({
code: "custom",
path: ["enabledContainerSizes"],
message: "Enable at least one container size.",
});
}
// Containerized cargo must say WHAT is inside — required description.
if (!data.cargoFreeText.trim()) {
ctx.addIssue({
code: "custom",
path: ["cargoFreeText"],
message: "Describe the cargo carried in the containers.",
});
}
}
// Container scope needs no validation: both sizes are always in scope and
// the cargo description moved to booking time.
if (data.cargoType === "bulk") {
// Bulk scope: a commodity is required.
if (!data.cargoTypePath[0]) {
@@ -280,7 +265,7 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
customsClearingAgent: "",
cargoType: "container",
enabledContainerSizes: [],
enabledContainerSizes: [...CONTAINER_SIZES],
containerSizeCaps: {},
cargoTypePath: [],
cargoFreeText: "",

View File

@@ -152,10 +152,11 @@ export function Step1ContractType({
contract.freightType === "BULK" ? "bulk" : "container",
);
const scope = contract.cargoScope ?? [];
const sizes = scope
.map((s) => s.containerSize)
.filter((s): s is "20ft" | "40ft" => s === "20ft" || s === "40ft");
if (sizes.length > 0) form.setValue("enabledContainerSizes", sizes);
// Contracts always cover both sizes now — even when renewing an older
// single-size contract.
if (contract.freightType !== "BULK") {
form.setValue("enabledContainerSizes", ["20ft", "40ft"]);
}
const bulkScope = scope.find((s) => s.cargoTypeId);
if (bulkScope?.cargoTypeId) {
// Find the parent group for this commodity so the cascader prefills.

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { Check, Container, Flame, RotateCcw, Snowflake } from "lucide-react";
import { Container, Flame, RotateCcw, Snowflake } from "lucide-react";
import {
Box,
Group,
@@ -9,33 +9,15 @@ import {
Stack,
Switch,
Text,
Textarea,
UnstyledButton,
} from "@mantine/core";
import type { Freight } from "@edr/types";
import {
CONTAINER_SIZES,
ContractFormInputValues,
type ContractFormValues,
} from "./schema";
import { fieldStyles, SelectField, StepLabel } from "./shared";
const CONTAINER_SIZE_OPTIONS: Array<{
value: "20ft" | "40ft";
label: string;
description: string;
}> = [
{
value: "20ft",
label: "20ft Container",
description: "Standard twenty-foot unit (TEU)",
},
{
value: "40ft",
label: "40ft Container",
description: "Standard forty-foot unit (FEU)",
},
];
const CARGO_TYPE_OPTIONS = [
{ value: "container", label: "Containerized (20ft / 40ft)" },
{ value: "bulk", label: "General / Bulk cargo" },
@@ -128,11 +110,17 @@ export function Step3CargoScope({
onChange={(v) => {
if (!v) return;
field.onChange(v);
// cargoFreeText is shared (bulk commodity label / container
// description) — clear it so text never carries across types.
// cargoFreeText is the bulk commodity label clear it so text
// never carries across types (container description is captured
// at booking time now).
form.setValue("cargoFreeText", "", { shouldDirty: true });
if (v === "container") {
form.setValue("cargoTypePath", [], { shouldDirty: true });
// Contracts always cover BOTH sizes; quantities are chosen per
// booking (a size can be 0 there).
form.setValue("enabledContainerSizes", [...CONTAINER_SIZES], {
shouldDirty: true,
});
} else {
form.setValue("enabledContainerSizes", [], {
shouldDirty: true,
@@ -152,74 +140,47 @@ export function Step3CargoScope({
</div>
{/* Container scope: enabled sizes as tick-cards — tap to toggle, one or
both can be in scope. Clearer than a multi-select for two options. */}
{/* Container scope: the contract always covers BOTH sizes and quotes both
rates. Quantities (a size can be 0) and the cargo description are
captured at booking time. */}
{cargoType === "container" && (
<Controller
name="enabledContainerSizes"
control={form.control}
render={({ field, fieldState }) => {
const selected = (field.value ?? []) as ("20ft" | "40ft")[];
const toggle = (size: "20ft" | "40ft") => {
field.onChange(
selected.includes(size)
? selected.filter((s) => s !== size)
: [...selected, size],
);
field.onBlur();
};
return (
<Box>
<StepLabel>Container sizes in scope *</StepLabel>
<Text fz={12} c="#6B7C8E" mt={2}>
Tick every size this contract should cover you can select
both.
</Text>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{CONTAINER_SIZE_OPTIONS.map((opt) => (
<SizeCard
key={opt.value}
label={opt.label}
description={opt.description}
checked={selected.includes(opt.value)}
hasError={Boolean(fieldState.error)}
onToggle={() => toggle(opt.value)}
/>
))}
</div>
{fieldState.error?.message && (
<Text fz={12} c="red.7" mt={6}>
{fieldState.error.message}
</Text>
)}
</Box>
);
<Group
gap={13}
align="center"
wrap="nowrap"
px={16}
py={13}
style={{
borderRadius: 14,
border: "1.5px solid #CDEBDD",
background: "#F6FBF8",
}}
/>
)}
{/* Container scope: required description of what the containers carry. */}
{cargoType === "container" && (
<Controller
name="cargoFreeText"
control={form.control}
render={({ field, fieldState }) => (
<Textarea
label="Cargo description *"
description="What will the containers carry under this contract?"
placeholder="e.g. Electronics, garments, machinery spare parts…"
value={field.value ?? ""}
onChange={(e) => field.onChange(e.currentTarget.value)}
onBlur={field.onBlur}
error={fieldState.error?.message}
radius={10}
autosize
minRows={2}
maxRows={4}
styles={fieldStyles}
/>
)}
/>
>
<Box
style={{
width: 38,
height: 38,
borderRadius: 11,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#EAF6EC",
color: "#1E7B34",
}}
>
<Container size={18} />
</Box>
<Box>
<Text fz={14} fw={700} c="#10202F">
20ft &amp; 40ft containers covered
</Text>
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
This contract quotes rates for both sizes. You choose the
quantities on each booking either size can be 0.
</Text>
</Box>
</Group>
)}
{/* Bulk scope: a single commodity (cargo type path). No tonnage. */}
@@ -330,85 +291,6 @@ export function Step3CargoScope({
);
}
/** Checkbox-style card for one container size. Whole card toggles. */
function SizeCard({
label,
description,
checked,
hasError,
onToggle,
}: {
label: string;
description: string;
checked: boolean;
hasError: boolean;
onToggle: () => void;
}) {
return (
<UnstyledButton
role="checkbox"
aria-checked={checked}
aria-label={label}
onClick={onToggle}
px={16}
py={13}
style={{
borderRadius: 14,
border: `1.5px solid ${
checked ? "#0A6F4D" : hasError ? "#E8B4AC" : "#E6ECF2"
}`,
background: checked ? "#F6FBF8" : "#fff",
transition: "all 150ms ease",
width: "100%",
}}
>
<Group gap={13} align="center" wrap="nowrap">
<Box
style={{
width: 22,
height: 22,
borderRadius: 7,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
border: `1.5px solid ${checked ? "#0A6F4D" : "#C7D2DC"}`,
background: checked ? "#0A6F4D" : "#fff",
color: "#fff",
transition: "all 150ms ease",
}}
>
{checked && <Check size={14} strokeWidth={3} />}
</Box>
<Box
style={{
width: 38,
height: 38,
borderRadius: 11,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: checked ? "#EAF6EC" : "#F1F5F8",
color: checked ? "#1E7B34" : "#6B7C8E",
transition: "all 150ms ease",
}}
>
<Container size={18} />
</Box>
<Box style={{ textAlign: "left" }}>
<Text fz={14} fw={700} c="#10202F">
{label}
</Text>
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
{description}
</Text>
</Box>
</Group>
</UnstyledButton>
);
}
function ToggleRow({
icon,
iconBg,

View File

@@ -57,10 +57,13 @@ const containerUnitSchema = z.object({
const containerLineSchema = z.object({
containerSize: z.enum(["20ft", "40ft"]),
// 0 is allowed: the contract covers both sizes, so a booking that only needs
// one size zeroes the other line out. At least one line must be ≥ 1
// (enforced in the superRefine).
quantity: z
.string()
.refine((v) => v.trim().length > 0, "Quantity is required.")
.refine((v) => !Number.isNaN(Number(v)) && Number(v) >= 1, "At least 1."),
.refine((v) => !Number.isNaN(Number(v)) && Number(v) >= 0, "Enter 0 or more."),
hazardousQuantity: z.string().default("0"),
reeferQuantity: z.string().default("0"),
returnQuantity: z.string().default("0"),
@@ -74,6 +77,8 @@ const shipmentFormBase = z.object({
// unloading. Seeded from the contract's equipment return; bulk ignores it.
withReturn: z.boolean().default(false),
containers: z.array(containerLineSchema).default([]),
// What the containers carry — captured per booking (moved off the contract).
cargoDescription: z.string().default(""),
cargoWeightTons: z.string().default(""),
itemCount: z.string().default(""),
bulkHazardousQuantity: z.string().default("0"),
@@ -92,6 +97,28 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
}
if (ctx.isContainer) {
// Containerized cargo must say WHAT is inside — required per booking.
if (!data.cargoDescription.trim()) {
refineCtx.addIssue({
code: "custom",
path: ["cargoDescription"],
message: "Describe the cargo carried in the containers.",
});
}
// Both sizes are always in contract scope and a line can be 0 — but the
// booking as a whole needs at least one container. Anchor the error on
// the first line's quantity so it renders in the field.
const totalQty = data.containers.reduce(
(sum, l) => sum + Math.max(0, Number(l.quantity) || 0),
0,
);
if (data.containers.length > 0 && totalQty < 1) {
refineCtx.addIssue({
code: "custom",
path: ["containers", 0, "quantity"],
message: "Book at least one container (either size).",
});
}
// 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.
@@ -243,6 +270,7 @@ export const initialShipmentFormValues: DeepPartial<ShipmentFormValues> = {
scheduledDate: "",
withReturn: false,
containers: [],
cargoDescription: "",
cargoWeightTons: "",
itemCount: "",
bulkHazardousQuantity: "0",
@@ -257,6 +285,7 @@ export const shipmentStepFields: Record<
0: ["contractRouteId"],
1: [
"containers",
"cargoDescription",
"cargoWeightTons",
"itemCount",
"bulkHazardousQuantity",