mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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;
|
||||
quantity: number;
|
||||
vgmPerUnitTons: number;
|
||||
hazardousQuantity?: number;
|
||||
reeferQuantity?: number;
|
||||
weightResult: ContainerWeightResult;
|
||||
}>,
|
||||
): Promise<BookingContainer[]> {
|
||||
@@ -133,11 +135,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
|
||||
const totalVgm = item.quantity * item.vgmPerUnitTons;
|
||||
const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit);
|
||||
// A per-line breakdown can never exceed the line's own quantity.
|
||||
const clamp = (v?: number) =>
|
||||
Math.max(0, Math.min(item.quantity, Math.floor(Number(v ?? 0)) || 0));
|
||||
|
||||
const row = containerRepo.create({
|
||||
bookingId,
|
||||
containerTypeId: item.containerTypeId,
|
||||
quantity: item.quantity,
|
||||
hazardousQuantity: clamp(item.hazardousQuantity),
|
||||
reeferQuantity: clamp(item.reeferQuantity),
|
||||
vgmPerUnitTons: item.vgmPerUnitTons,
|
||||
totalVgmTons: totalVgm,
|
||||
wagonsRequired,
|
||||
|
||||
@@ -66,6 +66,17 @@ const NEEDS_ACTION_STATUSES = [
|
||||
'APPROVED_PENDING_SIGNATURE',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Clamp a bulk hazardous/reefer amount into 0..cargoAmount: it can never exceed
|
||||
* the total cargo it's a portion of, and is never negative.
|
||||
*/
|
||||
function clampToCargo(value: number | undefined, cargoAmount: number): number {
|
||||
const v = Number(value ?? 0);
|
||||
if (!Number.isFinite(v) || v <= 0) return 0;
|
||||
const cap = Number.isFinite(cargoAmount) && cargoAmount > 0 ? cargoAmount : 0;
|
||||
return Math.min(v, cap);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BookingsService {
|
||||
constructor(
|
||||
@@ -505,6 +516,16 @@ export class BookingsService {
|
||||
// the container type at pricing time, so the booking-level flag stays off
|
||||
// for container freight to avoid double-counting.
|
||||
isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false,
|
||||
// Bulk-only hazardous/reefer amount, clamped to the cargo amount. Container
|
||||
// freight tracks this per line, so these are 0 for CONTAINER.
|
||||
bulkHazardousQuantity:
|
||||
dto.freightType === 'BULK'
|
||||
? clampToCargo(dto.bulkHazardousQuantity, dto.cargoTotalWeightVgm)
|
||||
: 0,
|
||||
bulkReeferQuantity:
|
||||
dto.freightType === 'BULK'
|
||||
? clampToCargo(dto.bulkReeferQuantity, dto.cargoTotalWeightVgm)
|
||||
: 0,
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
pnrCode: dto.pnrCode,
|
||||
financialTerms: dto.financialTerms,
|
||||
@@ -527,6 +548,8 @@ export class BookingsService {
|
||||
containerTypeId: c.containerTypeId,
|
||||
quantity: c.quantity,
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
hazardousQuantity: c.hazardousQuantity,
|
||||
reeferQuantity: c.reeferQuantity,
|
||||
weightResult: ruleResult.containerWeightResults[i],
|
||||
})),
|
||||
);
|
||||
@@ -664,6 +687,8 @@ export class BookingsService {
|
||||
containers,
|
||||
);
|
||||
|
||||
const cargoAmount =
|
||||
dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0);
|
||||
const updates: Record<string, unknown> = {
|
||||
...dto,
|
||||
freightType,
|
||||
@@ -674,6 +699,22 @@ export class BookingsService {
|
||||
freightType === 'BULK'
|
||||
? (dto.isReefer ?? existing.isReefer ?? false)
|
||||
: false,
|
||||
// Bulk-only hazardous/reefer amount, clamped to the cargo amount; 0 for
|
||||
// container freight (per-line on the containers instead).
|
||||
bulkHazardousQuantity:
|
||||
freightType === 'BULK'
|
||||
? clampToCargo(
|
||||
dto.bulkHazardousQuantity ?? Number(existing.bulkHazardousQuantity ?? 0),
|
||||
cargoAmount,
|
||||
)
|
||||
: 0,
|
||||
bulkReeferQuantity:
|
||||
freightType === 'BULK'
|
||||
? clampToCargo(
|
||||
dto.bulkReeferQuantity ?? Number(existing.bulkReeferQuantity ?? 0),
|
||||
cargoAmount,
|
||||
)
|
||||
: 0,
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
tradeDirection,
|
||||
};
|
||||
@@ -720,6 +761,8 @@ export class BookingsService {
|
||||
containerTypeId: c.containerTypeId,
|
||||
quantity: c.quantity,
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
hazardousQuantity: c.hazardousQuantity,
|
||||
reeferQuantity: c.reeferQuantity,
|
||||
weightResult: ruleResult.containerWeightResults[i],
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -52,6 +52,28 @@ export class CreateBookingContainerDto {
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
vgmPerUnitTons!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'How many of this line are hazardous (0..quantity)',
|
||||
minimum: 0,
|
||||
default: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
hazardousQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'How many of this line are refrigerated (0..quantity)',
|
||||
minimum: 0,
|
||||
default: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
reeferQuantity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,6 +342,25 @@ export class CreateBookingDto {
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isReefer?: boolean;
|
||||
|
||||
/**
|
||||
* Bulk-only: how much of the cargo is hazardous / refrigerated, in the cargo's
|
||||
* unit of measure (tons for PER_TON, item count for PER_ITEM). Must not exceed
|
||||
* cargoTotalWeightVgm. Ignored for container freight (per-line on containers).
|
||||
*/
|
||||
@ApiPropertyOptional({ minimum: 0, default: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
bulkHazardousQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0, default: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
bulkReeferQuantity?: number;
|
||||
|
||||
@ApiProperty({ enum: PAYMENT_CURRENCIES })
|
||||
@IsIn([...PAYMENT_CURRENCIES])
|
||||
paymentCurrency!: string;
|
||||
|
||||
@@ -320,6 +320,19 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'is_reefer', type: 'boolean', default: false })
|
||||
isReefer!: boolean;
|
||||
|
||||
/**
|
||||
* Bulk-only hazardous / reefer amount, in the cargo's own unit of measure
|
||||
* (tons for PER_TON commodities, item count for PER_ITEM) — i.e. how much of
|
||||
* `cargoTotalWeightVgm` is hazardous / refrigerated. 0 when none. Container
|
||||
* freight carries this per line on `booking_container` instead, so these stay
|
||||
* 0 for CONTAINER bookings. The booleans above remain the surcharge trigger.
|
||||
*/
|
||||
@Column({ name: 'bulk_hazardous_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||
bulkHazardousQuantity!: number;
|
||||
|
||||
@Column({ name: 'bulk_reefer_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||
bulkReeferQuantity!: number;
|
||||
|
||||
@Column({ name: 'payment_currency', type: 'varchar', length: 5 })
|
||||
paymentCurrency!: string;
|
||||
|
||||
|
||||
@@ -65,7 +65,6 @@ export function BookingActionsMenu({
|
||||
};
|
||||
|
||||
const hasMenu = listRowHasActions(row, user);
|
||||
const primary = actions.find((a) => a.primary) ?? actions[0];
|
||||
|
||||
if (!hasMenu && variant === "table") {
|
||||
return (
|
||||
@@ -117,19 +116,6 @@ export function BookingActionsMenu({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{variant === "table" && primary && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
visibleFrom="lg"
|
||||
leftSection={<primary.icon size={14} />}
|
||||
disabled={mutations.isPending}
|
||||
onClick={() => handleAction(primary)}
|
||||
>
|
||||
{primary.shortLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Menu position="bottom-end" width={220} withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
|
||||
@@ -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,
|
||||
CalendarClock,
|
||||
FileText,
|
||||
Files,
|
||||
Flame,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Route as RouteIcon,
|
||||
ShieldCheck,
|
||||
Snowflake,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Badge,
|
||||
@@ -31,6 +34,8 @@ import {
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import "@/components/overview/overview.css";
|
||||
import { PageContainer } from "@/components/page";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
@@ -41,11 +46,18 @@ import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflow
|
||||
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
|
||||
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import {
|
||||
ContractCustomerCard,
|
||||
ContractDocumentsCard,
|
||||
} from "@/components/contracts/detail/ContractDetailTabCards";
|
||||
import { getContractStatusMeta } from "@/features/contracts/contract-status.config";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import {
|
||||
useContractDetail,
|
||||
useContractMutations,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
// Clearance phase — staff can still ACT (approve / query / finalize).
|
||||
const CLEARANCE_ACTIVE_STATUSES = [
|
||||
@@ -94,7 +106,8 @@ export default function ContractRequestDetailPage() {
|
||||
} = useContractDetail(id);
|
||||
const mutations = useContractMutations(id ?? "");
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab = searchParams.get("tab") === "clearance" ? "clearance" : "details";
|
||||
const { view, viewer } = useFileViewer();
|
||||
const requestedTab = searchParams.get("tab");
|
||||
const setTab = (tab: string) =>
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
@@ -106,6 +119,23 @@ export default function ContractRequestDetailPage() {
|
||||
{ replace: true },
|
||||
);
|
||||
|
||||
const handleViewFile = (file: NonNullable<Freight.IContract["files"]>[number]) =>
|
||||
view({
|
||||
name: file.name,
|
||||
url: file.signedUrl ?? file.url,
|
||||
mimeType: file.mimeType,
|
||||
});
|
||||
|
||||
const handleDownloadFile = async (
|
||||
file: NonNullable<Freight.IContract["files"]>[number],
|
||||
) => {
|
||||
try {
|
||||
await downloadBookingFile(file.id, file.name);
|
||||
} catch {
|
||||
toast.error("Could not download file.");
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -176,9 +206,17 @@ export default function ContractRequestDetailPage() {
|
||||
const clearanceReadOnly = CLEARANCE_DONE_STATUSES.includes(contract.status);
|
||||
// Path A (no customs) → Operations reviews; Path B (customs) → GL reviews.
|
||||
const selfClear = !contract.customsClearingEnabled;
|
||||
// If the tab param points at clearance but the contract isn't in a clearance
|
||||
// phase, fall back to details so we never show an empty tab.
|
||||
const currentTab = activeTab === "clearance" && showClearanceTab ? "clearance" : "details";
|
||||
const files = contract.files ?? [];
|
||||
// Resolve the active tab from the URL, falling back to details when the
|
||||
// requested tab isn't available for this contract (e.g. clearance pre-phase).
|
||||
const currentTab =
|
||||
requestedTab === "documents"
|
||||
? "documents"
|
||||
: requestedTab === "customer"
|
||||
? "customer"
|
||||
: requestedTab === "clearance" && showClearanceTab
|
||||
? "clearance"
|
||||
: "details";
|
||||
|
||||
const customerLabel = contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
@@ -264,27 +302,43 @@ export default function ContractRequestDetailPage() {
|
||||
description={statusMeta.description}
|
||||
/>
|
||||
|
||||
{showClearanceTab && (
|
||||
<Tabs
|
||||
value={currentTab}
|
||||
onChange={(v) => setTab(v ?? "details")}
|
||||
variant="pills"
|
||||
color="edr-green"
|
||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="details" leftSection={<FileText size={16} />}>
|
||||
Details
|
||||
</Tabs.Tab>
|
||||
<Tabs
|
||||
value={currentTab}
|
||||
onChange={(v) => setTab(v ?? "details")}
|
||||
variant="pills"
|
||||
color="edr-green"
|
||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="details" leftSection={<LayoutGrid size={16} />}>
|
||||
Details
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="documents"
|
||||
leftSection={<Files size={16} />}
|
||||
rightSection={
|
||||
files.length > 0 ? (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
{files.length}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
Documents
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="customer" leftSection={<Users size={16} />}>
|
||||
Customer
|
||||
</Tabs.Tab>
|
||||
{showClearanceTab && (
|
||||
<Tabs.Tab
|
||||
value="clearance"
|
||||
leftSection={<ShieldCheck size={16} />}
|
||||
>
|
||||
Clearance Review
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
)}
|
||||
)}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
<Grid gap="lg">
|
||||
{/* LEFT — primary content */}
|
||||
@@ -296,6 +350,14 @@ export default function ContractRequestDetailPage() {
|
||||
readOnly={clearanceReadOnly}
|
||||
onChanged={() => refetch()}
|
||||
/>
|
||||
) : currentTab === "documents" ? (
|
||||
<ContractDocumentsCard
|
||||
files={files}
|
||||
onView={handleViewFile}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
) : currentTab === "customer" ? (
|
||||
<ContractCustomerCard contract={contract} />
|
||||
) : (
|
||||
<Stack gap="lg">
|
||||
<SectionCard icon={RouteIcon} title="Routes">
|
||||
@@ -453,6 +515,8 @@ export default function ContractRequestDetailPage() {
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
|
||||
{viewer}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@edr/ui-common": "workspace:*",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@mantine/core": "^9.3.0",
|
||||
"@mantine/dates": "^9.3.0",
|
||||
"@mantine/hooks": "^9.3.0",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz",
|
||||
|
||||
@@ -272,6 +272,10 @@ const App = () => {
|
||||
/>
|
||||
<Route path="/contracts" element={<ContractsList />} />
|
||||
<Route path="/contracts/new" element={<NewContractPage />} />
|
||||
<Route
|
||||
path="/contracts/:id/edit"
|
||||
element={<NewContractPage mode="edit" />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/bookings/new"
|
||||
element={<NewShipmentPage />}
|
||||
|
||||
@@ -796,7 +796,7 @@ export function AppLayout({
|
||||
{/* ── Main ── */}
|
||||
<AppShell.Main
|
||||
style={{
|
||||
backgroundColor: "#F1F5F9",
|
||||
backgroundColor: "##f8fafc",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BrowserRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import "@mantine/core/styles.css";
|
||||
import "@mantine/dates/styles.css";
|
||||
import "@edr/ui-common/styles.css";
|
||||
import "../index.css";
|
||||
import "@edr/ui-common/theme.css";
|
||||
|
||||
@@ -29,9 +29,7 @@ import {
|
||||
Check,
|
||||
Download,
|
||||
FileText,
|
||||
Flame,
|
||||
MapPin,
|
||||
Snowflake,
|
||||
Truck,
|
||||
Upload,
|
||||
X,
|
||||
@@ -102,6 +100,8 @@ function defaultContainerTypeForSize(
|
||||
interface BookingContainerRow {
|
||||
quantity: number;
|
||||
vgmPerUnitTons: number | string;
|
||||
hazardousQuantity?: number | string | null;
|
||||
reeferQuantity?: number | string | null;
|
||||
containerType?: {
|
||||
sizeFt?: number | null;
|
||||
label?: string | null;
|
||||
@@ -150,6 +150,8 @@ function mapBookingToFormValues(
|
||||
cargoWeight: String(booking.cargoTotalWeightVgm ?? ""),
|
||||
isHazardous: booking.isHazardous ?? false,
|
||||
isRefrigerated: booking.isRefrigerated ?? false,
|
||||
bulkHazardousQty: String(Number(booking.bulkHazardousQuantity ?? 0)),
|
||||
bulkReeferQty: String(Number(booking.bulkReeferQuantity ?? 0)),
|
||||
paymentCurrency:
|
||||
booking.paymentCurrency === "ETB" ? "ETB" : "USD",
|
||||
scheduledDate: booking.scheduledDate
|
||||
@@ -183,10 +185,16 @@ function mapBookingToFormValues(
|
||||
? bc.containerType.label
|
||||
: (bc.containerType?.code ??
|
||||
defaultContainerTypeForSize(referenceData, size));
|
||||
const hazQty = Number(bc.hazardousQuantity ?? 0);
|
||||
const reeQty = Number(bc.reeferQuantity ?? 0);
|
||||
return {
|
||||
type: size,
|
||||
containerType: typeName,
|
||||
qty: String(bc.quantity ?? 1),
|
||||
isHazardous: hazQty > 0,
|
||||
hazardousQty: String(hazQty),
|
||||
isReefer: reeQty > 0,
|
||||
reeferQty: String(reeQty),
|
||||
vgm: String(Number(bc.vgmPerUnitTons ?? 0)),
|
||||
};
|
||||
})
|
||||
@@ -195,6 +203,10 @@ function mapBookingToFormValues(
|
||||
type: "20ft" as const,
|
||||
containerType: defaultContainerTypeForSize(referenceData, "20ft"),
|
||||
qty: "1",
|
||||
isHazardous: false,
|
||||
hazardousQty: "0",
|
||||
isReefer: false,
|
||||
reeferQty: "0",
|
||||
vgm: "",
|
||||
},
|
||||
];
|
||||
@@ -435,7 +447,27 @@ export default function EditBookingPage() {
|
||||
: "IMPORT",
|
||||
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
|
||||
cargoTotalWeightVgm: totalWeight,
|
||||
isHazardous: data.isHazardous,
|
||||
// Containers: booking-level flags are the OR of the per-container switches;
|
||||
// bulk uses the route-step toggles.
|
||||
isHazardous:
|
||||
data.cargoType === "container"
|
||||
? data.containers.some((c) => c.isHazardous)
|
||||
: data.isHazardous,
|
||||
isReefer:
|
||||
data.cargoType === "container"
|
||||
? data.containers.some((c) => c.isReefer)
|
||||
: data.isRefrigerated,
|
||||
// Bulk-only hazardous / refrigerated amount in the cargo's unit.
|
||||
...(data.cargoType === "bulk"
|
||||
? {
|
||||
bulkHazardousQuantity: data.isHazardous
|
||||
? Number(data.bulkHazardousQty || 0)
|
||||
: 0,
|
||||
bulkReeferQuantity: data.isRefrigerated
|
||||
? Number(data.bulkReeferQty || 0)
|
||||
: 0,
|
||||
}
|
||||
: {}),
|
||||
paymentCurrency: data.paymentCurrency,
|
||||
freightType:
|
||||
data.cargoType === "container"
|
||||
@@ -446,6 +478,8 @@ export default function EditBookingPage() {
|
||||
? data.containers.map((c) => ({
|
||||
containerTypeId: findContainerTypeId(c.containerType),
|
||||
quantity: Number(c.qty || 1),
|
||||
hazardousQuantity: c.isHazardous ? Number(c.hazardousQty || 0) : 0,
|
||||
reeferQuantity: c.isReefer ? Number(c.reeferQty || 0) : 0,
|
||||
vgmPerUnitTons: Number(c.vgm || 0),
|
||||
}))
|
||||
: [],
|
||||
@@ -857,35 +891,9 @@ export default function EditBookingPage() {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Controller
|
||||
name="isHazardous"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ToggleRow
|
||||
icon={<Flame size={16} color="#E03131" />}
|
||||
title="Hazardous Material"
|
||||
description="Applies a Hazard Surcharge to the final bill."
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Divider />
|
||||
<Controller
|
||||
name="isRefrigerated"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ToggleRow
|
||||
icon={<Snowflake size={16} color="#1C7ED6" />}
|
||||
title="Refrigerated Cargo"
|
||||
description="Temperature-controlled transport applies a Refrigerator Surcharge."
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Paper>
|
||||
{/* Cargo handling (hazardous / refrigerated, with per-unit amounts)
|
||||
now lives in the Cargo tab via Step5CargoDetails — no separate
|
||||
toggles here, to avoid duplicate controls that diverge. */}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
|
||||
@@ -458,10 +458,28 @@ export default function NewBookingPage() {
|
||||
// Day-level pool: the customer picks only a day (scheduledDate); the batch
|
||||
// engine assigns the train, so no trainScheduleId is sent.
|
||||
cargoTotalWeightVgm: totalWeight,
|
||||
isHazardous: data.isHazardous,
|
||||
// Reefer is a customer choice for bulk only; container reefer is decided by
|
||||
// the container type on the backend, so never send it for containers.
|
||||
isReefer: data.cargoType === "bulk" ? data.isRefrigerated : false,
|
||||
// Booking-level flags drive the HAZARD / REEFER surcharge triggers. For
|
||||
// containers they're the OR of the per-container switches; for bulk they
|
||||
// come from the cargo-step toggles.
|
||||
isHazardous:
|
||||
data.cargoType === "container"
|
||||
? data.containers.some((c) => c.isHazardous)
|
||||
: data.isHazardous,
|
||||
isReefer:
|
||||
data.cargoType === "container"
|
||||
? data.containers.some((c) => c.isReefer)
|
||||
: data.isRefrigerated,
|
||||
// Bulk-only: the hazardous / refrigerated amount in the cargo's unit.
|
||||
...(data.cargoType === "bulk"
|
||||
? {
|
||||
bulkHazardousQuantity: data.isHazardous
|
||||
? Number(data.bulkHazardousQty || 0)
|
||||
: 0,
|
||||
bulkReeferQuantity: data.isRefrigerated
|
||||
? Number(data.bulkReeferQty || 0)
|
||||
: 0,
|
||||
}
|
||||
: {}),
|
||||
freightType:
|
||||
data.cargoType === "container"
|
||||
? ("CONTAINER" as const)
|
||||
@@ -471,6 +489,9 @@ export default function NewBookingPage() {
|
||||
? data.containers.map((c) => ({
|
||||
containerTypeId: findContainerTypeId(c.containerType),
|
||||
quantity: Number(c.qty || 1),
|
||||
// Per-line breakdown: how many of this line are hazardous / reefer.
|
||||
hazardousQuantity: c.isHazardous ? Number(c.hazardousQty || 0) : 0,
|
||||
reeferQuantity: c.isReefer ? Number(c.reeferQty || 0) : 0,
|
||||
// Weight (VGM) is not collected at the wizard — captured in operations.
|
||||
vgmPerUnitTons: 0,
|
||||
}))
|
||||
|
||||
@@ -169,6 +169,11 @@ export const bookingFormSchema = z
|
||||
cargoFreeText: z.string(),
|
||||
isHazardous: z.boolean(),
|
||||
isRefrigerated: z.boolean(),
|
||||
// Bulk-only: how much of the cargo is hazardous / refrigerated, in the
|
||||
// commodity's unit (tons for PER_TON, item count for PER_ITEM). Bounded by
|
||||
// cargoWeight in superRefine when the matching flag is on.
|
||||
bulkHazardousQty: z.string().default("0"),
|
||||
bulkReeferQty: z.string().default("0"),
|
||||
containers: z.array(
|
||||
z.object({
|
||||
type: z.enum(["20ft", "40ft"]),
|
||||
@@ -178,6 +183,13 @@ export const bookingFormSchema = z
|
||||
.refine((q) => q.length !== 0, "Quantity is required.")
|
||||
.refine((q) => !isNaN(+q), "Enter a valid Number")
|
||||
.refine((qty) => Number(qty) >= 1, "Must be greater than 0"),
|
||||
// Per-line hazmat/reefer: how many of this line's containers are
|
||||
// hazardous / refrigerated. The flag drives whether the quantity input
|
||||
// shows; the quantity (bounded by qty above) is validated in superRefine.
|
||||
isHazardous: z.boolean().default(false),
|
||||
hazardousQty: z.string().default("0"),
|
||||
isReefer: z.boolean().default(false),
|
||||
reeferQty: z.string().default("0"),
|
||||
// Weight (VGM) is NOT collected at the wizard — it is captured later in
|
||||
// operations. Kept optional so existing payload code stays valid.
|
||||
vgm: z.string().default("0"),
|
||||
@@ -253,9 +265,48 @@ export const bookingFormSchema = z
|
||||
message: "Select a Cargo type.",
|
||||
});
|
||||
}
|
||||
// Bulk hazardous / refrigerated amounts, when their flag is on, must be
|
||||
// 1..cargoWeight (in the commodity's own unit). cargoWeight itself is
|
||||
// validated above, so here we just bound the portion against it.
|
||||
const cargoQty = Number(data.cargoWeight);
|
||||
const boundBulkPortion = (
|
||||
on: boolean,
|
||||
raw: string,
|
||||
path: "bulkHazardousQty" | "bulkReeferQty",
|
||||
noun: string,
|
||||
) => {
|
||||
if (!on) return;
|
||||
const v = Number(raw);
|
||||
if (!raw || Number.isNaN(v) || v < 1) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: [path],
|
||||
message: `Enter how much is ${noun} (at least 1).`,
|
||||
});
|
||||
} else if (!Number.isNaN(cargoQty) && cargoQty > 0 && v > cargoQty) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: [path],
|
||||
message: `Can't exceed the cargo quantity (${cargoQty}).`,
|
||||
});
|
||||
}
|
||||
};
|
||||
boundBulkPortion(
|
||||
data.isHazardous,
|
||||
data.bulkHazardousQty,
|
||||
"bulkHazardousQty",
|
||||
"hazardous",
|
||||
);
|
||||
boundBulkPortion(
|
||||
data.isRefrigerated,
|
||||
data.bulkReeferQty,
|
||||
"bulkReeferQty",
|
||||
"refrigerated",
|
||||
);
|
||||
}
|
||||
if (data.cargoType === "container") {
|
||||
data.containers.forEach((c, i) => {
|
||||
const lineQty = Number(c.qty);
|
||||
if (!c.qty || +c.qty < 1) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
@@ -263,6 +314,40 @@ export const bookingFormSchema = z
|
||||
message: "Enter at least 1 container.",
|
||||
});
|
||||
}
|
||||
// When a per-line flag is on, its quantity must be 1..lineQty: at least
|
||||
// one affected container, never more than the line holds.
|
||||
if (c.isHazardous) {
|
||||
const h = Number(c.hazardousQty);
|
||||
if (!c.hazardousQty || Number.isNaN(h) || h < 1) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["containers", i, "hazardousQty"],
|
||||
message: "Enter how many are hazardous (at least 1).",
|
||||
});
|
||||
} else if (!Number.isNaN(lineQty) && h > lineQty) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["containers", i, "hazardousQty"],
|
||||
message: `Can't exceed the ${lineQty} container(s) in this line.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (c.isReefer) {
|
||||
const r = Number(c.reeferQty);
|
||||
if (!c.reeferQty || Number.isNaN(r) || r < 1) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["containers", i, "reeferQty"],
|
||||
message: "Enter how many are refrigerated (at least 1).",
|
||||
});
|
||||
} else if (!Number.isNaN(lineQty) && r > lineQty) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["containers", i, "reeferQty"],
|
||||
message: `Can't exceed the ${lineQty} container(s) in this line.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -301,7 +386,20 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
||||
cargoFreeText: "",
|
||||
isHazardous: false,
|
||||
isRefrigerated: false,
|
||||
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "0" }],
|
||||
bulkHazardousQty: "0",
|
||||
bulkReeferQty: "0",
|
||||
containers: [
|
||||
{
|
||||
type: "20ft",
|
||||
containerType: "",
|
||||
qty: "1",
|
||||
isHazardous: false,
|
||||
hazardousQty: "0",
|
||||
isReefer: false,
|
||||
reeferQty: "0",
|
||||
vgm: "0",
|
||||
},
|
||||
],
|
||||
documents: {},
|
||||
notes: "",
|
||||
};
|
||||
@@ -320,14 +418,23 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
||||
"firstMile",
|
||||
"lastMile",
|
||||
],
|
||||
3: ["cargoType", "cargoWeight", "cargoTypePath", "containers"],
|
||||
3: [
|
||||
"cargoType",
|
||||
"cargoWeight",
|
||||
"cargoTypePath",
|
||||
"containers",
|
||||
// Cargo-handling flags + their bulk amounts now live in the Cargo step,
|
||||
// under the quantity where the unit (tons/items) is known.
|
||||
"isHazardous",
|
||||
"isRefrigerated",
|
||||
"bulkHazardousQty",
|
||||
"bulkReeferQty",
|
||||
],
|
||||
4: [
|
||||
"originYard",
|
||||
"destinationYard",
|
||||
"primaryRouteQuantity",
|
||||
"extraRoutes",
|
||||
"isHazardous",
|
||||
"isRefrigerated",
|
||||
// Estimated shipment date now lives in the Route step (one-time bookings only).
|
||||
"scheduledDate",
|
||||
],
|
||||
@@ -339,6 +446,12 @@ export interface ContainerConfig {
|
||||
type: "20ft" | "40ft";
|
||||
containerType: string;
|
||||
qty: string;
|
||||
// Per-line hazmat/reefer breakdown. Optional on the watched input shape: the
|
||||
// schema defaults them, so a restored draft may omit them.
|
||||
isHazardous?: boolean;
|
||||
hazardousQty?: string;
|
||||
isReefer?: boolean;
|
||||
reeferQty?: string;
|
||||
// Optional: VGM is captured later in operations, not at the wizard, and the
|
||||
// form schema defaults it — so the watched input shape has it as optional.
|
||||
vgm?: string;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
InputBase,
|
||||
Paper,
|
||||
Select,
|
||||
Switch,
|
||||
Text,
|
||||
Title,
|
||||
useCombobox,
|
||||
@@ -358,6 +359,81 @@ export function SelectField({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon + title + description row with a trailing Switch, in a card that tints
|
||||
* green when on. Used for the cargo-handling toggles (hazardous / reefer) on the
|
||||
* Route step and per container on the Cargo step. `children` renders below the
|
||||
* row when the switch is on (e.g. a bounded quantity input).
|
||||
*/
|
||||
export function ToggleRow({
|
||||
icon,
|
||||
iconBg,
|
||||
iconColor,
|
||||
title,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
iconBg: string;
|
||||
iconColor: string;
|
||||
title: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
px={16}
|
||||
py={13}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: `1.5px solid ${checked ? "#CDEBDD" : BORDER}`,
|
||||
background: checked ? "#F6FBF8" : "#fff",
|
||||
transition: "all 150ms ease",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap={13} align="center" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 11,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: iconBg,
|
||||
color: iconColor,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={14} fw={700} c={INK}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text fz={12} c={MUTED} style={{ lineHeight: 1.4 }}>
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.currentTarget.checked)}
|
||||
color="edr-green"
|
||||
size="md"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Group>
|
||||
{checked && children ? <Box mt={12}>{children}</Box> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface AsyncComboboxOption {
|
||||
value: string;
|
||||
label: string;
|
||||
|
||||
@@ -2,21 +2,17 @@ import type { Freight } from "@edr/types";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import {
|
||||
CalendarDays,
|
||||
Flame,
|
||||
MapPin,
|
||||
Plus,
|
||||
Route as RouteIcon,
|
||||
Snowflake,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
@@ -168,16 +164,8 @@ export function Step4Route({
|
||||
// A general contract can cover several routes, but each route is just an
|
||||
// (origin, destination) pair — the same shape as the one-time route. The
|
||||
// contracted quantity comes from the cargo step, so no per-route quantity or
|
||||
// distance is collected here.
|
||||
const cargoType = form.watch("cargoType");
|
||||
|
||||
// The reefer toggle only exists for bulk; if the customer switches to
|
||||
// containers, drop any reefer flag they set so it can't ride along unseen.
|
||||
useEffect(() => {
|
||||
if (cargoType !== "bulk" && form.getValues("isRefrigerated")) {
|
||||
form.setValue("isRefrigerated", false);
|
||||
}
|
||||
}, [cargoType, form]);
|
||||
// distance is collected here. Cargo handling (hazardous / refrigerated) also
|
||||
// lives in the Cargo step now, not here.
|
||||
|
||||
// Earliest selectable shipment date (today, local) for the date input's `min`.
|
||||
const todayISODate = useMemo(() => {
|
||||
@@ -240,21 +228,26 @@ export function Step4Route({
|
||||
{/* Estimated shipment date — one-time bookings only. General contracts
|
||||
pick the date per order drawn against the contract later. */}
|
||||
{!isGeneralContract && (
|
||||
<Box style={{ maxWidth: 280 }}>
|
||||
<Box w="100%">
|
||||
<Controller
|
||||
name="scheduledDate"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
type="date"
|
||||
<DatePickerInput
|
||||
w="100%"
|
||||
label="Estimated shipment date *"
|
||||
description="A planning estimate. You'll confirm the actual date when you request the operation."
|
||||
min={todayISODate}
|
||||
placeholder="Pick a date"
|
||||
minDate={todayISODate}
|
||||
leftSection={<CalendarDays size={16} />}
|
||||
error={fieldState.error?.message}
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => field.onChange(e.currentTarget.value)}
|
||||
// Mantine v9 DatePickerInput uses string (YYYY-MM-DD) values,
|
||||
// matching the form's `scheduledDate` string directly.
|
||||
value={field.value || null}
|
||||
onChange={(v) => field.onChange(v ?? "")}
|
||||
onBlur={field.onBlur}
|
||||
radius="md"
|
||||
popoverProps={{ withinPortal: true }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -360,116 +353,10 @@ export function Step4Route({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider my={22} color="#EEF2F6" />
|
||||
|
||||
<StepLabel>Cargo handling</StepLabel>
|
||||
<Stack gap={12} mt={12}>
|
||||
<Controller
|
||||
name="isHazardous"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ToggleRow
|
||||
icon={<Flame size={18} />}
|
||||
iconBg="#FBEAE7"
|
||||
iconColor="#C0392B"
|
||||
title="Hazardous Material"
|
||||
description="Applies a hazard surcharge to the final bill."
|
||||
checked={field.value}
|
||||
onChange={(v) => field.onChange(v)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{/* Reefer is a customer choice for bulk freight only. For containers the
|
||||
reefer surcharge is driven by the container type, so the toggle is
|
||||
hidden there to avoid a control that doesn't affect the price. */}
|
||||
{cargoType === "bulk" && (
|
||||
<Controller
|
||||
name="isRefrigerated"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ToggleRow
|
||||
icon={<Snowflake size={18} />}
|
||||
iconBg="#E9F0F8"
|
||||
iconColor="#2E5B96"
|
||||
title="Refrigerated Cargo"
|
||||
description="Temperature-controlled transport applies a refrigeration surcharge."
|
||||
checked={field.value}
|
||||
onChange={(v) => field.onChange(v)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
icon,
|
||||
iconBg,
|
||||
iconColor,
|
||||
title,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
iconBg: string;
|
||||
iconColor: string;
|
||||
title: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
px={16}
|
||||
py={13}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: `1.5px solid ${checked ? "#CDEBDD" : "#E6ECF2"}`,
|
||||
background: checked ? "#F6FBF8" : "#fff",
|
||||
transition: "all 150ms ease",
|
||||
}}
|
||||
>
|
||||
<Group gap={13} align="center" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 11,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: iconBg,
|
||||
color: iconColor,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={14} fw={700} c="#10202F">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.currentTarget.checked)}
|
||||
color="edr-green"
|
||||
size="md"
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 rounded-xl border border-gray-200 p-4">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
|
||||
import { Package, Plus, Trash2, Weight } from "lucide-react";
|
||||
import { Flame, Package, Plus, Snowflake, Trash2, Weight } from "lucide-react";
|
||||
import { ActionIcon, Button, Skeleton, Text, TextInput } from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
StepCard,
|
||||
StepHeader,
|
||||
StepLabel,
|
||||
ToggleRow,
|
||||
} from "./shared";
|
||||
|
||||
type BookingForm = UseFormReturn<
|
||||
@@ -114,6 +115,59 @@ export function Step5CargoDetails({
|
||||
);
|
||||
}, [referenceData, parentId]);
|
||||
|
||||
// ── Bulk hazmat/reefer (whole-cargo portion) helpers ──────────────────────
|
||||
// The bulk amounts are in the commodity's own unit and bounded by cargoWeight.
|
||||
const bulkUnitLabel = isPerItem ? "items" : "tons";
|
||||
const bulkCargoQty = () =>
|
||||
Math.max(0, Number(form.getValues("cargoWeight") ?? 0) || 0);
|
||||
const bulkMax = () => bulkCargoQty() || undefined;
|
||||
// Default a switched-on portion to the whole cargo amount.
|
||||
const bulkDefaultQty = () => {
|
||||
const q = bulkCargoQty();
|
||||
return q > 0 ? String(q) : "1";
|
||||
};
|
||||
// Clamp a typed value into 0..cargoWeight (items round down). Empty stays empty
|
||||
// so the field can be cleared; the schema then flags it as required.
|
||||
const clampToBulk = (raw: string) => {
|
||||
if (raw === "") return "";
|
||||
const n = Number(raw);
|
||||
if (Number.isNaN(n)) return raw;
|
||||
const cap = bulkCargoQty();
|
||||
const stepped = isPerItem ? Math.floor(n) : n;
|
||||
const bounded = cap > 0 ? Math.min(cap, stepped) : stepped;
|
||||
return Math.max(0, bounded).toString();
|
||||
};
|
||||
|
||||
// ── Per-line hazmat/reefer quantity helpers ───────────────────────────────
|
||||
// The line quantity (number of containers in this line) is the upper bound for
|
||||
// both the hazardous and the refrigerated counts.
|
||||
const lineQtyOf = (index: number) =>
|
||||
Math.max(1, Number(form.getValues(`containers.${index}.qty`) ?? 1) || 1);
|
||||
const lineMax = (index: number) => lineQtyOf(index);
|
||||
// When a flag is switched on, default its count to the whole line.
|
||||
const defaultLineQty = (index: number) => lineQtyOf(index).toString();
|
||||
// Clamp a typed value into 1..lineQty (empty stays empty so the field can be
|
||||
// cleared; the schema flags an empty value as required while the switch is on).
|
||||
const clampToLine = (raw: string, index: number) => {
|
||||
if (raw === "") return "";
|
||||
const n = Number(raw);
|
||||
if (Number.isNaN(n)) return raw;
|
||||
return Math.min(lineQtyOf(index), Math.max(1, Math.floor(n))).toString();
|
||||
};
|
||||
// After the line quantity changes, pull any active count back within bounds.
|
||||
const clampDependentQty = (index: number, newLineQty: number) => {
|
||||
const max = Math.max(1, newLineQty);
|
||||
(["hazardousQty", "reeferQty"] as const).forEach((key) => {
|
||||
const cur = Number(form.getValues(`containers.${index}.${key}`) ?? 0);
|
||||
if (cur > max) {
|
||||
form.setValue(`containers.${index}.${key}`, max.toString(), {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<StepCard>
|
||||
@@ -250,6 +304,25 @@ export function Step5CargoDetails({
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
// Pull any active bulk hazmat/reefer portion back within the
|
||||
// new cargo amount so it can't outlive a shrink.
|
||||
const cap = Number(e.currentTarget.value);
|
||||
if (!Number.isNaN(cap)) {
|
||||
(["bulkHazardousQty", "bulkReeferQty"] as const).forEach(
|
||||
(key) => {
|
||||
const cur = Number(form.getValues(key) ?? 0);
|
||||
if (cap > 0 && cur > cap) {
|
||||
form.setValue(key, String(cap), {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
id="cargoWeight"
|
||||
type="number"
|
||||
label={isPerItem ? "Quantity (Items) *" : "Quantity (Tons) *"}
|
||||
@@ -275,6 +348,111 @@ export function Step5CargoDetails({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Cargo handling — how much of the cargo is hazardous / refrigerated,
|
||||
in the SAME unit as the quantity above (tons or items). Shown once a
|
||||
commodity is chosen so the unit is known; general contracts handle
|
||||
handling per order. */}
|
||||
{selectedCommodity && !isGeneralContract && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Controller
|
||||
name="isHazardous"
|
||||
control={form.control}
|
||||
render={({ field: hazField }) => (
|
||||
<ToggleRow
|
||||
icon={<Flame size={18} />}
|
||||
iconBg="#FBEAE7"
|
||||
iconColor="#C0392B"
|
||||
title="Hazardous"
|
||||
description={`Part of this cargo is hazardous (in ${bulkUnitLabel}).`}
|
||||
checked={!!hazField.value}
|
||||
onChange={(v) => {
|
||||
hazField.onChange(v);
|
||||
form.setValue(
|
||||
"bulkHazardousQty",
|
||||
v ? bulkDefaultQty() : "0",
|
||||
{ shouldDirty: true, shouldValidate: true },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
name="bulkHazardousQty"
|
||||
control={form.control}
|
||||
render={({ field: hq, fieldState }) => (
|
||||
<TextInput
|
||||
type="number"
|
||||
size="sm"
|
||||
label={
|
||||
isPerItem
|
||||
? "How many items are hazardous?"
|
||||
: "How many tons are hazardous?"
|
||||
}
|
||||
min={isPerItem ? 1 : 0}
|
||||
max={bulkMax()}
|
||||
step={isPerItem ? 1 : 0.01}
|
||||
value={hq.value ?? ""}
|
||||
onChange={(e) =>
|
||||
hq.onChange(clampToBulk(e.currentTarget.value))
|
||||
}
|
||||
onBlur={hq.onBlur}
|
||||
error={fieldState.error?.message}
|
||||
radius="md"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</ToggleRow>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="isRefrigerated"
|
||||
control={form.control}
|
||||
render={({ field: reeField }) => (
|
||||
<ToggleRow
|
||||
icon={<Snowflake size={18} />}
|
||||
iconBg="#E9F0F8"
|
||||
iconColor="#2E5B96"
|
||||
title="Refrigerated"
|
||||
description={`Part of this cargo needs reefer transport (in ${bulkUnitLabel}).`}
|
||||
checked={!!reeField.value}
|
||||
onChange={(v) => {
|
||||
reeField.onChange(v);
|
||||
form.setValue(
|
||||
"bulkReeferQty",
|
||||
v ? bulkDefaultQty() : "0",
|
||||
{ shouldDirty: true, shouldValidate: true },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
name="bulkReeferQty"
|
||||
control={form.control}
|
||||
render={({ field: rq, fieldState }) => (
|
||||
<TextInput
|
||||
type="number"
|
||||
size="sm"
|
||||
label={
|
||||
isPerItem
|
||||
? "How many items are refrigerated?"
|
||||
: "How many tons are refrigerated?"
|
||||
}
|
||||
min={isPerItem ? 1 : 0}
|
||||
max={bulkMax()}
|
||||
step={isPerItem ? 1 : 0.01}
|
||||
value={rq.value ?? ""}
|
||||
onChange={(e) =>
|
||||
rq.onChange(clampToBulk(e.currentTarget.value))
|
||||
}
|
||||
onBlur={rq.onBlur}
|
||||
error={fieldState.error?.message}
|
||||
radius="md"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</ToggleRow>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -364,53 +542,61 @@ export function Step5CargoDetails({
|
||||
<Controller
|
||||
name={`containers.${index}.qty`}
|
||||
control={form.control}
|
||||
render={({ field: qtyField, fieldState }) => (
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
Quantity *
|
||||
</Text>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
qtyField.onChange(
|
||||
Math.max(
|
||||
1,
|
||||
Number(qtyField.value ?? 1) - 1,
|
||||
).toString(),
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
value={qtyField.value ?? 1}
|
||||
onChange={(e) => qtyField.onChange(e.target.value)}
|
||||
onBlur={qtyField.onBlur}
|
||||
type="number"
|
||||
min={1}
|
||||
className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-center text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
qtyField.onChange(
|
||||
(Number(qtyField.value ?? 1) + 1).toString(),
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{fieldState.error?.message && (
|
||||
<Text size="xs" c="red" mt={4}>
|
||||
{fieldState.error.message}
|
||||
render={({ field: qtyField, fieldState }) => {
|
||||
// Keep the per-line hazmat/reefer quantities within the new
|
||||
// line quantity whenever it drops, so an old larger value
|
||||
// can't outlive a shrink.
|
||||
const setQty = (next: number) => {
|
||||
const n = Math.max(1, next);
|
||||
qtyField.onChange(n.toString());
|
||||
clampDependentQty(index, n);
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
Quantity *
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setQty(Number(qtyField.value ?? 1) - 1)
|
||||
}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
value={qtyField.value ?? 1}
|
||||
onChange={(e) => {
|
||||
qtyField.onChange(e.target.value);
|
||||
const n = Number(e.target.value);
|
||||
if (!Number.isNaN(n) && n >= 1)
|
||||
clampDependentQty(index, n);
|
||||
}}
|
||||
onBlur={qtyField.onBlur}
|
||||
type="number"
|
||||
min={1}
|
||||
className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-center text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setQty(Number(qtyField.value ?? 1) + 1)
|
||||
}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{fieldState.error?.message && (
|
||||
<Text size="xs" c="red" mt={4}>
|
||||
{fieldState.error.message}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
@@ -431,6 +617,101 @@ export function Step5CargoDetails({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Per-container hazmat / reefer. Each switch reveals a bounded
|
||||
"how many of this line" input (1..line quantity). */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Controller
|
||||
name={`containers.${index}.isHazardous`}
|
||||
control={form.control}
|
||||
render={({ field: hazField }) => (
|
||||
<ToggleRow
|
||||
icon={<Flame size={18} />}
|
||||
iconBg="#FBEAE7"
|
||||
iconColor="#C0392B"
|
||||
title="Hazardous"
|
||||
description="Some of these containers carry hazardous cargo."
|
||||
checked={!!hazField.value}
|
||||
onChange={(v) => {
|
||||
hazField.onChange(v);
|
||||
form.setValue(
|
||||
`containers.${index}.hazardousQty`,
|
||||
v ? defaultLineQty(index) : "0",
|
||||
{ shouldDirty: true, shouldValidate: true },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
name={`containers.${index}.hazardousQty`}
|
||||
control={form.control}
|
||||
render={({ field: hq, fieldState }) => (
|
||||
<TextInput
|
||||
type="number"
|
||||
size="sm"
|
||||
label="How many hazardous?"
|
||||
min={1}
|
||||
max={lineMax(index)}
|
||||
value={hq.value ?? ""}
|
||||
onChange={(e) =>
|
||||
hq.onChange(
|
||||
clampToLine(e.currentTarget.value, index),
|
||||
)
|
||||
}
|
||||
onBlur={hq.onBlur}
|
||||
error={fieldState.error?.message}
|
||||
radius="md"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</ToggleRow>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name={`containers.${index}.isReefer`}
|
||||
control={form.control}
|
||||
render={({ field: reeField }) => (
|
||||
<ToggleRow
|
||||
icon={<Snowflake size={18} />}
|
||||
iconBg="#E9F0F8"
|
||||
iconColor="#2E5B96"
|
||||
title="Refrigerated"
|
||||
description="Some of these containers need reefer transport."
|
||||
checked={!!reeField.value}
|
||||
onChange={(v) => {
|
||||
reeField.onChange(v);
|
||||
form.setValue(
|
||||
`containers.${index}.reeferQty`,
|
||||
v ? defaultLineQty(index) : "0",
|
||||
{ shouldDirty: true, shouldValidate: true },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
name={`containers.${index}.reeferQty`}
|
||||
control={form.control}
|
||||
render={({ field: rq, fieldState }) => (
|
||||
<TextInput
|
||||
type="number"
|
||||
size="sm"
|
||||
label="How many refrigerated?"
|
||||
min={1}
|
||||
max={lineMax(index)}
|
||||
value={rq.value ?? ""}
|
||||
onChange={(e) =>
|
||||
rq.onChange(
|
||||
clampToLine(e.currentTarget.value, index),
|
||||
)
|
||||
}
|
||||
onBlur={rq.onBlur}
|
||||
error={fieldState.error?.message}
|
||||
radius="md"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</ToggleRow>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -204,6 +204,12 @@ export function Step8Review({
|
||||
|
||||
// Bulk PER_ITEM cargo is a whole item count, not tons — label it accordingly.
|
||||
const isPerItem = selectedCommodity?.unit_of_measure === "PER_ITEM";
|
||||
const bulkUnit = isPerItem ? "items" : "tons";
|
||||
const bulkUnitAmount = (raw?: string) => {
|
||||
const n = Number(raw ?? 0);
|
||||
if (Number.isNaN(n)) return "0";
|
||||
return isPerItem ? String(Math.round(n)) : n.toFixed(1);
|
||||
};
|
||||
const totalQuantityRow = isPerItem
|
||||
? {
|
||||
label: "Total quantity",
|
||||
@@ -306,7 +312,16 @@ export function Step8Review({
|
||||
<DetailRow
|
||||
label="Modifiers"
|
||||
value={
|
||||
[values.isHazardous && "Hazardous", values.isRefrigerated && "Refrigerated"]
|
||||
(values.cargoType === "container"
|
||||
? [
|
||||
values.containers.some((c) => c.isHazardous) && "Hazardous",
|
||||
values.containers.some((c) => c.isReefer) && "Refrigerated",
|
||||
]
|
||||
: [
|
||||
values.isHazardous && "Hazardous",
|
||||
values.isRefrigerated && "Refrigerated",
|
||||
]
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(", ") || "None"
|
||||
}
|
||||
@@ -377,12 +392,26 @@ export function Step8Review({
|
||||
value={totalQuantityRow.value}
|
||||
/>
|
||||
)}
|
||||
{values.cargoType === "bulk" && values.isHazardous && (
|
||||
<DetailRow
|
||||
label="Hazardous"
|
||||
value={`${bulkUnitAmount(values.bulkHazardousQty)} ${bulkUnit}`}
|
||||
/>
|
||||
)}
|
||||
{values.cargoType === "bulk" && values.isRefrigerated && (
|
||||
<DetailRow
|
||||
label="Refrigerated"
|
||||
value={`${bulkUnitAmount(values.bulkReeferQty)} ${bulkUnit}`}
|
||||
/>
|
||||
)}
|
||||
{values.cargoType === "container" && values.containers.length > 0 && (
|
||||
<Table mt="sm" withTableBorder withColumnBorders fz="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Qty</Table.Th>
|
||||
<Table.Th>Hazardous</Table.Th>
|
||||
<Table.Th>Refrigerated</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
@@ -392,6 +421,12 @@ export function Step8Review({
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>{c.containerType || c.type}</Table.Td>
|
||||
<Table.Td>{c.qty}</Table.Td>
|
||||
<Table.Td>
|
||||
{c.isHazardous ? `${c.hazardousQty} of ${c.qty}` : "—"}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{c.isReefer ? `${c.reeferQty} of ${c.qty}` : "—"}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
|
||||
@@ -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 { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
Navigate,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Badge,
|
||||
@@ -23,6 +28,7 @@ import {
|
||||
CheckCircle2,
|
||||
Download,
|
||||
Eye,
|
||||
FileBadge,
|
||||
FileSignature,
|
||||
FileText,
|
||||
Flame,
|
||||
@@ -46,7 +52,6 @@ import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { labelForDocCode } from "@/pages/bookings/resubmit";
|
||||
import { ContractClearancePanel } from "./ContractClearancePanel";
|
||||
import { ContractChangesRequestedView } from "./ContractChangesRequestedView";
|
||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||
import {
|
||||
BORDER,
|
||||
@@ -72,14 +77,19 @@ const CLEARANCE_UPLOAD_STATUSES = [
|
||||
|
||||
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
|
||||
|
||||
// Business-license document codes — surfaced as their own section so they stand
|
||||
// out from the rest of the onboarding/profile set.
|
||||
const BUSINESS_LICENSE_DOC_CODES = new Set([
|
||||
"business_license",
|
||||
"commercial_license",
|
||||
"investment_license",
|
||||
]);
|
||||
|
||||
// Onboarding / company-profile document codes seeded in file-upload-settings.
|
||||
// These get attached to the contract at creation and belong under "Profile
|
||||
// documents" rather than the clearance set.
|
||||
const PROFILE_DOC_CODES = new Set([
|
||||
"tin_certificate",
|
||||
"commercial_license",
|
||||
"business_license",
|
||||
"investment_license",
|
||||
"national_id",
|
||||
"national_id_passport",
|
||||
"passport",
|
||||
@@ -98,18 +108,21 @@ interface DocGroup {
|
||||
* groups are dropped so the tab only renders sections that have files.
|
||||
*/
|
||||
function groupContractDocuments(files: ContractFile[]): DocGroup[] {
|
||||
const businessLicense: ContractFile[] = [];
|
||||
const profile: ContractFile[] = [];
|
||||
const clearance: ContractFile[] = [];
|
||||
for (const f of files) {
|
||||
// The generated contract PDF lives in the contract list / home rows, not
|
||||
// here. Signature images are baked into that PDF — skip both.
|
||||
if (f.code === "contract" || f.code.startsWith("signature_")) continue;
|
||||
else if (BUSINESS_LICENSE_DOC_CODES.has(f.code)) businessLicense.push(f);
|
||||
else if (PROFILE_DOC_CODES.has(f.code)) profile.push(f);
|
||||
else clearance.push(f);
|
||||
}
|
||||
return [
|
||||
{ key: "profile", title: "Profile documents", files: profile },
|
||||
{ key: "clearance", title: "Clearance documents", files: clearance },
|
||||
{ key: "businessLicense", title: "Business license", files: businessLicense },
|
||||
{ key: "profile", title: "Profile documents", files: profile },
|
||||
].filter((g) => g.files.length > 0);
|
||||
}
|
||||
|
||||
@@ -194,10 +207,11 @@ export default function ContractDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Staff returned the contract for changes — show the edit-and-resubmit view
|
||||
// (update documents → resubmit) instead of the read-only detail.
|
||||
// Staff returned the contract for changes — send the customer to the full edit
|
||||
// wizard (edit any term + replace documents → resubmit) rather than the
|
||||
// read-only detail.
|
||||
if (contract.status === "CHANGES_REQUESTED") {
|
||||
return <ContractChangesRequestedView contract={contract} />;
|
||||
return <Navigate to={`/contracts/${contract.id}/edit`} replace />;
|
||||
}
|
||||
|
||||
const isContainer = contract.freightType === "CONTAINER";
|
||||
@@ -980,12 +994,14 @@ const KEY_FACT_ACCENT: Record<string, string> = {
|
||||
|
||||
// Per-section accent + icon for the Documents tab groups.
|
||||
const DOC_GROUP_ACCENT: Record<string, string> = {
|
||||
profile: "#2B6CB0",
|
||||
clearance: "#C77F09",
|
||||
businessLicense: "#0A6F4D",
|
||||
profile: "#2B6CB0",
|
||||
};
|
||||
const DOC_GROUP_ICON: Record<string, LucideIcon> = {
|
||||
profile: FileText,
|
||||
clearance: Upload,
|
||||
businessLicense: FileBadge,
|
||||
profile: FileText,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,9 +28,14 @@ import {
|
||||
Upload,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Navigate,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useParams,
|
||||
} from "react-router-dom";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import {
|
||||
CONTRACT_STEPS,
|
||||
@@ -47,6 +52,12 @@ import {
|
||||
operationToProfileType,
|
||||
operationToTradeDirection,
|
||||
} from "./new-contract-form/helpers";
|
||||
import { contractToFormValues } from "./new-contract-form/contractToForm";
|
||||
import {
|
||||
ContractDocsEditor,
|
||||
documentSettingCode,
|
||||
missingRequiredDocKeys,
|
||||
} from "./new-contract-form/ContractDocsEditor";
|
||||
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
||||
import type { ProfileTypeValue } from "@/services/companies.service";
|
||||
import { StepIndicator } from "./new-contract-form/StepIndicator";
|
||||
@@ -55,7 +66,6 @@ import {
|
||||
useContractDraft,
|
||||
} from "./new-contract-form/useContractDraft";
|
||||
import {
|
||||
Step0OperationType,
|
||||
Step1ContractType,
|
||||
Step2ServiceType,
|
||||
Step3CargoScope,
|
||||
@@ -67,14 +77,42 @@ import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||
|
||||
type PriceModalMode = "submit" | "draft";
|
||||
|
||||
export default function NewContractPage() {
|
||||
/**
|
||||
* The contract wizard, used both to create a new contract and — in `edit` mode —
|
||||
* to edit & resubmit a contract staff returned with CHANGES_REQUESTED. Edit mode
|
||||
* hydrates the form from the saved contract, lets the customer change any term
|
||||
* and replace documents, then runs the same update → price → submit flow.
|
||||
*/
|
||||
export default function NewContractPage({
|
||||
mode = "create",
|
||||
}: {
|
||||
mode?: "create" | "edit";
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { id: editId } = useParams<{ id: string }>();
|
||||
const isEdit = mode === "edit" && Boolean(editId);
|
||||
const [step, setStep] = useState(0);
|
||||
const auth = useAuth();
|
||||
const { data: referenceData, isLoading: refDataLoading } = useQuery(
|
||||
api.bookings.referenceData.queryOptions(),
|
||||
);
|
||||
const { data: editContract } = useQuery({
|
||||
...api.contracts.get.queryOptions({ input: { id: editId ?? "" } }),
|
||||
enabled: isEdit,
|
||||
});
|
||||
// Onboarding document requirements — used in edit mode to block resubmit until
|
||||
// every required document is on file (existing or freshly attached).
|
||||
const editDocSettingQuery = useQuery({
|
||||
...api.fileUploadSettings.getByCode.queryOptions({
|
||||
input: {
|
||||
code: documentSettingCode(
|
||||
auth.company?.company?.nationality as string | null | undefined,
|
||||
),
|
||||
},
|
||||
}),
|
||||
enabled: isEdit,
|
||||
});
|
||||
|
||||
// Contract creation is gated on profile approval, same as bookings.
|
||||
if (!auth.isPending && auth.company && !auth.canBook) {
|
||||
@@ -105,7 +143,17 @@ export default function NewContractPage() {
|
||||
|
||||
const [pricingData, setPricingData] =
|
||||
useState<GenerateContractPriceResponse | null>(null);
|
||||
const [priceContractId, setPriceContractId] = useState<string | null>(null);
|
||||
// In edit mode the contract already exists, so seed its id — this makes
|
||||
// persistAndPriceMutation take the UPDATE branch instead of creating anew.
|
||||
const [priceContractId, setPriceContractId] = useState<string | null>(
|
||||
isEdit ? (editId ?? null) : null,
|
||||
);
|
||||
// Documents freshly attached on the review step (edit mode only). Merged into
|
||||
// the form's `documents` map before the contract is updated.
|
||||
const [editDocuments, setEditDocuments] = useState<
|
||||
Record<string, File | File[] | null>
|
||||
>({});
|
||||
const [showDocErrors, setShowDocErrors] = useState(false);
|
||||
const [priceModalMode, setPriceModalMode] = useState<PriceModalMode | null>(
|
||||
null,
|
||||
);
|
||||
@@ -205,7 +253,30 @@ export default function NewContractPage() {
|
||||
const location = useLocation();
|
||||
const startFresh =
|
||||
(location.state as { fresh?: boolean } | null)?.fresh === true;
|
||||
useContractDraft({ form, step, setStep, fresh: startFresh });
|
||||
useContractDraft({
|
||||
form,
|
||||
step,
|
||||
setStep,
|
||||
fresh: startFresh,
|
||||
enabled: !isEdit,
|
||||
});
|
||||
|
||||
// Edit mode: hydrate the form from the saved contract once both the contract
|
||||
// and the reference data (needed to rebuild the cargo-type path) have loaded.
|
||||
const hydratedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!isEdit || hydratedRef.current) return;
|
||||
if (!editContract || !referenceData) return;
|
||||
hydratedRef.current = true;
|
||||
const forwarderProfile =
|
||||
(auth.company?.company?.companyProfiles ?? []).find(
|
||||
(p) => p.id === editContract.companyProfileId,
|
||||
)?.type === "freight_forwarder";
|
||||
form.reset(
|
||||
contractToFormValues(editContract, referenceData, forwarderProfile),
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isEdit, editContract, referenceData]);
|
||||
|
||||
const originYard = form.watch("originYard");
|
||||
const destinationYard = form.watch("destinationYard");
|
||||
@@ -239,19 +310,43 @@ export default function NewContractPage() {
|
||||
);
|
||||
}, [originYard, destinationYard, operationType, referenceData]);
|
||||
|
||||
// type -> status ("active" = approved | "pending" = awaiting staff approval)
|
||||
// for the company's operational profiles. Drives both the select-time gate and
|
||||
// the per-option dropdown badges.
|
||||
const profileStatusByType = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const p of auth.company?.company?.companyProfiles ?? [])
|
||||
m.set(p.type, p.status);
|
||||
return m;
|
||||
}, [auth.company]);
|
||||
const profileTypes = useMemo(
|
||||
() => (auth.company?.company?.companyProfiles ?? []).map((p) => p.type),
|
||||
[auth.company],
|
||||
() => [...profileStatusByType.keys()],
|
||||
[profileStatusByType],
|
||||
);
|
||||
|
||||
// All operation types are always selectable. Picking one the company has no
|
||||
// profile for prompts a license upload that creates the profile on the fly
|
||||
// (mirrors the header "Add service" flow).
|
||||
// (mirrors the header "Add service" flow); picking one backed by a not-yet-
|
||||
// approved profile is blocked with an "awaiting approval" notice.
|
||||
const allowedOperations = useMemo<OperationType[]>(
|
||||
() => [...OPERATION_TYPES],
|
||||
[],
|
||||
);
|
||||
|
||||
// Approval state of the profile each operation maps to — used for the dropdown
|
||||
// badges. Intercity rides any customer profile, so always "approved".
|
||||
const operationStatus = useMemo(
|
||||
() =>
|
||||
(op: OperationType): "approved" | "pending" | "missing" => {
|
||||
if (op === "intercity") return "approved";
|
||||
const target = operationToProfileType(op, profileTypes);
|
||||
const status = profileStatusByType.get(target);
|
||||
if (!status) return "missing";
|
||||
return status === "active" ? "approved" : "pending";
|
||||
},
|
||||
[profileStatusByType, profileTypes],
|
||||
);
|
||||
|
||||
// Create-profile modal state (license upload → createProfileAndSwitch).
|
||||
const [createTarget, setCreateTarget] = useState<ProfileTypeValue | null>(
|
||||
null,
|
||||
@@ -260,6 +355,13 @@ export default function NewContractPage() {
|
||||
useState<OperationType | null>(null);
|
||||
const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
// After a license is uploaded the new profile comes back "pending", so the
|
||||
// create-profile modal switches to an "awaiting approval" success state.
|
||||
const [licenseSubmitted, setLicenseSubmitted] = useState(false);
|
||||
// Set when the user picks an operation whose profile exists but isn't approved
|
||||
// yet — drives the "awaiting approval" block modal.
|
||||
const [pendingApprovalProfile, setPendingApprovalProfile] =
|
||||
useState<ProfileTypeValue | null>(null);
|
||||
|
||||
const createProfileMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
@@ -275,11 +377,13 @@ export default function NewContractPage() {
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
// The operation was already set on the form when the modal opened.
|
||||
setCreateTarget(null);
|
||||
setPendingOperation(null);
|
||||
// The new profile comes back "pending", so the user can't proceed under
|
||||
// this operation yet: revert the select and switch the modal to its
|
||||
// "awaiting approval" success state (kept open until the user dismisses).
|
||||
form.setValue("operationType", undefined as never, { shouldDirty: true });
|
||||
setLicenseFiles([]);
|
||||
setCreateError(null);
|
||||
setLicenseSubmitted(true);
|
||||
},
|
||||
onError: (err) => {
|
||||
setCreateError(
|
||||
@@ -292,15 +396,25 @@ export default function NewContractPage() {
|
||||
// Intercity (domestic) runs on any existing customer profile — no switch.
|
||||
if (op === "intercity") return;
|
||||
const target = operationToProfileType(op, profileTypes) as ProfileTypeValue;
|
||||
const hasProfile = profileTypes.includes(target);
|
||||
if (!hasProfile) {
|
||||
// No matching profile — collect a license and create one.
|
||||
const status = profileStatusByType.get(target);
|
||||
|
||||
if (!status) {
|
||||
// Case 3 — no matching profile: collect a license and create one.
|
||||
setPendingOperation(op);
|
||||
setCreateTarget(target);
|
||||
setLicenseFiles([]);
|
||||
setCreateError(null);
|
||||
setLicenseSubmitted(false);
|
||||
return;
|
||||
}
|
||||
if (status !== "active") {
|
||||
// Case 2 — profile exists but isn't approved yet: block + revert the
|
||||
// select so an unusable operation is never left chosen.
|
||||
setPendingApprovalProfile(target);
|
||||
form.setValue("operationType", undefined as never, { shouldDirty: true });
|
||||
return;
|
||||
}
|
||||
// Case 1 — approved: proceed, switching the active profile if needed.
|
||||
if (auth.activeProfileType !== target) {
|
||||
void auth.switchMode(target as never);
|
||||
}
|
||||
@@ -324,6 +438,17 @@ export default function NewContractPage() {
|
||||
setPendingOperation(null);
|
||||
setLicenseFiles([]);
|
||||
setCreateError(null);
|
||||
setLicenseSubmitted(false);
|
||||
};
|
||||
|
||||
// Dismiss the post-submit "awaiting approval" success state. The select was
|
||||
// already reverted on success — just close and reset the modal.
|
||||
const handleCreateProfileDone = () => {
|
||||
setCreateTarget(null);
|
||||
setPendingOperation(null);
|
||||
setLicenseFiles([]);
|
||||
setCreateError(null);
|
||||
setLicenseSubmitted(false);
|
||||
};
|
||||
|
||||
const createTargetLabel = createTarget
|
||||
@@ -466,6 +591,25 @@ export default function NewContractPage() {
|
||||
|
||||
const handleSubmitContract = form.handleSubmit((data) => {
|
||||
try {
|
||||
// Edit mode: all required documents must be on file (already uploaded or
|
||||
// freshly attached) before resubmitting, and freshly attached files are
|
||||
// merged into the form's documents map so the update sends them.
|
||||
if (isEdit && editContract) {
|
||||
const missing = missingRequiredDocKeys(
|
||||
editDocSettingQuery.data,
|
||||
editContract,
|
||||
editDocuments,
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
setShowDocErrors(true);
|
||||
return;
|
||||
}
|
||||
setShowDocErrors(false);
|
||||
form.setValue("documents", {
|
||||
...(form.getValues("documents") ?? {}),
|
||||
...editDocuments,
|
||||
});
|
||||
}
|
||||
const apiPayload = buildApiPayload(data);
|
||||
persistAndPriceMutation.mutate({
|
||||
payload: apiPayload,
|
||||
@@ -511,20 +655,23 @@ export default function NewContractPage() {
|
||||
>
|
||||
<Box>
|
||||
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||
New Contract
|
||||
{isEdit ? "Edit Contract" : "New Contract"}
|
||||
</Title>
|
||||
<Text size="sm" c="edr-muted" mt={4}>
|
||||
Define your freight contract — scope, routes, and unit rates. Book
|
||||
shipments against it after signing.
|
||||
{isEdit
|
||||
? "Update your contract details and documents, then resubmit it for EDR staff review."
|
||||
: "Define your freight contract — scope, routes, and unit rates. Book shipments against it after signing."}
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<ChevronLeft size={16} />}
|
||||
onClick={() => navigate("/contracts")}
|
||||
onClick={() =>
|
||||
navigate(isEdit && editId ? `/contracts/${editId}` : "/contracts")
|
||||
}
|
||||
>
|
||||
Back to Contracts
|
||||
{isEdit ? "Back to Contract" : "Back to Contracts"}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -535,6 +682,20 @@ export default function NewContractPage() {
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<Box flex={1} p="24px">
|
||||
{isEdit && (
|
||||
<Alert
|
||||
color="orange"
|
||||
radius="lg"
|
||||
variant="light"
|
||||
icon={<AlertCircle size={18} />}
|
||||
title="A reviewer asked for changes"
|
||||
mb="lg"
|
||||
>
|
||||
Update any contract detail or document that needs to change, then
|
||||
resubmit the contract for review.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box mb="lg">
|
||||
<StepIndicator step={step} steps={visibleSteps} />
|
||||
</Box>
|
||||
@@ -565,12 +726,13 @@ export default function NewContractPage() {
|
||||
description="Define the operation, contract kind, and the service this contract is for."
|
||||
/>
|
||||
<Stack gap={24}>
|
||||
<Step0OperationType
|
||||
<Step1ContractType
|
||||
form={form}
|
||||
referenceData={referenceData}
|
||||
allowedOperations={allowedOperations}
|
||||
onSelect={handleOperationSelect}
|
||||
onOperationSelect={handleOperationSelect}
|
||||
operationStatus={operationStatus}
|
||||
/>
|
||||
<Step1ContractType form={form} referenceData={referenceData} />
|
||||
<Step2ServiceType referenceData={referenceData} form={form} />
|
||||
</Stack>
|
||||
</StepCard>
|
||||
@@ -625,6 +787,27 @@ export default function NewContractPage() {
|
||||
persistAndPriceMutation.isPending &&
|
||||
persistAndPriceMutation.variables?.mode === "submit"
|
||||
}
|
||||
isEdit={isEdit}
|
||||
documentsEditor={
|
||||
isEdit && editContract ? (
|
||||
<ContractDocsEditor
|
||||
contract={editContract}
|
||||
value={editDocuments}
|
||||
onChange={setEditDocuments}
|
||||
errors={
|
||||
showDocErrors
|
||||
? Object.fromEntries(
|
||||
missingRequiredDocKeys(
|
||||
editDocSettingQuery.data,
|
||||
editContract,
|
||||
editDocuments,
|
||||
).map((k) => [k, "Required"]),
|
||||
)
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
@@ -891,47 +1074,116 @@ export default function NewContractPage() {
|
||||
|
||||
{/* Create-profile modal — opens when the chosen operation type has no
|
||||
matching company profile yet. Collects a license, creates the profile,
|
||||
and switches to it (mirrors the header "Add service" flow). */}
|
||||
then shows an "awaiting approval" state (the new profile is pending). */}
|
||||
<Modal
|
||||
opened={createTarget !== null}
|
||||
onClose={() => {
|
||||
if (!createProfileMutation.isPending) handleCreateProfileCancel();
|
||||
if (createProfileMutation.isPending) return;
|
||||
if (licenseSubmitted) handleCreateProfileDone();
|
||||
else handleCreateProfileCancel();
|
||||
}}
|
||||
title={`Set up your ${createTargetLabel} profile`}
|
||||
title={
|
||||
licenseSubmitted
|
||||
? "Awaiting approval"
|
||||
: `Set up your ${createTargetLabel} profile`
|
||||
}
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
{licenseSubmitted ? (
|
||||
<Stack gap="md">
|
||||
<Group gap={10} align="flex-start" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
flexShrink: 0,
|
||||
borderRadius: 11,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#ECF6F1",
|
||||
color: "#0A6F4D",
|
||||
}}
|
||||
>
|
||||
<Check size={18} />
|
||||
</Box>
|
||||
<Text size="sm" c="dimmed">
|
||||
License submitted. Your {createTargetLabel.toLowerCase()} profile
|
||||
is now awaiting staff approval. We'll notify you once it's
|
||||
approved — then you can create this contract as{" "}
|
||||
{createTargetLabel.toLowerCase()}.
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button color="edr-green" onClick={handleCreateProfileDone}>
|
||||
Got it
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
You don't have a {createTargetLabel.toLowerCase()} profile yet. Add
|
||||
your business license to create one. It goes to staff for approval
|
||||
before you can use it.
|
||||
</Text>
|
||||
<FileInput
|
||||
label="Business license"
|
||||
multiple
|
||||
clearable
|
||||
accept="application/pdf,image/png,image/jpeg"
|
||||
leftSection={<Upload size={16} />}
|
||||
placeholder="Select license file(s)"
|
||||
value={licenseFiles}
|
||||
onChange={(files) => setLicenseFiles(files ?? [])}
|
||||
error={createError ?? undefined}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleCreateProfileCancel}
|
||||
disabled={createProfileMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleCreateProfileConfirm}
|
||||
loading={createProfileMutation.isPending}
|
||||
>
|
||||
Submit license
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Awaiting-approval modal — the chosen operation maps to a profile that
|
||||
exists but isn't approved yet. The select was already reverted. */}
|
||||
<Modal
|
||||
opened={pendingApprovalProfile !== null}
|
||||
onClose={() => setPendingApprovalProfile(null)}
|
||||
title="Awaiting approval"
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
You don't have a {createTargetLabel.toLowerCase()} profile yet. Add
|
||||
your business license to create one and continue this contract as{" "}
|
||||
{createTargetLabel.toLowerCase()}.
|
||||
Your{" "}
|
||||
{pendingApprovalProfile
|
||||
? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ??
|
||||
pendingApprovalProfile)
|
||||
: ""}{" "}
|
||||
profile was submitted and is under staff review. You can start a
|
||||
contract under it once it's approved.
|
||||
</Text>
|
||||
<FileInput
|
||||
label="Business license"
|
||||
multiple
|
||||
clearable
|
||||
accept="application/pdf,image/png,image/jpeg"
|
||||
leftSection={<Upload size={16} />}
|
||||
placeholder="Select license file(s)"
|
||||
value={licenseFiles}
|
||||
onChange={(files) => setLicenseFiles(files ?? [])}
|
||||
error={createError ?? undefined}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleCreateProfileCancel}
|
||||
disabled={createProfileMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleCreateProfileConfirm}
|
||||
loading={createProfileMutation.isPending}
|
||||
onClick={() => setPendingApprovalProfile(null)}
|
||||
>
|
||||
Create & continue
|
||||
OK
|
||||
</Button>
|
||||
</Group>
|
||||
</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 { useMemo, useState } from "react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { Select, Stack, Text } from "@mantine/core";
|
||||
import { Badge, Group, Select, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
CONTRACT_KIND_OPTIONS,
|
||||
ContractFormInputValues,
|
||||
OPERATION_TYPE_OPTIONS,
|
||||
type ContractFormValues,
|
||||
type OperationType,
|
||||
} from "./schema";
|
||||
import { AlertBox, AsyncComboboxField, fieldStyles } from "./shared";
|
||||
|
||||
@@ -31,11 +33,22 @@ interface PreviousContractOption {
|
||||
export function Step1ContractType({
|
||||
form,
|
||||
referenceData,
|
||||
allowedOperations,
|
||||
onOperationSelect,
|
||||
operationStatus,
|
||||
}: {
|
||||
form: ContractForm;
|
||||
referenceData?: Freight.BookingReferenceData;
|
||||
allowedOperations: OperationType[];
|
||||
onOperationSelect?: (op: OperationType) => void;
|
||||
/** Approval state of the profile each operation maps to (for the badges). */
|
||||
operationStatus?: (op: OperationType) => "approved" | "pending" | "missing";
|
||||
}) {
|
||||
const contractType = form.watch("contractType");
|
||||
|
||||
const operationData = OPERATION_TYPE_OPTIONS.filter((opt) =>
|
||||
allowedOperations.includes(opt.value),
|
||||
).map((opt) => ({ value: opt.value, label: opt.label }));
|
||||
const previousContractRef = form.watch("previousContractRef");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
@@ -167,38 +180,81 @@ export function Step1ContractType({
|
||||
|
||||
return (
|
||||
<Stack gap={16}>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{allowedOperations.length === 0 && (
|
||||
<AlertBox tone="error">
|
||||
Your company has no operational profile yet. Complete onboarding to
|
||||
register as an importer, exporter, or freight forwarder.
|
||||
</AlertBox>
|
||||
)}
|
||||
|
||||
{/* Operation Type + Contract Kind + New/Renewal on one wrapping row. Each
|
||||
field keeps a sensible min width and flexes to fill / wrap below on
|
||||
narrow screens. */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Controller
|
||||
name="operationType"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Select
|
||||
className="flex-1 min-w-[200px]"
|
||||
label="Operation Type *"
|
||||
placeholder="Select an operation…"
|
||||
data={operationData}
|
||||
value={field.value || null}
|
||||
onChange={(v) => {
|
||||
if (!v) return;
|
||||
field.onChange(v);
|
||||
onOperationSelect?.(v as OperationType);
|
||||
}}
|
||||
onBlur={field.onBlur}
|
||||
error={fieldState.error?.message}
|
||||
allowDeselect={false}
|
||||
radius={10}
|
||||
checkIconPosition="right"
|
||||
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
|
||||
styles={fieldStyles}
|
||||
renderOption={({ option }) => {
|
||||
const status = operationStatus?.(option.value as OperationType);
|
||||
return (
|
||||
<Group justify="space-between" gap="sm" wrap="nowrap" w="100%">
|
||||
<Text fz={14}>{option.label}</Text>
|
||||
{status === "pending" && (
|
||||
<Badge size="xs" color="yellow" variant="light" radius="sm">
|
||||
Pending
|
||||
</Badge>
|
||||
)}
|
||||
{status === "missing" && (
|
||||
<Badge size="xs" color="gray" variant="light" radius="sm">
|
||||
Add license
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="contractKind"
|
||||
control={form.control}
|
||||
render={({ field }) => {
|
||||
const selected = CONTRACT_KIND_OPTIONS.find(
|
||||
(o) => o.value === (field.value ?? "one_time"),
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<Select
|
||||
label="Contract Kind *"
|
||||
data={CONTRACT_KIND_OPTIONS.map((o) => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
}))}
|
||||
value={field.value ?? "one_time"}
|
||||
onChange={(v) => field.onChange(v ?? "one_time")}
|
||||
allowDeselect={false}
|
||||
radius={10}
|
||||
checkIconPosition="right"
|
||||
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
{selected && (
|
||||
<Text fz={12} c="#6B7C8E" mt={6}>
|
||||
{selected.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
className="flex-1 min-w-[200px]"
|
||||
label="Contract Kind *"
|
||||
data={CONTRACT_KIND_OPTIONS.map((o) => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
}))}
|
||||
value={field.value ?? "one_time"}
|
||||
onChange={(v) => field.onChange(v ?? "one_time")}
|
||||
allowDeselect={false}
|
||||
radius={10}
|
||||
checkIconPosition="right"
|
||||
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
@@ -206,6 +262,7 @@ export function Step1ContractType({
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Select
|
||||
className="flex-1 min-w-[200px]"
|
||||
label="New or Renewal *"
|
||||
placeholder="Select…"
|
||||
data={CONTRACT_TYPE_OPTIONS}
|
||||
|
||||
@@ -132,7 +132,7 @@ function ServiceTypeSelector({
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{services.map((s) => {
|
||||
const selected = s.id === value;
|
||||
return (
|
||||
@@ -147,8 +147,8 @@ function ServiceTypeSelector({
|
||||
position: "relative",
|
||||
textAlign: "left",
|
||||
cursor: "pointer",
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
padding: 12,
|
||||
borderRadius: 14,
|
||||
border: `1.5px solid ${selected ? GREEN : error ? "#F0B4B4" : BORDER}`,
|
||||
background: selected ? GREEN_SOFT : "#fff",
|
||||
boxShadow: selected
|
||||
@@ -162,10 +162,10 @@ function ServiceTypeSelector({
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
right: 12,
|
||||
width: 22,
|
||||
height: 22,
|
||||
top: 10,
|
||||
right: 10,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 999,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -175,16 +175,16 @@ function ServiceTypeSelector({
|
||||
transition: "all 160ms ease",
|
||||
}}
|
||||
>
|
||||
{selected ? <Check size={13} color="#fff" strokeWidth={3} /> : null}
|
||||
{selected ? <Check size={11} color="#fff" strokeWidth={3} /> : null}
|
||||
</Box>
|
||||
|
||||
<Group gap={11} align="flex-start" wrap="nowrap" mb={10}>
|
||||
<Group gap={9} align="flex-start" wrap="nowrap" mb={8}>
|
||||
<Box
|
||||
style={{
|
||||
width: 42,
|
||||
height: 42,
|
||||
width: 34,
|
||||
height: 34,
|
||||
flexShrink: 0,
|
||||
borderRadius: 12,
|
||||
borderRadius: 10,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -193,21 +193,15 @@ function ServiceTypeSelector({
|
||||
transition: "all 160ms ease",
|
||||
}}
|
||||
>
|
||||
<Container size={20} strokeWidth={2} />
|
||||
<Container size={17} strokeWidth={2} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0, paddingRight: 24 }}>
|
||||
<Text fz={14.5} fw={750} c={INK} style={{ lineHeight: 1.25 }}>
|
||||
<Box style={{ minWidth: 0, paddingRight: 20 }}>
|
||||
<Text fz={13} fw={700} c={INK} style={{ lineHeight: 1.25 }}>
|
||||
{s.serviceName}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{s.description ? (
|
||||
<Text fz={12.5} c={MUTED} mb={11} style={{ lineHeight: 1.45 }}>
|
||||
{s.description}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Group gap={6} wrap="wrap">
|
||||
{serviceFeatures(s).map((f) => (
|
||||
<FeatureChip
|
||||
@@ -315,6 +309,9 @@ export function Step2ServiceType({
|
||||
<Stack gap={12}>
|
||||
<StepLabel>Trucking & customs options</StepLabel>
|
||||
|
||||
{/* First mile, last mile and customs sit side by side on one wrapping
|
||||
row; each keeps a min width and stacks below on narrow screens. */}
|
||||
<div className="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:min-w-[280px]">
|
||||
{includesFirstMile && (
|
||||
<Controller
|
||||
name="firstMile.enabled"
|
||||
@@ -577,6 +574,7 @@ export function Step2ServiceType({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -188,6 +188,8 @@ export function Step8Review({
|
||||
onSubmit,
|
||||
saveDraftPending = false,
|
||||
submitPending = false,
|
||||
documentsEditor,
|
||||
isEdit = false,
|
||||
}: {
|
||||
form: ContractForm;
|
||||
/** Retained for caller compatibility; the review page is read-only. */
|
||||
@@ -201,6 +203,14 @@ export function Step8Review({
|
||||
onSubmit?: () => void;
|
||||
saveDraftPending?: boolean;
|
||||
submitPending?: boolean;
|
||||
/**
|
||||
* Document replace/upload block, shown only when editing an existing contract
|
||||
* (resubmit after CHANGES_REQUESTED). Omitted for fresh creation, where docs
|
||||
* come from the company profile automatically.
|
||||
*/
|
||||
documentsEditor?: React.ReactNode;
|
||||
/** Editing an existing contract (resubmit) rather than creating a new one. */
|
||||
isEdit?: boolean;
|
||||
}) {
|
||||
const values = form.watch();
|
||||
const serviceType = referenceData?.service.find(
|
||||
@@ -429,6 +439,17 @@ export function Step8Review({
|
||||
/>
|
||||
)}
|
||||
|
||||
{documentsEditor && (
|
||||
<Paper
|
||||
radius={18}
|
||||
p="lg"
|
||||
withBorder
|
||||
style={{ borderColor: "var(--mantine-color-edr-border-0)" }}
|
||||
>
|
||||
{documentsEditor}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
name="notes"
|
||||
control={form.control}
|
||||
@@ -485,9 +506,13 @@ export function Step8Review({
|
||||
|
||||
<Paper radius={20} p="lg" withBorder bg="white">
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
{pricing
|
||||
? "Review your unit-rate quotation. Approve to submit the contract for EDR staff review."
|
||||
: "Ready to submit. You'll review the unit-rate quotation before final submission."}
|
||||
{isEdit
|
||||
? pricing
|
||||
? "Review your updated unit-rate quotation. Approve to resubmit the contract for EDR staff review."
|
||||
: "Ready to resubmit. You'll review the unit-rate quotation before final resubmission."
|
||||
: pricing
|
||||
? "Review your unit-rate quotation. Approve to submit the contract for EDR staff review."
|
||||
: "Ready to submit. You'll review the unit-rate quotation before final submission."}
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
@@ -501,20 +526,28 @@ export function Step8Review({
|
||||
loading={submitPending}
|
||||
disabled={submitPending}
|
||||
>
|
||||
{pricing ? "Approve quotation & submit" : "Submit"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
fullWidth
|
||||
onClick={onSaveDraft}
|
||||
loading={saveDraftPending}
|
||||
disabled={submitPending}
|
||||
>
|
||||
Save as draft
|
||||
{isEdit
|
||||
? pricing
|
||||
? "Approve quotation & resubmit"
|
||||
: "Resubmit"
|
||||
: pricing
|
||||
? "Approve quotation & submit"
|
||||
: "Submit"}
|
||||
</Button>
|
||||
{!isEdit && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
fullWidth
|
||||
onClick={onSaveDraft}
|
||||
loading={saveDraftPending}
|
||||
disabled={submitPending}
|
||||
>
|
||||
Save as draft
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { Step0OperationType } from "./step0-operation-type";
|
||||
export { Step1ContractType } from "./step1-contract-type";
|
||||
export { Step2ServiceType } from "./step2-service-type";
|
||||
export { Step3CargoScope } from "./step3-cargo-scope";
|
||||
|
||||
@@ -58,14 +58,22 @@ export function useContractDraft({
|
||||
step,
|
||||
setStep,
|
||||
fresh,
|
||||
enabled = true,
|
||||
}: {
|
||||
form: ContractForm;
|
||||
step: number;
|
||||
setStep: (step: number) => void;
|
||||
fresh: boolean;
|
||||
/**
|
||||
* When false the draft is neither restored nor persisted — used in edit mode,
|
||||
* where the server contract is the source of truth and the localStorage key is
|
||||
* shared with the create flow.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
}): { clearDraft: () => void } {
|
||||
const restoredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (restoredRef.current) return;
|
||||
restoredRef.current = true;
|
||||
|
||||
@@ -107,6 +115,7 @@ export function useContractDraft({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (!restoredRef.current) return;
|
||||
const sub = form.watch(() => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
@@ -117,9 +126,10 @@ export function useContractDraft({
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [form]);
|
||||
}, [form, enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (!restoredRef.current) return;
|
||||
write();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -432,6 +432,10 @@ export interface IBooking extends BaseEntity {
|
||||
|
||||
isHazardous: boolean;
|
||||
isRefrigerated: boolean;
|
||||
/** Bulk-only hazardous amount in the cargo's unit (tons/items); 0 otherwise. */
|
||||
bulkHazardousQuantity?: number;
|
||||
/** Bulk-only refrigerated amount in the cargo's unit (tons/items); 0 otherwise. */
|
||||
bulkReeferQuantity?: number;
|
||||
|
||||
tradeDirection: "IMPORT" | "EXPORT";
|
||||
paymentCurrency: string;
|
||||
@@ -717,6 +721,10 @@ export interface CreateBookingContainerDto {
|
||||
containerTypeId: string;
|
||||
quantity: number;
|
||||
vgmPerUnitTons: number;
|
||||
/** How many of this line's containers are hazardous (0..quantity). */
|
||||
hazardousQuantity?: number;
|
||||
/** How many of this line's containers are refrigerated (0..quantity). */
|
||||
reeferQuantity?: number;
|
||||
}
|
||||
|
||||
/** A contracted route+quantity line for a GENERAL contract. */
|
||||
@@ -768,6 +776,10 @@ export interface CreateBookingDto {
|
||||
isHazardous?: boolean | undefined;
|
||||
/** Booking-level refrigerated flag (bulk freight only; containers derive reefer from the container type). */
|
||||
isReefer?: boolean | undefined;
|
||||
/** Bulk-only: hazardous amount in the cargo's unit (tons or items), <= cargoTotalWeightVgm. */
|
||||
bulkHazardousQuantity?: number | undefined;
|
||||
/** Bulk-only: refrigerated amount in the cargo's unit (tons or items), <= cargoTotalWeightVgm. */
|
||||
bulkReeferQuantity?: number | undefined;
|
||||
paymentCurrency: string;
|
||||
pnrCode?: string | undefined;
|
||||
startDate?: string | undefined;
|
||||
|
||||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
@@ -326,6 +326,9 @@ importers:
|
||||
'@mantine/core':
|
||||
specifier: ^9.3.0
|
||||
version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/dates':
|
||||
specifier: ^9.3.0
|
||||
version: 9.3.2(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/hooks':
|
||||
specifier: ^9.3.0
|
||||
version: 9.3.0(react@19.2.6)
|
||||
@@ -12495,10 +12498,19 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
|
||||
'@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
'@mantine/dates@7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/hooks': 7.17.8(react@19.2.6)
|
||||
'@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/hooks': 9.3.0(react@19.2.6)
|
||||
clsx: 2.1.1
|
||||
dayjs: 1.11.21
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
|
||||
'@mantine/dates@9.3.2(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/hooks': 9.3.0(react@19.2.6)
|
||||
clsx: 2.1.1
|
||||
dayjs: 1.11.21
|
||||
react: 19.2.6
|
||||
@@ -15399,7 +15411,7 @@ snapshots:
|
||||
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
|
||||
'@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))
|
||||
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/hooks': 7.17.8(react@19.2.6)
|
||||
'@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@@ -20233,7 +20245,7 @@ snapshots:
|
||||
mantine-react-table@2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/hooks': 7.17.8(react@19.2.6)
|
||||
'@tabler/icons-react': 3.44.0(react@19.2.6)
|
||||
'@tanstack/match-sorter-utils': 8.19.4
|
||||
|
||||
Reference in New Issue
Block a user