diff --git a/apps/edr-freight-api/src/migrations/1828000000000-AddBulkHazmatReeferQuantity.ts b/apps/edr-freight-api/src/migrations/1828000000000-AddBulkHazmatReeferQuantity.ts new file mode 100644 index 000000000..cc9437eaf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1828000000000-AddBulkHazmatReeferQuantity.ts @@ -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 { + 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 { + 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;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 57e811e34..87eea031b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -121,6 +121,8 @@ export class BookingsRepository extends BaseRepository { containerTypeId: string; quantity: number; vgmPerUnitTons: number; + hazardousQuantity?: number; + reeferQuantity?: number; weightResult: ContainerWeightResult; }>, ): Promise { @@ -133,11 +135,16 @@ export class BookingsRepository extends BaseRepository { 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, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 50d15cc64..9c0b4931f 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -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 = { ...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], })), ); diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index 9380faba5..c4d971f51 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index b9573d680..ad40c731e 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -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; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx index 01a0db69a..13ac9577d 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx @@ -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 && ( - - )} - [number]; + +// ── shared bits ────────────────────────────────────────────────────────────── + +interface InfoRowProps { + icon: LucideIcon; + label: string; + value?: string | null; +} + +function InfoRow({ icon: Icon, label, value }: InfoRowProps) { + return ( + + + + + {label} + + + + {value || "—"} + + + ); +} + +function InfoRows({ rows }: { rows: InfoRowProps[] }) { + const visible = rows.filter((r) => r.value); + if (visible.length === 0) { + return ( + + No details available. + + ); + } + return ( + + {visible.map((row, i) => ( +
+ {i > 0 && } + +
+ ))} +
+ ); +} + +// ── 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 ( + + + + ); + } + + if (isLoading) { + return ( + + + + + Loading customer… + + + + ); + } + + if (!company) { + return ( + + + No customer linked to this contract. + + + ); + } + + return ( + + + + + + + + + + + + + + ); +} + +// ── 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 ( + + {files.length} + + } + > + {files.length === 0 ? ( + + No documents attached to this contract. + + ) : ( + + {files.map((file) => { + const label = codeLabel(file.code); + return ( + { + 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)"; + }} + > + + + + + + + {file.name} + + + {label ? ( + + {label} + + ) : null} + + {formatBytes(file.size)} + + + + + + {onView ? ( + + onView(file)} + aria-label={`View ${file.name}`} + > + + + + ) : null} + {onDownload ? ( + + onDownload(file)} + aria-label={`Download ${file.name}`} + > + + + + ) : null} + + + ); + })} + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx index f00a3b7c6..44d46c652 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx @@ -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[number]) => + view({ + name: file.name, + url: file.signedUrl ?? file.url, + mimeType: file.mimeType, + }); + + const handleDownloadFile = async ( + file: NonNullable[number], + ) => { + try { + await downloadBookingFile(file.id, file.name); + } catch { + toast.error("Could not download file."); + } + }; + if (isLoading) { return ( @@ -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 && ( - setTab(v ?? "details")} - variant="pills" - color="edr-green" - classNames={{ list: "ov-tablist", tab: "ov-tab" }} - > - - }> - Details - + setTab(v ?? "details")} + variant="pills" + color="edr-green" + classNames={{ list: "ov-tablist", tab: "ov-tab" }} + > + + }> + Details + + } + rightSection={ + files.length > 0 ? ( + + {files.length} + + ) : null + } + > + Documents + + }> + Customer + + {showClearanceTab && ( } > Clearance Review - - - )} + )} + + {/* LEFT — primary content */} @@ -296,6 +350,14 @@ export default function ContractRequestDetailPage() { readOnly={clearanceReadOnly} onChanged={() => refetch()} /> + ) : currentTab === "documents" ? ( + + ) : currentTab === "customer" ? ( + ) : ( @@ -453,6 +515,8 @@ export default function ContractRequestDetailPage() { + + {viewer} ); } diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index 9f866d756..1095be4fa 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -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", diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 05c942290..0f641e44e 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -272,6 +272,10 @@ const App = () => { /> } /> } /> + } + /> } diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index 2d7fcd469..5f4de9f87 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -796,7 +796,7 @@ export function AppLayout({ {/* ── Main ── */} {children} diff --git a/apps/edr-freight-web/portal/src/main.tsx b/apps/edr-freight-web/portal/src/main.tsx index 56d6838df..d932bb0f3 100644 --- a/apps/edr-freight-web/portal/src/main.tsx +++ b/apps/edr-freight-web/portal/src/main.tsx @@ -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"; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx index e9cdf1bf2..d9b03d3a9 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx @@ -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() { )} - - ( - } - title="Hazardous Material" - description="Applies a Hazard Surcharge to the final bill." - checked={field.value} - onChange={field.onChange} - /> - )} - /> - - ( - } - title="Refrigerated Cargo" - description="Temperature-controlled transport applies a Refrigerator Surcharge." - checked={field.value} - onChange={field.onChange} - /> - )} - /> - + {/* 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. */} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index cbc16d2ab..8e11a64d2 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -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, })) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index 84b8172c6..668bf02cb 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -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 = { 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>> = { "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; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx index 8c461997f..2de959e5f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx @@ -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 ( + + + + + {icon} + + + + {title} + + + {description} + + + + onChange(e.currentTarget.checked)} + color="edr-green" + size="md" + style={{ flexShrink: 0 }} + /> + + {checked && children ? {children} : null} + + ); +} + interface AsyncComboboxOption { value: string; label: string; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx index 85453ccb5..783162a35 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -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 && ( - + ( - } 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({ )} - - - Cargo handling - - ( - } - 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" && ( - ( - } - iconBg="#E9F0F8" - iconColor="#2E5B96" - title="Refrigerated Cargo" - description="Temperature-controlled transport applies a refrigeration surcharge." - checked={field.value} - onChange={(v) => field.onChange(v)} - /> - )} - /> - )} - ); } -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 ( - - - - {icon} - - - - {title} - - - {description} - - - - onChange(e.currentTarget.checked)} - color="edr-green" - size="md" - /> - - ); -} - function LoadingSkeleton() { return (
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index f3af690a3..ee5831fe8 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -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 ( @@ -250,6 +304,25 @@ export function Step5CargoDetails({ render={({ field, fieldState }) => ( { + 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 && ( +
+ ( + } + 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 }, + ); + }} + > + ( + + hq.onChange(clampToBulk(e.currentTarget.value)) + } + onBlur={hq.onBlur} + error={fieldState.error?.message} + radius="md" + /> + )} + /> + + )} + /> + ( + } + 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 }, + ); + }} + > + ( + + rq.onChange(clampToBulk(e.currentTarget.value)) + } + onBlur={rq.onBlur} + error={fieldState.error?.message} + radius="md" + /> + )} + /> + + )} + /> +
+ )}
)} @@ -364,53 +542,61 @@ export function Step5CargoDetails({ ( -
- - Quantity * - -
- - 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" - /> - -
- {fieldState.error?.message && ( - - {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 ( +
+ + Quantity * - )} -
- )} +
+ + { + 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" + /> + +
+ {fieldState.error?.message && ( + + {fieldState.error.message} + + )} +
+ ); + }} /> + + {/* Per-container hazmat / reefer. Each switch reveals a bounded + "how many of this line" input (1..line quantity). */} +
+ ( + } + 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 }, + ); + }} + > + ( + + hq.onChange( + clampToLine(e.currentTarget.value, index), + ) + } + onBlur={hq.onBlur} + error={fieldState.error?.message} + radius="md" + /> + )} + /> + + )} + /> + ( + } + 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 }, + ); + }} + > + ( + + rq.onChange( + clampToLine(e.currentTarget.value, index), + ) + } + onBlur={rq.onBlur} + error={fieldState.error?.message} + radius="md" + /> + )} + /> + + )} + /> +
))} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx index f658b84cd..a789943c2 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx @@ -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({ 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 && ( + + )} + {values.cargoType === "bulk" && values.isRefrigerated && ( + + )} {values.cargoType === "container" && values.containers.length > 0 && ( Type Qty + Hazardous + Refrigerated @@ -392,6 +421,12 @@ export function Step8Review({ {c.containerType || c.type} {c.qty} + + {c.isHazardous ? `${c.hazardousQty} of ${c.qty}` : "—"} + + + {c.isReefer ? `${c.reeferQty} of ${c.qty}` : "—"} + ))} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractChangesRequestedView.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractChangesRequestedView.tsx deleted file mode 100644 index 66cd94ac8..000000000 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractChangesRequestedView.tsx +++ /dev/null @@ -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; -type ContractFile = NonNullable[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(); - 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({}); - 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 ( - - - - -
- - {contract.reference} - - - Changes requested — update your documents and resubmit. - -
-
- - } - 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. - - - - {onFile.length > 0 && ( - - - Already on file - - {onFile.map((file) => ( - - - - - - - {labelForDocCode(file.code)} - - - {file.name} - - - - - - - On file - - - - - - ))} - - )} - - - Update documents - - - Replace any document you need to change. Documents marked required - must be on file before you can resubmit. - - - {settingQuery.isLoading ? ( - - - - ) : settingQuery.data ? ( - - ) : ( - - No document requirements are configured for your account. You can - resubmit using the documents already on file. - - )} - - {error && ( - } - mt="md" - > - {error} - - )} - {(updateMutation.isError || submitMutation.isError) && ( - } - mt="md" - > - Couldn't resubmit. Please try again. - - )} - - - -
-
- ); -} - -export default ContractChangesRequestedView; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index bd7e1bafe..03be22005 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -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[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 ; + return ; } const isContainer = contract.freightType === "CONTAINER"; @@ -980,12 +994,14 @@ const KEY_FACT_ACCENT: Record = { // Per-section accent + icon for the Documents tab groups. const DOC_GROUP_ACCENT: Record = { - profile: "#2B6CB0", clearance: "#C77F09", + businessLicense: "#0A6F4D", + profile: "#2B6CB0", }; const DOC_GROUP_ICON: Record = { - profile: FileText, clearance: Upload, + businessLicense: FileBadge, + profile: FileText, }; /** diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx index 5a623e296..0456bca3e 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx @@ -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(null); - const [priceContractId, setPriceContractId] = useState(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( + 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 + >({}); + const [showDocErrors, setShowDocErrors] = useState(false); const [priceModalMode, setPriceModalMode] = useState( 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(); + 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( () => [...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( null, @@ -260,6 +355,13 @@ export default function NewContractPage() { useState(null); const [licenseFiles, setLicenseFiles] = useState([]); const [createError, setCreateError] = useState(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(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() { > - New Contract + {isEdit ? "Edit Contract" : "New Contract"} - 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."} @@ -535,6 +682,20 @@ export default function NewContractPage() { onSubmit={(e) => e.preventDefault()} > + {isEdit && ( + } + title="A reviewer asked for changes" + mb="lg" + > + Update any contract detail or document that needs to change, then + resubmit the contract for review. + + )} + @@ -565,12 +726,13 @@ export default function NewContractPage() { description="Define the operation, contract kind, and the service this contract is for." /> - - @@ -625,6 +787,27 @@ export default function NewContractPage() { persistAndPriceMutation.isPending && persistAndPriceMutation.variables?.mode === "submit" } + isEdit={isEdit} + documentsEditor={ + isEdit && editContract ? ( + [k, "Required"]), + ) + : {} + } + /> + ) : undefined + } /> )} @@ -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). */} { - 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 ? ( + + + + + + + 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()}. + + + + + + + ) : ( + + + 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. + + } + placeholder="Select license file(s)" + value={licenseFiles} + onChange={(files) => setLicenseFiles(files ?? [])} + error={createError ?? undefined} + /> + + + + + + )} + + + {/* Awaiting-approval modal — the chosen operation maps to a profile that + exists but isn't approved yet. The select was already reverted. */} + setPendingApprovalProfile(null)} + title="Awaiting approval" centered radius="lg" > - 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. - } - placeholder="Select license file(s)" - value={licenseFiles} - onChange={(files) => setLicenseFiles(files ?? [])} - error={createError ?? undefined} - /> - diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/ContractDocsEditor.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/ContractDocsEditor.tsx new file mode 100644 index 000000000..e491cd88a --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/ContractDocsEditor.tsx @@ -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; +type ContractFile = NonNullable[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(); + 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; +}) { + 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 ( + + {onFile.length > 0 && ( + + + Already on file + + {onFile.map((file) => ( + + + + + + + {labelForDocCode(file.code)} + + + {file.name} + + + + + + + On file + + + + + + ))} + + )} + + + + Update documents + + + Replace any document you need to change. Documents marked required must + be on file before you can resubmit. + + + {settingQuery.isLoading ? ( + + + + ) : settingQuery.data ? ( + + ) : ( + + No document requirements are configured for your account. You can + resubmit using the documents already on file. + + )} + + + ); +} + +/** + * 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; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts new file mode 100644 index 000000000..f3d66de3d --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts @@ -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 { + 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 = {}; + 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; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx index 7ffb38e97..c86e17fc5 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx @@ -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 ( -
+ {allowedOperations.length === 0 && ( + + Your company has no operational profile yet. Complete onboarding to + register as an importer, exporter, or freight forwarder. + + )} + + {/* 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. */} +
+ ( + ({ - 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 && ( - - {selected.description} - - )} -
- ); - }} + render={({ field }) => ( + ) : ( -
+
{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({ - {selected ? : null} + {selected ? : null} - + - + - - + + {s.serviceName} - {s.description ? ( - - {s.description} - - ) : null} - {serviceFeatures(s).map((f) => ( Trucking & customs options + {/* 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. */} +
{includesFirstMile && ( )} +
)} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx index 7fbf45d71..a5fbbc989 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx @@ -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 && ( + + {documentsEditor} + + )} + - {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."} - + {!isEdit && ( + + )} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/steps.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/steps.tsx index e40b33a4f..0226ccadd 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/steps.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/steps.tsx @@ -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"; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/useContractDraft.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/useContractDraft.ts index 7c44b7382..a94e77168 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/useContractDraft.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/useContractDraft.ts @@ -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 diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index dbfcf655d..100b1a20a 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd8c81c83..203eb4d71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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