style: improved the review ui

This commit is contained in:
Nathnael
2026-06-17 06:24:03 +00:00
parent 1861d45f1e
commit 1ca98f32ea
3 changed files with 243 additions and 256 deletions

View File

@@ -1,8 +1,7 @@
import { useMemo, useRef, type ReactNode } from "react"; import { api } from "@/services/api";
import { Controller, useForm } from "react-hook-form"; import type { CreateBookingPayload } from "@/services/bookings.service";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Freight } from "@edr/types";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router-dom";
import { import {
ActionIcon, ActionIcon,
Alert, Alert,
@@ -21,6 +20,7 @@ import {
TextInput, TextInput,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
AlertCircle, AlertCircle,
AlertTriangle, AlertTriangle,
@@ -34,12 +34,17 @@ import {
Upload, Upload,
X, X,
} from "lucide-react"; } from "lucide-react";
import type { Freight } from "@edr/types"; import { useMemo, useRef, type ReactNode } from "react";
import { api } from "@/services/api"; import { Controller, useForm } from "react-hook-form";
import type { CreateBookingPayload } from "@/services/bookings.service"; import { useNavigate, useParams } from "react-router-dom";
import {
CountChip,
DocRow,
IconSquare,
} from "./BookingDetailPage/components/Documents";
import { import {
BookingFormInputValues,
BOOKING_DOCS_SETTING, BOOKING_DOCS_SETTING,
BookingFormInputValues,
bookingFormSchema, bookingFormSchema,
getRouteDirection, getRouteDirection,
initialBookingFormValues, initialBookingFormValues,
@@ -48,11 +53,6 @@ import {
} from "./new-booking-form/schema"; } from "./new-booking-form/schema";
import { SelectField } from "./new-booking-form/shared"; import { SelectField } from "./new-booking-form/shared";
import { Step5CargoDetails } from "./new-booking-form/steps"; import { Step5CargoDetails } from "./new-booking-form/steps";
import {
CountChip,
DocRow,
IconSquare,
} from "./BookingDetailPage/components/Documents";
function yardNameFromBooking( function yardNameFromBooking(
yard: { label?: string; code?: string; name?: string } | undefined | null, yard: { label?: string; code?: string; name?: string } | undefined | null,
@@ -117,8 +117,6 @@ function mapBookingToFormValues(
shippingLine: (booking as any).shippingLine?.name ?? "", shippingLine: (booking as any).shippingLine?.name ?? "",
consolidationEnabled: booking.allowConsolidation ?? false, consolidationEnabled: booking.allowConsolidation ?? false,
notes: "", notes: "",
// Terms were accepted at creation; editing shouldn't re-gate on them.
termsAccepted: true,
containers: [], containers: [],
} as BookingFormInputValues; } as BookingFormInputValues;

View File

@@ -121,7 +121,6 @@ export const bookingFormSchema = z
consolidationEnabled: z.boolean(), consolidationEnabled: z.boolean(),
documents: z.record(z.string(), z.any()).default({}), documents: z.record(z.string(), z.any()).default({}),
notes: z.string(), notes: z.string(),
termsAccepted: z.boolean(),
}) })
.refine( .refine(
(data) => (data) =>
@@ -165,10 +164,6 @@ export const bookingFormSchema = z
(data) => !(data.cargoType === "container" && data.containers.length === 0), (data) => !(data.cargoType === "container" && data.containers.length === 0),
{ message: "Add at least one container.", path: ["containers"] }, { message: "Add at least one container.", path: ["containers"] },
) )
.refine((data) => data.termsAccepted, {
message: "Accept the freight contract terms to submit.",
path: ["termsAccepted"],
})
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.cargoType === "bulk") { if (data.cargoType === "bulk") {
if (!data.cargoTypePath[0]) { if (!data.cargoTypePath[0]) {
@@ -237,7 +232,6 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
consolidationEnabled: false, consolidationEnabled: false,
documents: {}, documents: {},
notes: "", notes: "",
termsAccepted: false,
}; };
export const stepFields: Record<number, Array<Path<BookingFormValues>>> = { export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
@@ -265,7 +259,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
], ],
5: ["scheduledDate", "trainScheduleId"], 5: ["scheduledDate", "trainScheduleId"],
6: ["documents"], 6: ["documents"],
7: ["notes", "termsAccepted"], 7: ["notes"],
}; };
export interface ContainerConfig { export interface ContainerConfig {

View File

@@ -3,15 +3,15 @@ import {
Box, Box,
Button, Button,
Card, Card,
Checkbox,
Divider, Divider,
Group, Group,
Loader, Loader,
SimpleGrid, SimpleGrid,
Stack,
Text, Text,
Textarea, Textarea,
} from "@mantine/core"; } from "@mantine/core";
import { Check, Send, XCircle } from "lucide-react"; import { Check, Send, XCircle, FileText, Route, Package, Truck } from "lucide-react";
import { import {
BookingFormInputValues, BookingFormInputValues,
BOOKING_DOCS_SETTING, BOOKING_DOCS_SETTING,
@@ -54,12 +54,11 @@ export function Step8Review({
abortPending?: boolean; abortPending?: boolean;
}) { }) {
const values = form.watch(); const values = form.watch();
const errors = form.formState.errors;
const serviceType = referenceData?.service.find( const serviceType = referenceData?.service.find(
(s) => s.id === values.serviceTypeId, (s) => s.id === values.serviceTypeId,
); );
function Row({ function CompactRow({
label, label,
value, value,
target, target,
@@ -69,19 +68,19 @@ export function Step8Review({
target: number; target: number;
}) { }) {
return ( return (
<div className="flex items-start justify-between gap-4 py-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0 flex-1">
<Text size="xs" c="dimmed"> <Text size="10px" c="dimmed" fw={600} tt="uppercase" className="mb-1 tracking-wider">
{label} {label}
</Text> </Text>
<Text size="sm" fw={500} mt={2} className="truncate"> <Text size="sm" fw={500} className="truncate">
{value || "—"} {value || "—"}
</Text> </Text>
</div> </div>
<button <button
type="button" type="button"
onClick={() => setStep(target)} onClick={() => setStep(target)}
className="shrink-0 text-xs font-medium text-emerald-600 hover:underline" className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
> >
Edit Edit
</button> </button>
@@ -89,6 +88,28 @@ export function Step8Review({
); );
} }
function CompactCard({
icon: Icon,
title,
children,
}: {
icon: React.ReactNode;
title: string;
children: React.ReactNode;
}) {
return (
<Card radius="md" p="sm" withBorder className="border-gray-200 bg-white hover:shadow-sm transition-shadow">
<Group gap="xs" mb="xs" wrap="nowrap">
<Box c="edr-green">{Icon}</Box>
<Text size="xs" fw={700} tt="uppercase" c="dimmed" className="tracking-wider">
{title}
</Text>
</Group>
<Stack gap="xs">{children}</Stack>
</Card>
);
}
const containerSummary = const containerSummary =
values.cargoType === "container" && values.containers.length > 0 values.cargoType === "container" && values.containers.length > 0
? values.containers ? values.containers
@@ -122,186 +143,24 @@ export function Step8Review({
return child ? `${group.name}${child.name}` : group.name; return child ? `${group.name}${child.name}` : group.name;
})(); })();
function ReviewCard({ const originYardName = referenceData?.yard.find(
title, (y) => y.id === values.originYard,
children, )?.name ?? values.originYard;
}: {
title: string; const destinationYardName = referenceData?.yard.find(
children: React.ReactNode; (y) => y.id === values.destinationYard,
}) { )?.name ?? values.destinationYard;
return (
<Card radius="lg" withBorder p={0} className="overflow-hidden">
<Box
px="md"
py="sm"
className="border-b border-[var(--mantine-color-gray-2)] bg-gray-50/60"
>
<Text
size="xs"
fw={600}
tt="uppercase"
c="dimmed"
className="tracking-wider"
>
{title}
</Text>
</Box>
<Box px="md" py="xs" className="divide-y divide-gray-100">
{children}
</Box>
</Card>
);
}
return ( return (
<div className="space-y-6"> <Stack gap="md">
<StepHeader <StepHeader
title="Review & Submit" title="Review & Submit"
description="Confirm your contract request before sending it for EDR staff review." description="Confirm your contract request before sending it for EDR staff review."
/> />
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md"> {/* Pricing Card - Prominent at top */}
<ReviewCard title="Contract & Service">
<Row
label="Type"
value={values.contractType === "new" ? "New Contract" : "Renewal"}
target={1}
/>
<Row label="Service" value={serviceType?.name ?? ""} target={2} />
</ReviewCard>
<ReviewCard title="First & Last Mile">
<Row
label="First Mile"
value={
values.firstMile.enabled
? values.firstMile.pickUpAddress
: "Not requested"
}
target={2}
/>
<Row
label="Last Mile"
value={
values.lastMile.enabled
? values.lastMile.deliveryAddress
: "Not requested"
}
target={2}
/>
<Row
label="Equipment Return"
value={
values.equipmentReturn === "with_return"
? "With Return"
: "Without Return"
}
target={2}
/>
<Row
label="Customs Clearing"
value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
target={2}
/>
</ReviewCard>
<ReviewCard title="Route & Cargo">
<Row
label="Route"
value={`${values.originYard}${values.destinationYard}`}
target={3}
/>
<Row
label="Workflow"
value={
direction
? direction.charAt(0).toUpperCase() + direction.slice(1)
: ""
}
target={3}
/>
<Row
label="Weight (VGM)"
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
target={4}
/>
<Row label="Cargo" value={cargoValue} target={4} />
<Row
label="Modifiers"
value={
[
values.isHazardous && "Hazardous",
values.isRefrigerated && "Refrigerated",
]
.filter(Boolean)
.join(", ") || "None"
}
target={3}
/>
</ReviewCard>
<ReviewCard title="Container & Wagons">
<Row label="Containers" value={containerSummary || "—"} target={4} />
<Row
label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : ""}
target={4}
/>
</ReviewCard>
<ReviewCard title="Documents">
<Row
label="Attached"
value={
docsAttached > 0
? `${docsAttached} of ${docsTotal} attached`
: "None — upload later from the booking page"
}
target={5}
/>
</ReviewCard>
</SimpleGrid>
<Controller
name="notes"
control={form.control}
render={({ field }) => (
<Textarea
{...field}
id="notes"
label="Additional Notes"
placeholder="Any special instructions or notes for EDR operations..."
rows={3}
radius="md"
/>
)}
/>
<Controller
name="termsAccepted"
control={form.control}
render={({ field, fieldState }) => (
<Checkbox
label={
<Text size="sm" c="dimmed">
I confirm the information is accurate and agree to EDR's{" "}
<Text component="span" c="edr-green" fw={500}>
freight contract terms and conditions
</Text>
.
</Text>
}
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
error={fieldState.error?.message ?? errors.termsAccepted?.message}
color="edr-green"
radius="sm"
/>
)}
/>
{pricingPhase === "generating" && ( {pricingPhase === "generating" && (
<Card radius="lg" withBorder p="lg"> <Card radius="lg" withBorder p="lg" className="border-edr-green border-2">
<Group justify="center" py="lg"> <Group justify="center" py="lg">
<Loader size="sm" /> <Loader size="sm" />
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
@@ -312,62 +171,198 @@ export function Step8Review({
)} )}
{pricingPhase === "ready" && pricingData && ( {pricingPhase === "ready" && pricingData && (
<Card radius="lg" withBorder p="lg"> <Card radius="lg" withBorder p="lg" className="border-edr-green border-2 bg-gradient-to-br from-white to-emerald-50/30">
<Text size="sm" fw={600} tt="uppercase" c="dimmed" mb="sm"> <Stack gap="sm">
Price Estimate <Text size="sm" fw={700} tt="uppercase" c="edr-green" className="tracking-wider">
</Text> 💳 Price Breakdown
{pricingData.lineItems.map((item) => ( </Text>
<Group key={item.code} justify="space-between" py={4}> <Stack gap="xs">
<Text size="sm">{item.description}</Text> {pricingData.lineItems.map((item) => (
<Text size="sm" fw={500}> <Group key={item.code} justify="space-between" py={2}>
{item.amount.toLocaleString()} {item.currency} <Text size="sm" c="dimmed">
{item.description}
</Text>
<Text size="sm" fw={600}>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
))}
</Stack>
<Divider my="xs" />
<Group justify="space-between" py={2}>
<Text fw={700} size="md">
Total
</Text>
<Text fw={800} size="lg" c="edr-green">
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
</Text> </Text>
</Group> </Group>
))} {pricingData.warnings.length > 0 && (
<Divider my="sm" /> <Text size="xs" c="orange.7" mt="xs" p="xs" className="bg-orange-50 rounded">
<Group justify="space-between" py={2}> {pricingData.warnings.join(", ")}
<Text fw={700}>Total</Text> </Text>
<Text fw={700}> )}
{pricingData.totalAmount.toLocaleString()} {pricingData.currency} <Group mt="md">
</Text> <Button
</Group> color="edr-green"
{pricingData.warnings.length > 0 && ( radius="md"
<Text size="xs" c="orange.7" mt="sm"> leftSection={<Check size={16} />}
{pricingData.warnings.join(", ")} onClick={onConfirm}
</Text> loading={confirmPending}
)} className="flex-1"
<Group mt="lg"> >
<Button {confirmPending ? "Confirming…" : "Confirm"}
color="edr-green" </Button>
radius="md" <Button
leftSection={<Check size={16} />} variant="outline"
onClick={onConfirm} color="edr-green"
loading={confirmPending} radius="md"
> leftSection={<Send size={16} />}
{confirmPending ? "Confirming…" : "Confirm"} onClick={onContinueLater}
</Button> className="flex-1"
<Button >
variant="outline" Continue later
color="edr-green" </Button>
radius="md" <Button
leftSection={<Send size={16} />} variant="outline"
onClick={onContinueLater} color="red"
> radius="md"
Continue later leftSection={!abortPending ? <XCircle size={16} /> : undefined}
</Button> onClick={onAbort}
<Button loading={abortPending}
variant="outline" >
color="red" Abort
radius="md" </Button>
leftSection={!abortPending ? <XCircle size={16} /> : undefined} </Group>
onClick={onAbort} </Stack>
loading={abortPending}
>
{abortPending ? "Aborting…" : "Abort"}
</Button>
</Group>
</Card> </Card>
)} )}
</div>
{/* Review Details - Compact Cards Grid */}
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="sm" mt="md">
<CompactCard icon={<Package size={16} />} title="Contract & Service">
<CompactRow
label="Type"
value={values.contractType === "new" ? "New Contract" : "Renewal"}
target={1}
/>
<CompactRow label="Service" value={serviceType?.name ?? ""} target={2} />
</CompactCard>
<CompactCard icon={<Route size={16} />} title="Route">
<CompactRow
label="Origin → Destination"
value={`${originYardName}${destinationYardName}`}
target={3}
/>
<CompactRow
label="Workflow"
value={
direction ? direction.charAt(0).toUpperCase() + direction.slice(1) : ""
}
target={3}
/>
</CompactCard>
<CompactCard icon={<Truck size={16} />} title="Logistics">
<CompactRow
label="First Mile"
value={
values.firstMile.enabled ? values.firstMile.pickUpAddress : "Not requested"
}
target={2}
/>
<CompactRow
label="Last Mile"
value={
values.lastMile.enabled ? values.lastMile.deliveryAddress : "Not requested"
}
target={2}
/>
<CompactRow
label="Equipment Return"
value={
values.equipmentReturn === "with_return" ? "With Return" : "Without Return"
}
target={2}
/>
<CompactRow
label="Customs Clearing"
value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
target={2}
/>
</CompactCard>
<CompactCard icon={<Package size={16} />} title="Cargo Details">
<CompactRow
label="Weight (VGM)"
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
target={4}
/>
<CompactRow label="Cargo Type" value={cargoValue} target={4} />
<CompactRow
label="Modifiers"
value={
[values.isHazardous && "Hazardous", values.isRefrigerated && "Refrigerated"]
.filter(Boolean)
.join(", ") || "None"
}
target={3}
/>
</CompactCard>
<CompactCard icon={<Package size={16} />} title="Containers">
<CompactRow
label="Count & Type"
value={containerSummary || "—"}
target={4}
/>
<CompactRow
label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
target={4}
/>
</CompactCard>
<CompactCard icon={<FileText size={16} />} title="Documents">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<Text size="10px" c="dimmed" fw={600} tt="uppercase" className="mb-1 tracking-wider">
Attached
</Text>
<Text size="sm" fw={500}>
{docsAttached > 0
? `${docsAttached} of ${docsTotal}`
: "None"}
</Text>
</div>
<button
type="button"
onClick={() => setStep(5)}
className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
>
Edit
</button>
</div>
</CompactCard>
</SimpleGrid>
{/* Notes */}
<Controller
name="notes"
control={form.control}
render={({ field }) => (
<Textarea
{...field}
id="notes"
label="Additional Notes"
placeholder="Any special instructions or notes for EDR operations…"
rows={2}
radius="md"
size="sm"
/>
)}
/>
</Stack>
); );
} }