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."}
}
onClick={() =>
navigate(isEdit && editId ? `/contracts/${editId}` : "/contracts")
}
>
{isEdit ? "Back to Contract" : "Back to Contracts"}
{/* 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" ? (
<>
}
onClick={() => rejectMutation.mutate()}
loading={rejectMutation.isPending}
disabled={confirmMutation.isPending}
>
Discard
}
onClick={() => setPriceModalMode(null)}
disabled={
rejectMutation.isPending || confirmMutation.isPending
}
>
Edit & regenerate
}
onClick={() => confirmMutation.mutate()}
loading={confirmMutation.isPending}
disabled={rejectMutation.isPending}
>
Approve & 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}
);
}