mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
1262 lines
41 KiB
TypeScript
1262 lines
41 KiB
TypeScript
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,
|
|
} 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,
|
|
});
|
|
|
|
// Contract creation is gated on profile approval, same as bookings.
|
|
if (!auth.isPending && auth.company && !auth.canBook) {
|
|
return <Navigate to="/contracts" replace />;
|
|
}
|
|
|
|
if (!auth.isPending && !auth.company) {
|
|
return (
|
|
<GateNotice
|
|
title="Complete Your Company Setup"
|
|
body="You need to complete your company onboarding before you can create contracts."
|
|
actionLabel="Go to Onboarding"
|
|
onAction={() => navigate("/settings")}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (!auth.isPending && auth.companyStatus === "pending") {
|
|
return (
|
|
<GateNotice
|
|
title="Awaiting Approval"
|
|
body="Your company is awaiting EDR approval. Creating contracts is disabled until your company has been approved."
|
|
actionLabel="Back to Contracts"
|
|
onAction={() => navigate("/contracts")}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const [pricingData, setPricingData] =
|
|
useState<GenerateContractPriceResponse | null>(null);
|
|
// In edit mode the contract already exists, so seed its id — this makes
|
|
// persistAndPriceMutation take the UPDATE branch instead of creating anew.
|
|
const [priceContractId, setPriceContractId] = useState<string | null>(
|
|
isEdit ? (editId ?? null) : null,
|
|
);
|
|
// Documents freshly attached on the review step (edit mode only). Merged into
|
|
// the form's `documents` map before the contract is updated.
|
|
const [editDocuments, setEditDocuments] = useState<
|
|
Record<string, File | File[] | null>
|
|
>({});
|
|
const [showDocErrors, setShowDocErrors] = useState(false);
|
|
const [priceModalMode, setPriceModalMode] = useState<PriceModalMode | null>(
|
|
null,
|
|
);
|
|
const [priceChangeResult, setPriceChangeResult] =
|
|
useState<SubmitContractResponse | null>(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<ContractFormInputValues, any, ContractFormValues>({
|
|
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<number[]>(
|
|
() => 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<string, string>();
|
|
for (const p of auth.company?.company?.companyProfiles ?? [])
|
|
m.set(p.type, p.status);
|
|
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<OperationType[]>(
|
|
() => [...OPERATION_TYPES],
|
|
[],
|
|
);
|
|
|
|
// Approval state of the profile each operation maps to — used for the dropdown
|
|
// badges. Intercity rides any customer profile, so always "approved".
|
|
const operationStatus = useMemo(
|
|
() =>
|
|
(op: OperationType): "approved" | "pending" | "missing" => {
|
|
if (op === "intercity") return "approved";
|
|
const target = operationToProfileType(op, profileTypes);
|
|
const status = profileStatusByType.get(target);
|
|
if (!status) return "missing";
|
|
return status === "active" ? "approved" : "pending";
|
|
},
|
|
[profileStatusByType, profileTypes],
|
|
);
|
|
|
|
// Create-profile modal state (license upload → createProfileAndSwitch).
|
|
const [createTarget, setCreateTarget] = useState<ProfileTypeValue | null>(
|
|
null,
|
|
);
|
|
const [pendingOperation, setPendingOperation] =
|
|
useState<OperationType | null>(null);
|
|
const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
|
|
const [createError, setCreateError] = useState<string | null>(null);
|
|
// After a license is uploaded the new profile comes back "pending", so the
|
|
// create-profile modal switches to an "awaiting approval" success state.
|
|
const [licenseSubmitted, setLicenseSubmitted] = useState(false);
|
|
// Set when the user picks an operation whose profile exists but isn't approved
|
|
// yet — drives the "awaiting approval" block modal.
|
|
const [pendingApprovalProfile, setPendingApprovalProfile] =
|
|
useState<ProfileTypeValue | null>(null);
|
|
|
|
const createProfileMutation = useMutation({
|
|
mutationFn: async ({
|
|
type,
|
|
files,
|
|
}: {
|
|
type: ProfileTypeValue;
|
|
files: File[];
|
|
}) => {
|
|
const res = await auth.createProfileAndSwitch(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",
|
|
);
|
|
},
|
|
});
|
|
|
|
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,
|
|
);
|
|
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";
|
|
|
|
// Cargo scope rows — no quantities (doc §5.4). Container: one row per enabled
|
|
// size; bulk: a single commodity row.
|
|
// GENERAL contracts carry a quantity cap (draw-down); ONE_TIME does not.
|
|
const isGeneral = data.contractKind === "general_contract";
|
|
const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer
|
|
? data.enabledContainerSizes.map((size) => ({
|
|
containerSize: size,
|
|
quantityCap:
|
|
isGeneral && data.containerSizeCaps[size]
|
|
? data.containerSizeCaps[size]
|
|
: undefined,
|
|
}))
|
|
: [
|
|
{
|
|
cargoTypeId: data.cargoTypePath?.[1] || undefined,
|
|
cargoFreeText: data.cargoFreeText || undefined,
|
|
quantityCap:
|
|
isGeneral && data.bulkQuantityCap ? data.bulkQuantityCap : 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,
|
|
);
|
|
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 (
|
|
<Box
|
|
style={{
|
|
padding: "28px 0 0",
|
|
minHeight: "calc(100dvh - var(--app-shell-header-height, 56px))",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
}}
|
|
>
|
|
<Group
|
|
justify="space-between"
|
|
px="24px"
|
|
align="flex-end"
|
|
wrap="wrap"
|
|
gap="md"
|
|
mb="lg"
|
|
>
|
|
<Box>
|
|
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
|
{isEdit ? "Edit Contract" : "New Contract"}
|
|
</Title>
|
|
<Text size="sm" c="edr-muted" mt={4}>
|
|
{isEdit
|
|
? "Update your contract details and documents, then resubmit it for EDR staff review."
|
|
: "Define your freight contract — scope, routes, and unit rates. Book shipments against it after signing."}
|
|
</Text>
|
|
</Box>
|
|
<Button
|
|
variant="default"
|
|
radius="md"
|
|
leftSection={<ChevronLeft size={16} />}
|
|
onClick={() =>
|
|
navigate(isEdit && editId ? `/contracts/${editId}` : "/contracts")
|
|
}
|
|
>
|
|
{isEdit ? "Back to Contract" : "Back to Contracts"}
|
|
</Button>
|
|
</Group>
|
|
|
|
<form
|
|
id="new-contract-form"
|
|
className="flex flex-col"
|
|
style={{ flex: 1 }}
|
|
onSubmit={(e) => e.preventDefault()}
|
|
>
|
|
<Box flex={1} p="24px">
|
|
{isEdit && (
|
|
<Alert
|
|
color="orange"
|
|
radius="lg"
|
|
variant="light"
|
|
icon={<AlertCircle size={18} />}
|
|
title="A reviewer asked for changes"
|
|
mb="lg"
|
|
>
|
|
{editContract?.latestChangeRequestNote ? (
|
|
<Stack gap={6}>
|
|
<Text size="sm" fw={600}>
|
|
What the reviewer asked for:
|
|
</Text>
|
|
<Text
|
|
size="sm"
|
|
style={{ whiteSpace: "pre-wrap" }}
|
|
>
|
|
{editContract.latestChangeRequestNote}
|
|
</Text>
|
|
<Text size="sm" c="dimmed" mt={2}>
|
|
Update the details or documents below, then resubmit the
|
|
contract for review.
|
|
</Text>
|
|
</Stack>
|
|
) : (
|
|
"Update any contract detail or document that needs to change, then resubmit the contract for review."
|
|
)}
|
|
</Alert>
|
|
)}
|
|
|
|
<Box mb="lg">
|
|
<StepIndicator step={step} steps={visibleSteps} />
|
|
</Box>
|
|
|
|
{persistAndPriceMutation.isError && (
|
|
<Alert
|
|
color="red"
|
|
icon={<AlertCircle size={16} />}
|
|
radius="md"
|
|
mb="lg"
|
|
>
|
|
<Text size="sm" fw={600}>
|
|
Failed to save contract or generate price
|
|
</Text>
|
|
<Text size="sm" mt={4} c="red.7">
|
|
{persistAndPriceMutation.error instanceof Error
|
|
? persistAndPriceMutation.error.message
|
|
: "An unexpected error occurred. Please try again."}
|
|
</Text>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Step 0 — Setup: operation, contract, service, currency, miles. */}
|
|
{step === 0 && (
|
|
<StepCard>
|
|
<StepHeader
|
|
title="Contract Setup"
|
|
description="Define the operation, contract kind, and the service this contract is for."
|
|
/>
|
|
<Stack gap={24}>
|
|
<Step1ContractType
|
|
form={form}
|
|
referenceData={referenceData}
|
|
allowedOperations={allowedOperations}
|
|
onOperationSelect={handleOperationSelect}
|
|
operationStatus={operationStatus}
|
|
/>
|
|
<Step2ServiceType referenceData={referenceData} form={form} />
|
|
</Stack>
|
|
</StepCard>
|
|
)}
|
|
|
|
{/* Step 1 — Cargo & Route. */}
|
|
{step === 1 && (
|
|
<StepCard>
|
|
<StepHeader
|
|
title="Cargo & Route"
|
|
description="Define what cargo this contract covers and the routes it runs. Quantities are captured later at booking."
|
|
/>
|
|
<Stack gap={24}>
|
|
<Step3CargoScope
|
|
form={form}
|
|
referenceData={referenceData}
|
|
isLoading={refDataLoading}
|
|
/>
|
|
<Step4Route
|
|
form={form}
|
|
referenceData={referenceData}
|
|
isLoading={refDataLoading}
|
|
/>
|
|
</Stack>
|
|
</StepCard>
|
|
)}
|
|
|
|
{/* Step 2 (edit) — Documents. */}
|
|
{step === 2 && isEdit && editContract && (
|
|
<StepCard>
|
|
<StepHeader
|
|
title="Contract Documents"
|
|
description="Upload or replace the documents required for this contract before resubmitting."
|
|
/>
|
|
<ContractDocsEditor
|
|
contract={editContract}
|
|
value={editDocuments}
|
|
onChange={setEditDocuments}
|
|
errors={
|
|
showDocErrors
|
|
? Object.fromEntries(
|
|
missingRequiredDocKeys(
|
|
editDocSettingQuery.data,
|
|
editContract,
|
|
editDocuments,
|
|
).map((k) => [k, "Required"]),
|
|
)
|
|
: {}
|
|
}
|
|
/>
|
|
</StepCard>
|
|
)}
|
|
|
|
{/* Step 2 (create) / Step 3 (edit) — Review & Submit. */}
|
|
{((step === 2 && !isEdit) || (step === 3 && isEdit)) && (
|
|
<Step8Review
|
|
form={form}
|
|
setStep={setStep}
|
|
direction={direction!}
|
|
referenceData={referenceData}
|
|
onboardingDocs={onboardingDocs}
|
|
pricing={
|
|
pricingData
|
|
? {
|
|
currency: pricingData.currency,
|
|
lineItems: pricingData.lineItems,
|
|
}
|
|
: null
|
|
}
|
|
onSaveDraft={handleSaveDraft}
|
|
onSubmit={handleSubmitContract}
|
|
saveDraftPending={
|
|
persistAndPriceMutation.isPending &&
|
|
persistAndPriceMutation.variables?.mode === "draft"
|
|
}
|
|
submitPending={
|
|
persistAndPriceMutation.isPending &&
|
|
persistAndPriceMutation.variables?.mode === "submit"
|
|
}
|
|
isEdit={isEdit}
|
|
/>
|
|
)}
|
|
</Box>
|
|
|
|
<Box
|
|
style={{
|
|
position: "sticky",
|
|
bottom: 0,
|
|
zIndex: 20,
|
|
borderTop: "1px solid var(--mantine-color-edr-border-0)",
|
|
backgroundColor: "rgba(255,255,255,0.94)",
|
|
backdropFilter: "blur(14px)",
|
|
WebkitBackdropFilter: "blur(14px)",
|
|
padding: "16px 24px",
|
|
marginTop: "auto",
|
|
}}
|
|
>
|
|
<Group justify="space-between" className="mx-auto max-w-4xl">
|
|
<Button
|
|
type="button"
|
|
variant="default"
|
|
radius="md"
|
|
leftSection={<ChevronLeft size={16} />}
|
|
onClick={() => goToStep(-1)}
|
|
disabled={isFirstStep}
|
|
>
|
|
Back
|
|
</Button>
|
|
|
|
{!isLastStep ? (
|
|
<Button
|
|
type="button"
|
|
color="edr-green"
|
|
radius="md"
|
|
rightSection={<ChevronRight size={16} />}
|
|
onClick={handleContinue}
|
|
>
|
|
Continue
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
color="edr-green"
|
|
radius="md"
|
|
leftSection={<Send size={16} />}
|
|
onClick={handleSubmitContract}
|
|
loading={isPricing}
|
|
>
|
|
Submit
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
</Box>
|
|
</form>
|
|
|
|
{/* Unit-rate quotation modal (doc §9.3 — Approve Quotation). */}
|
|
<Modal
|
|
opened={priceModalMode !== null && pricingData !== null}
|
|
onClose={closePriceModal}
|
|
title={
|
|
<Text fw={700}>
|
|
{priceModalMode === "submit"
|
|
? "Approve your quotation"
|
|
: "Draft saved — unit-rate quotation"}
|
|
</Text>
|
|
}
|
|
radius="lg"
|
|
centered
|
|
size="md"
|
|
>
|
|
{pricingData && (
|
|
<Stack gap="md">
|
|
<Text size="sm" c="dimmed">
|
|
{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."}
|
|
</Text>
|
|
<Box
|
|
p="lg"
|
|
style={{
|
|
borderRadius: 16,
|
|
border: "1px solid var(--mantine-color-edr-border-0)",
|
|
background: "linear-gradient(135deg, #F4FBF7 0%, #FFFFFF 70%)",
|
|
}}
|
|
>
|
|
<Text
|
|
size="xs"
|
|
fw={700}
|
|
tt="uppercase"
|
|
c="edr-green"
|
|
mb="xs"
|
|
style={{ letterSpacing: "0.06em" }}
|
|
>
|
|
Pricing schedule
|
|
</Text>
|
|
<Text size="xs" c="dimmed" mb="md">
|
|
Final amount is calculated at booking — quantities are unknown at
|
|
the contract stage.
|
|
</Text>
|
|
<Stack gap={10}>
|
|
{pricingData.lineItems.map((item) => (
|
|
<Group
|
|
key={item.code}
|
|
justify="space-between"
|
|
align="flex-start"
|
|
wrap="nowrap"
|
|
gap="sm"
|
|
>
|
|
<Box style={{ minWidth: 0 }}>
|
|
<Text size="sm" c="#10202F" fw={500}>
|
|
{item.label}
|
|
</Text>
|
|
{item.containerSize && (
|
|
<Text size="xs" c="dimmed">
|
|
{item.containerSize}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
<Text
|
|
size="sm"
|
|
fw={700}
|
|
c="#10202F"
|
|
style={{ whiteSpace: "nowrap" }}
|
|
>
|
|
{item.unitPrice.toLocaleString()} {pricingData.currency}{" "}
|
|
<Text span fz={12} fw={600} c="edr-muted">
|
|
/ {formatRateUnit(item.unit)}
|
|
</Text>
|
|
</Text>
|
|
</Group>
|
|
))}
|
|
{pricingData.lineItems.length === 0 && (
|
|
<Text size="sm" c="dimmed">
|
|
No unit rates available.
|
|
</Text>
|
|
)}
|
|
</Stack>
|
|
</Box>
|
|
{pricingData.warnings && pricingData.warnings.length > 0 && (
|
|
<Text
|
|
size="xs"
|
|
c="orange.7"
|
|
p="xs"
|
|
className="rounded bg-orange-50"
|
|
>
|
|
{pricingData.warnings.join(", ")}
|
|
</Text>
|
|
)}
|
|
<Group justify="flex-end" gap="sm" mt="md">
|
|
{priceModalMode === "submit" ? (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
color="red"
|
|
radius="md"
|
|
leftSection={<XCircle size={16} />}
|
|
onClick={() => rejectMutation.mutate()}
|
|
loading={rejectMutation.isPending}
|
|
disabled={confirmMutation.isPending}
|
|
>
|
|
Discard
|
|
</Button>
|
|
<Button
|
|
variant="default"
|
|
radius="md"
|
|
leftSection={<ChevronLeft size={16} />}
|
|
onClick={() => setPriceModalMode(null)}
|
|
disabled={
|
|
rejectMutation.isPending || confirmMutation.isPending
|
|
}
|
|
>
|
|
Edit & regenerate
|
|
</Button>
|
|
<Button
|
|
color="edr-green"
|
|
radius="md"
|
|
leftSection={<Check size={16} />}
|
|
onClick={() => confirmMutation.mutate()}
|
|
loading={confirmMutation.isPending}
|
|
disabled={rejectMutation.isPending}
|
|
>
|
|
Approve & submit
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button
|
|
color="edr-green"
|
|
radius="md"
|
|
onClick={handleDraftModalOk}
|
|
>
|
|
OK
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
</Stack>
|
|
)}
|
|
</Modal>
|
|
|
|
{/* Re-priced on resubmit — confirm the new unit rates. */}
|
|
<Modal
|
|
opened={priceChangeResult !== null}
|
|
onClose={() => setPriceChangeResult(null)}
|
|
title={<Text fw={700}>Unit rates changed</Text>}
|
|
radius="lg"
|
|
centered
|
|
>
|
|
{priceChangeResult && (
|
|
<Stack gap="md">
|
|
<Text size="sm" c="dimmed">
|
|
{priceChangeResult.message ??
|
|
"The contract unit rates have been updated. Confirm to submit with the new schedule."}
|
|
</Text>
|
|
{priceChangeResult.lineItems &&
|
|
priceChangeResult.lineItems.length > 0 && (
|
|
<Box
|
|
p="md"
|
|
style={{
|
|
borderRadius: 16,
|
|
border: "1px solid var(--mantine-color-edr-border-0)",
|
|
background: "#fff",
|
|
}}
|
|
>
|
|
<Stack gap={10}>
|
|
{priceChangeResult.lineItems.map((item) => (
|
|
<Group
|
|
key={item.code}
|
|
justify="space-between"
|
|
wrap="nowrap"
|
|
gap="sm"
|
|
>
|
|
<Text size="sm" c="#10202F" fw={500}>
|
|
{item.label}
|
|
</Text>
|
|
<Text size="sm" fw={700} c="#10202F">
|
|
{item.unitPrice.toLocaleString()}{" "}
|
|
{priceChangeResult.currency} /{" "}
|
|
{formatRateUnit(item.unit)}
|
|
</Text>
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
</Box>
|
|
)}
|
|
<Group justify="flex-end" gap="sm">
|
|
<Button
|
|
variant="default"
|
|
radius="md"
|
|
onClick={() => setPriceChangeResult(null)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
color="edr-green"
|
|
radius="md"
|
|
loading={confirmSubmitMutation.isPending}
|
|
onClick={() => confirmSubmitMutation.mutate()}
|
|
>
|
|
Confirm & submit
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
)}
|
|
</Modal>
|
|
|
|
{/* 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). */}
|
|
<Modal
|
|
opened={createTarget !== null}
|
|
onClose={() => {
|
|
if (createProfileMutation.isPending) return;
|
|
if (licenseSubmitted) handleCreateProfileDone();
|
|
else handleCreateProfileCancel();
|
|
}}
|
|
title={
|
|
licenseSubmitted
|
|
? "Awaiting approval"
|
|
: `Set up your ${createTargetLabel} profile`
|
|
}
|
|
centered
|
|
radius="lg"
|
|
>
|
|
{licenseSubmitted ? (
|
|
<Stack gap="md">
|
|
<Group gap={10} align="flex-start" wrap="nowrap">
|
|
<Box
|
|
style={{
|
|
width: 38,
|
|
height: 38,
|
|
flexShrink: 0,
|
|
borderRadius: 11,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
background: "#ECF6F1",
|
|
color: "#0A6F4D",
|
|
}}
|
|
>
|
|
<Check size={18} />
|
|
</Box>
|
|
<Text size="sm" c="dimmed">
|
|
License submitted. Your {createTargetLabel.toLowerCase()} profile
|
|
is now awaiting staff approval. We'll notify you once it's
|
|
approved — then you can create this contract as{" "}
|
|
{createTargetLabel.toLowerCase()}.
|
|
</Text>
|
|
</Group>
|
|
<Group justify="flex-end" gap="sm">
|
|
<Button color="edr-green" onClick={handleCreateProfileDone}>
|
|
Got it
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
) : (
|
|
<Stack gap="md">
|
|
<Text size="sm" c="dimmed">
|
|
You don't have a {createTargetLabel.toLowerCase()} profile yet. Add
|
|
your business license to create one. It goes to staff for approval
|
|
before you can use it.
|
|
</Text>
|
|
<FileInput
|
|
label="Business license"
|
|
multiple
|
|
clearable
|
|
accept="application/pdf,image/png,image/jpeg"
|
|
leftSection={<Upload size={16} />}
|
|
placeholder="Select license file(s)"
|
|
value={licenseFiles}
|
|
onChange={(files) => setLicenseFiles(files ?? [])}
|
|
error={createError ?? undefined}
|
|
/>
|
|
<Group justify="flex-end" gap="sm">
|
|
<Button
|
|
variant="default"
|
|
onClick={handleCreateProfileCancel}
|
|
disabled={createProfileMutation.isPending}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
color="edr-green"
|
|
onClick={handleCreateProfileConfirm}
|
|
loading={createProfileMutation.isPending}
|
|
>
|
|
Submit license
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
)}
|
|
</Modal>
|
|
|
|
{/* Awaiting-approval modal — the chosen operation maps to a profile that
|
|
exists but isn't approved yet. The select was already reverted. */}
|
|
<Modal
|
|
opened={pendingApprovalProfile !== null}
|
|
onClose={() => setPendingApprovalProfile(null)}
|
|
title="Awaiting approval"
|
|
centered
|
|
radius="lg"
|
|
>
|
|
<Stack gap="md">
|
|
<Text size="sm" c="dimmed">
|
|
Your{" "}
|
|
{pendingApprovalProfile
|
|
? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ??
|
|
pendingApprovalProfile)
|
|
: ""}{" "}
|
|
profile was submitted and is under staff review. You can start a
|
|
contract under it once it's approved.
|
|
</Text>
|
|
<Group justify="flex-end" gap="sm">
|
|
<Button
|
|
color="edr-green"
|
|
onClick={() => setPendingApprovalProfile(null)}
|
|
>
|
|
OK
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function GateNotice({
|
|
title,
|
|
body,
|
|
actionLabel,
|
|
onAction,
|
|
}: {
|
|
title: string;
|
|
body: string;
|
|
actionLabel: string;
|
|
onAction: () => void;
|
|
}) {
|
|
return (
|
|
<Box
|
|
style={{
|
|
padding: "28px",
|
|
minHeight: "calc(100dvh - var(--app-shell-header-height, 56px))",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<Alert
|
|
color="orange"
|
|
icon={<AlertCircle size={20} />}
|
|
radius="md"
|
|
style={{ maxWidth: "500px" }}
|
|
mb="lg"
|
|
>
|
|
<Text size="lg" fw={600} mb="md">
|
|
{title}
|
|
</Text>
|
|
<Text size="sm" mb="md">
|
|
{body}
|
|
</Text>
|
|
<Button color="orange" onClick={onAction} mt="md">
|
|
{actionLabel}
|
|
</Button>
|
|
</Alert>
|
|
</Box>
|
|
);
|
|
}
|