Files
edr-platform/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx

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 &amp; regenerate
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Check size={16} />}
onClick={() => confirmMutation.mutate()}
loading={confirmMutation.isPending}
disabled={rejectMutation.isPending}
>
Approve &amp; 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 &amp; 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>
);
}