feat: add document upload to booking creation form

This commit is contained in:
Nathnael
2026-06-11 09:11:35 +00:00
parent 3fa09ec644
commit 103ff53fe0
4 changed files with 163 additions and 5 deletions

View File

@@ -22,6 +22,7 @@ import {
Step2ServiceType,
Step4Route,
Step5CargoDetails,
StepDocuments,
Step8Review,
} from "./new-booking-form/steps";
@@ -34,8 +35,25 @@ export default function NewBookingPage() {
);
const createMutation = useMutation({
mutationFn: (payload: CreateBookingPayload) =>
api.bookings.create.call(payload),
mutationFn: async (payload: CreateBookingPayload) => {
const booking = await api.bookings.create.call(payload);
// Documents can't ride along with creation — upload them against the
// new booking id once it exists. Optional here; the booking detail page
// remains the catch-all for any docs the user skips.
const documents = form.getValues("documents") ?? {};
const hasDocuments = Object.values(documents).some((value) =>
Array.isArray(value) ? value.length > 0 : Boolean(value),
);
if (hasDocuments) {
await api.bookings.uploadDocuments.call({
id: booking.id,
files: documents,
});
}
return booking;
},
onSuccess: (booking) => {
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
navigate(`/bookings/${booking.id}`);
@@ -282,7 +300,8 @@ export default function NewBookingPage() {
isLoading={refDataLoading}
/>
)}
{step === 5 && (
{step === 5 && <StepDocuments form={form} />}
{step === 6 && (
<Step8Review
form={form}
setStep={setStep}

View File

@@ -1,3 +1,4 @@
import type { Freight } from "@edr/types";
import { DeepPartial, Path } from "react-hook-form";
import * as z from "zod";
@@ -22,9 +23,61 @@ export const STEPS = [
{ id: 2, label: "Service Type & Mile", short: "Service" },
{ id: 3, label: "Route", short: "Route" },
{ id: 4, label: "Cargo Details", short: "Cargo" },
{ id: 5, label: "Review & Submit", short: "Submit" },
{ id: 5, label: "Documents", short: "Documents" },
{ id: 6, label: "Review & Submit", short: "Submit" },
] as const;
/**
* Shipment documents collected during booking creation. The fileKeys mirror
* `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts so anything attached
* here shows up as "Uploaded" on the booking detail page. All optional in this
* flow — the detail page remains the catch-all for uploading them later.
*/
const DOC_SETTING_TS = "2024-01-01T00:00:00.000Z";
function docField(
fileKey: string,
fileLabel: string,
order: number,
): Freight.IFileUploadField {
return {
id: fileKey,
settingId: "booking_documents",
createdAt: DOC_SETTING_TS,
updatedAt: DOC_SETTING_TS,
deletedAt: null,
fileKey,
fileLabel,
helpText: null,
isRequired: false,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
maxSizeMb: 10,
order,
};
}
export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
id: "booking_documents",
createdAt: DOC_SETTING_TS,
updatedAt: DOC_SETTING_TS,
deletedAt: null,
code: "booking_documents",
label: "Booking Documents",
description:
"Attach your shipment documents now, or skip and upload them later from the booking page.",
entity: "booking",
fields: [
docField("commercial_invoice", "Commercial Invoice", 1),
docField("packing_list", "Packing List", 2),
docField("certificate_of_origin", "Certificate of Origin", 3),
docField("letter_of_credit", "Letter of Credit / LC", 4),
],
};
export type BookingDocuments = Record<string, File | File[] | null>;
export const bookingFormSchema = z
.object({
contractType: z.enum(["new", "renewal"], "Select a contract type."),
@@ -78,6 +131,7 @@ export const bookingFormSchema = z
}),
),
consolidationEnabled: z.boolean(),
documents: z.record(z.string(), z.any()).default({}),
notes: z.string(),
termsAccepted: z.boolean(),
})
@@ -187,6 +241,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
isRefrigerated: false,
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
consolidationEnabled: false,
documents: {},
notes: "",
termsAccepted: false,
};
@@ -215,7 +270,8 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
"containers",
"consolidationEnabled",
],
5: ["notes", "termsAccepted"],
5: ["documents"],
6: ["notes", "termsAccepted"],
};
export type RouteDirection = "import" | "export" | "domestic" | null;

View File

@@ -0,0 +1,82 @@
import { Box, Group, Text } from "@mantine/core";
import { SmartFileInput } from "@edr/ui-common";
import { CheckCircle2 } from "lucide-react";
import { Controller, type UseFormReturn } from "react-hook-form";
import {
BOOKING_DOCS_SETTING,
type BookingDocuments,
type BookingFormValues,
} from "./schema";
import { StepHeader } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
function countAttached(documents: BookingDocuments): number {
return BOOKING_DOCS_SETTING.fields.filter((f) => {
const value = documents[f.fileKey];
return Array.isArray(value) ? value.length > 0 : Boolean(value);
}).length;
}
export function StepDocuments({ form }: { form: BookingForm }) {
const documents = (form.watch("documents") ?? {}) as BookingDocuments;
const attached = countAttached(documents);
const total = BOOKING_DOCS_SETTING.fields.length;
return (
<div className="space-y-6">
<StepHeader
title="Shipment Documents"
description="Attach your shipment documents now, or skip this step and upload them later from the booking page."
/>
<Group
gap={10}
align="center"
wrap="nowrap"
className="rounded-xl"
style={{
border: "1px solid var(--mantine-color-edr-border-0)",
backgroundColor: "var(--mantine-color-gray-0)",
padding: "12px 16px",
}}
>
<Box
style={{
flexShrink: 0,
width: 32,
height: 32,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 999,
fontSize: 12,
fontWeight: 700,
backgroundColor: attached === total ? "#ECF6F1" : "#EAF1FB",
color: attached === total ? "#0A6F4D" : "#2E5B96",
}}
>
{attached === total ? <CheckCircle2 size={16} /> : `${attached}/${total}`}
</Box>
<Text size="sm" c="dimmed">
{attached === 0
? "All documents are optional here — you can upload them later from the booking page."
: `${attached} of ${total} attached. You can finish the rest later from the booking page.`}
</Text>
</Group>
<Controller
name="documents"
control={form.control}
render={({ field }) => (
<SmartFileInput
file={BOOKING_DOCS_SETTING}
value={(field.value ?? {}) as BookingDocuments}
onChange={(value) => field.onChange(value)}
/>
)}
/>
</div>
);
}

View File

@@ -2,4 +2,5 @@ export { Step1ContractType } from "./step1-contract-type";
export { Step2ServiceType } from "./step2-service-type";
export { Step4Route } from "./step4-route";
export { Step5CargoDetails } from "./step5-cargo-details";
export { StepDocuments } from "./step-documents";
export { Step8Review } from "./step8-review";