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

Enhance cargo description handling in contract forms
This commit is contained in:
marshal
2026-07-20 09:11:45 +03:00
committed by GitHub
5 changed files with 185 additions and 33 deletions

View File

@@ -520,6 +520,8 @@ export default function NewContractPage({
const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer
? data.enabledContainerSizes.map((size) => ({
containerSize: size,
// Required cargo description — what the containers carry.
cargoFreeText: data.cargoFreeText.trim() || undefined,
}))
: [
{

View File

@@ -119,7 +119,10 @@ export function contractToFormValues(
enabledContainerSizes as ContractFormInputValues["enabledContainerSizes"],
containerSizeCaps,
cargoTypePath,
cargoFreeText: bulkRow?.cargoFreeText ?? "",
// 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) ?? "",
bulkQuantityCap:
isGeneral && bulkRow?.quantityCap != null ? bulkRow.quantityCap : 0,
isHazardous: contract.isHazardous,

View File

@@ -231,6 +231,14 @@ export const contractFormSchema = z
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.",
});
}
}
if (data.cargoType === "bulk") {
// Bulk scope: a commodity is required.

View File

@@ -1,15 +1,16 @@
import { useEffect, useMemo, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { Flame, RotateCcw, Snowflake } from "lucide-react";
import { Check, Container, Flame, RotateCcw, Snowflake } from "lucide-react";
import {
Box,
Group,
MultiSelect,
Select,
Skeleton,
Stack,
Switch,
Text,
Textarea,
UnstyledButton,
} from "@mantine/core";
import type { Freight } from "@edr/types";
import {
@@ -18,9 +19,21 @@ import {
} from "./schema";
import { fieldStyles, SelectField, StepLabel } from "./shared";
const CONTAINER_SIZE_OPTIONS = [
{ value: "20ft", label: "20ft Container (TEU)" },
{ value: "40ft", label: "40ft Container (FEU)" },
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 = [
@@ -115,6 +128,9 @@ 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.
form.setValue("cargoFreeText", "", { shouldDirty: true });
if (v === "container") {
form.setValue("cargoTypePath", [], { shouldDirty: true });
} else {
@@ -134,34 +150,78 @@ export function Step3CargoScope({
)}
/>
{/* Container scope: enabled sizes as a multi-select. */}
{cargoType === "container" && (
<Controller
name="enabledContainerSizes"
control={form.control}
render={({ field, fieldState }) => (
<MultiSelect
label="Container sizes in scope *"
placeholder={
(field.value ?? []).length ? undefined : "Select sizes…"
}
data={CONTAINER_SIZE_OPTIONS}
value={field.value ?? []}
onChange={(v) =>
field.onChange(v as ("20ft" | "40ft")[])
}
onBlur={field.onBlur}
error={fieldState.error?.message}
radius={10}
checkIconPosition="right"
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
styles={fieldStyles}
/>
)}
/>
)}
</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. */}
{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>
);
}}
/>
)}
{/* 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}
/>
)}
/>
)}
{/* Bulk scope: a single commodity (cargo type path). No tonnage. */}
{cargoType === "bulk" && (
<Stack gap={12} mt={18}>
@@ -270,6 +330,85 @@ 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

@@ -345,7 +345,7 @@ export function Step8Review({
value={
<>
{cargoValue || "—"}
{values.cargoType === "bulk" && values.cargoFreeText?.trim() && (
{values.cargoFreeText?.trim() && (
<Text fz="sm" c="dimmed" mt={4}>
{values.cargoFreeText}
</Text>