mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 11:55:42 +00:00
enhance contract review and editing experience
This commit is contained in:
@@ -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;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -121,6 +121,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
containerTypeId: string;
|
containerTypeId: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
vgmPerUnitTons: number;
|
vgmPerUnitTons: number;
|
||||||
|
hazardousQuantity?: number;
|
||||||
|
reeferQuantity?: number;
|
||||||
weightResult: ContainerWeightResult;
|
weightResult: ContainerWeightResult;
|
||||||
}>,
|
}>,
|
||||||
): Promise<BookingContainer[]> {
|
): Promise<BookingContainer[]> {
|
||||||
@@ -133,11 +135,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
|
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
|
||||||
const totalVgm = item.quantity * item.vgmPerUnitTons;
|
const totalVgm = item.quantity * item.vgmPerUnitTons;
|
||||||
const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit);
|
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({
|
const row = containerRepo.create({
|
||||||
bookingId,
|
bookingId,
|
||||||
containerTypeId: item.containerTypeId,
|
containerTypeId: item.containerTypeId,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
|
hazardousQuantity: clamp(item.hazardousQuantity),
|
||||||
|
reeferQuantity: clamp(item.reeferQuantity),
|
||||||
vgmPerUnitTons: item.vgmPerUnitTons,
|
vgmPerUnitTons: item.vgmPerUnitTons,
|
||||||
totalVgmTons: totalVgm,
|
totalVgmTons: totalVgm,
|
||||||
wagonsRequired,
|
wagonsRequired,
|
||||||
|
|||||||
@@ -66,6 +66,17 @@ const NEEDS_ACTION_STATUSES = [
|
|||||||
'APPROVED_PENDING_SIGNATURE',
|
'APPROVED_PENDING_SIGNATURE',
|
||||||
] as const;
|
] 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()
|
@Injectable()
|
||||||
export class BookingsService {
|
export class BookingsService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -505,6 +516,16 @@ export class BookingsService {
|
|||||||
// the container type at pricing time, so the booking-level flag stays off
|
// the container type at pricing time, so the booking-level flag stays off
|
||||||
// for container freight to avoid double-counting.
|
// for container freight to avoid double-counting.
|
||||||
isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false,
|
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,
|
paymentCurrency: dto.paymentCurrency,
|
||||||
pnrCode: dto.pnrCode,
|
pnrCode: dto.pnrCode,
|
||||||
financialTerms: dto.financialTerms,
|
financialTerms: dto.financialTerms,
|
||||||
@@ -527,6 +548,8 @@ export class BookingsService {
|
|||||||
containerTypeId: c.containerTypeId,
|
containerTypeId: c.containerTypeId,
|
||||||
quantity: c.quantity,
|
quantity: c.quantity,
|
||||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||||
|
hazardousQuantity: c.hazardousQuantity,
|
||||||
|
reeferQuantity: c.reeferQuantity,
|
||||||
weightResult: ruleResult.containerWeightResults[i],
|
weightResult: ruleResult.containerWeightResults[i],
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -664,6 +687,8 @@ export class BookingsService {
|
|||||||
containers,
|
containers,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const cargoAmount =
|
||||||
|
dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0);
|
||||||
const updates: Record<string, unknown> = {
|
const updates: Record<string, unknown> = {
|
||||||
...dto,
|
...dto,
|
||||||
freightType,
|
freightType,
|
||||||
@@ -674,6 +699,22 @@ export class BookingsService {
|
|||||||
freightType === 'BULK'
|
freightType === 'BULK'
|
||||||
? (dto.isReefer ?? existing.isReefer ?? false)
|
? (dto.isReefer ?? existing.isReefer ?? false)
|
||||||
: 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,
|
priorityScore: ruleResult.priorityScore,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
};
|
};
|
||||||
@@ -720,6 +761,8 @@ export class BookingsService {
|
|||||||
containerTypeId: c.containerTypeId,
|
containerTypeId: c.containerTypeId,
|
||||||
quantity: c.quantity,
|
quantity: c.quantity,
|
||||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||||
|
hazardousQuantity: c.hazardousQuantity,
|
||||||
|
reeferQuantity: c.reeferQuantity,
|
||||||
weightResult: ruleResult.containerWeightResults[i],
|
weightResult: ruleResult.containerWeightResults[i],
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -52,6 +52,28 @@ export class CreateBookingContainerDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
@Transform(({ value }) => Number(value))
|
@Transform(({ value }) => Number(value))
|
||||||
vgmPerUnitTons!: number;
|
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)
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
isReefer?: boolean;
|
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 })
|
@ApiProperty({ enum: PAYMENT_CURRENCIES })
|
||||||
@IsIn([...PAYMENT_CURRENCIES])
|
@IsIn([...PAYMENT_CURRENCIES])
|
||||||
paymentCurrency!: string;
|
paymentCurrency!: string;
|
||||||
|
|||||||
@@ -320,6 +320,19 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'is_reefer', type: 'boolean', default: false })
|
@Column({ name: 'is_reefer', type: 'boolean', default: false })
|
||||||
isReefer!: boolean;
|
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 })
|
@Column({ name: 'payment_currency', type: 'varchar', length: 5 })
|
||||||
paymentCurrency!: string;
|
paymentCurrency!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ export function BookingActionsMenu({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hasMenu = listRowHasActions(row, user);
|
const hasMenu = listRowHasActions(row, user);
|
||||||
const primary = actions.find((a) => a.primary) ?? actions[0];
|
|
||||||
|
|
||||||
if (!hasMenu && variant === "table") {
|
if (!hasMenu && variant === "table") {
|
||||||
return (
|
return (
|
||||||
@@ -117,19 +116,6 @@ export function BookingActionsMenu({
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={(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 position="bottom-end" width={220} withinPortal>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,13 +7,16 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
FileText,
|
FileText,
|
||||||
|
Files,
|
||||||
Flame,
|
Flame,
|
||||||
|
LayoutGrid,
|
||||||
Package,
|
Package,
|
||||||
Receipt,
|
Receipt,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Route as RouteIcon,
|
Route as RouteIcon,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Snowflake,
|
Snowflake,
|
||||||
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -31,6 +34,8 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import "@/components/overview/overview.css";
|
import "@/components/overview/overview.css";
|
||||||
import { PageContainer } from "@/components/page";
|
import { PageContainer } from "@/components/page";
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
@@ -41,11 +46,18 @@ import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflow
|
|||||||
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
|
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
|
||||||
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
||||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||||
|
import {
|
||||||
|
ContractCustomerCard,
|
||||||
|
ContractDocumentsCard,
|
||||||
|
} from "@/components/contracts/detail/ContractDetailTabCards";
|
||||||
import { getContractStatusMeta } from "@/features/contracts/contract-status.config";
|
import { getContractStatusMeta } from "@/features/contracts/contract-status.config";
|
||||||
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
import {
|
import {
|
||||||
useContractDetail,
|
useContractDetail,
|
||||||
useContractMutations,
|
useContractMutations,
|
||||||
} from "@/hooks/contracts/useContracts";
|
} 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).
|
// Clearance phase — staff can still ACT (approve / query / finalize).
|
||||||
const CLEARANCE_ACTIVE_STATUSES = [
|
const CLEARANCE_ACTIVE_STATUSES = [
|
||||||
@@ -94,7 +106,8 @@ export default function ContractRequestDetailPage() {
|
|||||||
} = useContractDetail(id);
|
} = useContractDetail(id);
|
||||||
const mutations = useContractMutations(id ?? "");
|
const mutations = useContractMutations(id ?? "");
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
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) =>
|
const setTab = (tab: string) =>
|
||||||
setSearchParams(
|
setSearchParams(
|
||||||
(prev) => {
|
(prev) => {
|
||||||
@@ -106,6 +119,23 @@ export default function ContractRequestDetailPage() {
|
|||||||
{ replace: true },
|
{ 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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
@@ -176,9 +206,17 @@ export default function ContractRequestDetailPage() {
|
|||||||
const clearanceReadOnly = CLEARANCE_DONE_STATUSES.includes(contract.status);
|
const clearanceReadOnly = CLEARANCE_DONE_STATUSES.includes(contract.status);
|
||||||
// Path A (no customs) → Operations reviews; Path B (customs) → GL reviews.
|
// Path A (no customs) → Operations reviews; Path B (customs) → GL reviews.
|
||||||
const selfClear = !contract.customsClearingEnabled;
|
const selfClear = !contract.customsClearingEnabled;
|
||||||
// If the tab param points at clearance but the contract isn't in a clearance
|
const files = contract.files ?? [];
|
||||||
// phase, fall back to details so we never show an empty tab.
|
// Resolve the active tab from the URL, falling back to details when the
|
||||||
const currentTab = activeTab === "clearance" && showClearanceTab ? "clearance" : "details";
|
// 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
|
const customerLabel = contract.isGovernment
|
||||||
? (contract.governmentInstitution ?? "Government")
|
? (contract.governmentInstitution ?? "Government")
|
||||||
@@ -264,27 +302,43 @@ export default function ContractRequestDetailPage() {
|
|||||||
description={statusMeta.description}
|
description={statusMeta.description}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showClearanceTab && (
|
<Tabs
|
||||||
<Tabs
|
value={currentTab}
|
||||||
value={currentTab}
|
onChange={(v) => setTab(v ?? "details")}
|
||||||
onChange={(v) => setTab(v ?? "details")}
|
variant="pills"
|
||||||
variant="pills"
|
color="edr-green"
|
||||||
color="edr-green"
|
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
>
|
||||||
>
|
<Tabs.List>
|
||||||
<Tabs.List>
|
<Tabs.Tab value="details" leftSection={<LayoutGrid size={16} />}>
|
||||||
<Tabs.Tab value="details" leftSection={<FileText size={16} />}>
|
Details
|
||||||
Details
|
</Tabs.Tab>
|
||||||
</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
|
<Tabs.Tab
|
||||||
value="clearance"
|
value="clearance"
|
||||||
leftSection={<ShieldCheck size={16} />}
|
leftSection={<ShieldCheck size={16} />}
|
||||||
>
|
>
|
||||||
Clearance Review
|
Clearance Review
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
)}
|
||||||
</Tabs>
|
</Tabs.List>
|
||||||
)}
|
</Tabs>
|
||||||
|
|
||||||
<Grid gap="lg">
|
<Grid gap="lg">
|
||||||
{/* LEFT — primary content */}
|
{/* LEFT — primary content */}
|
||||||
@@ -296,6 +350,14 @@ export default function ContractRequestDetailPage() {
|
|||||||
readOnly={clearanceReadOnly}
|
readOnly={clearanceReadOnly}
|
||||||
onChanged={() => refetch()}
|
onChanged={() => refetch()}
|
||||||
/>
|
/>
|
||||||
|
) : currentTab === "documents" ? (
|
||||||
|
<ContractDocumentsCard
|
||||||
|
files={files}
|
||||||
|
onView={handleViewFile}
|
||||||
|
onDownload={handleDownloadFile}
|
||||||
|
/>
|
||||||
|
) : currentTab === "customer" ? (
|
||||||
|
<ContractCustomerCard contract={contract} />
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<SectionCard icon={RouteIcon} title="Routes">
|
<SectionCard icon={RouteIcon} title="Routes">
|
||||||
@@ -453,6 +515,8 @@ export default function ContractRequestDetailPage() {
|
|||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
{viewer}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@edr/ui-common": "workspace:*",
|
"@edr/ui-common": "workspace:*",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@mantine/core": "^9.3.0",
|
"@mantine/core": "^9.3.0",
|
||||||
|
"@mantine/dates": "^9.3.0",
|
||||||
"@mantine/hooks": "^9.3.0",
|
"@mantine/hooks": "^9.3.0",
|
||||||
"@tanstack/react-query": "^5.59.0",
|
"@tanstack/react-query": "^5.59.0",
|
||||||
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz",
|
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz",
|
||||||
|
|||||||
@@ -272,6 +272,10 @@ const App = () => {
|
|||||||
/>
|
/>
|
||||||
<Route path="/contracts" element={<ContractsList />} />
|
<Route path="/contracts" element={<ContractsList />} />
|
||||||
<Route path="/contracts/new" element={<NewContractPage />} />
|
<Route path="/contracts/new" element={<NewContractPage />} />
|
||||||
|
<Route
|
||||||
|
path="/contracts/:id/edit"
|
||||||
|
element={<NewContractPage mode="edit" />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/contracts/:id/bookings/new"
|
path="/contracts/:id/bookings/new"
|
||||||
element={<NewShipmentPage />}
|
element={<NewShipmentPage />}
|
||||||
|
|||||||
@@ -796,7 +796,7 @@ export function AppLayout({
|
|||||||
{/* ── Main ── */}
|
{/* ── Main ── */}
|
||||||
<AppShell.Main
|
<AppShell.Main
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "#F1F5F9",
|
backgroundColor: "##f8fafc",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { BrowserRouter } from "react-router-dom";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import "@mantine/core/styles.css";
|
import "@mantine/core/styles.css";
|
||||||
|
import "@mantine/dates/styles.css";
|
||||||
import "@edr/ui-common/styles.css";
|
import "@edr/ui-common/styles.css";
|
||||||
import "../index.css";
|
import "../index.css";
|
||||||
import "@edr/ui-common/theme.css";
|
import "@edr/ui-common/theme.css";
|
||||||
|
|||||||
@@ -29,9 +29,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Download,
|
Download,
|
||||||
FileText,
|
FileText,
|
||||||
Flame,
|
|
||||||
MapPin,
|
MapPin,
|
||||||
Snowflake,
|
|
||||||
Truck,
|
Truck,
|
||||||
Upload,
|
Upload,
|
||||||
X,
|
X,
|
||||||
@@ -102,6 +100,8 @@ function defaultContainerTypeForSize(
|
|||||||
interface BookingContainerRow {
|
interface BookingContainerRow {
|
||||||
quantity: number;
|
quantity: number;
|
||||||
vgmPerUnitTons: number | string;
|
vgmPerUnitTons: number | string;
|
||||||
|
hazardousQuantity?: number | string | null;
|
||||||
|
reeferQuantity?: number | string | null;
|
||||||
containerType?: {
|
containerType?: {
|
||||||
sizeFt?: number | null;
|
sizeFt?: number | null;
|
||||||
label?: string | null;
|
label?: string | null;
|
||||||
@@ -150,6 +150,8 @@ function mapBookingToFormValues(
|
|||||||
cargoWeight: String(booking.cargoTotalWeightVgm ?? ""),
|
cargoWeight: String(booking.cargoTotalWeightVgm ?? ""),
|
||||||
isHazardous: booking.isHazardous ?? false,
|
isHazardous: booking.isHazardous ?? false,
|
||||||
isRefrigerated: booking.isRefrigerated ?? false,
|
isRefrigerated: booking.isRefrigerated ?? false,
|
||||||
|
bulkHazardousQty: String(Number(booking.bulkHazardousQuantity ?? 0)),
|
||||||
|
bulkReeferQty: String(Number(booking.bulkReeferQuantity ?? 0)),
|
||||||
paymentCurrency:
|
paymentCurrency:
|
||||||
booking.paymentCurrency === "ETB" ? "ETB" : "USD",
|
booking.paymentCurrency === "ETB" ? "ETB" : "USD",
|
||||||
scheduledDate: booking.scheduledDate
|
scheduledDate: booking.scheduledDate
|
||||||
@@ -183,10 +185,16 @@ function mapBookingToFormValues(
|
|||||||
? bc.containerType.label
|
? bc.containerType.label
|
||||||
: (bc.containerType?.code ??
|
: (bc.containerType?.code ??
|
||||||
defaultContainerTypeForSize(referenceData, size));
|
defaultContainerTypeForSize(referenceData, size));
|
||||||
|
const hazQty = Number(bc.hazardousQuantity ?? 0);
|
||||||
|
const reeQty = Number(bc.reeferQuantity ?? 0);
|
||||||
return {
|
return {
|
||||||
type: size,
|
type: size,
|
||||||
containerType: typeName,
|
containerType: typeName,
|
||||||
qty: String(bc.quantity ?? 1),
|
qty: String(bc.quantity ?? 1),
|
||||||
|
isHazardous: hazQty > 0,
|
||||||
|
hazardousQty: String(hazQty),
|
||||||
|
isReefer: reeQty > 0,
|
||||||
|
reeferQty: String(reeQty),
|
||||||
vgm: String(Number(bc.vgmPerUnitTons ?? 0)),
|
vgm: String(Number(bc.vgmPerUnitTons ?? 0)),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -195,6 +203,10 @@ function mapBookingToFormValues(
|
|||||||
type: "20ft" as const,
|
type: "20ft" as const,
|
||||||
containerType: defaultContainerTypeForSize(referenceData, "20ft"),
|
containerType: defaultContainerTypeForSize(referenceData, "20ft"),
|
||||||
qty: "1",
|
qty: "1",
|
||||||
|
isHazardous: false,
|
||||||
|
hazardousQty: "0",
|
||||||
|
isReefer: false,
|
||||||
|
reeferQty: "0",
|
||||||
vgm: "",
|
vgm: "",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -435,7 +447,27 @@ export default function EditBookingPage() {
|
|||||||
: "IMPORT",
|
: "IMPORT",
|
||||||
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
|
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
|
||||||
cargoTotalWeightVgm: totalWeight,
|
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,
|
paymentCurrency: data.paymentCurrency,
|
||||||
freightType:
|
freightType:
|
||||||
data.cargoType === "container"
|
data.cargoType === "container"
|
||||||
@@ -446,6 +478,8 @@ export default function EditBookingPage() {
|
|||||||
? data.containers.map((c) => ({
|
? data.containers.map((c) => ({
|
||||||
containerTypeId: findContainerTypeId(c.containerType),
|
containerTypeId: findContainerTypeId(c.containerType),
|
||||||
quantity: Number(c.qty || 1),
|
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),
|
vgmPerUnitTons: Number(c.vgm || 0),
|
||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
@@ -857,35 +891,9 @@ export default function EditBookingPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Paper withBorder radius="md">
|
{/* Cargo handling (hazardous / refrigerated, with per-unit amounts)
|
||||||
<Controller
|
now lives in the Cargo tab via Step5CargoDetails — no separate
|
||||||
name="isHazardous"
|
toggles here, to avoid duplicate controls that diverge. */}
|
||||||
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>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
|||||||
@@ -458,10 +458,28 @@ export default function NewBookingPage() {
|
|||||||
// Day-level pool: the customer picks only a day (scheduledDate); the batch
|
// Day-level pool: the customer picks only a day (scheduledDate); the batch
|
||||||
// engine assigns the train, so no trainScheduleId is sent.
|
// engine assigns the train, so no trainScheduleId is sent.
|
||||||
cargoTotalWeightVgm: totalWeight,
|
cargoTotalWeightVgm: totalWeight,
|
||||||
isHazardous: data.isHazardous,
|
// Booking-level flags drive the HAZARD / REEFER surcharge triggers. For
|
||||||
// Reefer is a customer choice for bulk only; container reefer is decided by
|
// containers they're the OR of the per-container switches; for bulk they
|
||||||
// the container type on the backend, so never send it for containers.
|
// come from the cargo-step toggles.
|
||||||
isReefer: data.cargoType === "bulk" ? data.isRefrigerated : false,
|
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:
|
freightType:
|
||||||
data.cargoType === "container"
|
data.cargoType === "container"
|
||||||
? ("CONTAINER" as const)
|
? ("CONTAINER" as const)
|
||||||
@@ -471,6 +489,9 @@ export default function NewBookingPage() {
|
|||||||
? data.containers.map((c) => ({
|
? data.containers.map((c) => ({
|
||||||
containerTypeId: findContainerTypeId(c.containerType),
|
containerTypeId: findContainerTypeId(c.containerType),
|
||||||
quantity: Number(c.qty || 1),
|
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.
|
// Weight (VGM) is not collected at the wizard — captured in operations.
|
||||||
vgmPerUnitTons: 0,
|
vgmPerUnitTons: 0,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -169,6 +169,11 @@ export const bookingFormSchema = z
|
|||||||
cargoFreeText: z.string(),
|
cargoFreeText: z.string(),
|
||||||
isHazardous: z.boolean(),
|
isHazardous: z.boolean(),
|
||||||
isRefrigerated: 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(
|
containers: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
type: z.enum(["20ft", "40ft"]),
|
type: z.enum(["20ft", "40ft"]),
|
||||||
@@ -178,6 +183,13 @@ export const bookingFormSchema = z
|
|||||||
.refine((q) => q.length !== 0, "Quantity is required.")
|
.refine((q) => q.length !== 0, "Quantity is required.")
|
||||||
.refine((q) => !isNaN(+q), "Enter a valid Number")
|
.refine((q) => !isNaN(+q), "Enter a valid Number")
|
||||||
.refine((qty) => Number(qty) >= 1, "Must be greater than 0"),
|
.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
|
// Weight (VGM) is NOT collected at the wizard — it is captured later in
|
||||||
// operations. Kept optional so existing payload code stays valid.
|
// operations. Kept optional so existing payload code stays valid.
|
||||||
vgm: z.string().default("0"),
|
vgm: z.string().default("0"),
|
||||||
@@ -253,9 +265,48 @@ export const bookingFormSchema = z
|
|||||||
message: "Select a Cargo type.",
|
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") {
|
if (data.cargoType === "container") {
|
||||||
data.containers.forEach((c, i) => {
|
data.containers.forEach((c, i) => {
|
||||||
|
const lineQty = Number(c.qty);
|
||||||
if (!c.qty || +c.qty < 1) {
|
if (!c.qty || +c.qty < 1) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
@@ -263,6 +314,40 @@ export const bookingFormSchema = z
|
|||||||
message: "Enter at least 1 container.",
|
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: "",
|
cargoFreeText: "",
|
||||||
isHazardous: false,
|
isHazardous: false,
|
||||||
isRefrigerated: 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: {},
|
documents: {},
|
||||||
notes: "",
|
notes: "",
|
||||||
};
|
};
|
||||||
@@ -320,14 +418,23 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
|||||||
"firstMile",
|
"firstMile",
|
||||||
"lastMile",
|
"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: [
|
4: [
|
||||||
"originYard",
|
"originYard",
|
||||||
"destinationYard",
|
"destinationYard",
|
||||||
"primaryRouteQuantity",
|
"primaryRouteQuantity",
|
||||||
"extraRoutes",
|
"extraRoutes",
|
||||||
"isHazardous",
|
|
||||||
"isRefrigerated",
|
|
||||||
// Estimated shipment date now lives in the Route step (one-time bookings only).
|
// Estimated shipment date now lives in the Route step (one-time bookings only).
|
||||||
"scheduledDate",
|
"scheduledDate",
|
||||||
],
|
],
|
||||||
@@ -339,6 +446,12 @@ export interface ContainerConfig {
|
|||||||
type: "20ft" | "40ft";
|
type: "20ft" | "40ft";
|
||||||
containerType: string;
|
containerType: string;
|
||||||
qty: 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
|
// 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.
|
// form schema defaults it — so the watched input shape has it as optional.
|
||||||
vgm?: string;
|
vgm?: string;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
InputBase,
|
InputBase,
|
||||||
Paper,
|
Paper,
|
||||||
Select,
|
Select,
|
||||||
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
useCombobox,
|
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 {
|
interface AsyncComboboxOption {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -2,21 +2,17 @@ import type { Freight } from "@edr/types";
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Divider,
|
|
||||||
Group,
|
Group,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
Switch,
|
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { DatePickerInput } from "@mantine/dates";
|
||||||
import {
|
import {
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
Flame,
|
|
||||||
MapPin,
|
MapPin,
|
||||||
Plus,
|
Plus,
|
||||||
Route as RouteIcon,
|
Route as RouteIcon,
|
||||||
Snowflake,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo } from "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
|
// 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
|
// (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
|
// contracted quantity comes from the cargo step, so no per-route quantity or
|
||||||
// distance is collected here.
|
// distance is collected here. Cargo handling (hazardous / refrigerated) also
|
||||||
const cargoType = form.watch("cargoType");
|
// lives in the Cargo step now, not here.
|
||||||
|
|
||||||
// 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]);
|
|
||||||
|
|
||||||
// Earliest selectable shipment date (today, local) for the date input's `min`.
|
// Earliest selectable shipment date (today, local) for the date input's `min`.
|
||||||
const todayISODate = useMemo(() => {
|
const todayISODate = useMemo(() => {
|
||||||
@@ -240,21 +228,26 @@ export function Step4Route({
|
|||||||
{/* Estimated shipment date — one-time bookings only. General contracts
|
{/* Estimated shipment date — one-time bookings only. General contracts
|
||||||
pick the date per order drawn against the contract later. */}
|
pick the date per order drawn against the contract later. */}
|
||||||
{!isGeneralContract && (
|
{!isGeneralContract && (
|
||||||
<Box style={{ maxWidth: 280 }}>
|
<Box w="100%">
|
||||||
<Controller
|
<Controller
|
||||||
name="scheduledDate"
|
name="scheduledDate"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<TextInput
|
<DatePickerInput
|
||||||
type="date"
|
w="100%"
|
||||||
label="Estimated shipment date *"
|
label="Estimated shipment date *"
|
||||||
description="A planning estimate. You'll confirm the actual date when you request the operation."
|
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} />}
|
leftSection={<CalendarDays size={16} />}
|
||||||
error={fieldState.error?.message}
|
error={fieldState.error?.message}
|
||||||
value={field.value ?? ""}
|
// Mantine v9 DatePickerInput uses string (YYYY-MM-DD) values,
|
||||||
onChange={(e) => field.onChange(e.currentTarget.value)}
|
// matching the form's `scheduledDate` string directly.
|
||||||
|
value={field.value || null}
|
||||||
|
onChange={(v) => field.onChange(v ?? "")}
|
||||||
|
onBlur={field.onBlur}
|
||||||
radius="md"
|
radius="md"
|
||||||
|
popoverProps={{ withinPortal: true }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -360,116 +353,10 @@ export function Step4Route({
|
|||||||
</Box>
|
</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>
|
</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() {
|
function LoadingSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 rounded-xl border border-gray-200 p-4">
|
<div className="space-y-4 rounded-xl border border-gray-200 p-4">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
|
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 { ActionIcon, Button, Skeleton, Text, TextInput } from "@mantine/core";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
StepCard,
|
StepCard,
|
||||||
StepHeader,
|
StepHeader,
|
||||||
StepLabel,
|
StepLabel,
|
||||||
|
ToggleRow,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<
|
type BookingForm = UseFormReturn<
|
||||||
@@ -114,6 +115,59 @@ export function Step5CargoDetails({
|
|||||||
);
|
);
|
||||||
}, [referenceData, parentId]);
|
}, [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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<StepCard>
|
<StepCard>
|
||||||
@@ -250,6 +304,25 @@ export function Step5CargoDetails({
|
|||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<TextInput
|
<TextInput
|
||||||
{...field}
|
{...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"
|
id="cargoWeight"
|
||||||
type="number"
|
type="number"
|
||||||
label={isPerItem ? "Quantity (Items) *" : "Quantity (Tons) *"}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -364,53 +542,61 @@ export function Step5CargoDetails({
|
|||||||
<Controller
|
<Controller
|
||||||
name={`containers.${index}.qty`}
|
name={`containers.${index}.qty`}
|
||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field: qtyField, fieldState }) => (
|
render={({ field: qtyField, fieldState }) => {
|
||||||
<div>
|
// Keep the per-line hazmat/reefer quantities within the new
|
||||||
<Text size="sm" fw={500} mb={4}>
|
// line quantity whenever it drops, so an old larger value
|
||||||
Quantity *
|
// can't outlive a shrink.
|
||||||
</Text>
|
const setQty = (next: number) => {
|
||||||
<div className="flex items-center gap-2">
|
const n = Math.max(1, next);
|
||||||
<button
|
qtyField.onChange(n.toString());
|
||||||
type="button"
|
clampDependentQty(index, n);
|
||||||
onClick={() =>
|
};
|
||||||
qtyField.onChange(
|
return (
|
||||||
Math.max(
|
<div>
|
||||||
1,
|
<Text size="sm" fw={500} mb={4}>
|
||||||
Number(qtyField.value ?? 1) - 1,
|
Quantity *
|
||||||
).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}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<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
|
<Controller
|
||||||
@@ -431,6 +617,101 @@ export function Step5CargoDetails({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -204,6 +204,12 @@ export function Step8Review({
|
|||||||
|
|
||||||
// Bulk PER_ITEM cargo is a whole item count, not tons — label it accordingly.
|
// Bulk PER_ITEM cargo is a whole item count, not tons — label it accordingly.
|
||||||
const isPerItem = selectedCommodity?.unit_of_measure === "PER_ITEM";
|
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
|
const totalQuantityRow = isPerItem
|
||||||
? {
|
? {
|
||||||
label: "Total quantity",
|
label: "Total quantity",
|
||||||
@@ -306,7 +312,16 @@ export function Step8Review({
|
|||||||
<DetailRow
|
<DetailRow
|
||||||
label="Modifiers"
|
label="Modifiers"
|
||||||
value={
|
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)
|
.filter(Boolean)
|
||||||
.join(", ") || "None"
|
.join(", ") || "None"
|
||||||
}
|
}
|
||||||
@@ -377,12 +392,26 @@ export function Step8Review({
|
|||||||
value={totalQuantityRow.value}
|
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 && (
|
{values.cargoType === "container" && values.containers.length > 0 && (
|
||||||
<Table mt="sm" withTableBorder withColumnBorders fz="sm">
|
<Table mt="sm" withTableBorder withColumnBorders fz="sm">
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Type</Table.Th>
|
<Table.Th>Type</Table.Th>
|
||||||
<Table.Th>Qty</Table.Th>
|
<Table.Th>Qty</Table.Th>
|
||||||
|
<Table.Th>Hazardous</Table.Th>
|
||||||
|
<Table.Th>Refrigerated</Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
@@ -392,6 +421,12 @@ export function Step8Review({
|
|||||||
<Table.Tr key={i}>
|
<Table.Tr key={i}>
|
||||||
<Table.Td>{c.containerType || c.type}</Table.Td>
|
<Table.Td>{c.containerType || c.type}</Table.Td>
|
||||||
<Table.Td>{c.qty}</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.Tr>
|
||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -23,6 +28,7 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
|
FileBadge,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
FileText,
|
FileText,
|
||||||
Flame,
|
Flame,
|
||||||
@@ -46,7 +52,6 @@ import { fileViewUrl } from "@/constants/apiConfig";
|
|||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
import { labelForDocCode } from "@/pages/bookings/resubmit";
|
import { labelForDocCode } from "@/pages/bookings/resubmit";
|
||||||
import { ContractClearancePanel } from "./ContractClearancePanel";
|
import { ContractClearancePanel } from "./ContractClearancePanel";
|
||||||
import { ContractChangesRequestedView } from "./ContractChangesRequestedView";
|
|
||||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||||
import {
|
import {
|
||||||
BORDER,
|
BORDER,
|
||||||
@@ -72,14 +77,19 @@ const CLEARANCE_UPLOAD_STATUSES = [
|
|||||||
|
|
||||||
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
|
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.
|
// Onboarding / company-profile document codes seeded in file-upload-settings.
|
||||||
// These get attached to the contract at creation and belong under "Profile
|
// These get attached to the contract at creation and belong under "Profile
|
||||||
// documents" rather than the clearance set.
|
// documents" rather than the clearance set.
|
||||||
const PROFILE_DOC_CODES = new Set([
|
const PROFILE_DOC_CODES = new Set([
|
||||||
"tin_certificate",
|
"tin_certificate",
|
||||||
"commercial_license",
|
|
||||||
"business_license",
|
|
||||||
"investment_license",
|
|
||||||
"national_id",
|
"national_id",
|
||||||
"national_id_passport",
|
"national_id_passport",
|
||||||
"passport",
|
"passport",
|
||||||
@@ -98,18 +108,21 @@ interface DocGroup {
|
|||||||
* groups are dropped so the tab only renders sections that have files.
|
* groups are dropped so the tab only renders sections that have files.
|
||||||
*/
|
*/
|
||||||
function groupContractDocuments(files: ContractFile[]): DocGroup[] {
|
function groupContractDocuments(files: ContractFile[]): DocGroup[] {
|
||||||
|
const businessLicense: ContractFile[] = [];
|
||||||
const profile: ContractFile[] = [];
|
const profile: ContractFile[] = [];
|
||||||
const clearance: ContractFile[] = [];
|
const clearance: ContractFile[] = [];
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
// The generated contract PDF lives in the contract list / home rows, not
|
// The generated contract PDF lives in the contract list / home rows, not
|
||||||
// here. Signature images are baked into that PDF — skip both.
|
// here. Signature images are baked into that PDF — skip both.
|
||||||
if (f.code === "contract" || f.code.startsWith("signature_")) continue;
|
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 if (PROFILE_DOC_CODES.has(f.code)) profile.push(f);
|
||||||
else clearance.push(f);
|
else clearance.push(f);
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{ key: "profile", title: "Profile documents", files: profile },
|
|
||||||
{ key: "clearance", title: "Clearance documents", files: clearance },
|
{ 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);
|
].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
|
// Staff returned the contract for changes — send the customer to the full edit
|
||||||
// (update documents → resubmit) instead of the read-only detail.
|
// wizard (edit any term + replace documents → resubmit) rather than the
|
||||||
|
// read-only detail.
|
||||||
if (contract.status === "CHANGES_REQUESTED") {
|
if (contract.status === "CHANGES_REQUESTED") {
|
||||||
return <ContractChangesRequestedView contract={contract} />;
|
return <Navigate to={`/contracts/${contract.id}/edit`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isContainer = contract.freightType === "CONTAINER";
|
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.
|
// Per-section accent + icon for the Documents tab groups.
|
||||||
const DOC_GROUP_ACCENT: Record<string, string> = {
|
const DOC_GROUP_ACCENT: Record<string, string> = {
|
||||||
profile: "#2B6CB0",
|
|
||||||
clearance: "#C77F09",
|
clearance: "#C77F09",
|
||||||
|
businessLicense: "#0A6F4D",
|
||||||
|
profile: "#2B6CB0",
|
||||||
};
|
};
|
||||||
const DOC_GROUP_ICON: Record<string, LucideIcon> = {
|
const DOC_GROUP_ICON: Record<string, LucideIcon> = {
|
||||||
profile: FileText,
|
|
||||||
clearance: Upload,
|
clearance: Upload,
|
||||||
|
businessLicense: FileBadge,
|
||||||
|
profile: FileText,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -28,9 +28,14 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
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 useAuth from "@/hooks/useAuth";
|
||||||
import {
|
import {
|
||||||
CONTRACT_STEPS,
|
CONTRACT_STEPS,
|
||||||
@@ -47,6 +52,12 @@ import {
|
|||||||
operationToProfileType,
|
operationToProfileType,
|
||||||
operationToTradeDirection,
|
operationToTradeDirection,
|
||||||
} from "./new-contract-form/helpers";
|
} 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 { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
||||||
import type { ProfileTypeValue } from "@/services/companies.service";
|
import type { ProfileTypeValue } from "@/services/companies.service";
|
||||||
import { StepIndicator } from "./new-contract-form/StepIndicator";
|
import { StepIndicator } from "./new-contract-form/StepIndicator";
|
||||||
@@ -55,7 +66,6 @@ import {
|
|||||||
useContractDraft,
|
useContractDraft,
|
||||||
} from "./new-contract-form/useContractDraft";
|
} from "./new-contract-form/useContractDraft";
|
||||||
import {
|
import {
|
||||||
Step0OperationType,
|
|
||||||
Step1ContractType,
|
Step1ContractType,
|
||||||
Step2ServiceType,
|
Step2ServiceType,
|
||||||
Step3CargoScope,
|
Step3CargoScope,
|
||||||
@@ -67,14 +77,42 @@ import { formatRateUnit } from "./new-contract-form/unit-rates";
|
|||||||
|
|
||||||
type PriceModalMode = "submit" | "draft";
|
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 navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { id: editId } = useParams<{ id: string }>();
|
||||||
|
const isEdit = mode === "edit" && Boolean(editId);
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const { data: referenceData, isLoading: refDataLoading } = useQuery(
|
const { data: referenceData, isLoading: refDataLoading } = useQuery(
|
||||||
api.bookings.referenceData.queryOptions(),
|
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.
|
// Contract creation is gated on profile approval, same as bookings.
|
||||||
if (!auth.isPending && auth.company && !auth.canBook) {
|
if (!auth.isPending && auth.company && !auth.canBook) {
|
||||||
@@ -105,7 +143,17 @@ export default function NewContractPage() {
|
|||||||
|
|
||||||
const [pricingData, setPricingData] =
|
const [pricingData, setPricingData] =
|
||||||
useState<GenerateContractPriceResponse | null>(null);
|
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>(
|
const [priceModalMode, setPriceModalMode] = useState<PriceModalMode | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -205,7 +253,30 @@ export default function NewContractPage() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const startFresh =
|
const startFresh =
|
||||||
(location.state as { fresh?: boolean } | null)?.fresh === true;
|
(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 originYard = form.watch("originYard");
|
||||||
const destinationYard = form.watch("destinationYard");
|
const destinationYard = form.watch("destinationYard");
|
||||||
@@ -239,19 +310,43 @@ export default function NewContractPage() {
|
|||||||
);
|
);
|
||||||
}, [originYard, destinationYard, operationType, referenceData]);
|
}, [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(
|
const profileTypes = useMemo(
|
||||||
() => (auth.company?.company?.companyProfiles ?? []).map((p) => p.type),
|
() => [...profileStatusByType.keys()],
|
||||||
[auth.company],
|
[profileStatusByType],
|
||||||
);
|
);
|
||||||
|
|
||||||
// All operation types are always selectable. Picking one the company has no
|
// 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
|
// 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[]>(
|
const allowedOperations = useMemo<OperationType[]>(
|
||||||
() => [...OPERATION_TYPES],
|
() => [...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).
|
// Create-profile modal state (license upload → createProfileAndSwitch).
|
||||||
const [createTarget, setCreateTarget] = useState<ProfileTypeValue | null>(
|
const [createTarget, setCreateTarget] = useState<ProfileTypeValue | null>(
|
||||||
null,
|
null,
|
||||||
@@ -260,6 +355,13 @@ export default function NewContractPage() {
|
|||||||
useState<OperationType | null>(null);
|
useState<OperationType | null>(null);
|
||||||
const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
|
const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
|
||||||
const [createError, setCreateError] = useState<string | null>(null);
|
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({
|
const createProfileMutation = useMutation({
|
||||||
mutationFn: async ({
|
mutationFn: async ({
|
||||||
@@ -275,11 +377,13 @@ export default function NewContractPage() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// The operation was already set on the form when the modal opened.
|
// The new profile comes back "pending", so the user can't proceed under
|
||||||
setCreateTarget(null);
|
// this operation yet: revert the select and switch the modal to its
|
||||||
setPendingOperation(null);
|
// "awaiting approval" success state (kept open until the user dismisses).
|
||||||
|
form.setValue("operationType", undefined as never, { shouldDirty: true });
|
||||||
setLicenseFiles([]);
|
setLicenseFiles([]);
|
||||||
setCreateError(null);
|
setCreateError(null);
|
||||||
|
setLicenseSubmitted(true);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
setCreateError(
|
setCreateError(
|
||||||
@@ -292,15 +396,25 @@ export default function NewContractPage() {
|
|||||||
// Intercity (domestic) runs on any existing customer profile — no switch.
|
// Intercity (domestic) runs on any existing customer profile — no switch.
|
||||||
if (op === "intercity") return;
|
if (op === "intercity") return;
|
||||||
const target = operationToProfileType(op, profileTypes) as ProfileTypeValue;
|
const target = operationToProfileType(op, profileTypes) as ProfileTypeValue;
|
||||||
const hasProfile = profileTypes.includes(target);
|
const status = profileStatusByType.get(target);
|
||||||
if (!hasProfile) {
|
|
||||||
// No matching profile — collect a license and create one.
|
if (!status) {
|
||||||
|
// Case 3 — no matching profile: collect a license and create one.
|
||||||
setPendingOperation(op);
|
setPendingOperation(op);
|
||||||
setCreateTarget(target);
|
setCreateTarget(target);
|
||||||
setLicenseFiles([]);
|
setLicenseFiles([]);
|
||||||
setCreateError(null);
|
setCreateError(null);
|
||||||
|
setLicenseSubmitted(false);
|
||||||
return;
|
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) {
|
if (auth.activeProfileType !== target) {
|
||||||
void auth.switchMode(target as never);
|
void auth.switchMode(target as never);
|
||||||
}
|
}
|
||||||
@@ -324,6 +438,17 @@ export default function NewContractPage() {
|
|||||||
setPendingOperation(null);
|
setPendingOperation(null);
|
||||||
setLicenseFiles([]);
|
setLicenseFiles([]);
|
||||||
setCreateError(null);
|
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
|
const createTargetLabel = createTarget
|
||||||
@@ -466,6 +591,25 @@ export default function NewContractPage() {
|
|||||||
|
|
||||||
const handleSubmitContract = form.handleSubmit((data) => {
|
const handleSubmitContract = form.handleSubmit((data) => {
|
||||||
try {
|
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);
|
const apiPayload = buildApiPayload(data);
|
||||||
persistAndPriceMutation.mutate({
|
persistAndPriceMutation.mutate({
|
||||||
payload: apiPayload,
|
payload: apiPayload,
|
||||||
@@ -511,20 +655,23 @@ export default function NewContractPage() {
|
|||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||||
New Contract
|
{isEdit ? "Edit Contract" : "New Contract"}
|
||||||
</Title>
|
</Title>
|
||||||
<Text size="sm" c="edr-muted" mt={4}>
|
<Text size="sm" c="edr-muted" mt={4}>
|
||||||
Define your freight contract — scope, routes, and unit rates. Book
|
{isEdit
|
||||||
shipments against it after signing.
|
? "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>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
radius="md"
|
radius="md"
|
||||||
leftSection={<ChevronLeft size={16} />}
|
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>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -535,6 +682,20 @@ export default function NewContractPage() {
|
|||||||
onSubmit={(e) => e.preventDefault()}
|
onSubmit={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<Box flex={1} p="24px">
|
<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">
|
<Box mb="lg">
|
||||||
<StepIndicator step={step} steps={visibleSteps} />
|
<StepIndicator step={step} steps={visibleSteps} />
|
||||||
</Box>
|
</Box>
|
||||||
@@ -565,12 +726,13 @@ export default function NewContractPage() {
|
|||||||
description="Define the operation, contract kind, and the service this contract is for."
|
description="Define the operation, contract kind, and the service this contract is for."
|
||||||
/>
|
/>
|
||||||
<Stack gap={24}>
|
<Stack gap={24}>
|
||||||
<Step0OperationType
|
<Step1ContractType
|
||||||
form={form}
|
form={form}
|
||||||
|
referenceData={referenceData}
|
||||||
allowedOperations={allowedOperations}
|
allowedOperations={allowedOperations}
|
||||||
onSelect={handleOperationSelect}
|
onOperationSelect={handleOperationSelect}
|
||||||
|
operationStatus={operationStatus}
|
||||||
/>
|
/>
|
||||||
<Step1ContractType form={form} referenceData={referenceData} />
|
|
||||||
<Step2ServiceType referenceData={referenceData} form={form} />
|
<Step2ServiceType referenceData={referenceData} form={form} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</StepCard>
|
</StepCard>
|
||||||
@@ -625,6 +787,27 @@ export default function NewContractPage() {
|
|||||||
persistAndPriceMutation.isPending &&
|
persistAndPriceMutation.isPending &&
|
||||||
persistAndPriceMutation.variables?.mode === "submit"
|
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>
|
</Box>
|
||||||
@@ -891,47 +1074,116 @@ export default function NewContractPage() {
|
|||||||
|
|
||||||
{/* Create-profile modal — opens when the chosen operation type has no
|
{/* Create-profile modal — opens when the chosen operation type has no
|
||||||
matching company profile yet. Collects a license, creates the profile,
|
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
|
<Modal
|
||||||
opened={createTarget !== null}
|
opened={createTarget !== null}
|
||||||
onClose={() => {
|
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
|
centered
|
||||||
radius="lg"
|
radius="lg"
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
You don't have a {createTargetLabel.toLowerCase()} profile yet. Add
|
Your{" "}
|
||||||
your business license to create one and continue this contract as{" "}
|
{pendingApprovalProfile
|
||||||
{createTargetLabel.toLowerCase()}.
|
? (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>
|
</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">
|
<Group justify="flex-end" gap="sm">
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
onClick={handleCreateProfileCancel}
|
|
||||||
disabled={createProfileMutation.isPending}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
onClick={handleCreateProfileConfirm}
|
onClick={() => setPendingApprovalProfile(null)}
|
||||||
loading={createProfileMutation.isPending}
|
|
||||||
>
|
>
|
||||||
Create & continue
|
OK
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -3,11 +3,13 @@ import type { Freight } from "@edr/types";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
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 {
|
import {
|
||||||
CONTRACT_KIND_OPTIONS,
|
CONTRACT_KIND_OPTIONS,
|
||||||
ContractFormInputValues,
|
ContractFormInputValues,
|
||||||
|
OPERATION_TYPE_OPTIONS,
|
||||||
type ContractFormValues,
|
type ContractFormValues,
|
||||||
|
type OperationType,
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
import { AlertBox, AsyncComboboxField, fieldStyles } from "./shared";
|
import { AlertBox, AsyncComboboxField, fieldStyles } from "./shared";
|
||||||
|
|
||||||
@@ -31,11 +33,22 @@ interface PreviousContractOption {
|
|||||||
export function Step1ContractType({
|
export function Step1ContractType({
|
||||||
form,
|
form,
|
||||||
referenceData,
|
referenceData,
|
||||||
|
allowedOperations,
|
||||||
|
onOperationSelect,
|
||||||
|
operationStatus,
|
||||||
}: {
|
}: {
|
||||||
form: ContractForm;
|
form: ContractForm;
|
||||||
referenceData?: Freight.BookingReferenceData;
|
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 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 previousContractRef = form.watch("previousContractRef");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
@@ -167,38 +180,81 @@ export function Step1ContractType({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap={16}>
|
<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
|
<Controller
|
||||||
name="contractKind"
|
name="contractKind"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field }) => {
|
render={({ field }) => (
|
||||||
const selected = CONTRACT_KIND_OPTIONS.find(
|
<Select
|
||||||
(o) => o.value === (field.value ?? "one_time"),
|
className="flex-1 min-w-[200px]"
|
||||||
);
|
label="Contract Kind *"
|
||||||
return (
|
data={CONTRACT_KIND_OPTIONS.map((o) => ({
|
||||||
<div>
|
value: o.value,
|
||||||
<Select
|
label: o.label,
|
||||||
label="Contract Kind *"
|
}))}
|
||||||
data={CONTRACT_KIND_OPTIONS.map((o) => ({
|
value={field.value ?? "one_time"}
|
||||||
value: o.value,
|
onChange={(v) => field.onChange(v ?? "one_time")}
|
||||||
label: o.label,
|
allowDeselect={false}
|
||||||
}))}
|
radius={10}
|
||||||
value={field.value ?? "one_time"}
|
checkIconPosition="right"
|
||||||
onChange={(v) => field.onChange(v ?? "one_time")}
|
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
|
||||||
allowDeselect={false}
|
styles={fieldStyles}
|
||||||
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>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
@@ -206,6 +262,7 @@ export function Step1ContractType({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Select
|
<Select
|
||||||
|
className="flex-1 min-w-[200px]"
|
||||||
label="New or Renewal *"
|
label="New or Renewal *"
|
||||||
placeholder="Select…"
|
placeholder="Select…"
|
||||||
data={CONTRACT_TYPE_OPTIONS}
|
data={CONTRACT_TYPE_OPTIONS}
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ function ServiceTypeSelector({
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</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) => {
|
{services.map((s) => {
|
||||||
const selected = s.id === value;
|
const selected = s.id === value;
|
||||||
return (
|
return (
|
||||||
@@ -147,8 +147,8 @@ function ServiceTypeSelector({
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
padding: 16,
|
padding: 12,
|
||||||
borderRadius: 16,
|
borderRadius: 14,
|
||||||
border: `1.5px solid ${selected ? GREEN : error ? "#F0B4B4" : BORDER}`,
|
border: `1.5px solid ${selected ? GREEN : error ? "#F0B4B4" : BORDER}`,
|
||||||
background: selected ? GREEN_SOFT : "#fff",
|
background: selected ? GREEN_SOFT : "#fff",
|
||||||
boxShadow: selected
|
boxShadow: selected
|
||||||
@@ -162,10 +162,10 @@ function ServiceTypeSelector({
|
|||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 12,
|
top: 10,
|
||||||
right: 12,
|
right: 10,
|
||||||
width: 22,
|
width: 18,
|
||||||
height: 22,
|
height: 18,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -175,16 +175,16 @@ function ServiceTypeSelector({
|
|||||||
transition: "all 160ms ease",
|
transition: "all 160ms ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selected ? <Check size={13} color="#fff" strokeWidth={3} /> : null}
|
{selected ? <Check size={11} color="#fff" strokeWidth={3} /> : null}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Group gap={11} align="flex-start" wrap="nowrap" mb={10}>
|
<Group gap={9} align="flex-start" wrap="nowrap" mb={8}>
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
width: 42,
|
width: 34,
|
||||||
height: 42,
|
height: 34,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
@@ -193,21 +193,15 @@ function ServiceTypeSelector({
|
|||||||
transition: "all 160ms ease",
|
transition: "all 160ms ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Container size={20} strokeWidth={2} />
|
<Container size={17} strokeWidth={2} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box style={{ minWidth: 0, paddingRight: 24 }}>
|
<Box style={{ minWidth: 0, paddingRight: 20 }}>
|
||||||
<Text fz={14.5} fw={750} c={INK} style={{ lineHeight: 1.25 }}>
|
<Text fz={13} fw={700} c={INK} style={{ lineHeight: 1.25 }}>
|
||||||
{s.serviceName}
|
{s.serviceName}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{s.description ? (
|
|
||||||
<Text fz={12.5} c={MUTED} mb={11} style={{ lineHeight: 1.45 }}>
|
|
||||||
{s.description}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Group gap={6} wrap="wrap">
|
<Group gap={6} wrap="wrap">
|
||||||
{serviceFeatures(s).map((f) => (
|
{serviceFeatures(s).map((f) => (
|
||||||
<FeatureChip
|
<FeatureChip
|
||||||
@@ -315,6 +309,9 @@ export function Step2ServiceType({
|
|||||||
<Stack gap={12}>
|
<Stack gap={12}>
|
||||||
<StepLabel>Trucking & customs options</StepLabel>
|
<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 && (
|
{includesFirstMile && (
|
||||||
<Controller
|
<Controller
|
||||||
name="firstMile.enabled"
|
name="firstMile.enabled"
|
||||||
@@ -577,6 +574,7 @@ export function Step2ServiceType({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -188,6 +188,8 @@ export function Step8Review({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
saveDraftPending = false,
|
saveDraftPending = false,
|
||||||
submitPending = false,
|
submitPending = false,
|
||||||
|
documentsEditor,
|
||||||
|
isEdit = false,
|
||||||
}: {
|
}: {
|
||||||
form: ContractForm;
|
form: ContractForm;
|
||||||
/** Retained for caller compatibility; the review page is read-only. */
|
/** Retained for caller compatibility; the review page is read-only. */
|
||||||
@@ -201,6 +203,14 @@ export function Step8Review({
|
|||||||
onSubmit?: () => void;
|
onSubmit?: () => void;
|
||||||
saveDraftPending?: boolean;
|
saveDraftPending?: boolean;
|
||||||
submitPending?: 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 values = form.watch();
|
||||||
const serviceType = referenceData?.service.find(
|
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
|
<Controller
|
||||||
name="notes"
|
name="notes"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -485,9 +506,13 @@ export function Step8Review({
|
|||||||
|
|
||||||
<Paper radius={20} p="lg" withBorder bg="white">
|
<Paper radius={20} p="lg" withBorder bg="white">
|
||||||
<Text size="sm" c="dimmed" mb="md">
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
{pricing
|
{isEdit
|
||||||
? "Review your unit-rate quotation. Approve to submit the contract for EDR staff review."
|
? pricing
|
||||||
: "Ready to submit. You'll review the unit-rate quotation before final submission."}
|
? "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>
|
</Text>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Button
|
<Button
|
||||||
@@ -501,20 +526,28 @@ export function Step8Review({
|
|||||||
loading={submitPending}
|
loading={submitPending}
|
||||||
disabled={submitPending}
|
disabled={submitPending}
|
||||||
>
|
>
|
||||||
{pricing ? "Approve quotation & submit" : "Submit"}
|
{isEdit
|
||||||
</Button>
|
? pricing
|
||||||
<Button
|
? "Approve quotation & resubmit"
|
||||||
type="button"
|
: "Resubmit"
|
||||||
variant="outline"
|
: pricing
|
||||||
color="edr-green"
|
? "Approve quotation & submit"
|
||||||
radius="md"
|
: "Submit"}
|
||||||
fullWidth
|
|
||||||
onClick={onSaveDraft}
|
|
||||||
loading={saveDraftPending}
|
|
||||||
disabled={submitPending}
|
|
||||||
>
|
|
||||||
Save as draft
|
|
||||||
</Button>
|
</Button>
|
||||||
|
{!isEdit && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
fullWidth
|
||||||
|
onClick={onSaveDraft}
|
||||||
|
loading={saveDraftPending}
|
||||||
|
disabled={submitPending}
|
||||||
|
>
|
||||||
|
Save as draft
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export { Step0OperationType } from "./step0-operation-type";
|
|
||||||
export { Step1ContractType } from "./step1-contract-type";
|
export { Step1ContractType } from "./step1-contract-type";
|
||||||
export { Step2ServiceType } from "./step2-service-type";
|
export { Step2ServiceType } from "./step2-service-type";
|
||||||
export { Step3CargoScope } from "./step3-cargo-scope";
|
export { Step3CargoScope } from "./step3-cargo-scope";
|
||||||
|
|||||||
@@ -58,14 +58,22 @@ export function useContractDraft({
|
|||||||
step,
|
step,
|
||||||
setStep,
|
setStep,
|
||||||
fresh,
|
fresh,
|
||||||
|
enabled = true,
|
||||||
}: {
|
}: {
|
||||||
form: ContractForm;
|
form: ContractForm;
|
||||||
step: number;
|
step: number;
|
||||||
setStep: (step: number) => void;
|
setStep: (step: number) => void;
|
||||||
fresh: boolean;
|
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 } {
|
}): { clearDraft: () => void } {
|
||||||
const restoredRef = useRef(false);
|
const restoredRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
if (restoredRef.current) return;
|
if (restoredRef.current) return;
|
||||||
restoredRef.current = true;
|
restoredRef.current = true;
|
||||||
|
|
||||||
@@ -107,6 +115,7 @@ export function useContractDraft({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
if (!restoredRef.current) return;
|
if (!restoredRef.current) return;
|
||||||
const sub = form.watch(() => {
|
const sub = form.watch(() => {
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
@@ -117,9 +126,10 @@ export function useContractDraft({
|
|||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [form]);
|
}, [form, enabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
if (!restoredRef.current) return;
|
if (!restoredRef.current) return;
|
||||||
write();
|
write();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|||||||
@@ -432,6 +432,10 @@ export interface IBooking extends BaseEntity {
|
|||||||
|
|
||||||
isHazardous: boolean;
|
isHazardous: boolean;
|
||||||
isRefrigerated: 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";
|
tradeDirection: "IMPORT" | "EXPORT";
|
||||||
paymentCurrency: string;
|
paymentCurrency: string;
|
||||||
@@ -717,6 +721,10 @@ export interface CreateBookingContainerDto {
|
|||||||
containerTypeId: string;
|
containerTypeId: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
vgmPerUnitTons: 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. */
|
/** A contracted route+quantity line for a GENERAL contract. */
|
||||||
@@ -768,6 +776,10 @@ export interface CreateBookingDto {
|
|||||||
isHazardous?: boolean | undefined;
|
isHazardous?: boolean | undefined;
|
||||||
/** Booking-level refrigerated flag (bulk freight only; containers derive reefer from the container type). */
|
/** Booking-level refrigerated flag (bulk freight only; containers derive reefer from the container type). */
|
||||||
isReefer?: boolean | undefined;
|
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;
|
paymentCurrency: string;
|
||||||
pnrCode?: string | undefined;
|
pnrCode?: string | undefined;
|
||||||
startDate?: string | undefined;
|
startDate?: string | undefined;
|
||||||
|
|||||||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
@@ -326,6 +326,9 @@ importers:
|
|||||||
'@mantine/core':
|
'@mantine/core':
|
||||||
specifier: ^9.3.0
|
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)
|
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':
|
'@mantine/hooks':
|
||||||
specifier: ^9.3.0
|
specifier: ^9.3.0
|
||||||
version: 9.3.0(react@19.2.6)
|
version: 9.3.0(react@19.2.6)
|
||||||
@@ -12495,10 +12498,19 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/react'
|
- '@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:
|
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/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': 7.17.8(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
|
clsx: 2.1.1
|
||||||
dayjs: 1.11.21
|
dayjs: 1.11.21
|
||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
@@ -15399,7 +15411,7 @@ snapshots:
|
|||||||
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
|
'@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/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/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/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)
|
'@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)
|
'@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):
|
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:
|
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/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/hooks': 7.17.8(react@19.2.6)
|
||||||
'@tabler/icons-react': 3.44.0(react@19.2.6)
|
'@tabler/icons-react': 3.44.0(react@19.2.6)
|
||||||
'@tanstack/match-sorter-utils': 8.19.4
|
'@tanstack/match-sorter-utils': 8.19.4
|
||||||
|
|||||||
Reference in New Issue
Block a user