diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx index f20679ab9..351ff6f5f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx @@ -23,6 +23,7 @@ import { useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { api } from "@/services/api"; +import type { SubmitBookingResponse } from "@/services/bookings.service"; import type { Freight } from "@edr/types"; import { REQUIRED_DOC_FIELDS } from "./constants"; @@ -60,6 +61,8 @@ export function DraftBookingView({ const [cancelDialogOpen, setCancelDialogOpen] = useState(false); const [cancelReason, setCancelReason] = useState(""); const [docError, setDocError] = useState(""); + const [priceChangeModal, setPriceChangeModal] = + useState(null); const anyFileSelected = Object.values(selectedFiles).some(Boolean); const uploadedCodes = useMemo( @@ -72,9 +75,11 @@ export function DraftBookingView({ const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length; const { data: generatedPricing } = useQuery( - api.bookings.generatePrice.queryOptions({ input: { id: booking.id }, - - enabled: booking.status === "DRAFT" && !booking.pricingBreakdown, + api.bookings.generatePrice.queryOptions({ + input: { id: booking.id }, + enabled: + (booking.status === "DRAFT" || booking.status === "CHANGES_REQUESTED") && + !booking.pricingBreakdown, }), ); const pricing = (booking.pricingBreakdown ?? @@ -82,8 +87,17 @@ export function DraftBookingView({ null) as Freight.PricingBreakdown | null; const uploadMutation = useMutation({ - mutationFn: (files: Record) => - api.bookings.uploadDocuments.call({ id: booking.id, files }), + mutationFn: async (files: Record) => { + if (booking.status === "CHANGES_REQUESTED") { + const result = await api.bookings.update.call({ + id: booking.id, + dto: {}, + documents: files, + }); + return result.booking; + } + return api.bookings.uploadDocuments.call({ id: booking.id, files }); + }, onSuccess: () => { setSelectedFiles({}); setDocError(""); @@ -93,7 +107,20 @@ export function DraftBookingView({ const submitMutation = useMutation({ mutationFn: () => api.bookings.submit.call({ id: booking.id }), + onSuccess: (result) => { + if (result.priceChanged) { + setPriceChangeModal(result); + return; + } + onBookingUpdated(); + queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); + }, + }); + + const confirmSubmitMutation = useMutation({ + mutationFn: () => api.bookings.confirmSubmit.call({ id: booking.id }), onSuccess: () => { + setPriceChangeModal(null); onBookingUpdated(); queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); }, @@ -155,7 +182,12 @@ export function DraftBookingView({ /> @@ -163,7 +195,9 @@ export function DraftBookingView({ booking.latestChangeRequestNote ? ( navigate(`/bookings/${booking.id}/edit`)} + onAction={() => + navigate(`/bookings/${booking.id}/edit?section=documents`) + } > {booking.latestChangeRequestNote} @@ -260,6 +294,8 @@ export function DraftBookingView({ const isUploaded = uploadedCodes.has(doc.key); const selected = selectedFiles[doc.key]; const file = booking.files?.find((f) => f.code === doc.key); + const allowReplace = + !isUploaded || booking.status === "CHANGES_REQUESTED"; return ( } /> ) : ( <> + {isUploaded && ( + } + /> + )} { fileInputRefs.current[doc.key] = el; @@ -318,7 +360,7 @@ export function DraftBookingView({ }, }} > - {selected ? "Change" : "Add"} + {selected ? "Change" : isUploaded ? "Replace" : "Add"} {selected && ( + setPriceChangeModal(null)} + title={Price has changed} + radius="lg" + centered + > + {priceChangeModal && ( + + + {priceChangeModal.message ?? + "The booking price has been updated. Confirm to submit with the new total."} + + {priceChangeModal.previousTotalAmount !== undefined && ( + + + Previous total + + + {priceChangeModal.previousTotalAmount.toLocaleString()}{" "} + {priceChangeModal.currency} + + + )} + + New total + + {priceChangeModal.totalAmount.toLocaleString()}{" "} + {priceChangeModal.currency} + + + {priceChangeModal.lineItems && priceChangeModal.lineItems.length > 0 && ( + + {priceChangeModal.lineItems.map((item) => ( + + + {item.description} + + + {item.amount.toLocaleString()} {item.currency} + + + ))} + + )} + + + + + + )} + + setCancelDialogOpen(false)} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx index b81cc455a..99d0d7f8c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx @@ -15,6 +15,7 @@ import { SimpleGrid, Stack, Switch, + Tabs, Text, Textarea, TextInput, @@ -36,7 +37,7 @@ import { } from "lucide-react"; import { useMemo, useRef, type ReactNode } from "react"; import { Controller, useForm } from "react-hook-form"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { CountChip, DocRow, @@ -52,12 +53,32 @@ import { type BookingFormValues, } from "./new-booking-form/schema"; import { SelectField } from "./new-booking-form/shared"; -import { Step5CargoDetails } from "./new-booking-form/steps"; +import { Step5CargoDetails, StepScheduling } from "./new-booking-form/steps"; -function yardNameFromBooking( - yard: { label?: string; code?: string; name?: string } | undefined | null, +const EDIT_SECTIONS = [ + "service", + "route", + "cargo", + "schedule", + "documents", + "notes", +] as const; + +type EditSection = (typeof EDIT_SECTIONS)[number]; + +function isEditSection(value: string | null): value is EditSection { + return EDIT_SECTIONS.includes(value as EditSection); +} + +function yardIdFromBooking( + yard: Freight.IYard | null | undefined, + referenceData: Freight.BookingReferenceData, ): string { - return yard?.label ?? yard?.name ?? yard?.code ?? ""; + if (yard?.id) return yard.id; + const label = yard?.label ?? ""; + return ( + referenceData.yard.find((y) => y.name === label || y.id === label)?.id ?? "" + ); } /** Fallback container type for a size, used only when a booking row has no @@ -108,14 +129,18 @@ function mapBookingToFormValues( booking.equipmentReturn === "WITH_RETURN" ? "with_return" : "without_return", - originYard: yardNameFromBooking(booking.originYard), - destinationYard: yardNameFromBooking(booking.destinationYard), + originYard: yardIdFromBooking(booking.originYard, referenceData), + destinationYard: yardIdFromBooking(booking.destinationYard, referenceData), cargoType: booking.freightType === "BULK" ? "bulk" : "container", cargoWeight: String(booking.cargoTotalWeightVgm ?? ""), isHazardous: booking.isHazardous ?? false, isRefrigerated: booking.isRefrigerated ?? false, shippingLine: (booking as any).shippingLine?.name ?? "", consolidationEnabled: booking.allowConsolidation ?? false, + scheduledDate: booking.scheduledDate + ? new Date(booking.scheduledDate).toISOString().slice(0, 10) + : "", + trainScheduleId: (booking as { trainScheduleId?: string }).trainScheduleId ?? "", notes: "", containers: [], } as BookingFormInputValues; @@ -237,9 +262,20 @@ const DIRECTION_LABEL: Record = { export default function EditBookingPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); const queryClient = useQueryClient(); const docInputRefs = useRef>({}); + const sectionParam = searchParams.get("section"); + const activeSection: EditSection = isEditSection(sectionParam) + ? sectionParam + : "service"; + + function setSection(section: EditSection) { + setSearchParams({ section }); + window.scrollTo({ top: 0, behavior: "smooth" }); + } + const bookingQuery = useQuery( api.bookings.get.queryOptions({ input: { id: id! }, @@ -269,18 +305,17 @@ export default function EditBookingPage() { const updateMutation = useMutation({ mutationFn: async (payload: Partial) => { - const result = await api.bookings.update.call({ id: id!, dto: payload }); - - // Upload any newly attached documents against the existing booking. const documents = (form.getValues("documents") ?? {}) as BookingDocuments; - const hasDocuments = Object.values(documents).some((value) => - Array.isArray(value) ? value.length > 0 : Boolean(value), - ); - if (hasDocuments) { - await api.bookings.uploadDocuments.call({ id: id!, files: documents }); + const newDocuments: BookingDocuments = {}; + for (const [key, value] of Object.entries(documents)) { + if (value) newDocuments[key] = value; } - - return result; + return api.bookings.update.call({ + id: id!, + dto: payload, + documents: + Object.keys(newDocuments).length > 0 ? newDocuments : undefined, + }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); @@ -304,9 +339,9 @@ export default function EditBookingPage() { ); const direction = useMemo(() => { - const origin = referenceData?.yard.find((y) => y.name === originYard); + const origin = referenceData?.yard.find((y) => y.id === originYard); const destination = referenceData?.yard.find( - (y) => y.name === destinationYard, + (y) => y.id === destinationYard, ); return getRouteDirection(origin, destination); }, [originYard, destinationYard, referenceData]); @@ -314,7 +349,7 @@ export default function EditBookingPage() { const yardOptions = useMemo(() => { if (!referenceData?.yard) return []; return referenceData.yard.map((y) => ({ - value: y.name, + value: y.id, label: y.name, country: y.country, })); @@ -338,14 +373,9 @@ export default function EditBookingPage() { }; const handleSubmit = form.handleSubmit((data) => { - const yards = referenceData?.yard ?? []; - const services = referenceData?.service ?? []; const shippingLines = referenceData?.shipping_line ?? []; const containerGroups = referenceData?.containers ?? []; - const findYardId = (name: string): string => - yards.find((y) => y.name === name)?.id ?? ""; - const findShippingLineId = (name: string): string | undefined => shippingLines.find((l) => l.name === name)?.id; @@ -369,10 +399,15 @@ export default function EditBookingPage() { ) : Number(data.cargoWeight || 0); - const selectedSvc = services.find((s) => s.id === data.serviceTypeId); + const selectedSvc = referenceData?.service.find( + (s) => s.id === data.serviceTypeId, + ); const apiPayload: Partial = { - scheduledDate: new Date().toISOString().slice(0, 10), + scheduledDate: data.scheduledDate + ? new Date(data.scheduledDate).toISOString() + : undefined, + trainScheduleId: data.trainScheduleId || undefined, contractType: data.contractType.toUpperCase() as CreateBookingPayload["contractType"], serviceTypeId: data.serviceTypeId, @@ -380,8 +415,8 @@ export default function EditBookingPage() { data.equipmentReturn === "with_return" ? "WITH_RETURN" : "WITHOUT_RETURN", - originYardId: findYardId(data.originYard), - destinationYardId: findYardId(data.destinationYard), + originYardId: data.originYard, + destinationYardId: data.destinationYard, tradeDirection: direction === "EXPORT" ? "EXPORT" @@ -393,7 +428,6 @@ export default function EditBookingPage() { isHazardous: data.isHazardous, paymentCurrency: "USD", allowConsolidation: data.consolidationEnabled, - // @ts-ignore freightType: data.cargoType === "container" ? ("CONTAINER" as const) @@ -505,9 +539,34 @@ export default function EditBookingPage() { )} - - {/* ── Section 1: Service ── */} - + {booking.status === "CHANGES_REQUESTED" && ( + } radius="md" mt="lg"> + + Staff requested changes + + + Update the sections below and save. Then return to the booking page to + resubmit for review. + + + )} + + value && setSection(value as EditSection)} + mt="xl" + > + + Service + Route + Cargo + Schedule + Documents + Notes + + + + )} + - - - {/* ── Section 3: Route ── */} + + - - - {/* ── Section 4: Cargo ── */} + + - + + + - {/* ── Section 5: Documents ── */} + + - - - {/* ── Section 6: Notes ── */} + - + + {/* ── Submit ── */} ); } + if (status === "CHANGES_REQUESTED") { + return ( + + ); + } if (status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID") { return ; } @@ -235,7 +250,7 @@ function useStatusCount(statuses: string | undefined): number | undefined { staleTime: 30_000, }), ); - return data?.total; + return data?.meta?.total; } function StatCard({ @@ -354,7 +369,7 @@ export default function MyBookings() { }; const allItems = data?.items ?? []; - const total = data?.total ?? allItems.length; + const total = data?.meta?.total ?? allItems.length; // Server handles status + pagination; reference search is applied on the page. const rows = useMemo(() => { diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index a7b3cab11..16c948f0b 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -1,7 +1,9 @@ import { api } from "@/services/api"; +import { hasAllRequiredDocuments } from "@/services/booking-form-data"; import type { CreateBookingPayload, GeneratePriceResponse, + SubmitBookingResponse, } from "@/services/bookings.service"; import { zodResolver } from "@hookform/resolvers/zod"; import { @@ -35,6 +37,7 @@ import { getRouteDirection, initialBookingFormValues, stepFields, + type BookingDocuments, type BookingFormValues, } from "./new-booking-form/schema"; import { StepIndicator } from "./new-booking-form/StepIndicator"; @@ -48,6 +51,8 @@ import { StepScheduling, } from "./new-booking-form/steps"; +type PriceModalMode = "submit" | "draft"; + export default function NewBookingPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -91,68 +96,61 @@ export default function NewBookingPage() { ); } - const createMutation = useMutation({ - mutationFn: async (payload: CreateBookingPayload) => { - const booking = await api.bookings.create.call(payload); + const persistAndPriceMutation = useMutation({ + mutationFn: async ({ + payload, + mode, + existingBookingId, + }: { + payload: CreateBookingPayload; + mode: PriceModalMode; + existingBookingId: string | null; + }) => { + const documents = (form.getValues("documents") ?? {}) as BookingDocuments; + let bookingId = existingBookingId; - // 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, - }); + if (bookingId) { + await api.bookings.update.call({ id: bookingId, dto: payload, documents }); + } else { + const booking = await api.bookings.create.call({ payload, documents }); + bookingId = booking.id; } - return booking; + const pricing = await api.bookings.generatePrice.call({ id: bookingId }); + return { bookingId, pricing, mode }; }, - onSuccess: (booking) => { - queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); - navigate(`/bookings/${booking.id}`); - }, - }); - - const createAndPriceMutation = useMutation({ - mutationFn: async (payload: CreateBookingPayload) => { - const booking = await api.bookings.create.call(payload); - - const documents = form.getValues("documents") ?? {}; - const hasDocs = Object.values(documents).some((value) => - Array.isArray(value) ? value.length > 0 : Boolean(value), - ); - if (hasDocs) { - await api.bookings.uploadDocuments.call({ - id: booking.id, - files: documents, - }); - } - - const pricing = await api.bookings.generatePrice.call({ id: booking.id }); - - return { bookingId: booking.id, pricing }; - }, - onSuccess: ({ bookingId, pricing }) => { + onSuccess: ({ bookingId, pricing, mode }) => { setPriceBookingId(bookingId); setPricingData(pricing); - setPricingPhase("ready"); + setPriceModalMode(mode); queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); }, - onError: () => { - setPricingPhase("idle"); - }, }); const confirmMutation = useMutation({ mutationFn: async () => { if (!priceBookingId) throw new Error("No booking to confirm"); - await api.bookings.submit.call({ id: priceBookingId }); + return api.bookings.submit.call({ id: priceBookingId }); + }, + onSuccess: (result) => { + if (result.priceChanged) { + setPriceChangeResult(result); + return; + } + setPriceModalMode(null); + queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); + navigate(`/bookings/${priceBookingId}`); + }, + }); + + const confirmSubmitMutation = useMutation({ + mutationFn: async () => { + if (!priceBookingId) throw new Error("No booking to confirm"); + return api.bookings.confirmSubmit.call({ id: priceBookingId }); }, onSuccess: () => { + setPriceChangeResult(null); + setPriceModalMode(null); queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); navigate(`/bookings/${priceBookingId}`); }, @@ -188,22 +186,15 @@ export default function NewBookingPage() { return route; }, [originYard, destinationYard]); - const docValues = form.watch("documents") ?? {}; - const hasDocuments = useMemo( - () => - Object.values(docValues).some((value) => - Array.isArray(value) ? value.length > 0 : Boolean(value), - ), - [docValues], - ); - - const [pricingPhase, setPricingPhase] = useState< - "idle" | "generating" | "ready" - >("idle"); const [pricingData, setPricingData] = useState( null, ); const [priceBookingId, setPriceBookingId] = useState(null); + const [priceModalMode, setPriceModalMode] = useState( + null, + ); + const [priceChangeResult, setPriceChangeResult] = + useState(null); const [cancelDialogOpen, setCancelDialogOpen] = useState(false); const [cancelReason, setCancelReason] = useState(""); @@ -211,6 +202,14 @@ export default function NewBookingPage() { const valid = await form.trigger(stepFields[step], { shouldFocus: true }); if (!valid) return; + if (step === 6 && !hasAllRequiredDocuments(form.getValues("documents"))) { + form.setError("documents", { + type: "manual", + message: "Upload all four required documents.", + }); + return; + } + setStep((currentStep) => Math.min(STEPS.length, currentStep + 1)); } @@ -265,7 +264,9 @@ export default function NewBookingPage() { )!; return { - scheduledDate: new Date().toISOString(), + scheduledDate: data.scheduledDate + ? new Date(data.scheduledDate).toISOString() + : new Date().toISOString(), contractType: data.contractType.toUpperCase() as CreateBookingPayload["contractType"], serviceTypeId: data.serviceTypeId, @@ -313,25 +314,55 @@ export default function NewBookingPage() { }; } - const handleDraftSubmit = form.handleSubmit((data) => { + const handleSaveDraft = form.handleSubmit((data) => { try { const apiPayload = buildApiPayload(data); - createMutation.mutate(apiPayload); + persistAndPriceMutation.mutate({ + payload: apiPayload, + mode: "draft", + existingBookingId: priceBookingId, + }); } catch { // validation error already handled } }); - const handleGeneratePrice = form.handleSubmit((data) => { + const handleSubmitBooking = form.handleSubmit((data) => { + if (!hasAllRequiredDocuments(data.documents)) { + form.setError("documents", { + type: "manual", + message: "Upload all four required documents.", + }); + setStep(6); + return; + } try { const apiPayload = buildApiPayload(data); - setPricingPhase("generating"); - createAndPriceMutation.mutate(apiPayload); + persistAndPriceMutation.mutate({ + payload: apiPayload, + mode: "submit", + existingBookingId: priceBookingId, + }); } catch { // validation error already handled } }); + const isPricing = + persistAndPriceMutation.isPending || confirmMutation.isPending; + + function closePriceModal() { + setPriceModalMode(null); + if (priceModalMode === "draft" && priceBookingId) { + navigate(`/bookings/${priceBookingId}`); + } + } + + function handleDraftModalOk() { + setPriceModalMode(null); + if (priceBookingId) navigate(`/bookings/${priceBookingId}`); + } + return ( e.preventDefault()} > - {createMutation.isError && ( + {persistAndPriceMutation.isError && ( } @@ -391,29 +422,11 @@ export default function NewBookingPage() { mb="lg" > - Failed to save draft + Failed to save booking or generate price - {createMutation.error instanceof Error - ? createMutation.error.message - : "An unexpected error occurred. Please try again."} - - - )} - - {createAndPriceMutation.isError && ( - } - radius="md" - mb="lg" - > - - Failed to generate price estimate - - - {createAndPriceMutation.error instanceof Error - ? createAndPriceMutation.error.message + {persistAndPriceMutation.error instanceof Error + ? persistAndPriceMutation.error.message : "An unexpected error occurred. Please try again."} @@ -450,17 +463,16 @@ export default function NewBookingPage() { setStep={setStep} direction={direction!} referenceData={referenceData} - pricingPhase={pricingPhase} - pricingData={pricingData} - onConfirm={() => confirmMutation.mutate()} - onContinueLater={ - priceBookingId - ? () => navigate(`/bookings/${priceBookingId}`) - : undefined + onSaveDraft={handleSaveDraft} + onSubmit={handleSubmitBooking} + saveDraftPending={ + persistAndPriceMutation.isPending && + persistAndPriceMutation.variables?.mode === "draft" + } + submitPending={ + persistAndPriceMutation.isPending && + persistAndPriceMutation.variables?.mode === "submit" } - onAbort={() => setCancelDialogOpen(true)} - confirmPending={confirmMutation.isPending} - abortPending={abortMutation.isPending} /> )} @@ -501,51 +513,151 @@ export default function NewBookingPage() { > Continue - ) : pricingPhase === "idle" ? ( - - - {hasDocuments && ( - - )} - - ) : pricingPhase === "generating" ? ( - - ) : null} + )} + + {priceModalMode === "submit" + ? "Confirm booking submission" + : "Draft saved — price estimate"} + + } + radius="lg" + centered + size="md" + > + {pricingData && ( + + + {priceModalMode === "submit" + ? "Review the price estimate below. Confirm to submit your booking for EDR staff review." + : "Your booking has been saved as a draft. Here is the estimated price."} + + + {pricingData.lineItems.map((item) => ( + + + {item.description} + + + {item.amount.toLocaleString()} {item.currency} + + + ))} + + + + Total + + + {pricingData.totalAmount.toLocaleString()} {pricingData.currency} + + + {pricingData.warnings.length > 0 && ( + + {pricingData.warnings.join(", ")} + + )} + + {priceModalMode === "submit" ? ( + <> + + + + ) : ( + + )} + + + )} + + + setPriceChangeResult(null)} + title={Price has changed} + radius="lg" + centered + > + {priceChangeResult && ( + + + {priceChangeResult.message ?? + "The booking price has been updated. Confirm to submit with the new total."} + + {priceChangeResult.previousTotalAmount !== undefined && ( + + + Previous total + + + {priceChangeResult.previousTotalAmount.toLocaleString()}{" "} + {priceChangeResult.currency} + + + )} + + New total + + {priceChangeResult.totalAmount.toLocaleString()}{" "} + {priceChangeResult.currency} + + + + + + + + )} + + setCancelDialogOpen(false)} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index dbf943068..7bc6666dc 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -14,9 +14,7 @@ export const STEPS = [ /** * 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. + * `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts. */ const DOC_SETTING_TS = "2024-01-01T00:00:00.000Z"; @@ -34,7 +32,7 @@ function docField( fileKey, fileLabel, helpText: null, - isRequired: false, + isRequired: true, isMultiple: false, maxFiles: 1, allowedExtensions: ["pdf", "jpg", "jpeg", "png"], @@ -51,7 +49,7 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = { code: "booking_documents", label: "Booking Documents", description: - "Attach your shipment documents now, or skip and upload them later from the booking page.", + "Attach all four required shipment documents before submitting your booking.", entity: "booking", fields: [ docField("commercial_invoice", "Commercial Invoice", 1), diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx index 20f63812f..e8edc5e5e 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx @@ -115,7 +115,7 @@ export function Step2ServiceType({ icon={} title="First Mile — Pick-up" description="Truck pick-up from your premises (Door to Port) to the origin rail yard." - checked={field.value} + checked={field.value ?? false} onChange={(value) => { field.onChange(value); if (!value) { @@ -157,7 +157,7 @@ export function Step2ServiceType({ icon={} title="Last Mile — Delivery" description="Truck delivery from the destination rail yard to the final address (Port to Door)." - checked={field.value} + checked={field.value ?? false} onChange={(value) => { field.onChange(value); if (!value) { @@ -225,7 +225,7 @@ export function Step2ServiceType({ icon={} title="Customs Clearing Service" description="EDR handles customs documentation and clearance on your behalf." - checked={field.value} + checked={field.value ?? false} onChange={(v) => field.onChange(v)} /> )} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx index 387034ec5..255eea380 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx @@ -1,27 +1,46 @@ import { Controller, type UseFormReturn } from "react-hook-form"; import { + Badge, Box, Button, - Card, - Divider, Group, - Loader, - SimpleGrid, + Paper, Stack, + Table, Text, Textarea, } from "@mantine/core"; -import { Check, Send, XCircle, FileText, Route, Package, Truck } from "lucide-react"; +import { format } from "date-fns"; +import { + Calendar, + CheckCircle2, + Circle, + ClipboardCheck, + FileText, + Package, + Pencil, + Route, + Send, + Truck, +} from "lucide-react"; +import type { Freight } from "@/types"; +import { hasAllRequiredDocuments } from "@/services/booking-form-data"; import { - BookingFormInputValues, BOOKING_DOCS_SETTING, type BookingDocuments, + type BookingFormInputValues, type BookingFormValues, } from "./schema"; import { StepHeader } from "./shared"; -import { ClipboardCheck } from "lucide-react"; -import type { Freight } from "@/types"; -import type { GeneratePriceResponse } from "@/services/bookings.service"; + +export const REVIEW_STEP_TARGETS = { + contract: 1, + service: 2, + route: 3, + cargo: 4, + schedule: 5, + documents: 6, +} as const; type BookingForm = UseFormReturn< BookingFormInputValues, @@ -29,113 +48,135 @@ type BookingForm = UseFormReturn< BookingFormValues >; +function OverviewSection({ + icon, + title, + onEdit, + children, +}: { + icon: React.ReactNode; + title: string; + onEdit: () => void; + children: React.ReactNode; +}) { + return ( + + + + + {icon} + + + {title} + + + + + {children} + + ); +} + +function DetailRow({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + + {value || "—"} + + + ); +} + +function ReadinessItem({ + done, + label, +}: { + done: boolean; + label: string; +}) { + return ( + + {done ? ( + + ) : ( + + )} + + {label} + + + ); +} + export function Step8Review({ form, setStep, direction, referenceData, - pricingPhase = "idle", - pricingData, - onConfirm, - onContinueLater, - onAbort, - confirmPending = false, - abortPending = false, + onSaveDraft, + onSubmit, + saveDraftPending = false, + submitPending = false, }: { form: BookingForm; setStep: (step: number) => void; direction: Freight.ScheduleTradeDirection; referenceData?: Freight.BookingReferenceData; - pricingPhase?: "idle" | "generating" | "ready"; - pricingData?: GeneratePriceResponse | null; - onConfirm?: () => void; - onContinueLater?: () => void; - onAbort?: () => void; - confirmPending?: boolean; - abortPending?: boolean; + onSaveDraft?: () => void; + onSubmit?: () => void; + saveDraftPending?: boolean; + submitPending?: boolean; }) { const values = form.watch(); const serviceType = referenceData?.service.find( (s) => s.id === values.serviceTypeId, ); - function CompactRow({ - label, - value, - target, - }: { - label: string; - value: string; - target: number; - }) { - return ( -
-
- - {label} - - - {value || "—"} - -
- -
- ); - } - - function CompactCard({ - icon: Icon, - title, - children, - }: { - icon: React.ReactNode; - title: string; - children: React.ReactNode; - }) { - return ( - - - {Icon} - - {title} - - - {children} - - ); - } - const containerSummary = values.cargoType === "container" && values.containers.length > 0 ? values.containers - .filter((c) => +c.qty > 0) - .map((c) => `${c.qty} × ${c.type}`) - .join(", ") + .filter((c) => +c.qty > 0) + .map((c) => `${c.qty} × ${c.containerType || c.type}`) + .join(", ") : ""; const totalVgm = values.cargoType === "container" ? values.containers.reduce( - (sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0), - 0, - ) - : 0; + (sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0), + 0, + ) + : Number(values.cargoWeight || 0); const documents = (values.documents ?? {}) as BookingDocuments; const docsAttached = BOOKING_DOCS_SETTING.fields.filter((f) => { const value = documents[f.fileKey]; return Array.isArray(value) ? value.length > 0 : Boolean(value); }).length; - const docsTotal = BOOKING_DOCS_SETTING.fields.length; + const allDocsReady = hasAllRequiredDocuments(documents); const cargoValue = (() => { - if (values.cargoType === "container") return containerSummary; + if (values.cargoType === "container") return "Container freight"; if (!referenceData) return ""; const path = values.cargoTypePath ?? []; const group = referenceData.cargo_type.find((g) => g.id === path[0]); @@ -144,227 +185,312 @@ export function Step8Review({ return child ? `${group.name} — ${child.name}` : group.name; })(); - const originYardName = referenceData?.yard.find( - (y) => y.id === values.originYard, - )?.name ?? values.originYard; + const originYardName = + referenceData?.yard.find((y) => y.id === values.originYard)?.name ?? + values.originYard; - const destinationYardName = referenceData?.yard.find( - (y) => y.id === values.destinationYard, - )?.name ?? values.destinationYard; + const destinationYardName = + referenceData?.yard.find((y) => y.id === values.destinationYard)?.name ?? + values.destinationYard; + + const scheduleLabel = values.scheduledDate + ? format(new Date(values.scheduledDate), "EEEE, MMM d, yyyy") + : "—"; + + const directionLabel = direction + ? direction.charAt(0) + direction.slice(1).toLowerCase() + : "—"; return ( - + } title="Review & Submit" - description="Confirm your contract request before sending it for EDR staff review." + description="Review your booking overview before sending it for EDR staff review." /> - {/* Pricing Card - Prominent at top */} - {pricingPhase === "generating" && ( - - - - - Generating price estimate… - - - - )} - - {pricingPhase === "ready" && pricingData && ( - - - - 💳 Price Breakdown - - - {pricingData.lineItems.map((item) => ( - - - {item.description} - - - {item.amount.toLocaleString()} {item.currency} - - - ))} - - - - - Total - - - {pricingData.totalAmount.toLocaleString()} {pricingData.currency} - +
+ {/* Left — booking summary */} + + + + + + Booking overview + + + {values.contractType === "new" ? "New Contract" : "Contract Renewal"} + + + {serviceType?.name ?? "—"} · {originYardName} → {destinationYardName} + + + + {directionLabel} + - {pricingData.warnings.length > 0 && ( - - ⚠️ {pricingData.warnings.join(", ")} - + + + } + title="Contract & Service" + onEdit={() => setStep(REVIEW_STEP_TARGETS.contract)} + > + + {values.contractType === "renewal" && values.previousContractRef && ( + )} - - - - - - - - )} - - {/* Review Details - Compact Cards Grid */} - - } title="Contract & Service"> - - - - - } title="Route"> - - - - - } title="Logistics"> - - - - - - - } title="Cargo Details"> - - - - - - } title="Containers"> - - 0 ? `${totalVgm.toFixed(1)} tons` : "—"} - target={4} - /> - - - } title="Documents"> -
-
- - Attached - - - {docsAttached > 0 - ? `${docsAttached} of ${docsTotal}` - : "None"} - -
- -
-
-
+ Edit service options + + - {/* Notes */} - ( -