import { api } from "@/services/api"; import { Freight } from "@edr/types"; import type { CreateContractPayload, ContractDocuments as ServiceContractDocuments, GenerateContractPriceResponse, SubmitContractResponse, } from "@/services/contracts.service"; import { zodResolver } from "@hookform/resolvers/zod"; import { Alert, Box, Button, FileInput, Group, Modal, Stack, Text, Title, } from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, Check, ChevronLeft, ChevronRight, Send, Upload, XCircle, } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { Navigate, useLocation, useNavigate, useParams, } from "react-router-dom"; import useAuth from "@/hooks/useAuth"; import { CONTRACT_STEPS, EDIT_CONTRACT_STEPS, ContractFormInputValues, contractFormSchema, contractStepFields, editContractStepFields, initialContractFormValues, OPERATION_TYPES, type ContractFormValues, type OperationType, } from "./new-contract-form/schema"; import { getRouteDirection, operationToProfileType, operationToTradeDirection, } from "./new-contract-form/helpers"; import { contractToFormValues } from "./new-contract-form/contractToForm"; import { ContractDocsEditor, documentSettingCode, missingRequiredDocKeys, useCompanyDocuments, } 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"; import { clearContractDraft, useContractDraft, } from "./new-contract-form/useContractDraft"; import { Step1ContractType, Step2ServiceType, Step3CargoScope, Step4Route, Step8Review, } from "./new-contract-form/steps"; import { StepCard, StepHeader } from "./new-contract-form/shared"; import { formatRateUnit } from "./new-contract-form/unit-rates"; type PriceModalMode = "submit" | "draft"; /** * The contract wizard, used both to create a new contract and — in `edit` mode — * to continue an unsubmitted DRAFT or 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, }); // Profile documents (TIN, licenses, IDs) satisfy requirements too — the API // carries them onto the contract on save. const companyDocs = useCompanyDocuments(); // Contract creation is gated on profile approval, same as bookings. if (!auth.isPending && auth.company && !auth.canBook) { return ; } if (!auth.isPending && !auth.company) { return ( navigate("/settings")} /> ); } if (!auth.isPending && auth.companyStatus === "pending") { return ( navigate("/contracts")} /> ); } // Profile changes pending review lock out new contract creation too. if (!auth.isPending && auth.isUnderReview) { return ( navigate("/contracts")} /> ); } const [pricingData, setPricingData] = 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, ); const [priceChangeResult, setPriceChangeResult] = useState(null); const persistAndPriceMutation = useMutation({ mutationFn: async ({ payload, mode, existingContractId, }: { payload: CreateContractPayload; mode: PriceModalMode; existingContractId: string | null; }) => { const documents = (form.getValues("documents") ?? {}) as ServiceContractDocuments; let contractId = existingContractId; if (contractId) { await api.contracts.update.call({ id: contractId, dto: payload, documents, }); } else { const contract = await api.contracts.create.call({ payload, documents, }); contractId = contract.id; } const pricing = await api.contracts.generatePrice.call({ id: contractId, }); return { contractId, pricing, mode }; }, onSuccess: ({ contractId, pricing, mode }) => { setPriceContractId(contractId); setPricingData(pricing); setPriceModalMode(mode); queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey(), }); }, }); const confirmMutation = useMutation({ mutationFn: async () => { if (!priceContractId) throw new Error("No contract to confirm"); return api.contracts.submit.call({ id: priceContractId }); }, onSuccess: (result) => { if (result.priceChanged) { setPriceChangeResult(result); return; } clearContractDraft(); setPriceModalMode(null); queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey(), }); navigate("/contracts"); }, }); const confirmSubmitMutation = useMutation({ mutationFn: async () => { if (!priceContractId) throw new Error("No contract to confirm"); return api.contracts.confirmSubmit.call({ id: priceContractId }); }, onSuccess: () => { clearContractDraft(); setPriceChangeResult(null); setPriceModalMode(null); queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey(), }); navigate("/contracts"); }, }); const rejectMutation = useMutation({ mutationFn: async () => { if (!priceContractId) throw new Error("No contract to discard"); return api.contracts.remove.call({ id: priceContractId }); }, onSuccess: () => { clearContractDraft(); setPriceModalMode(null); setPriceContractId(null); queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey(), }); navigate("/contracts"); }, }); const form = useForm({ defaultValues: initialContractFormValues, resolver: zodResolver(contractFormSchema), mode: "onChange", }); const location = useLocation(); const startFresh = (location.state as { fresh?: boolean } | null)?.fresh === true; 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"); const operationType = form.watch("operationType"); const visibleSteps = useMemo( () => (isEdit ? EDIT_CONTRACT_STEPS : CONTRACT_STEPS), [isEdit], ); const visibleStepIds = useMemo( () => visibleSteps.map((s) => s.id), [visibleSteps], ); const currentStepIndex = visibleStepIds.indexOf(step); const isLastStep = currentStepIndex === visibleStepIds.length - 1; const isFirstStep = currentStepIndex <= 0; const goToStep = (delta: number) => { const idx = visibleStepIds.indexOf(step); const nextIdx = Math.min( visibleStepIds.length - 1, Math.max(0, idx + delta), ); setStep(visibleStepIds[nextIdx]); }; const direction = useMemo(() => { const origin = referenceData?.yard.find((y) => y.id === originYard); const destination = referenceData?.yard.find( (y) => y.id === destinationYard, ); return ( getRouteDirection(origin, destination) ?? (operationType ? operationToTradeDirection(operationType) : null) ); }, [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]); // Full profile per type, so the awaiting/rejected modal can show the reviewer // note and offer a reapply for a rejected role. const profileByType = useMemo(() => { const m = new Map< string, { id: string; status: string; reviewNote?: string | null } >(); for (const p of auth.company?.company?.companyProfiles ?? []) m.set(p.type, { id: p.id, status: p.status, reviewNote: p.reviewNote }); return m; }, [auth.company]); const profileTypes = useMemo( () => [...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); 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" | "rejected" | "missing" => { if (op === "intercity") return "approved"; const target = operationToProfileType(op, profileTypes); const status = profileStatusByType.get(target); if (!status) return "missing"; if (status === "active") return "approved"; if (status === "rejected") return "rejected"; return "pending"; }, [profileStatusByType, profileTypes], ); // Create-profile modal state (license upload → createProfile). const [createTarget, setCreateTarget] = useState( null, ); const [pendingOperation, setPendingOperation] = 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 ({ type, files, }: { type: ProfileTypeValue; files: File[]; }) => { const res = await auth.createProfile(type, files); if (!res.success) { throw new Error(res.error?.message ?? "Failed to create profile"); } }, onSuccess: () => { // 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( err instanceof Error ? err.message : "Failed to create profile", ); }, }); // Resubmit a rejected operational role for approval (from the block modal). const reapplyMutation = useMutation({ mutationFn: async (profileId: string) => { const res = await auth.reapplyProfile(profileId); if (!res.success) { throw new Error(res.error?.message ?? "Failed to resubmit for approval"); } }, onSuccess: () => setPendingApprovalProfile(null), }); const handleOperationSelect = (op: OperationType) => { // Intercity (domestic) runs on any existing customer profile — no switch. if (op === "intercity") return; const target = operationToProfileType(op, profileTypes) as ProfileTypeValue; 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); } }; const handleCreateProfileConfirm = () => { if (!createTarget) return; if (licenseFiles.length === 0) { setCreateError("Please upload at least one business license file."); return; } createProfileMutation.mutate({ type: createTarget, files: licenseFiles }); }; const handleCreateProfileCancel = () => { // Roll back the operation selection that triggered the modal. if (pendingOperation) { form.setValue("operationType", undefined as never, { shouldDirty: true }); } setCreateTarget(null); 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 ? (PROFILE_TYPE_LABELS[createTarget] ?? createTarget) : ""; const onboardingDocs = useMemo(() => { const profiles = auth.company?.company?.companyProfiles ?? []; const active = profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0]; return active?.licenseFiles ?? []; }, [auth.company, auth.activeCompanyProfileId]); async function handleContinue() { const stepFields = isEdit ? editContractStepFields : contractStepFields; const fields = stepFields[step]; if (fields.length > 0) { const valid = await form.trigger(fields, { shouldFocus: true }); if (!valid) return; } if (isEdit && step === 2 && editContract) { const missing = missingRequiredDocKeys( editDocSettingQuery.data, editContract, editDocuments, companyDocs, ); if (missing.length > 0) { setShowDocErrors(true); return; } setShowDocErrors(false); } goToStep(1); } function buildApiPayload(data: ContractFormValues): CreateContractPayload { if (data.contractType === "renewal" && !data.previousContractRef) { form.setError("previousContractRef", { type: "manual", message: "Select a previous contract reference.", }); setStep(1); throw new Error("Validation failed"); } const serviceType = referenceData?.service.find( (s) => s.id === data.serviceTypeId, )!; const isContainer = data.cargoType === "container"; const isGeneral = data.contractKind === "general_contract"; // Cargo scope rows — no quantities (doc §5.4). Container: one row per enabled // size; bulk: a single commodity row. Both GENERAL and ONE_TIME are uncapped // (quantityCap omitted → NULL): the customer books repeatedly against a // GENERAL contract until its validity expires. const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer ? data.enabledContainerSizes.map((size) => ({ containerSize: size, })) : [ { cargoTypeId: data.cargoTypePath?.[1] || undefined, cargoFreeText: data.cargoFreeText || undefined, }, ]; // Route — a single origin→destination lane, general contracts included. const routes: Freight.CreateContractRouteInputDto[] = [ { originYardId: data.originYard, destinationYardId: data.destinationYard, sortOrder: 0, }, ]; return { contractKind: isGeneral ? Freight.ContractKind.General : Freight.ContractKind.OneTime, tradeDirection: direction!, freightType: isContainer ? Freight.ContractFreightType.Container : Freight.ContractFreightType.Bulk, serviceTypeId: data.serviceTypeId, paymentCurrency: data.paymentCurrency, // Equipment return is decided at booking time, not on the contract. Omit // it here so we don't send a value the contract API rejects. isHazardous: data.isHazardous, // Reefer is a contract-level flag for both container and bulk. isReefer: data.isRefrigerated, ...(data.previousContractRef ? { renewalOfId: data.previousContractRef } : {}), ...(serviceType?.includesFirstMile && data.firstMile.enabled ? { firstMilePickupAddress: data.firstMile.pickUpAddress, firstMilePickupLat: data.firstMile.lat ?? undefined, firstMilePickupLng: data.firstMile.lng ?? undefined, } : {}), ...(serviceType?.includesLastMile && data.lastMile.enabled ? { lastMileDeliveryAddress: data.lastMile.deliveryAddress, lastMileDeliveryLat: data.lastMile.lat ?? undefined, lastMileDeliveryLng: data.lastMile.lng ?? undefined, } : {}), ...(serviceType?.includesCustoms && data.customsClearingEnabled ? { customsClearingEnabled: true, customsClearingAgent: data.customsClearingAgent || undefined, } : { customsClearingEnabled: false }), cargoScope, routes, }; } const handleSaveDraft = form.handleSubmit((data) => { try { const apiPayload = buildApiPayload(data); persistAndPriceMutation.mutate({ payload: apiPayload, mode: "draft", existingContractId: priceContractId, }); } catch { // validation error already surfaced } }); 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, companyDocs, ); if (missing.length > 0) { setShowDocErrors(true); return; } setShowDocErrors(false); form.setValue("documents", { ...(form.getValues("documents") ?? {}), ...editDocuments, }); } const apiPayload = buildApiPayload(data); persistAndPriceMutation.mutate({ payload: apiPayload, mode: "submit", existingContractId: priceContractId, }); } catch { // validation error already surfaced } }); const isPricing = persistAndPriceMutation.isPending || confirmMutation.isPending; function closePriceModal() { setPriceModalMode(null); if (priceModalMode === "draft" && priceContractId) { navigate(`/contracts/${priceContractId}`); } } function handleDraftModalOk() { setPriceModalMode(null); if (priceContractId) navigate(`/contracts/${priceContractId}`); } return ( {isEdit ? "Edit Contract" : "New Contract"} {isEdit ? editContract?.status === "CHANGES_REQUESTED" ? "Update your contract details and documents, then resubmit it for EDR staff review." : "Update your draft contract details and documents, then submit it for EDR staff review." : "Define your freight contract — scope, routes, and unit rates. Book shipments against it after signing."}
e.preventDefault()} > {isEdit && editContract?.status === "CHANGES_REQUESTED" && ( } title="A reviewer asked for changes" mb="lg" > {editContract?.latestChangeRequestNote ? ( What the reviewer asked for: {editContract.latestChangeRequestNote} Update the details or documents below, then resubmit the contract for review. ) : ( "Update any contract detail or document that needs to change, then resubmit the contract for review." )} )} {persistAndPriceMutation.isError && ( } radius="md" mb="lg" > Failed to save contract or generate price {persistAndPriceMutation.error instanceof Error ? persistAndPriceMutation.error.message : "An unexpected error occurred. Please try again."} )} {/* Step 0 — Setup: operation, contract, service, currency, miles. */} {step === 0 && ( )} {/* Step 1 — Cargo & Route. */} {step === 1 && ( )} {/* Step 2 (edit) — Documents. */} {step === 2 && isEdit && editContract && ( [k, "Required"]), ) : {} } /> )} {/* Step 2 (create) / Step 3 (edit) — Review & Submit. */} {((step === 2 && !isEdit) || (step === 3 && isEdit)) && ( )} {!isLastStep ? ( ) : ( )}
{/* Unit-rate quotation modal (doc §9.3 — Approve Quotation). */} {priceModalMode === "submit" ? "Approve your quotation" : "Draft saved — unit-rate quotation"} } radius="lg" centered size="md" > {pricingData && ( {priceModalMode === "submit" ? "Review your unit rates below. Approve to submit the contract for EDR staff review, edit & regenerate to change details, or discard this draft." : "Your contract has been saved as a draft. Here are your estimated unit rates."} Pricing schedule Final amount is calculated at booking — quantities are unknown at the contract stage. {pricingData.lineItems.map((item) => ( {item.label} {item.containerSize && ( {item.containerSize} )} {item.unitPrice.toLocaleString()} {pricingData.currency}{" "} / {formatRateUnit(item.unit)} ))} {pricingData.lineItems.length === 0 && ( No unit rates available. )} {pricingData.warnings && pricingData.warnings.length > 0 && ( {pricingData.warnings.join(", ")} )} {priceModalMode === "submit" ? ( <> ) : ( )} )} {/* Re-priced on resubmit — confirm the new unit rates. */} setPriceChangeResult(null)} title={Unit rates changed} radius="lg" centered > {priceChangeResult && ( {priceChangeResult.message ?? "The contract unit rates have been updated. Confirm to submit with the new schedule."} {priceChangeResult.lineItems && priceChangeResult.lineItems.length > 0 && ( {priceChangeResult.lineItems.map((item) => ( {item.label} {item.unitPrice.toLocaleString()}{" "} {priceChangeResult.currency} /{" "} {formatRateUnit(item.unit)} ))} )} )} {/* Create-profile modal — opens when the chosen operation type has no matching company profile yet. Collects a license, creates the profile, then shows an "awaiting approval" state (the new profile is pending). */} { if (createProfileMutation.isPending) return; if (licenseSubmitted) handleCreateProfileDone(); else handleCreateProfileCancel(); }} 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 / rejected modal — the chosen operation maps to a profile that exists but isn't active. The select was already reverted. */} {(() => { const target = pendingApprovalProfile ? profileByType.get(pendingApprovalProfile) : undefined; const isRejected = target?.status === "rejected"; const label = pendingApprovalProfile ? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ?? pendingApprovalProfile) : ""; return ( setPendingApprovalProfile(null)} title={isRejected ? "Profile not approved" : "Awaiting approval"} centered radius="lg" > {isRejected ? ( <> Your {label} profile was not approved. Fix the issue below and resubmit it for review. {target?.reviewNote && ( Reviewer note: {target.reviewNote} )} ) : ( <> Your {label} profile was submitted and is under staff review. You can start a contract under it once it's approved. )} ); })()}
); } function GateNotice({ title, body, actionLabel, onAction, }: { title: string; body: string; actionLabel: string; onAction: () => void; }) { return ( } radius="md" style={{ maxWidth: "500px" }} mb="lg" > {title} {body} ); }