Merge branch 'freight_feature/contrat' of github.com:Tria-plc/edr-platform into freight_feature/contrat

This commit is contained in:
marshal
2026-06-30 18:23:56 +03:00
34 changed files with 2066 additions and 695 deletions

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Bulk / break-bulk freight can now declare HOW MUCH of the cargo is hazardous
* or refrigerated, in the cargo's own unit of measure (tons for PER_TON, item
* count for PER_ITEM). These two columns hold that amount on the booking; they
* stay 0 for container freight (which tracks it per line on booking_container)
* and for bulk cargo with no hazardous/reefer portion. The existing
* is_hazardous / is_reefer booleans remain the surcharge trigger.
*/
export class AddBulkHazmatReeferQuantity1828000000000 implements MigrationInterface {
name = 'AddBulkHazmatReeferQuantity1828000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_hazardous_quantity NUMERIC(12,3) NOT NULL DEFAULT 0;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_reefer_quantity NUMERIC(12,3) NOT NULL DEFAULT 0;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_reefer_quantity;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_hazardous_quantity;`,
);
}
}

View File

@@ -121,6 +121,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
containerTypeId: string;
quantity: number;
vgmPerUnitTons: number;
hazardousQuantity?: number;
reeferQuantity?: number;
weightResult: ContainerWeightResult;
}>,
): Promise<BookingContainer[]> {
@@ -133,11 +135,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
const totalVgm = item.quantity * item.vgmPerUnitTons;
const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit);
// A per-line breakdown can never exceed the line's own quantity.
const clamp = (v?: number) =>
Math.max(0, Math.min(item.quantity, Math.floor(Number(v ?? 0)) || 0));
const row = containerRepo.create({
bookingId,
containerTypeId: item.containerTypeId,
quantity: item.quantity,
hazardousQuantity: clamp(item.hazardousQuantity),
reeferQuantity: clamp(item.reeferQuantity),
vgmPerUnitTons: item.vgmPerUnitTons,
totalVgmTons: totalVgm,
wagonsRequired,

View File

@@ -66,6 +66,17 @@ const NEEDS_ACTION_STATUSES = [
'APPROVED_PENDING_SIGNATURE',
] as const;
/**
* Clamp a bulk hazardous/reefer amount into 0..cargoAmount: it can never exceed
* the total cargo it's a portion of, and is never negative.
*/
function clampToCargo(value: number | undefined, cargoAmount: number): number {
const v = Number(value ?? 0);
if (!Number.isFinite(v) || v <= 0) return 0;
const cap = Number.isFinite(cargoAmount) && cargoAmount > 0 ? cargoAmount : 0;
return Math.min(v, cap);
}
@Injectable()
export class BookingsService {
constructor(
@@ -505,6 +516,16 @@ export class BookingsService {
// the container type at pricing time, so the booking-level flag stays off
// for container freight to avoid double-counting.
isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false,
// Bulk-only hazardous/reefer amount, clamped to the cargo amount. Container
// freight tracks this per line, so these are 0 for CONTAINER.
bulkHazardousQuantity:
dto.freightType === 'BULK'
? clampToCargo(dto.bulkHazardousQuantity, dto.cargoTotalWeightVgm)
: 0,
bulkReeferQuantity:
dto.freightType === 'BULK'
? clampToCargo(dto.bulkReeferQuantity, dto.cargoTotalWeightVgm)
: 0,
paymentCurrency: dto.paymentCurrency,
pnrCode: dto.pnrCode,
financialTerms: dto.financialTerms,
@@ -527,6 +548,8 @@ export class BookingsService {
containerTypeId: c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons,
hazardousQuantity: c.hazardousQuantity,
reeferQuantity: c.reeferQuantity,
weightResult: ruleResult.containerWeightResults[i],
})),
);
@@ -664,6 +687,8 @@ export class BookingsService {
containers,
);
const cargoAmount =
dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0);
const updates: Record<string, unknown> = {
...dto,
freightType,
@@ -674,6 +699,22 @@ export class BookingsService {
freightType === 'BULK'
? (dto.isReefer ?? existing.isReefer ?? false)
: false,
// Bulk-only hazardous/reefer amount, clamped to the cargo amount; 0 for
// container freight (per-line on the containers instead).
bulkHazardousQuantity:
freightType === 'BULK'
? clampToCargo(
dto.bulkHazardousQuantity ?? Number(existing.bulkHazardousQuantity ?? 0),
cargoAmount,
)
: 0,
bulkReeferQuantity:
freightType === 'BULK'
? clampToCargo(
dto.bulkReeferQuantity ?? Number(existing.bulkReeferQuantity ?? 0),
cargoAmount,
)
: 0,
priorityScore: ruleResult.priorityScore,
tradeDirection,
};
@@ -720,6 +761,8 @@ export class BookingsService {
containerTypeId: c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons,
hazardousQuantity: c.hazardousQuantity,
reeferQuantity: c.reeferQuantity,
weightResult: ruleResult.containerWeightResults[i],
})),
);

View File

@@ -52,6 +52,28 @@ export class CreateBookingContainerDto {
@Min(0)
@Transform(({ value }) => Number(value))
vgmPerUnitTons!: number;
@ApiPropertyOptional({
description: 'How many of this line are hazardous (0..quantity)',
minimum: 0,
default: 0,
})
@IsOptional()
@IsInt()
@Min(0)
@Transform(({ value }) => Number(value ?? 0))
hazardousQuantity?: number;
@ApiPropertyOptional({
description: 'How many of this line are refrigerated (0..quantity)',
minimum: 0,
default: 0,
})
@IsOptional()
@IsInt()
@Min(0)
@Transform(({ value }) => Number(value ?? 0))
reeferQuantity?: number;
}
/**
@@ -320,6 +342,25 @@ export class CreateBookingDto {
@Transform(({ value }) => value === 'true' || value === true)
isReefer?: boolean;
/**
* Bulk-only: how much of the cargo is hazardous / refrigerated, in the cargo's
* unit of measure (tons for PER_TON, item count for PER_ITEM). Must not exceed
* cargoTotalWeightVgm. Ignored for container freight (per-line on containers).
*/
@ApiPropertyOptional({ minimum: 0, default: 0 })
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value ?? 0))
bulkHazardousQuantity?: number;
@ApiPropertyOptional({ minimum: 0, default: 0 })
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value ?? 0))
bulkReeferQuantity?: number;
@ApiProperty({ enum: PAYMENT_CURRENCIES })
@IsIn([...PAYMENT_CURRENCIES])
paymentCurrency!: string;

View File

@@ -320,6 +320,19 @@ export class Booking extends BaseEntity {
@Column({ name: 'is_reefer', type: 'boolean', default: false })
isReefer!: boolean;
/**
* Bulk-only hazardous / reefer amount, in the cargo's own unit of measure
* (tons for PER_TON commodities, item count for PER_ITEM) — i.e. how much of
* `cargoTotalWeightVgm` is hazardous / refrigerated. 0 when none. Container
* freight carries this per line on `booking_container` instead, so these stay
* 0 for CONTAINER bookings. The booleans above remain the surcharge trigger.
*/
@Column({ name: 'bulk_hazardous_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
bulkHazardousQuantity!: number;
@Column({ name: 'bulk_reefer_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
bulkReeferQuantity!: number;
@Column({ name: 'payment_currency', type: 'varchar', length: 5 })
paymentCurrency!: string;

View File

@@ -65,7 +65,6 @@ export function BookingActionsMenu({
};
const hasMenu = listRowHasActions(row, user);
const primary = actions.find((a) => a.primary) ?? actions[0];
if (!hasMenu && variant === "table") {
return (
@@ -117,19 +116,6 @@ export function BookingActionsMenu({
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{variant === "table" && primary && (
<Button
size="compact-sm"
color="edr-green"
visibleFrom="lg"
leftSection={<primary.icon size={14} />}
disabled={mutations.isPending}
onClick={() => handleAction(primary)}
>
{primary.shortLabel}
</Button>
)}
<Menu position="bottom-end" width={220} withinPortal>
<Menu.Target>
<ActionIcon

View File

@@ -0,0 +1,320 @@
import type { LucideIcon } from "lucide-react";
import {
Building2,
Download,
Eye,
FileCheck,
FileText,
Globe,
Hash,
Mail,
MapPin,
Phone,
ShieldCheck,
User,
} from "lucide-react";
import {
ActionIcon,
Badge,
Divider,
Group,
Loader,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import type { Freight } from "@edr/types";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
import { customersService } from "@/services/customers.service";
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
// ── shared bits ──────────────────────────────────────────────────────────────
interface InfoRowProps {
icon: LucideIcon;
label: string;
value?: string | null;
}
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
return (
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
<Group gap="xs" wrap="nowrap">
<Icon size={15} color="var(--mantine-color-gray-5)" />
<Text size="sm" c="dimmed">
{label}
</Text>
</Group>
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
{value || "—"}
</Text>
</Group>
);
}
function InfoRows({ rows }: { rows: InfoRowProps[] }) {
const visible = rows.filter((r) => r.value);
if (visible.length === 0) {
return (
<Text size="sm" c="dimmed">
No details available.
</Text>
);
}
return (
<Stack gap={0}>
{visible.map((row, i) => (
<div key={row.label}>
{i > 0 && <Divider color="var(--mantine-color-gray-2)" />}
<InfoRow {...row} />
</div>
))}
</Stack>
);
}
// ── Customer tab ─────────────────────────────────────────────────────────────
/**
* Customer info for the contract. The contract detail payload only carries a
* `companyId`, so we fetch the full company record to surface contact + manager
* details (mirrors the booking-request customer card).
*/
export function ContractCustomerCard({
contract,
}: {
contract: Freight.IContract;
}) {
const companyId = contract.companyId ?? undefined;
const { data: company, isLoading } = useQuery({
queryKey: ["companies", "byId", companyId],
queryFn: () => customersService.getById(companyId!),
enabled: Boolean(companyId) && !contract.isGovernment,
});
// Government contracts carry an institution name instead of a company.
if (contract.isGovernment) {
return (
<SectionCard icon={Building2} title="Customer" accent="blue">
<InfoRows
rows={[
{
icon: Building2,
label: "Government",
value: contract.governmentInstitution ?? "Government",
},
]}
/>
</SectionCard>
);
}
if (isLoading) {
return (
<SectionCard icon={Building2} title="Customer" accent="blue">
<Group gap="sm" py="sm">
<Loader size="sm" color="gray" />
<Text size="sm" c="dimmed">
Loading customer
</Text>
</Group>
</SectionCard>
);
}
if (!company) {
return (
<SectionCard icon={Building2} title="Customer" accent="blue">
<Text size="sm" c="dimmed">
No customer linked to this contract.
</Text>
</SectionCard>
);
}
return (
<Stack gap="lg">
<SectionCard
icon={Building2}
title="Customer"
subtitle={company.name}
accent="blue"
>
<InfoRows
rows={[
{ icon: FileCheck, label: "TIN", value: company.tin },
{ icon: Hash, label: "VAT number", value: company.vatNumber },
{ icon: ShieldCheck, label: "FAN number", value: company.fanNumber },
{ icon: Globe, label: "Country", value: company.country },
{ icon: Mail, label: "Email", value: company.email },
{ icon: Phone, label: "Phone", value: company.phone },
{ icon: MapPin, label: "Address", value: company.address },
{ icon: Globe, label: "Website", value: company.website },
]}
/>
</SectionCard>
<SectionCard icon={User} title="Contact person" accent="teal">
<InfoRows
rows={[
{ icon: User, label: "Name", value: company.contactPersonName },
{ icon: Phone, label: "Phone", value: company.contactPersonPhone },
]}
/>
</SectionCard>
<SectionCard icon={User} title="General manager" accent="grape">
<InfoRows
rows={[
{ icon: User, label: "Name", value: company.generalManagerName },
{ icon: Mail, label: "Email", value: company.generalManagerEmail },
{ icon: Phone, label: "Phone", value: company.generalManagerPhone },
]}
/>
</SectionCard>
</Stack>
);
}
// ── Documents tab ────────────────────────────────────────────────────────────
function formatBytes(bytes?: number | null): string {
if (!bytes || bytes <= 0) return "—";
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
}
/** Human label for a file's `code` (e.g. "business_license" → "Business license"). */
function codeLabel(code?: string | null): string | null {
if (!code) return null;
return code
.replace(/[_-]+/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
export interface ContractDocumentsCardProps {
files: ContractFile[];
/** Open the file inline in a viewer modal. */
onView?: (file: ContractFile) => void;
/** Download the file to disk. */
onDownload?: (file: ContractFile) => void;
}
/** Rich list of the contract's attached documents: type, size, view + download. */
export function ContractDocumentsCard({
files,
onView,
onDownload,
}: ContractDocumentsCardProps) {
return (
<SectionCard
icon={FileText}
title="Documents"
accent="indigo"
extra={
<Badge color="gray" variant="light" radius="sm">
{files.length}
</Badge>
}
>
{files.length === 0 ? (
<Text size="sm" c="dimmed">
No documents attached to this contract.
</Text>
) : (
<Stack gap="xs">
{files.map((file) => {
const label = codeLabel(file.code);
return (
<Group
key={file.id}
justify="space-between"
wrap="nowrap"
p="xs"
style={detailStyles.fileRow}
onMouseEnter={(e) => {
e.currentTarget.style.background =
"var(--mantine-color-gray-0)";
e.currentTarget.style.borderColor =
"var(--freight-brand-border)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
e.currentTarget.style.borderColor =
"var(--mantine-color-gray-2)";
}}
>
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={36} radius="md" variant="light" color="indigo">
<FileText size={17} />
</ThemeIcon>
<Stack gap={2} style={{ minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{file.name}
</Text>
<Group gap={6} wrap="nowrap">
{label ? (
<Badge
variant="light"
color="gray"
radius="sm"
size="xs"
tt="none"
>
{label}
</Badge>
) : null}
<Text size="xs" c="dimmed">
{formatBytes(file.size)}
</Text>
</Group>
</Stack>
</Group>
<Group gap={4} wrap="nowrap">
{onView ? (
<Tooltip label="View" withArrow>
<ActionIcon
variant="subtle"
color="indigo"
radius="md"
onClick={() => onView(file)}
aria-label={`View ${file.name}`}
>
<Eye size={16} />
</ActionIcon>
</Tooltip>
) : null}
{onDownload ? (
<Tooltip label="Download" withArrow>
<ActionIcon
variant="subtle"
color="gray"
radius="md"
onClick={() => onDownload(file)}
aria-label={`Download ${file.name}`}
>
<Download size={16} />
</ActionIcon>
</Tooltip>
) : null}
</Group>
</Group>
);
})}
</Stack>
)}
</SectionCard>
);
}

View File

@@ -307,6 +307,14 @@ const RuleEngineFormDialog = ({
size="md"
radius="md"
styles={inputStyles}
rightSection={
field.suffix ? (
<Text size="sm" c="dimmed" fw={600} pr={4}>
{field.suffix}
</Text>
) : undefined
}
rightSectionWidth={field.suffix ? 52 : undefined}
/>
);
};

View File

@@ -94,6 +94,15 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
return <Text size="sm">{Number.isNaN(num) ? String(value) : num.toLocaleString()}</Text>;
}
if (format === "currency") {
const num = Number(value);
return (
<Text size="sm" fw={500}>
{Number.isNaN(num) ? String(value) : `USD ${num.toLocaleString()}`}
</Text>
);
}
if (format === "date") {
const d = new Date(String(value));
if (Number.isNaN(d.getTime())) return <Text size="sm">{String(value)}</Text>;

View File

@@ -7,13 +7,16 @@ import {
Calendar,
CalendarClock,
FileText,
Files,
Flame,
LayoutGrid,
Package,
Receipt,
RefreshCw,
Route as RouteIcon,
ShieldCheck,
Snowflake,
Users,
} from "lucide-react";
import {
Badge,
@@ -31,6 +34,8 @@ import {
Title,
} from "@mantine/core";
import toast from "react-hot-toast";
import "@/components/overview/overview.css";
import { PageContainer } from "@/components/page";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
@@ -41,11 +46,18 @@ import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflow
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import {
ContractCustomerCard,
ContractDocumentsCard,
} from "@/components/contracts/detail/ContractDetailTabCards";
import { getContractStatusMeta } from "@/features/contracts/contract-status.config";
import { useFileViewer } from "@/hooks/useFileViewer";
import {
useContractDetail,
useContractMutations,
} from "@/hooks/contracts/useContracts";
import { downloadBookingFile } from "@/services/files.service";
import type { Freight } from "@edr/types";
// Clearance phase — staff can still ACT (approve / query / finalize).
const CLEARANCE_ACTIVE_STATUSES = [
@@ -94,7 +106,8 @@ export default function ContractRequestDetailPage() {
} = useContractDetail(id);
const mutations = useContractMutations(id ?? "");
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get("tab") === "clearance" ? "clearance" : "details";
const { view, viewer } = useFileViewer();
const requestedTab = searchParams.get("tab");
const setTab = (tab: string) =>
setSearchParams(
(prev) => {
@@ -106,6 +119,23 @@ export default function ContractRequestDetailPage() {
{ replace: true },
);
const handleViewFile = (file: NonNullable<Freight.IContract["files"]>[number]) =>
view({
name: file.name,
url: file.signedUrl ?? file.url,
mimeType: file.mimeType,
});
const handleDownloadFile = async (
file: NonNullable<Freight.IContract["files"]>[number],
) => {
try {
await downloadBookingFile(file.id, file.name);
} catch {
toast.error("Could not download file.");
}
};
if (isLoading) {
return (
<PageContainer>
@@ -176,9 +206,17 @@ export default function ContractRequestDetailPage() {
const clearanceReadOnly = CLEARANCE_DONE_STATUSES.includes(contract.status);
// Path A (no customs) → Operations reviews; Path B (customs) → GL reviews.
const selfClear = !contract.customsClearingEnabled;
// If the tab param points at clearance but the contract isn't in a clearance
// phase, fall back to details so we never show an empty tab.
const currentTab = activeTab === "clearance" && showClearanceTab ? "clearance" : "details";
const files = contract.files ?? [];
// Resolve the active tab from the URL, falling back to details when the
// requested tab isn't available for this contract (e.g. clearance pre-phase).
const currentTab =
requestedTab === "documents"
? "documents"
: requestedTab === "customer"
? "customer"
: requestedTab === "clearance" && showClearanceTab
? "clearance"
: "details";
const customerLabel = contract.isGovernment
? (contract.governmentInstitution ?? "Government")
@@ -264,27 +302,43 @@ export default function ContractRequestDetailPage() {
description={statusMeta.description}
/>
{showClearanceTab && (
<Tabs
value={currentTab}
onChange={(v) => setTab(v ?? "details")}
variant="pills"
color="edr-green"
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
>
<Tabs.List>
<Tabs.Tab value="details" leftSection={<FileText size={16} />}>
Details
</Tabs.Tab>
<Tabs
value={currentTab}
onChange={(v) => setTab(v ?? "details")}
variant="pills"
color="edr-green"
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
>
<Tabs.List>
<Tabs.Tab value="details" leftSection={<LayoutGrid size={16} />}>
Details
</Tabs.Tab>
<Tabs.Tab
value="documents"
leftSection={<Files size={16} />}
rightSection={
files.length > 0 ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
{files.length}
</Badge>
) : null
}
>
Documents
</Tabs.Tab>
<Tabs.Tab value="customer" leftSection={<Users size={16} />}>
Customer
</Tabs.Tab>
{showClearanceTab && (
<Tabs.Tab
value="clearance"
leftSection={<ShieldCheck size={16} />}
>
Clearance Review
</Tabs.Tab>
</Tabs.List>
</Tabs>
)}
)}
</Tabs.List>
</Tabs>
<Grid gap="lg">
{/* LEFT — primary content */}
@@ -296,6 +350,14 @@ export default function ContractRequestDetailPage() {
readOnly={clearanceReadOnly}
onChanged={() => refetch()}
/>
) : currentTab === "documents" ? (
<ContractDocumentsCard
files={files}
onView={handleViewFile}
onDownload={handleDownloadFile}
/>
) : currentTab === "customer" ? (
<ContractCustomerCard contract={contract} />
) : (
<Stack gap="lg">
<SectionCard icon={RouteIcon} title="Routes">
@@ -453,6 +515,8 @@ export default function ContractRequestDetailPage() {
</Grid.Col>
</Grid>
</Stack>
{viewer}
</PageContainer>
);
}

View File

@@ -11,6 +11,7 @@ export type ColumnFormat =
| "rateStatus"
| "date"
| "number"
| "currency"
| "entityLabel"
| "rateLabel";
@@ -36,6 +37,8 @@ export interface FormFieldDef {
placeholder?: string;
description?: string;
disabled?: boolean;
/** Trailing unit label shown inside the input (e.g. "USD" on a rate value). */
suffix?: string;
/** Hide this field when another field currently equals one of these values. */
hideWhen?: { field: string; equals: string[] };
/**
@@ -402,7 +405,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
columns: [
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
{ id: "trigger", header: "Trigger", accessorKey: "trigger" },
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "number" },
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" },
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
@@ -454,7 +457,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
placeholder: "Select bulk commodity (optional)",
showWhen: { field: "appliesTo", equals: ["BULK", "INTERCITY"] },
},
{ name: "rateValue", label: "Rate value", type: "number", required: true },
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
{ name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS },
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
{ name: "effectiveTo", label: "Effective to", type: "date" },

View File

@@ -16,6 +16,7 @@
"@edr/ui-common": "workspace:*",
"@hookform/resolvers": "^5.4.0",
"@mantine/core": "^9.3.0",
"@mantine/dates": "^9.3.0",
"@mantine/hooks": "^9.3.0",
"@tanstack/react-query": "^5.59.0",
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz",

View File

@@ -272,6 +272,10 @@ const App = () => {
/>
<Route path="/contracts" element={<ContractsList />} />
<Route path="/contracts/new" element={<NewContractPage />} />
<Route
path="/contracts/:id/edit"
element={<NewContractPage mode="edit" />}
/>
<Route
path="/contracts/:id/bookings/new"
element={<NewShipmentPage />}

View File

@@ -796,7 +796,7 @@ export function AppLayout({
{/* ── Main ── */}
<AppShell.Main
style={{
backgroundColor: "#F1F5F9",
backgroundColor: "##f8fafc",
}}
>
{children}

View File

@@ -4,6 +4,7 @@ import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MantineProvider } from "@mantine/core";
import "@mantine/core/styles.css";
import "@mantine/dates/styles.css";
import "@edr/ui-common/styles.css";
import "../index.css";
import "@edr/ui-common/theme.css";

View File

@@ -29,9 +29,7 @@ import {
Check,
Download,
FileText,
Flame,
MapPin,
Snowflake,
Truck,
Upload,
X,
@@ -102,6 +100,8 @@ function defaultContainerTypeForSize(
interface BookingContainerRow {
quantity: number;
vgmPerUnitTons: number | string;
hazardousQuantity?: number | string | null;
reeferQuantity?: number | string | null;
containerType?: {
sizeFt?: number | null;
label?: string | null;
@@ -150,6 +150,8 @@ function mapBookingToFormValues(
cargoWeight: String(booking.cargoTotalWeightVgm ?? ""),
isHazardous: booking.isHazardous ?? false,
isRefrigerated: booking.isRefrigerated ?? false,
bulkHazardousQty: String(Number(booking.bulkHazardousQuantity ?? 0)),
bulkReeferQty: String(Number(booking.bulkReeferQuantity ?? 0)),
paymentCurrency:
booking.paymentCurrency === "ETB" ? "ETB" : "USD",
scheduledDate: booking.scheduledDate
@@ -183,10 +185,16 @@ function mapBookingToFormValues(
? bc.containerType.label
: (bc.containerType?.code ??
defaultContainerTypeForSize(referenceData, size));
const hazQty = Number(bc.hazardousQuantity ?? 0);
const reeQty = Number(bc.reeferQuantity ?? 0);
return {
type: size,
containerType: typeName,
qty: String(bc.quantity ?? 1),
isHazardous: hazQty > 0,
hazardousQty: String(hazQty),
isReefer: reeQty > 0,
reeferQty: String(reeQty),
vgm: String(Number(bc.vgmPerUnitTons ?? 0)),
};
})
@@ -195,6 +203,10 @@ function mapBookingToFormValues(
type: "20ft" as const,
containerType: defaultContainerTypeForSize(referenceData, "20ft"),
qty: "1",
isHazardous: false,
hazardousQty: "0",
isReefer: false,
reeferQty: "0",
vgm: "",
},
];
@@ -435,7 +447,27 @@ export default function EditBookingPage() {
: "IMPORT",
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous,
// Containers: booking-level flags are the OR of the per-container switches;
// bulk uses the route-step toggles.
isHazardous:
data.cargoType === "container"
? data.containers.some((c) => c.isHazardous)
: data.isHazardous,
isReefer:
data.cargoType === "container"
? data.containers.some((c) => c.isReefer)
: data.isRefrigerated,
// Bulk-only hazardous / refrigerated amount in the cargo's unit.
...(data.cargoType === "bulk"
? {
bulkHazardousQuantity: data.isHazardous
? Number(data.bulkHazardousQty || 0)
: 0,
bulkReeferQuantity: data.isRefrigerated
? Number(data.bulkReeferQty || 0)
: 0,
}
: {}),
paymentCurrency: data.paymentCurrency,
freightType:
data.cargoType === "container"
@@ -446,6 +478,8 @@ export default function EditBookingPage() {
? data.containers.map((c) => ({
containerTypeId: findContainerTypeId(c.containerType),
quantity: Number(c.qty || 1),
hazardousQuantity: c.isHazardous ? Number(c.hazardousQty || 0) : 0,
reeferQuantity: c.isReefer ? Number(c.reeferQty || 0) : 0,
vgmPerUnitTons: Number(c.vgm || 0),
}))
: [],
@@ -857,35 +891,9 @@ export default function EditBookingPage() {
</Stack>
)}
<Paper withBorder radius="md">
<Controller
name="isHazardous"
control={form.control}
render={({ field }) => (
<ToggleRow
icon={<Flame size={16} color="#E03131" />}
title="Hazardous Material"
description="Applies a Hazard Surcharge to the final bill."
checked={field.value}
onChange={field.onChange}
/>
)}
/>
<Divider />
<Controller
name="isRefrigerated"
control={form.control}
render={({ field }) => (
<ToggleRow
icon={<Snowflake size={16} color="#1C7ED6" />}
title="Refrigerated Cargo"
description="Temperature-controlled transport applies a Refrigerator Surcharge."
checked={field.value}
onChange={field.onChange}
/>
)}
/>
</Paper>
{/* Cargo handling (hazardous / refrigerated, with per-unit amounts)
now lives in the Cargo tab via Step5CargoDetails — no separate
toggles here, to avoid duplicate controls that diverge. */}
</Stack>
</Tabs.Panel>

View File

@@ -458,10 +458,28 @@ export default function NewBookingPage() {
// Day-level pool: the customer picks only a day (scheduledDate); the batch
// engine assigns the train, so no trainScheduleId is sent.
cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous,
// Reefer is a customer choice for bulk only; container reefer is decided by
// the container type on the backend, so never send it for containers.
isReefer: data.cargoType === "bulk" ? data.isRefrigerated : false,
// Booking-level flags drive the HAZARD / REEFER surcharge triggers. For
// containers they're the OR of the per-container switches; for bulk they
// come from the cargo-step toggles.
isHazardous:
data.cargoType === "container"
? data.containers.some((c) => c.isHazardous)
: data.isHazardous,
isReefer:
data.cargoType === "container"
? data.containers.some((c) => c.isReefer)
: data.isRefrigerated,
// Bulk-only: the hazardous / refrigerated amount in the cargo's unit.
...(data.cargoType === "bulk"
? {
bulkHazardousQuantity: data.isHazardous
? Number(data.bulkHazardousQty || 0)
: 0,
bulkReeferQuantity: data.isRefrigerated
? Number(data.bulkReeferQty || 0)
: 0,
}
: {}),
freightType:
data.cargoType === "container"
? ("CONTAINER" as const)
@@ -471,6 +489,9 @@ export default function NewBookingPage() {
? data.containers.map((c) => ({
containerTypeId: findContainerTypeId(c.containerType),
quantity: Number(c.qty || 1),
// Per-line breakdown: how many of this line are hazardous / reefer.
hazardousQuantity: c.isHazardous ? Number(c.hazardousQty || 0) : 0,
reeferQuantity: c.isReefer ? Number(c.reeferQty || 0) : 0,
// Weight (VGM) is not collected at the wizard — captured in operations.
vgmPerUnitTons: 0,
}))

View File

@@ -169,6 +169,11 @@ export const bookingFormSchema = z
cargoFreeText: z.string(),
isHazardous: z.boolean(),
isRefrigerated: z.boolean(),
// Bulk-only: how much of the cargo is hazardous / refrigerated, in the
// commodity's unit (tons for PER_TON, item count for PER_ITEM). Bounded by
// cargoWeight in superRefine when the matching flag is on.
bulkHazardousQty: z.string().default("0"),
bulkReeferQty: z.string().default("0"),
containers: z.array(
z.object({
type: z.enum(["20ft", "40ft"]),
@@ -178,6 +183,13 @@ export const bookingFormSchema = z
.refine((q) => q.length !== 0, "Quantity is required.")
.refine((q) => !isNaN(+q), "Enter a valid Number")
.refine((qty) => Number(qty) >= 1, "Must be greater than 0"),
// Per-line hazmat/reefer: how many of this line's containers are
// hazardous / refrigerated. The flag drives whether the quantity input
// shows; the quantity (bounded by qty above) is validated in superRefine.
isHazardous: z.boolean().default(false),
hazardousQty: z.string().default("0"),
isReefer: z.boolean().default(false),
reeferQty: z.string().default("0"),
// Weight (VGM) is NOT collected at the wizard — it is captured later in
// operations. Kept optional so existing payload code stays valid.
vgm: z.string().default("0"),
@@ -253,9 +265,48 @@ export const bookingFormSchema = z
message: "Select a Cargo type.",
});
}
// Bulk hazardous / refrigerated amounts, when their flag is on, must be
// 1..cargoWeight (in the commodity's own unit). cargoWeight itself is
// validated above, so here we just bound the portion against it.
const cargoQty = Number(data.cargoWeight);
const boundBulkPortion = (
on: boolean,
raw: string,
path: "bulkHazardousQty" | "bulkReeferQty",
noun: string,
) => {
if (!on) return;
const v = Number(raw);
if (!raw || Number.isNaN(v) || v < 1) {
ctx.addIssue({
code: "custom",
path: [path],
message: `Enter how much is ${noun} (at least 1).`,
});
} else if (!Number.isNaN(cargoQty) && cargoQty > 0 && v > cargoQty) {
ctx.addIssue({
code: "custom",
path: [path],
message: `Can't exceed the cargo quantity (${cargoQty}).`,
});
}
};
boundBulkPortion(
data.isHazardous,
data.bulkHazardousQty,
"bulkHazardousQty",
"hazardous",
);
boundBulkPortion(
data.isRefrigerated,
data.bulkReeferQty,
"bulkReeferQty",
"refrigerated",
);
}
if (data.cargoType === "container") {
data.containers.forEach((c, i) => {
const lineQty = Number(c.qty);
if (!c.qty || +c.qty < 1) {
ctx.addIssue({
code: "custom",
@@ -263,6 +314,40 @@ export const bookingFormSchema = z
message: "Enter at least 1 container.",
});
}
// When a per-line flag is on, its quantity must be 1..lineQty: at least
// one affected container, never more than the line holds.
if (c.isHazardous) {
const h = Number(c.hazardousQty);
if (!c.hazardousQty || Number.isNaN(h) || h < 1) {
ctx.addIssue({
code: "custom",
path: ["containers", i, "hazardousQty"],
message: "Enter how many are hazardous (at least 1).",
});
} else if (!Number.isNaN(lineQty) && h > lineQty) {
ctx.addIssue({
code: "custom",
path: ["containers", i, "hazardousQty"],
message: `Can't exceed the ${lineQty} container(s) in this line.`,
});
}
}
if (c.isReefer) {
const r = Number(c.reeferQty);
if (!c.reeferQty || Number.isNaN(r) || r < 1) {
ctx.addIssue({
code: "custom",
path: ["containers", i, "reeferQty"],
message: "Enter how many are refrigerated (at least 1).",
});
} else if (!Number.isNaN(lineQty) && r > lineQty) {
ctx.addIssue({
code: "custom",
path: ["containers", i, "reeferQty"],
message: `Can't exceed the ${lineQty} container(s) in this line.`,
});
}
}
});
}
});
@@ -301,7 +386,20 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
cargoFreeText: "",
isHazardous: false,
isRefrigerated: false,
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "0" }],
bulkHazardousQty: "0",
bulkReeferQty: "0",
containers: [
{
type: "20ft",
containerType: "",
qty: "1",
isHazardous: false,
hazardousQty: "0",
isReefer: false,
reeferQty: "0",
vgm: "0",
},
],
documents: {},
notes: "",
};
@@ -320,14 +418,23 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
"firstMile",
"lastMile",
],
3: ["cargoType", "cargoWeight", "cargoTypePath", "containers"],
3: [
"cargoType",
"cargoWeight",
"cargoTypePath",
"containers",
// Cargo-handling flags + their bulk amounts now live in the Cargo step,
// under the quantity where the unit (tons/items) is known.
"isHazardous",
"isRefrigerated",
"bulkHazardousQty",
"bulkReeferQty",
],
4: [
"originYard",
"destinationYard",
"primaryRouteQuantity",
"extraRoutes",
"isHazardous",
"isRefrigerated",
// Estimated shipment date now lives in the Route step (one-time bookings only).
"scheduledDate",
],
@@ -339,6 +446,12 @@ export interface ContainerConfig {
type: "20ft" | "40ft";
containerType: string;
qty: string;
// Per-line hazmat/reefer breakdown. Optional on the watched input shape: the
// schema defaults them, so a restored draft may omit them.
isHazardous?: boolean;
hazardousQty?: string;
isReefer?: boolean;
reeferQty?: string;
// Optional: VGM is captured later in operations, not at the wizard, and the
// form schema defaults it — so the watched input shape has it as optional.
vgm?: string;

View File

@@ -7,6 +7,7 @@ import {
InputBase,
Paper,
Select,
Switch,
Text,
Title,
useCombobox,
@@ -358,6 +359,81 @@ export function SelectField({
);
}
/**
* Icon + title + description row with a trailing Switch, in a card that tints
* green when on. Used for the cargo-handling toggles (hazardous / reefer) on the
* Route step and per container on the Cargo step. `children` renders below the
* row when the switch is on (e.g. a bounded quantity input).
*/
export function ToggleRow({
icon,
iconBg,
iconColor,
title,
description,
checked,
onChange,
children,
}: {
icon: ReactNode;
iconBg: string;
iconColor: string;
title: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
children?: ReactNode;
}) {
return (
<Box
px={16}
py={13}
style={{
borderRadius: 14,
border: `1.5px solid ${checked ? "#CDEBDD" : BORDER}`,
background: checked ? "#F6FBF8" : "#fff",
transition: "all 150ms ease",
}}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap={13} align="center" wrap="nowrap">
<Box
style={{
width: 38,
height: 38,
borderRadius: 11,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: iconBg,
color: iconColor,
}}
>
{icon}
</Box>
<Box>
<Text fz={14} fw={700} c={INK}>
{title}
</Text>
<Text fz={12} c={MUTED} style={{ lineHeight: 1.4 }}>
{description}
</Text>
</Box>
</Group>
<Switch
checked={checked}
onChange={(e) => onChange(e.currentTarget.checked)}
color="edr-green"
size="md"
style={{ flexShrink: 0 }}
/>
</Group>
{checked && children ? <Box mt={12}>{children}</Box> : null}
</Box>
);
}
interface AsyncComboboxOption {
value: string;
label: string;

View File

@@ -2,21 +2,17 @@ import type { Freight } from "@edr/types";
import {
Box,
Button,
Divider,
Group,
Skeleton,
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import {
CalendarDays,
Flame,
MapPin,
Plus,
Route as RouteIcon,
Snowflake,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useMemo } from "react";
@@ -168,16 +164,8 @@ export function Step4Route({
// A general contract can cover several routes, but each route is just an
// (origin, destination) pair — the same shape as the one-time route. The
// contracted quantity comes from the cargo step, so no per-route quantity or
// distance is collected here.
const cargoType = form.watch("cargoType");
// The reefer toggle only exists for bulk; if the customer switches to
// containers, drop any reefer flag they set so it can't ride along unseen.
useEffect(() => {
if (cargoType !== "bulk" && form.getValues("isRefrigerated")) {
form.setValue("isRefrigerated", false);
}
}, [cargoType, form]);
// distance is collected here. Cargo handling (hazardous / refrigerated) also
// lives in the Cargo step now, not here.
// Earliest selectable shipment date (today, local) for the date input's `min`.
const todayISODate = useMemo(() => {
@@ -240,21 +228,26 @@ export function Step4Route({
{/* Estimated shipment date — one-time bookings only. General contracts
pick the date per order drawn against the contract later. */}
{!isGeneralContract && (
<Box style={{ maxWidth: 280 }}>
<Box w="100%">
<Controller
name="scheduledDate"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
type="date"
<DatePickerInput
w="100%"
label="Estimated shipment date *"
description="A planning estimate. You'll confirm the actual date when you request the operation."
min={todayISODate}
placeholder="Pick a date"
minDate={todayISODate}
leftSection={<CalendarDays size={16} />}
error={fieldState.error?.message}
value={field.value ?? ""}
onChange={(e) => field.onChange(e.currentTarget.value)}
// Mantine v9 DatePickerInput uses string (YYYY-MM-DD) values,
// matching the form's `scheduledDate` string directly.
value={field.value || null}
onChange={(v) => field.onChange(v ?? "")}
onBlur={field.onBlur}
radius="md"
popoverProps={{ withinPortal: true }}
/>
)}
/>
@@ -360,116 +353,10 @@ export function Step4Route({
</Box>
)}
<Divider my={22} color="#EEF2F6" />
<StepLabel>Cargo handling</StepLabel>
<Stack gap={12} mt={12}>
<Controller
name="isHazardous"
control={form.control}
render={({ field }) => (
<ToggleRow
icon={<Flame size={18} />}
iconBg="#FBEAE7"
iconColor="#C0392B"
title="Hazardous Material"
description="Applies a hazard surcharge to the final bill."
checked={field.value}
onChange={(v) => field.onChange(v)}
/>
)}
/>
{/* Reefer is a customer choice for bulk freight only. For containers the
reefer surcharge is driven by the container type, so the toggle is
hidden there to avoid a control that doesn't affect the price. */}
{cargoType === "bulk" && (
<Controller
name="isRefrigerated"
control={form.control}
render={({ field }) => (
<ToggleRow
icon={<Snowflake size={18} />}
iconBg="#E9F0F8"
iconColor="#2E5B96"
title="Refrigerated Cargo"
description="Temperature-controlled transport applies a refrigeration surcharge."
checked={field.value}
onChange={(v) => field.onChange(v)}
/>
)}
/>
)}
</Stack>
</StepCard>
);
}
function ToggleRow({
icon,
iconBg,
iconColor,
title,
description,
checked,
onChange,
}: {
icon: React.ReactNode;
iconBg: string;
iconColor: string;
title: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<Group
justify="space-between"
align="center"
wrap="nowrap"
px={16}
py={13}
style={{
borderRadius: 14,
border: `1.5px solid ${checked ? "#CDEBDD" : "#E6ECF2"}`,
background: checked ? "#F6FBF8" : "#fff",
transition: "all 150ms ease",
}}
>
<Group gap={13} align="center" wrap="nowrap">
<Box
style={{
width: 38,
height: 38,
borderRadius: 11,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: iconBg,
color: iconColor,
}}
>
{icon}
</Box>
<Box>
<Text fz={14} fw={700} c="#10202F">
{title}
</Text>
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
{description}
</Text>
</Box>
</Group>
<Switch
checked={checked}
onChange={(e) => onChange(e.currentTarget.checked)}
color="edr-green"
size="md"
/>
</Group>
);
}
function LoadingSkeleton() {
return (
<div className="space-y-4 rounded-xl border border-gray-200 p-4">

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef } from "react";
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
import { Package, Plus, Trash2, Weight } from "lucide-react";
import { Flame, Package, Plus, Snowflake, Trash2, Weight } from "lucide-react";
import { ActionIcon, Button, Skeleton, Text, TextInput } from "@mantine/core";
import type { Freight } from "@edr/types";
import {
@@ -17,6 +17,7 @@ import {
StepCard,
StepHeader,
StepLabel,
ToggleRow,
} from "./shared";
type BookingForm = UseFormReturn<
@@ -114,6 +115,59 @@ export function Step5CargoDetails({
);
}, [referenceData, parentId]);
// ── Bulk hazmat/reefer (whole-cargo portion) helpers ──────────────────────
// The bulk amounts are in the commodity's own unit and bounded by cargoWeight.
const bulkUnitLabel = isPerItem ? "items" : "tons";
const bulkCargoQty = () =>
Math.max(0, Number(form.getValues("cargoWeight") ?? 0) || 0);
const bulkMax = () => bulkCargoQty() || undefined;
// Default a switched-on portion to the whole cargo amount.
const bulkDefaultQty = () => {
const q = bulkCargoQty();
return q > 0 ? String(q) : "1";
};
// Clamp a typed value into 0..cargoWeight (items round down). Empty stays empty
// so the field can be cleared; the schema then flags it as required.
const clampToBulk = (raw: string) => {
if (raw === "") return "";
const n = Number(raw);
if (Number.isNaN(n)) return raw;
const cap = bulkCargoQty();
const stepped = isPerItem ? Math.floor(n) : n;
const bounded = cap > 0 ? Math.min(cap, stepped) : stepped;
return Math.max(0, bounded).toString();
};
// ── Per-line hazmat/reefer quantity helpers ───────────────────────────────
// The line quantity (number of containers in this line) is the upper bound for
// both the hazardous and the refrigerated counts.
const lineQtyOf = (index: number) =>
Math.max(1, Number(form.getValues(`containers.${index}.qty`) ?? 1) || 1);
const lineMax = (index: number) => lineQtyOf(index);
// When a flag is switched on, default its count to the whole line.
const defaultLineQty = (index: number) => lineQtyOf(index).toString();
// Clamp a typed value into 1..lineQty (empty stays empty so the field can be
// cleared; the schema flags an empty value as required while the switch is on).
const clampToLine = (raw: string, index: number) => {
if (raw === "") return "";
const n = Number(raw);
if (Number.isNaN(n)) return raw;
return Math.min(lineQtyOf(index), Math.max(1, Math.floor(n))).toString();
};
// After the line quantity changes, pull any active count back within bounds.
const clampDependentQty = (index: number, newLineQty: number) => {
const max = Math.max(1, newLineQty);
(["hazardousQty", "reeferQty"] as const).forEach((key) => {
const cur = Number(form.getValues(`containers.${index}.${key}`) ?? 0);
if (cur > max) {
form.setValue(`containers.${index}.${key}`, max.toString(), {
shouldDirty: true,
shouldValidate: true,
});
}
});
};
if (isLoading) {
return (
<StepCard>
@@ -250,6 +304,25 @@ export function Step5CargoDetails({
render={({ field, fieldState }) => (
<TextInput
{...field}
onChange={(e) => {
field.onChange(e);
// Pull any active bulk hazmat/reefer portion back within the
// new cargo amount so it can't outlive a shrink.
const cap = Number(e.currentTarget.value);
if (!Number.isNaN(cap)) {
(["bulkHazardousQty", "bulkReeferQty"] as const).forEach(
(key) => {
const cur = Number(form.getValues(key) ?? 0);
if (cap > 0 && cur > cap) {
form.setValue(key, String(cap), {
shouldDirty: true,
shouldValidate: true,
});
}
},
);
}
}}
id="cargoWeight"
type="number"
label={isPerItem ? "Quantity (Items) *" : "Quantity (Tons) *"}
@@ -275,6 +348,111 @@ export function Step5CargoDetails({
)}
/>
)}
{/* Cargo handling — how much of the cargo is hazardous / refrigerated,
in the SAME unit as the quantity above (tons or items). Shown once a
commodity is chosen so the unit is known; general contracts handle
handling per order. */}
{selectedCommodity && !isGeneralContract && (
<div className="grid gap-3 sm:grid-cols-2">
<Controller
name="isHazardous"
control={form.control}
render={({ field: hazField }) => (
<ToggleRow
icon={<Flame size={18} />}
iconBg="#FBEAE7"
iconColor="#C0392B"
title="Hazardous"
description={`Part of this cargo is hazardous (in ${bulkUnitLabel}).`}
checked={!!hazField.value}
onChange={(v) => {
hazField.onChange(v);
form.setValue(
"bulkHazardousQty",
v ? bulkDefaultQty() : "0",
{ shouldDirty: true, shouldValidate: true },
);
}}
>
<Controller
name="bulkHazardousQty"
control={form.control}
render={({ field: hq, fieldState }) => (
<TextInput
type="number"
size="sm"
label={
isPerItem
? "How many items are hazardous?"
: "How many tons are hazardous?"
}
min={isPerItem ? 1 : 0}
max={bulkMax()}
step={isPerItem ? 1 : 0.01}
value={hq.value ?? ""}
onChange={(e) =>
hq.onChange(clampToBulk(e.currentTarget.value))
}
onBlur={hq.onBlur}
error={fieldState.error?.message}
radius="md"
/>
)}
/>
</ToggleRow>
)}
/>
<Controller
name="isRefrigerated"
control={form.control}
render={({ field: reeField }) => (
<ToggleRow
icon={<Snowflake size={18} />}
iconBg="#E9F0F8"
iconColor="#2E5B96"
title="Refrigerated"
description={`Part of this cargo needs reefer transport (in ${bulkUnitLabel}).`}
checked={!!reeField.value}
onChange={(v) => {
reeField.onChange(v);
form.setValue(
"bulkReeferQty",
v ? bulkDefaultQty() : "0",
{ shouldDirty: true, shouldValidate: true },
);
}}
>
<Controller
name="bulkReeferQty"
control={form.control}
render={({ field: rq, fieldState }) => (
<TextInput
type="number"
size="sm"
label={
isPerItem
? "How many items are refrigerated?"
: "How many tons are refrigerated?"
}
min={isPerItem ? 1 : 0}
max={bulkMax()}
step={isPerItem ? 1 : 0.01}
value={rq.value ?? ""}
onChange={(e) =>
rq.onChange(clampToBulk(e.currentTarget.value))
}
onBlur={rq.onBlur}
error={fieldState.error?.message}
radius="md"
/>
)}
/>
</ToggleRow>
)}
/>
</div>
)}
</div>
)}
@@ -364,53 +542,61 @@ export function Step5CargoDetails({
<Controller
name={`containers.${index}.qty`}
control={form.control}
render={({ field: qtyField, fieldState }) => (
<div>
<Text size="sm" fw={500} mb={4}>
Quantity *
</Text>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
qtyField.onChange(
Math.max(
1,
Number(qtyField.value ?? 1) - 1,
).toString(),
)
}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
>
</button>
<input
value={qtyField.value ?? 1}
onChange={(e) => qtyField.onChange(e.target.value)}
onBlur={qtyField.onBlur}
type="number"
min={1}
className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-center text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
<button
type="button"
onClick={() =>
qtyField.onChange(
(Number(qtyField.value ?? 1) + 1).toString(),
)
}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
>
+
</button>
</div>
{fieldState.error?.message && (
<Text size="xs" c="red" mt={4}>
{fieldState.error.message}
render={({ field: qtyField, fieldState }) => {
// Keep the per-line hazmat/reefer quantities within the new
// line quantity whenever it drops, so an old larger value
// can't outlive a shrink.
const setQty = (next: number) => {
const n = Math.max(1, next);
qtyField.onChange(n.toString());
clampDependentQty(index, n);
};
return (
<div>
<Text size="sm" fw={500} mb={4}>
Quantity *
</Text>
)}
</div>
)}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
setQty(Number(qtyField.value ?? 1) - 1)
}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
>
</button>
<input
value={qtyField.value ?? 1}
onChange={(e) => {
qtyField.onChange(e.target.value);
const n = Number(e.target.value);
if (!Number.isNaN(n) && n >= 1)
clampDependentQty(index, n);
}}
onBlur={qtyField.onBlur}
type="number"
min={1}
className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-center text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
<button
type="button"
onClick={() =>
setQty(Number(qtyField.value ?? 1) + 1)
}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
>
+
</button>
</div>
{fieldState.error?.message && (
<Text size="xs" c="red" mt={4}>
{fieldState.error.message}
</Text>
)}
</div>
);
}}
/>
<Controller
@@ -431,6 +617,101 @@ export function Step5CargoDetails({
)}
/>
</div>
{/* Per-container hazmat / reefer. Each switch reveals a bounded
"how many of this line" input (1..line quantity). */}
<div className="grid gap-3 sm:grid-cols-2">
<Controller
name={`containers.${index}.isHazardous`}
control={form.control}
render={({ field: hazField }) => (
<ToggleRow
icon={<Flame size={18} />}
iconBg="#FBEAE7"
iconColor="#C0392B"
title="Hazardous"
description="Some of these containers carry hazardous cargo."
checked={!!hazField.value}
onChange={(v) => {
hazField.onChange(v);
form.setValue(
`containers.${index}.hazardousQty`,
v ? defaultLineQty(index) : "0",
{ shouldDirty: true, shouldValidate: true },
);
}}
>
<Controller
name={`containers.${index}.hazardousQty`}
control={form.control}
render={({ field: hq, fieldState }) => (
<TextInput
type="number"
size="sm"
label="How many hazardous?"
min={1}
max={lineMax(index)}
value={hq.value ?? ""}
onChange={(e) =>
hq.onChange(
clampToLine(e.currentTarget.value, index),
)
}
onBlur={hq.onBlur}
error={fieldState.error?.message}
radius="md"
/>
)}
/>
</ToggleRow>
)}
/>
<Controller
name={`containers.${index}.isReefer`}
control={form.control}
render={({ field: reeField }) => (
<ToggleRow
icon={<Snowflake size={18} />}
iconBg="#E9F0F8"
iconColor="#2E5B96"
title="Refrigerated"
description="Some of these containers need reefer transport."
checked={!!reeField.value}
onChange={(v) => {
reeField.onChange(v);
form.setValue(
`containers.${index}.reeferQty`,
v ? defaultLineQty(index) : "0",
{ shouldDirty: true, shouldValidate: true },
);
}}
>
<Controller
name={`containers.${index}.reeferQty`}
control={form.control}
render={({ field: rq, fieldState }) => (
<TextInput
type="number"
size="sm"
label="How many refrigerated?"
min={1}
max={lineMax(index)}
value={rq.value ?? ""}
onChange={(e) =>
rq.onChange(
clampToLine(e.currentTarget.value, index),
)
}
onBlur={rq.onBlur}
error={fieldState.error?.message}
radius="md"
/>
)}
/>
</ToggleRow>
)}
/>
</div>
</div>
))}
</div>

View File

@@ -204,6 +204,12 @@ export function Step8Review({
// Bulk PER_ITEM cargo is a whole item count, not tons — label it accordingly.
const isPerItem = selectedCommodity?.unit_of_measure === "PER_ITEM";
const bulkUnit = isPerItem ? "items" : "tons";
const bulkUnitAmount = (raw?: string) => {
const n = Number(raw ?? 0);
if (Number.isNaN(n)) return "0";
return isPerItem ? String(Math.round(n)) : n.toFixed(1);
};
const totalQuantityRow = isPerItem
? {
label: "Total quantity",
@@ -306,7 +312,16 @@ export function Step8Review({
<DetailRow
label="Modifiers"
value={
[values.isHazardous && "Hazardous", values.isRefrigerated && "Refrigerated"]
(values.cargoType === "container"
? [
values.containers.some((c) => c.isHazardous) && "Hazardous",
values.containers.some((c) => c.isReefer) && "Refrigerated",
]
: [
values.isHazardous && "Hazardous",
values.isRefrigerated && "Refrigerated",
]
)
.filter(Boolean)
.join(", ") || "None"
}
@@ -377,12 +392,26 @@ export function Step8Review({
value={totalQuantityRow.value}
/>
)}
{values.cargoType === "bulk" && values.isHazardous && (
<DetailRow
label="Hazardous"
value={`${bulkUnitAmount(values.bulkHazardousQty)} ${bulkUnit}`}
/>
)}
{values.cargoType === "bulk" && values.isRefrigerated && (
<DetailRow
label="Refrigerated"
value={`${bulkUnitAmount(values.bulkReeferQty)} ${bulkUnit}`}
/>
)}
{values.cargoType === "container" && values.containers.length > 0 && (
<Table mt="sm" withTableBorder withColumnBorders fz="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Hazardous</Table.Th>
<Table.Th>Refrigerated</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -392,6 +421,12 @@ export function Step8Review({
<Table.Tr key={i}>
<Table.Td>{c.containerType || c.type}</Table.Td>
<Table.Td>{c.qty}</Table.Td>
<Table.Td>
{c.isHazardous ? `${c.hazardousQty} of ${c.qty}` : "—"}
</Table.Td>
<Table.Td>
{c.isReefer ? `${c.reeferQty} of ${c.qty}` : "—"}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>

View File

@@ -1,309 +0,0 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Box,
Button,
Group,
Loader,
Paper,
Stack,
Text,
} from "@mantine/core";
import { SmartFileInput } from "@edr/ui-common";
import {
AlertCircle,
ArrowLeft,
CheckCircle2,
Download,
FileText,
Send,
} from "lucide-react";
import type { Freight } from "@edr/types";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import { fileViewUrl } from "@/constants/apiConfig";
import { labelForDocCode } from "@/pages/bookings/resubmit/resubmitDocs";
import { BORDER, GREEN, INK } from "./contract-ui";
type DocumentsValue = Record<string, File | File[] | null>;
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
/** Onboarding document setting code for the company's nationality. */
function documentSettingCode(nationality: string | null | undefined): string {
return nationality === "foreign"
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}
function hasFile(value: File | File[] | null | undefined): boolean {
if (!value) return false;
return Array.isArray(value) ? value.length > 0 : true;
}
/** One row per distinct doc code already on the contract (latest upload). */
function dedupeLatestByCode(files: ContractFile[]): ContractFile[] {
const order: string[] = [];
const latest = new Map<string, ContractFile>();
for (const f of files) {
if (f.code === "contract" || f.code.startsWith("signature_")) continue;
if (!latest.has(f.code)) order.push(f.code);
latest.set(f.code, f);
}
return order.map((c) => latest.get(c)!);
}
/**
* Edit-and-resubmit view for a contract staff returned with CHANGES_REQUESTED.
* The customer reviews the request, updates their documents (business license,
* TIN, national ID, passport, … — driven by the company onboarding setting),
* then resubmits. Documents already on the contract are shown as "on file".
*/
export function ContractChangesRequestedView({
contract,
}: {
contract: Freight.IContract;
}) {
const navigate = useNavigate();
const auth = useAuth();
const queryClient = useQueryClient();
const nationality = auth.company?.company?.nationality as
| string
| null
| undefined;
const settingQuery = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode(nationality) },
}),
);
const files = contract.files ?? [];
const onFile = useMemo(() => dedupeLatestByCode(files), [files]);
const existingCodes = useMemo(() => new Set(files.map((f) => f.code)), [files]);
const [documents, setDocuments] = useState<DocumentsValue>({});
const [showErrors, setShowErrors] = useState(false);
const [error, setError] = useState("");
const fields = settingQuery.data?.fields ?? [];
const missingRequiredKeys = useMemo(
() =>
fields
.filter((f) => f.isRequired)
.filter(
(f) =>
!existingCodes.has(f.fileKey) && !hasFile(documents[f.fileKey]),
)
.map((f) => f.fileKey),
[fields, existingCodes, documents],
);
const updateMutation = useMutation({
mutationFn: (docs: DocumentsValue) =>
api.contracts.update.call({ id: contract.id, dto: {}, documents: docs }),
});
const submitMutation = useMutation({
mutationFn: () => api.contracts.submit.call({ id: contract.id }),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: api.contracts.get.queryKey({ id: contract.id }),
});
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
navigate(`/contracts/${contract.id}`);
},
});
const isBusy =
settingQuery.isLoading ||
updateMutation.isPending ||
submitMutation.isPending;
const resubmit = () => {
if (missingRequiredKeys.length > 0) {
setShowErrors(true);
setError("Please attach all required documents before resubmitting.");
return;
}
setShowErrors(false);
setError("");
const docs: DocumentsValue = {};
for (const [k, v] of Object.entries(documents)) if (hasFile(v)) docs[k] = v;
if (Object.keys(docs).length > 0) {
updateMutation.mutate(docs, { onSuccess: () => submitMutation.mutate() });
} else {
submitMutation.mutate();
}
};
const fieldErrors = showErrors
? Object.fromEntries(missingRequiredKeys.map((k) => [k, "Required"]))
: {};
return (
<Box style={{ padding: "28px 32px 40px" }}>
<Stack gap="lg" className="mx-auto" style={{ maxWidth: 760 }}>
<Group gap="md" align="center" wrap="nowrap">
<Button
variant="subtle"
color="gray"
radius="md"
px={8}
onClick={() => navigate("/contracts")}
>
<ArrowLeft size={18} />
</Button>
<div>
<Text fw={800} fz={22} style={{ color: INK }}>
{contract.reference}
</Text>
<Text size="sm" c="dimmed">
Changes requested update your documents and resubmit.
</Text>
</div>
</Group>
<Alert
color="orange"
radius="lg"
variant="light"
icon={<AlertCircle size={18} />}
title="A reviewer asked for changes"
>
Update the documents below replace anything that needs to change and
attach any required document that isn't on file yet — then resubmit the
contract for review.
</Alert>
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
{onFile.length > 0 && (
<Stack gap={8} mb="lg">
<Text fz={13} fw={700} style={{ color: INK }}>
Already on file
</Text>
{onFile.map((file) => (
<Group
key={file.id}
gap={12}
align="center"
wrap="nowrap"
className="rounded-xl"
style={{ border: `1px solid ${BORDER}`, padding: "10px 14px" }}
>
<Box
style={{
flexShrink: 0,
width: 34,
height: 34,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 9,
backgroundColor: "#EAF1FB",
color: "#2E5B96",
}}
>
<FileText size={17} />
</Box>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text fz="13px" fw={600} style={{ color: INK }} truncate>
{labelForDocCode(file.code)}
</Text>
<Text fz="12px" c="dimmed" truncate>
{file.name}
</Text>
</Box>
<Group gap={8} wrap="nowrap">
<Group gap={5} c={GREEN}>
<CheckCircle2 size={14} />
<Text fz="11.5px" fw={600} c={GREEN}>
On file
</Text>
</Group>
<Button
component="a"
href={fileViewUrl(file.id, true)}
variant="default"
size="compact-xs"
radius="md"
leftSection={<Download size={13} />}
>
Download
</Button>
</Group>
</Group>
))}
</Stack>
)}
<Text fz={13} fw={700} style={{ color: INK }} mb="xs">
Update documents
</Text>
<Text fz="12px" c="dimmed" mb="sm">
Replace any document you need to change. Documents marked required
must be on file before you can resubmit.
</Text>
{settingQuery.isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" color="edr-green" />
</Group>
) : settingQuery.data ? (
<SmartFileInput
file={settingQuery.data}
value={documents}
onChange={setDocuments}
errors={fieldErrors}
/>
) : (
<Text fz="13px" c="dimmed">
No document requirements are configured for your account. You can
resubmit using the documents already on file.
</Text>
)}
{error && (
<Alert
color="red"
radius="md"
icon={<AlertCircle size={16} />}
mt="md"
>
{error}
</Alert>
)}
{(updateMutation.isError || submitMutation.isError) && (
<Alert
color="red"
radius="md"
icon={<AlertCircle size={16} />}
mt="md"
>
Couldn't resubmit. Please try again.
</Alert>
)}
<Button
fullWidth
mt="lg"
radius={10}
color="edr-green"
leftSection={<Send size={16} />}
onClick={resubmit}
loading={isBusy}
disabled={isBusy}
styles={{ root: { height: 46 }, label: { fontSize: 14, fontWeight: 800 } }}
>
{isBusy ? "Resubmitting…" : "Resubmit for review"}
</Button>
</Paper>
</Stack>
</Box>
);
}
export default ContractChangesRequestedView;

View File

@@ -1,5 +1,10 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
Navigate,
useNavigate,
useParams,
useSearchParams,
} from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Badge,
@@ -23,6 +28,7 @@ import {
CheckCircle2,
Download,
Eye,
FileBadge,
FileSignature,
FileText,
Flame,
@@ -46,7 +52,6 @@ import { fileViewUrl } from "@/constants/apiConfig";
import { useFileViewer } from "@/hooks/useFileViewer";
import { labelForDocCode } from "@/pages/bookings/resubmit";
import { ContractClearancePanel } from "./ContractClearancePanel";
import { ContractChangesRequestedView } from "./ContractChangesRequestedView";
import { formatRateUnit } from "./new-contract-form/unit-rates";
import {
BORDER,
@@ -72,14 +77,19 @@ const CLEARANCE_UPLOAD_STATUSES = [
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
// 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([
"business_license",
"commercial_license",
"investment_license",
]);
// Onboarding / company-profile document codes seeded in file-upload-settings.
// These get attached to the contract at creation and belong under "Profile
// documents" rather than the clearance set.
const PROFILE_DOC_CODES = new Set([
"tin_certificate",
"commercial_license",
"business_license",
"investment_license",
"national_id",
"national_id_passport",
"passport",
@@ -98,18 +108,21 @@ interface DocGroup {
* groups are dropped so the tab only renders sections that have files.
*/
function groupContractDocuments(files: ContractFile[]): DocGroup[] {
const businessLicense: ContractFile[] = [];
const profile: ContractFile[] = [];
const clearance: ContractFile[] = [];
for (const f of files) {
// The generated contract PDF lives in the contract list / home rows, not
// here. Signature images are baked into that PDF — skip both.
if (f.code === "contract" || f.code.startsWith("signature_")) continue;
else if (BUSINESS_LICENSE_DOC_CODES.has(f.code)) businessLicense.push(f);
else if (PROFILE_DOC_CODES.has(f.code)) profile.push(f);
else clearance.push(f);
}
return [
{ key: "profile", title: "Profile documents", files: profile },
{ key: "clearance", title: "Clearance documents", files: clearance },
{ key: "businessLicense", title: "Business license", files: businessLicense },
{ key: "profile", title: "Profile documents", files: profile },
].filter((g) => g.files.length > 0);
}
@@ -194,10 +207,11 @@ export default function ContractDetailPage() {
);
}
// Staff returned the contract for changes — show the edit-and-resubmit view
// (update documents → resubmit) instead of the read-only detail.
// Staff returned the contract for changes — send the customer to the full edit
// wizard (edit any term + replace documents → resubmit) rather than the
// read-only detail.
if (contract.status === "CHANGES_REQUESTED") {
return <ContractChangesRequestedView contract={contract} />;
return <Navigate to={`/contracts/${contract.id}/edit`} replace />;
}
const isContainer = contract.freightType === "CONTAINER";
@@ -980,12 +994,14 @@ const KEY_FACT_ACCENT: Record<string, string> = {
// Per-section accent + icon for the Documents tab groups.
const DOC_GROUP_ACCENT: Record<string, string> = {
profile: "#2B6CB0",
clearance: "#C77F09",
businessLicense: "#0A6F4D",
profile: "#2B6CB0",
};
const DOC_GROUP_ICON: Record<string, LucideIcon> = {
profile: FileText,
clearance: Upload,
businessLicense: FileBadge,
profile: FileText,
};
/**

View File

@@ -28,9 +28,14 @@ import {
Upload,
XCircle,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { Navigate, useLocation, useNavigate } from "react-router-dom";
import {
Navigate,
useLocation,
useNavigate,
useParams,
} from "react-router-dom";
import useAuth from "@/hooks/useAuth";
import {
CONTRACT_STEPS,
@@ -47,6 +52,12 @@ import {
operationToProfileType,
operationToTradeDirection,
} from "./new-contract-form/helpers";
import { contractToFormValues } from "./new-contract-form/contractToForm";
import {
ContractDocsEditor,
documentSettingCode,
missingRequiredDocKeys,
} from "./new-contract-form/ContractDocsEditor";
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
import type { ProfileTypeValue } from "@/services/companies.service";
import { StepIndicator } from "./new-contract-form/StepIndicator";
@@ -55,7 +66,6 @@ import {
useContractDraft,
} from "./new-contract-form/useContractDraft";
import {
Step0OperationType,
Step1ContractType,
Step2ServiceType,
Step3CargoScope,
@@ -67,14 +77,42 @@ import { formatRateUnit } from "./new-contract-form/unit-rates";
type PriceModalMode = "submit" | "draft";
export default function NewContractPage() {
/**
* The contract wizard, used both to create a new contract and — in `edit` mode —
* to edit & resubmit a contract staff returned with CHANGES_REQUESTED. Edit mode
* hydrates the form from the saved contract, lets the customer change any term
* and replace documents, then runs the same update → price → submit flow.
*/
export default function NewContractPage({
mode = "create",
}: {
mode?: "create" | "edit";
}) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { id: editId } = useParams<{ id: string }>();
const isEdit = mode === "edit" && Boolean(editId);
const [step, setStep] = useState(0);
const auth = useAuth();
const { data: referenceData, isLoading: refDataLoading } = useQuery(
api.bookings.referenceData.queryOptions(),
);
const { data: editContract } = useQuery({
...api.contracts.get.queryOptions({ input: { id: editId ?? "" } }),
enabled: isEdit,
});
// Onboarding document requirements — used in edit mode to block resubmit until
// every required document is on file (existing or freshly attached).
const editDocSettingQuery = useQuery({
...api.fileUploadSettings.getByCode.queryOptions({
input: {
code: documentSettingCode(
auth.company?.company?.nationality as string | null | undefined,
),
},
}),
enabled: isEdit,
});
// Contract creation is gated on profile approval, same as bookings.
if (!auth.isPending && auth.company && !auth.canBook) {
@@ -105,7 +143,17 @@ export default function NewContractPage() {
const [pricingData, setPricingData] =
useState<GenerateContractPriceResponse | null>(null);
const [priceContractId, setPriceContractId] = useState<string | null>(null);
// In edit mode the contract already exists, so seed its id — this makes
// persistAndPriceMutation take the UPDATE branch instead of creating anew.
const [priceContractId, setPriceContractId] = useState<string | null>(
isEdit ? (editId ?? null) : null,
);
// Documents freshly attached on the review step (edit mode only). Merged into
// the form's `documents` map before the contract is updated.
const [editDocuments, setEditDocuments] = useState<
Record<string, File | File[] | null>
>({});
const [showDocErrors, setShowDocErrors] = useState(false);
const [priceModalMode, setPriceModalMode] = useState<PriceModalMode | null>(
null,
);
@@ -205,7 +253,30 @@ export default function NewContractPage() {
const location = useLocation();
const startFresh =
(location.state as { fresh?: boolean } | null)?.fresh === true;
useContractDraft({ form, step, setStep, fresh: startFresh });
useContractDraft({
form,
step,
setStep,
fresh: startFresh,
enabled: !isEdit,
});
// Edit mode: hydrate the form from the saved contract once both the contract
// and the reference data (needed to rebuild the cargo-type path) have loaded.
const hydratedRef = useRef(false);
useEffect(() => {
if (!isEdit || hydratedRef.current) return;
if (!editContract || !referenceData) return;
hydratedRef.current = true;
const forwarderProfile =
(auth.company?.company?.companyProfiles ?? []).find(
(p) => p.id === editContract.companyProfileId,
)?.type === "freight_forwarder";
form.reset(
contractToFormValues(editContract, referenceData, forwarderProfile),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, editContract, referenceData]);
const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard");
@@ -239,19 +310,43 @@ export default function NewContractPage() {
);
}, [originYard, destinationYard, operationType, referenceData]);
// type -> status ("active" = approved | "pending" = awaiting staff approval)
// for the company's operational profiles. Drives both the select-time gate and
// the per-option dropdown badges.
const profileStatusByType = useMemo(() => {
const m = new Map<string, string>();
for (const p of auth.company?.company?.companyProfiles ?? [])
m.set(p.type, p.status);
return m;
}, [auth.company]);
const profileTypes = useMemo(
() => (auth.company?.company?.companyProfiles ?? []).map((p) => p.type),
[auth.company],
() => [...profileStatusByType.keys()],
[profileStatusByType],
);
// All operation types are always selectable. Picking one the company has no
// profile for prompts a license upload that creates the profile on the fly
// (mirrors the header "Add service" flow).
// (mirrors the header "Add service" flow); picking one backed by a not-yet-
// approved profile is blocked with an "awaiting approval" notice.
const allowedOperations = useMemo<OperationType[]>(
() => [...OPERATION_TYPES],
[],
);
// Approval state of the profile each operation maps to — used for the dropdown
// badges. Intercity rides any customer profile, so always "approved".
const operationStatus = useMemo(
() =>
(op: OperationType): "approved" | "pending" | "missing" => {
if (op === "intercity") return "approved";
const target = operationToProfileType(op, profileTypes);
const status = profileStatusByType.get(target);
if (!status) return "missing";
return status === "active" ? "approved" : "pending";
},
[profileStatusByType, profileTypes],
);
// Create-profile modal state (license upload → createProfileAndSwitch).
const [createTarget, setCreateTarget] = useState<ProfileTypeValue | null>(
null,
@@ -260,6 +355,13 @@ export default function NewContractPage() {
useState<OperationType | null>(null);
const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
const [createError, setCreateError] = useState<string | null>(null);
// After a license is uploaded the new profile comes back "pending", so the
// create-profile modal switches to an "awaiting approval" success state.
const [licenseSubmitted, setLicenseSubmitted] = useState(false);
// Set when the user picks an operation whose profile exists but isn't approved
// yet — drives the "awaiting approval" block modal.
const [pendingApprovalProfile, setPendingApprovalProfile] =
useState<ProfileTypeValue | null>(null);
const createProfileMutation = useMutation({
mutationFn: async ({
@@ -275,11 +377,13 @@ export default function NewContractPage() {
}
},
onSuccess: () => {
// The operation was already set on the form when the modal opened.
setCreateTarget(null);
setPendingOperation(null);
// The new profile comes back "pending", so the user can't proceed under
// this operation yet: revert the select and switch the modal to its
// "awaiting approval" success state (kept open until the user dismisses).
form.setValue("operationType", undefined as never, { shouldDirty: true });
setLicenseFiles([]);
setCreateError(null);
setLicenseSubmitted(true);
},
onError: (err) => {
setCreateError(
@@ -292,15 +396,25 @@ export default function NewContractPage() {
// Intercity (domestic) runs on any existing customer profile — no switch.
if (op === "intercity") return;
const target = operationToProfileType(op, profileTypes) as ProfileTypeValue;
const hasProfile = profileTypes.includes(target);
if (!hasProfile) {
// No matching profile — collect a license and create one.
const status = profileStatusByType.get(target);
if (!status) {
// Case 3 — no matching profile: collect a license and create one.
setPendingOperation(op);
setCreateTarget(target);
setLicenseFiles([]);
setCreateError(null);
setLicenseSubmitted(false);
return;
}
if (status !== "active") {
// Case 2 — profile exists but isn't approved yet: block + revert the
// select so an unusable operation is never left chosen.
setPendingApprovalProfile(target);
form.setValue("operationType", undefined as never, { shouldDirty: true });
return;
}
// Case 1 — approved: proceed, switching the active profile if needed.
if (auth.activeProfileType !== target) {
void auth.switchMode(target as never);
}
@@ -324,6 +438,17 @@ export default function NewContractPage() {
setPendingOperation(null);
setLicenseFiles([]);
setCreateError(null);
setLicenseSubmitted(false);
};
// Dismiss the post-submit "awaiting approval" success state. The select was
// already reverted on success — just close and reset the modal.
const handleCreateProfileDone = () => {
setCreateTarget(null);
setPendingOperation(null);
setLicenseFiles([]);
setCreateError(null);
setLicenseSubmitted(false);
};
const createTargetLabel = createTarget
@@ -466,6 +591,25 @@ export default function NewContractPage() {
const handleSubmitContract = form.handleSubmit((data) => {
try {
// Edit mode: all required documents must be on file (already uploaded or
// freshly attached) before resubmitting, and freshly attached files are
// merged into the form's documents map so the update sends them.
if (isEdit && editContract) {
const missing = missingRequiredDocKeys(
editDocSettingQuery.data,
editContract,
editDocuments,
);
if (missing.length > 0) {
setShowDocErrors(true);
return;
}
setShowDocErrors(false);
form.setValue("documents", {
...(form.getValues("documents") ?? {}),
...editDocuments,
});
}
const apiPayload = buildApiPayload(data);
persistAndPriceMutation.mutate({
payload: apiPayload,
@@ -511,20 +655,23 @@ export default function NewContractPage() {
>
<Box>
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
New Contract
{isEdit ? "Edit Contract" : "New Contract"}
</Title>
<Text size="sm" c="edr-muted" mt={4}>
Define your freight contract scope, routes, and unit rates. Book
shipments against it after signing.
{isEdit
? "Update your contract details and documents, then resubmit it for EDR staff review."
: "Define your freight contract — scope, routes, and unit rates. Book shipments against it after signing."}
</Text>
</Box>
<Button
variant="default"
radius="md"
leftSection={<ChevronLeft size={16} />}
onClick={() => navigate("/contracts")}
onClick={() =>
navigate(isEdit && editId ? `/contracts/${editId}` : "/contracts")
}
>
Back to Contracts
{isEdit ? "Back to Contract" : "Back to Contracts"}
</Button>
</Group>
@@ -535,6 +682,20 @@ export default function NewContractPage() {
onSubmit={(e) => e.preventDefault()}
>
<Box flex={1} p="24px">
{isEdit && (
<Alert
color="orange"
radius="lg"
variant="light"
icon={<AlertCircle size={18} />}
title="A reviewer asked for changes"
mb="lg"
>
Update any contract detail or document that needs to change, then
resubmit the contract for review.
</Alert>
)}
<Box mb="lg">
<StepIndicator step={step} steps={visibleSteps} />
</Box>
@@ -565,12 +726,13 @@ export default function NewContractPage() {
description="Define the operation, contract kind, and the service this contract is for."
/>
<Stack gap={24}>
<Step0OperationType
<Step1ContractType
form={form}
referenceData={referenceData}
allowedOperations={allowedOperations}
onSelect={handleOperationSelect}
onOperationSelect={handleOperationSelect}
operationStatus={operationStatus}
/>
<Step1ContractType form={form} referenceData={referenceData} />
<Step2ServiceType referenceData={referenceData} form={form} />
</Stack>
</StepCard>
@@ -625,6 +787,27 @@ export default function NewContractPage() {
persistAndPriceMutation.isPending &&
persistAndPriceMutation.variables?.mode === "submit"
}
isEdit={isEdit}
documentsEditor={
isEdit && editContract ? (
<ContractDocsEditor
contract={editContract}
value={editDocuments}
onChange={setEditDocuments}
errors={
showDocErrors
? Object.fromEntries(
missingRequiredDocKeys(
editDocSettingQuery.data,
editContract,
editDocuments,
).map((k) => [k, "Required"]),
)
: {}
}
/>
) : undefined
}
/>
)}
</Box>
@@ -891,47 +1074,116 @@ export default function NewContractPage() {
{/* Create-profile modal — opens when the chosen operation type has no
matching company profile yet. Collects a license, creates the profile,
and switches to it (mirrors the header "Add service" flow). */}
then shows an "awaiting approval" state (the new profile is pending). */}
<Modal
opened={createTarget !== null}
onClose={() => {
if (!createProfileMutation.isPending) handleCreateProfileCancel();
if (createProfileMutation.isPending) return;
if (licenseSubmitted) handleCreateProfileDone();
else handleCreateProfileCancel();
}}
title={`Set up your ${createTargetLabel} profile`}
title={
licenseSubmitted
? "Awaiting approval"
: `Set up your ${createTargetLabel} profile`
}
centered
radius="lg"
>
{licenseSubmitted ? (
<Stack gap="md">
<Group gap={10} align="flex-start" wrap="nowrap">
<Box
style={{
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 11,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#ECF6F1",
color: "#0A6F4D",
}}
>
<Check size={18} />
</Box>
<Text size="sm" c="dimmed">
License submitted. Your {createTargetLabel.toLowerCase()} profile
is now awaiting staff approval. We'll notify you once it's
approved then you can create this contract as{" "}
{createTargetLabel.toLowerCase()}.
</Text>
</Group>
<Group justify="flex-end" gap="sm">
<Button color="edr-green" onClick={handleCreateProfileDone}>
Got it
</Button>
</Group>
</Stack>
) : (
<Stack gap="md">
<Text size="sm" c="dimmed">
You don't have a {createTargetLabel.toLowerCase()} profile yet. Add
your business license to create one. It goes to staff for approval
before you can use it.
</Text>
<FileInput
label="Business license"
multiple
clearable
accept="application/pdf,image/png,image/jpeg"
leftSection={<Upload size={16} />}
placeholder="Select license file(s)"
value={licenseFiles}
onChange={(files) => setLicenseFiles(files ?? [])}
error={createError ?? undefined}
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={handleCreateProfileCancel}
disabled={createProfileMutation.isPending}
>
Cancel
</Button>
<Button
color="edr-green"
onClick={handleCreateProfileConfirm}
loading={createProfileMutation.isPending}
>
Submit license
</Button>
</Group>
</Stack>
)}
</Modal>
{/* Awaiting-approval modal — the chosen operation maps to a profile that
exists but isn't approved yet. The select was already reverted. */}
<Modal
opened={pendingApprovalProfile !== null}
onClose={() => setPendingApprovalProfile(null)}
title="Awaiting approval"
centered
radius="lg"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
You don't have a {createTargetLabel.toLowerCase()} profile yet. Add
your business license to create one and continue this contract as{" "}
{createTargetLabel.toLowerCase()}.
Your{" "}
{pendingApprovalProfile
? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ??
pendingApprovalProfile)
: ""}{" "}
profile was submitted and is under staff review. You can start a
contract under it once it's approved.
</Text>
<FileInput
label="Business license"
multiple
clearable
accept="application/pdf,image/png,image/jpeg"
leftSection={<Upload size={16} />}
placeholder="Select license file(s)"
value={licenseFiles}
onChange={(files) => setLicenseFiles(files ?? [])}
error={createError ?? undefined}
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={handleCreateProfileCancel}
disabled={createProfileMutation.isPending}
>
Cancel
</Button>
<Button
color="edr-green"
onClick={handleCreateProfileConfirm}
loading={createProfileMutation.isPending}
onClick={() => setPendingApprovalProfile(null)}
>
Create &amp; continue
OK
</Button>
</Group>
</Stack>

View File

@@ -0,0 +1,192 @@
import { useMemo } from "react";
import { Box, Button, Group, Loader, Stack, Text } from "@mantine/core";
import { SmartFileInput } from "@edr/ui-common";
import { CheckCircle2, Download, FileText } from "lucide-react";
import type { Freight } from "@edr/types";
import { useQuery } from "@tanstack/react-query";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import { fileViewUrl } from "@/constants/apiConfig";
import { labelForDocCode } from "@/pages/bookings/resubmit/resubmitDocs";
import { BORDER, GREEN, INK } from "../contract-ui";
type DocumentsValue = Record<string, File | File[] | null>;
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
/** Onboarding document setting code for the company's nationality. */
function documentSettingCode(nationality: string | null | undefined): string {
return nationality === "foreign"
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}
function hasFile(value: File | File[] | null | undefined): boolean {
if (!value) return false;
return Array.isArray(value) ? value.length > 0 : true;
}
/** One row per distinct doc code already on the contract (latest upload). */
function dedupeLatestByCode(files: ContractFile[]): ContractFile[] {
const order: string[] = [];
const latest = new Map<string, ContractFile>();
for (const f of files) {
if (f.code === "contract" || f.code.startsWith("signature_")) continue;
if (!latest.has(f.code)) order.push(f.code);
latest.set(f.code, f);
}
return order.map((c) => latest.get(c)!);
}
/**
* Document replace/upload block for an existing contract. Lists the documents
* already on file (latest upload per code) and renders the onboarding-driven
* `SmartFileInput` so the customer can replace any of them or attach any
* required document that isn't on file yet. Used on the contract wizard's review
* step when editing a CHANGES_REQUESTED contract.
*
* The keys returned through `onChange` are doc-setting field keys; the parent
* wizard merges them into the form's `documents` map, which is uploaded with the
* contract update.
*/
export function ContractDocsEditor({
contract,
value,
onChange,
errors,
}: {
contract: Freight.IContract;
value: DocumentsValue;
onChange: (next: DocumentsValue) => void;
errors?: Record<string, string>;
}) {
const auth = useAuth();
const nationality = auth.company?.company?.nationality as
| string
| null
| undefined;
const settingQuery = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode(nationality) },
}),
);
const files = contract.files ?? [];
const onFile = useMemo(() => dedupeLatestByCode(files), [files]);
return (
<Stack gap="md">
{onFile.length > 0 && (
<Stack gap={8}>
<Text fz={13} fw={700} style={{ color: INK }}>
Already on file
</Text>
{onFile.map((file) => (
<Group
key={file.id}
gap={12}
align="center"
wrap="nowrap"
className="rounded-xl"
style={{ border: `1px solid ${BORDER}`, padding: "10px 14px" }}
>
<Box
style={{
flexShrink: 0,
width: 34,
height: 34,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 9,
backgroundColor: "#EAF1FB",
color: "#2E5B96",
}}
>
<FileText size={17} />
</Box>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text fz="13px" fw={600} style={{ color: INK }} truncate>
{labelForDocCode(file.code)}
</Text>
<Text fz="12px" c="dimmed" truncate>
{file.name}
</Text>
</Box>
<Group gap={8} wrap="nowrap">
<Group gap={5} c={GREEN}>
<CheckCircle2 size={14} />
<Text fz="11.5px" fw={600} c={GREEN}>
On file
</Text>
</Group>
<Button
component="a"
href={fileViewUrl(file.id, true)}
variant="default"
size="compact-xs"
radius="md"
leftSection={<Download size={13} />}
>
Download
</Button>
</Group>
</Group>
))}
</Stack>
)}
<Box>
<Text fz={13} fw={700} style={{ color: INK }} mb="xs">
Update documents
</Text>
<Text fz="12px" c="dimmed" mb="sm">
Replace any document you need to change. Documents marked required must
be on file before you can resubmit.
</Text>
{settingQuery.isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" color="edr-green" />
</Group>
) : settingQuery.data ? (
<SmartFileInput
file={settingQuery.data}
value={value}
onChange={onChange}
errors={errors}
/>
) : (
<Text fz="13px" c="dimmed">
No document requirements are configured for your account. You can
resubmit using the documents already on file.
</Text>
)}
</Box>
</Stack>
);
}
/**
* Keys of the documents the onboarding setting marks required that are neither
* already on the contract nor freshly attached in `documents`. Empty means the
* customer may resubmit.
*/
export function missingRequiredDocKeys(
setting: Freight.IFileUploadSetting | undefined,
contract: Freight.IContract,
documents: DocumentsValue,
): string[] {
const fields = setting?.fields ?? [];
const existingCodes = new Set((contract.files ?? []).map((f) => f.code));
return fields
.filter((f) => f.isRequired)
.filter(
(f) => !existingCodes.has(f.fileKey) && !hasFile(documents[f.fileKey]),
)
.map((f) => f.fileKey);
}
export { documentSettingCode, hasFile, dedupeLatestByCode };
export default ContractDocsEditor;

View File

@@ -0,0 +1,147 @@
import type { Freight } from "@edr/types";
import type { ContractFormInputValues, OperationType } from "./schema";
import { initialContractFormValues } from "./schema";
/**
* Trade direction → base operation type. The freight-forwarder variants
* (import_ff / export_ff) are selected by the caller when the contract is
* stamped to a freight_forwarder profile (see `isForwarderProfile`).
*/
function directionToOperation(
direction: Freight.ContractTradeDirection,
isForwarderProfile: boolean,
): OperationType {
if (direction === "IMPORT") return isForwarderProfile ? "import_ff" : "import";
if (direction === "EXPORT") return isForwarderProfile ? "export_ff" : "export";
return "intercity";
}
/** ISO timestamp → `YYYY-MM-DD` for the native date input. "" when absent. */
function isoToDateInput(iso: string | null | undefined): string {
if (!iso) return "";
return iso.slice(0, 10);
}
/**
* Rebuild the cargo-type path `[groupId, commodityId]` from a flat cargoTypeId
* by locating which reference cargo-type group owns it.
*/
function cargoTypePathFor(
cargoTypeId: string | null | undefined,
referenceData: Freight.BookingReferenceData | undefined,
): string[] {
if (!cargoTypeId || !referenceData?.cargo_type) return [];
for (const group of referenceData.cargo_type) {
if (group.children?.some((c) => c.id === cargoTypeId)) {
return [group.id, cargoTypeId];
}
}
return [];
}
/**
* Inverse of `buildApiPayload` in NewContractPage: hydrate the contract wizard
* form from a saved contract so a customer can edit a CHANGES_REQUESTED (or
* DRAFT) contract in the same UI used to create it. Missing fields fall back to
* `initialContractFormValues`. Files are not mapped — the wizard's `documents`
* map only holds freshly attached replacements; existing files are listed
* separately by `ContractDocsEditor`.
*/
export function contractToFormValues(
contract: Freight.IContract,
referenceData: Freight.BookingReferenceData | undefined,
isForwarderProfile: boolean,
): Partial<ContractFormInputValues> {
const isContainer = contract.freightType === "CONTAINER";
const isGeneral = contract.contractKind === "GENERAL";
const routes = [...(contract.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
);
const primaryRoute = routes[0];
const extraRoutes = isGeneral
? routes.slice(1).map((r) => ({
originYard: r.originYardId,
destinationYard: r.destinationYardId,
}))
: [];
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))
: [];
const containerSizeCaps: Record<string, number> = {};
if (isContainer && isGeneral) {
for (const s of scope) {
if (s.containerSize && s.quantityCap != null) {
containerSizeCaps[s.containerSize] = s.quantityCap;
}
}
}
// Bulk scope: a single commodity row.
const bulkRow = !isContainer ? scope[0] : undefined;
const cargoTypePath = bulkRow
? cargoTypePathFor(bulkRow.cargoTypeId, referenceData)
: [];
const hasFirstMile = Boolean(contract.firstMilePickupAddress);
const hasLastMile = Boolean(contract.lastMileDeliveryAddress);
return {
...initialContractFormValues,
operationType: directionToOperation(
contract.tradeDirection,
isForwarderProfile,
),
contractKind: isGeneral ? "general_contract" : "one_time",
contractType: contract.renewalOfId ? "renewal" : "new",
previousContractRef: contract.renewalOfId ?? "",
serviceTypeId: contract.serviceTypeId,
paymentCurrency:
contract.paymentCurrency === "ETB" ? "ETB" : "USD",
firstMile: {
enabled: hasFirstMile,
pickUpAddress: contract.firstMilePickupAddress ?? "",
exactLocation: "",
lat: contract.firstMilePickupLat ?? null,
lng: contract.firstMilePickupLng ?? null,
},
lastMile: {
enabled: hasLastMile,
deliveryAddress: contract.lastMileDeliveryAddress ?? "",
exactLocation: "",
lat: contract.lastMileDeliveryLat ?? null,
lng: contract.lastMileDeliveryLng ?? null,
},
customsClearingEnabled: contract.customsClearingEnabled,
customsClearingAgent: contract.customsClearingAgent ?? "",
cargoType: isContainer ? "container" : "bulk",
enabledContainerSizes:
enabledContainerSizes as ContractFormInputValues["enabledContainerSizes"],
containerSizeCaps,
cargoTypePath,
cargoFreeText: bulkRow?.cargoFreeText ?? "",
bulkQuantityCap:
isGeneral && bulkRow?.quantityCap != null ? bulkRow.quantityCap : 0,
isHazardous: contract.isHazardous,
isRefrigerated: contract.isReefer,
originYard: primaryRoute?.originYardId ?? "",
destinationYard: primaryRoute?.destinationYardId ?? "",
extraRoutes,
estimatedShipmentDate: isoToDateInput(contract.estimatedShipmentDate),
documents: {},
};
}
export default contractToFormValues;

View File

@@ -3,11 +3,13 @@ import type { Freight } from "@edr/types";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { Select, Stack, Text } from "@mantine/core";
import { Badge, Group, Select, Stack, Text } from "@mantine/core";
import {
CONTRACT_KIND_OPTIONS,
ContractFormInputValues,
OPERATION_TYPE_OPTIONS,
type ContractFormValues,
type OperationType,
} from "./schema";
import { AlertBox, AsyncComboboxField, fieldStyles } from "./shared";
@@ -31,11 +33,22 @@ interface PreviousContractOption {
export function Step1ContractType({
form,
referenceData,
allowedOperations,
onOperationSelect,
operationStatus,
}: {
form: ContractForm;
referenceData?: Freight.BookingReferenceData;
allowedOperations: OperationType[];
onOperationSelect?: (op: OperationType) => void;
/** Approval state of the profile each operation maps to (for the badges). */
operationStatus?: (op: OperationType) => "approved" | "pending" | "missing";
}) {
const contractType = form.watch("contractType");
const operationData = OPERATION_TYPE_OPTIONS.filter((opt) =>
allowedOperations.includes(opt.value),
).map((opt) => ({ value: opt.value, label: opt.label }));
const previousContractRef = form.watch("previousContractRef");
const [searchQuery, setSearchQuery] = useState("");
@@ -167,38 +180,81 @@ export function Step1ContractType({
return (
<Stack gap={16}>
<div className="grid gap-4 sm:grid-cols-2">
{allowedOperations.length === 0 && (
<AlertBox tone="error">
Your company has no operational profile yet. Complete onboarding to
register as an importer, exporter, or freight forwarder.
</AlertBox>
)}
{/* Operation Type + Contract Kind + New/Renewal on one wrapping row. Each
field keeps a sensible min width and flexes to fill / wrap below on
narrow screens. */}
<div className="flex flex-wrap gap-4">
<Controller
name="operationType"
control={form.control}
render={({ field, fieldState }) => (
<Select
className="flex-1 min-w-[200px]"
label="Operation Type *"
placeholder="Select an operation…"
data={operationData}
value={field.value || null}
onChange={(v) => {
if (!v) return;
field.onChange(v);
onOperationSelect?.(v as OperationType);
}}
onBlur={field.onBlur}
error={fieldState.error?.message}
allowDeselect={false}
radius={10}
checkIconPosition="right"
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
styles={fieldStyles}
renderOption={({ option }) => {
const status = operationStatus?.(option.value as OperationType);
return (
<Group justify="space-between" gap="sm" wrap="nowrap" w="100%">
<Text fz={14}>{option.label}</Text>
{status === "pending" && (
<Badge size="xs" color="yellow" variant="light" radius="sm">
Pending
</Badge>
)}
{status === "missing" && (
<Badge size="xs" color="gray" variant="light" radius="sm">
Add license
</Badge>
)}
</Group>
);
}}
/>
)}
/>
<Controller
name="contractKind"
control={form.control}
render={({ field }) => {
const selected = CONTRACT_KIND_OPTIONS.find(
(o) => o.value === (field.value ?? "one_time"),
);
return (
<div>
<Select
label="Contract Kind *"
data={CONTRACT_KIND_OPTIONS.map((o) => ({
value: o.value,
label: o.label,
}))}
value={field.value ?? "one_time"}
onChange={(v) => field.onChange(v ?? "one_time")}
allowDeselect={false}
radius={10}
checkIconPosition="right"
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
styles={fieldStyles}
/>
{selected && (
<Text fz={12} c="#6B7C8E" mt={6}>
{selected.description}
</Text>
)}
</div>
);
}}
render={({ field }) => (
<Select
className="flex-1 min-w-[200px]"
label="Contract Kind *"
data={CONTRACT_KIND_OPTIONS.map((o) => ({
value: o.value,
label: o.label,
}))}
value={field.value ?? "one_time"}
onChange={(v) => field.onChange(v ?? "one_time")}
allowDeselect={false}
radius={10}
checkIconPosition="right"
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
styles={fieldStyles}
/>
)}
/>
<Controller
@@ -206,6 +262,7 @@ export function Step1ContractType({
control={form.control}
render={({ field, fieldState }) => (
<Select
className="flex-1 min-w-[200px]"
label="New or Renewal *"
placeholder="Select…"
data={CONTRACT_TYPE_OPTIONS}

View File

@@ -132,7 +132,7 @@ function ServiceTypeSelector({
</Text>
</Box>
) : (
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{services.map((s) => {
const selected = s.id === value;
return (
@@ -147,8 +147,8 @@ function ServiceTypeSelector({
position: "relative",
textAlign: "left",
cursor: "pointer",
padding: 16,
borderRadius: 16,
padding: 12,
borderRadius: 14,
border: `1.5px solid ${selected ? GREEN : error ? "#F0B4B4" : BORDER}`,
background: selected ? GREEN_SOFT : "#fff",
boxShadow: selected
@@ -162,10 +162,10 @@ function ServiceTypeSelector({
<Box
style={{
position: "absolute",
top: 12,
right: 12,
width: 22,
height: 22,
top: 10,
right: 10,
width: 18,
height: 18,
borderRadius: 999,
display: "flex",
alignItems: "center",
@@ -175,16 +175,16 @@ function ServiceTypeSelector({
transition: "all 160ms ease",
}}
>
{selected ? <Check size={13} color="#fff" strokeWidth={3} /> : null}
{selected ? <Check size={11} color="#fff" strokeWidth={3} /> : null}
</Box>
<Group gap={11} align="flex-start" wrap="nowrap" mb={10}>
<Group gap={9} align="flex-start" wrap="nowrap" mb={8}>
<Box
style={{
width: 42,
height: 42,
width: 34,
height: 34,
flexShrink: 0,
borderRadius: 12,
borderRadius: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
@@ -193,21 +193,15 @@ function ServiceTypeSelector({
transition: "all 160ms ease",
}}
>
<Container size={20} strokeWidth={2} />
<Container size={17} strokeWidth={2} />
</Box>
<Box style={{ minWidth: 0, paddingRight: 24 }}>
<Text fz={14.5} fw={750} c={INK} style={{ lineHeight: 1.25 }}>
<Box style={{ minWidth: 0, paddingRight: 20 }}>
<Text fz={13} fw={700} c={INK} style={{ lineHeight: 1.25 }}>
{s.serviceName}
</Text>
</Box>
</Group>
{s.description ? (
<Text fz={12.5} c={MUTED} mb={11} style={{ lineHeight: 1.45 }}>
{s.description}
</Text>
) : null}
<Group gap={6} wrap="wrap">
{serviceFeatures(s).map((f) => (
<FeatureChip
@@ -315,6 +309,9 @@ export function Step2ServiceType({
<Stack gap={12}>
<StepLabel>Trucking & customs options</StepLabel>
{/* First mile, last mile and customs sit side by side on one wrapping
row; each keeps a min width and stacks below on narrow screens. */}
<div className="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:min-w-[280px]">
{includesFirstMile && (
<Controller
name="firstMile.enabled"
@@ -577,6 +574,7 @@ export function Step2ServiceType({
)}
/>
)}
</div>
</Stack>
)}
</Stack>

View File

@@ -188,6 +188,8 @@ export function Step8Review({
onSubmit,
saveDraftPending = false,
submitPending = false,
documentsEditor,
isEdit = false,
}: {
form: ContractForm;
/** Retained for caller compatibility; the review page is read-only. */
@@ -201,6 +203,14 @@ export function Step8Review({
onSubmit?: () => void;
saveDraftPending?: boolean;
submitPending?: boolean;
/**
* Document replace/upload block, shown only when editing an existing contract
* (resubmit after CHANGES_REQUESTED). Omitted for fresh creation, where docs
* come from the company profile automatically.
*/
documentsEditor?: React.ReactNode;
/** Editing an existing contract (resubmit) rather than creating a new one. */
isEdit?: boolean;
}) {
const values = form.watch();
const serviceType = referenceData?.service.find(
@@ -429,6 +439,17 @@ export function Step8Review({
/>
)}
{documentsEditor && (
<Paper
radius={18}
p="lg"
withBorder
style={{ borderColor: "var(--mantine-color-edr-border-0)" }}
>
{documentsEditor}
</Paper>
)}
<Controller
name="notes"
control={form.control}
@@ -485,9 +506,13 @@ export function Step8Review({
<Paper radius={20} p="lg" withBorder bg="white">
<Text size="sm" c="dimmed" mb="md">
{pricing
? "Review your unit-rate quotation. Approve to submit the contract for EDR staff review."
: "Ready to submit. You'll review the unit-rate quotation before final submission."}
{isEdit
? pricing
? "Review your updated unit-rate quotation. Approve to resubmit the contract for EDR staff review."
: "Ready to resubmit. You'll review the unit-rate quotation before final resubmission."
: pricing
? "Review your unit-rate quotation. Approve to submit the contract for EDR staff review."
: "Ready to submit. You'll review the unit-rate quotation before final submission."}
</Text>
<Stack gap="sm">
<Button
@@ -501,20 +526,28 @@ export function Step8Review({
loading={submitPending}
disabled={submitPending}
>
{pricing ? "Approve quotation & submit" : "Submit"}
</Button>
<Button
type="button"
variant="outline"
color="edr-green"
radius="md"
fullWidth
onClick={onSaveDraft}
loading={saveDraftPending}
disabled={submitPending}
>
Save as draft
{isEdit
? pricing
? "Approve quotation & resubmit"
: "Resubmit"
: pricing
? "Approve quotation & submit"
: "Submit"}
</Button>
{!isEdit && (
<Button
type="button"
variant="outline"
color="edr-green"
radius="md"
fullWidth
onClick={onSaveDraft}
loading={saveDraftPending}
disabled={submitPending}
>
Save as draft
</Button>
)}
</Stack>
</Paper>
</Stack>

View File

@@ -1,4 +1,3 @@
export { Step0OperationType } from "./step0-operation-type";
export { Step1ContractType } from "./step1-contract-type";
export { Step2ServiceType } from "./step2-service-type";
export { Step3CargoScope } from "./step3-cargo-scope";

View File

@@ -58,14 +58,22 @@ export function useContractDraft({
step,
setStep,
fresh,
enabled = true,
}: {
form: ContractForm;
step: number;
setStep: (step: number) => void;
fresh: boolean;
/**
* When false the draft is neither restored nor persisted — used in edit mode,
* where the server contract is the source of truth and the localStorage key is
* shared with the create flow.
*/
enabled?: boolean;
}): { clearDraft: () => void } {
const restoredRef = useRef(false);
useEffect(() => {
if (!enabled) return;
if (restoredRef.current) return;
restoredRef.current = true;
@@ -107,6 +115,7 @@ export function useContractDraft({
};
useEffect(() => {
if (!enabled) return;
if (!restoredRef.current) return;
const sub = form.watch(() => {
if (timerRef.current) clearTimeout(timerRef.current);
@@ -117,9 +126,10 @@ export function useContractDraft({
if (timerRef.current) clearTimeout(timerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form]);
}, [form, enabled]);
useEffect(() => {
if (!enabled) return;
if (!restoredRef.current) return;
write();
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -432,6 +432,10 @@ export interface IBooking extends BaseEntity {
isHazardous: boolean;
isRefrigerated: boolean;
/** Bulk-only hazardous amount in the cargo's unit (tons/items); 0 otherwise. */
bulkHazardousQuantity?: number;
/** Bulk-only refrigerated amount in the cargo's unit (tons/items); 0 otherwise. */
bulkReeferQuantity?: number;
tradeDirection: "IMPORT" | "EXPORT";
paymentCurrency: string;
@@ -717,6 +721,10 @@ export interface CreateBookingContainerDto {
containerTypeId: string;
quantity: number;
vgmPerUnitTons: number;
/** How many of this line's containers are hazardous (0..quantity). */
hazardousQuantity?: number;
/** How many of this line's containers are refrigerated (0..quantity). */
reeferQuantity?: number;
}
/** A contracted route+quantity line for a GENERAL contract. */
@@ -768,6 +776,10 @@ export interface CreateBookingDto {
isHazardous?: boolean | undefined;
/** Booking-level refrigerated flag (bulk freight only; containers derive reefer from the container type). */
isReefer?: boolean | undefined;
/** Bulk-only: hazardous amount in the cargo's unit (tons or items), <= cargoTotalWeightVgm. */
bulkHazardousQuantity?: number | undefined;
/** Bulk-only: refrigerated amount in the cargo's unit (tons or items), <= cargoTotalWeightVgm. */
bulkReeferQuantity?: number | undefined;
paymentCurrency: string;
pnrCode?: string | undefined;
startDate?: string | undefined;

22
pnpm-lock.yaml generated
View File

@@ -326,6 +326,9 @@ importers:
'@mantine/core':
specifier: ^9.3.0
version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/dates':
specifier: ^9.3.0
version: 9.3.2(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/hooks':
specifier: ^9.3.0
version: 9.3.0(react@19.2.6)
@@ -12495,10 +12498,19 @@ snapshots:
transitivePeerDependencies:
- '@types/react'
'@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
'@mantine/dates@7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/hooks': 7.17.8(react@19.2.6)
'@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/hooks': 9.3.0(react@19.2.6)
clsx: 2.1.1
dayjs: 1.11.21
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
'@mantine/dates@9.3.2(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/hooks': 9.3.0(react@19.2.6)
clsx: 2.1.1
dayjs: 1.11.21
react: 19.2.6
@@ -15399,7 +15411,7 @@ snapshots:
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
'@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/hooks': 7.17.8(react@19.2.6)
'@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -20233,7 +20245,7 @@ snapshots:
mantine-react-table@2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/hooks': 7.17.8(react@19.2.6)
'@tabler/icons-react': 3.44.0(react@19.2.6)
'@tanstack/match-sorter-utils': 8.19.4