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

View File

@@ -121,7 +121,6 @@ export const bookingFormSchema = z
consolidationEnabled: z.boolean(),
documents: z.record(z.string(), z.any()).default({}),
notes: z.string(),
termsAccepted: z.boolean(),
})
.refine(
(data) =>
@@ -165,10 +164,6 @@ export const bookingFormSchema = z
(data) => !(data.cargoType === "container" && data.containers.length === 0),
{ 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) => {
if (data.cargoType === "bulk") {
if (!data.cargoTypePath[0]) {
@@ -237,7 +232,6 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
consolidationEnabled: false,
documents: {},
notes: "",
termsAccepted: false,
};
export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
@@ -265,7 +259,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
],
5: ["scheduledDate", "trainScheduleId"],
6: ["documents"],
7: ["notes", "termsAccepted"],
7: ["notes"],
};
export interface ContainerConfig {

View File

@@ -3,15 +3,15 @@ import {
Box,
Button,
Card,
Checkbox,
Divider,
Group,
Loader,
SimpleGrid,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { Check, Send, XCircle } from "lucide-react";
import { Check, Send, XCircle, FileText, Route, Package, Truck } from "lucide-react";
import {
BookingFormInputValues,
BOOKING_DOCS_SETTING,
@@ -54,12 +54,11 @@ export function Step8Review({
abortPending?: boolean;
}) {
const values = form.watch();
const errors = form.formState.errors;
const serviceType = referenceData?.service.find(
(s) => s.id === values.serviceTypeId,
);
function Row({
function CompactRow({
label,
value,
target,
@@ -69,19 +68,19 @@ export function Step8Review({
target: number;
}) {
return (
<div className="flex items-start justify-between gap-4 py-2">
<div className="min-w-0">
<Text size="xs" c="dimmed">
<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">
{label}
</Text>
<Text size="sm" fw={500} mt={2} className="truncate">
<Text size="sm" fw={500} className="truncate">
{value || "—"}
</Text>
</div>
<button
type="button"
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
</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 =
values.cargoType === "container" && values.containers.length > 0
? values.containers
@@ -122,186 +143,24 @@ export function Step8Review({
return child ? `${group.name}${child.name}` : group.name;
})();
function ReviewCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
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>
);
}
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;
return (
<div className="space-y-6">
<Stack gap="md">
<StepHeader
title="Review & Submit"
description="Confirm your contract request before sending it for EDR staff review."
/>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<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"
/>
)}
/>
{/* Pricing Card - Prominent at top */}
{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">
<Loader size="sm" />
<Text size="sm" c="dimmed">
@@ -312,62 +171,198 @@ export function Step8Review({
)}
{pricingPhase === "ready" && pricingData && (
<Card radius="lg" withBorder p="lg">
<Text size="sm" fw={600} tt="uppercase" c="dimmed" mb="sm">
Price Estimate
</Text>
{pricingData.lineItems.map((item) => (
<Group key={item.code} justify="space-between" py={4}>
<Text size="sm">{item.description}</Text>
<Text size="sm" fw={500}>
{item.amount.toLocaleString()} {item.currency}
<Card radius="lg" withBorder p="lg" className="border-edr-green border-2 bg-gradient-to-br from-white to-emerald-50/30">
<Stack gap="sm">
<Text size="sm" fw={700} tt="uppercase" c="edr-green" className="tracking-wider">
💳 Price Breakdown
</Text>
<Stack gap="xs">
{pricingData.lineItems.map((item) => (
<Group key={item.code} justify="space-between" py={2}>
<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>
</Group>
))}
<Divider my="sm" />
<Group justify="space-between" py={2}>
<Text fw={700}>Total</Text>
<Text fw={700}>
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
</Text>
</Group>
{pricingData.warnings.length > 0 && (
<Text size="xs" c="orange.7" mt="sm">
{pricingData.warnings.join(", ")}
</Text>
)}
<Group mt="lg">
<Button
color="edr-green"
radius="md"
leftSection={<Check size={16} />}
onClick={onConfirm}
loading={confirmPending}
>
{confirmPending ? "Confirming…" : "Confirm"}
</Button>
<Button
variant="outline"
color="edr-green"
radius="md"
leftSection={<Send size={16} />}
onClick={onContinueLater}
>
Continue later
</Button>
<Button
variant="outline"
color="red"
radius="md"
leftSection={!abortPending ? <XCircle size={16} /> : undefined}
onClick={onAbort}
loading={abortPending}
>
{abortPending ? "Aborting…" : "Abort"}
</Button>
</Group>
{pricingData.warnings.length > 0 && (
<Text size="xs" c="orange.7" mt="xs" p="xs" className="bg-orange-50 rounded">
{pricingData.warnings.join(", ")}
</Text>
)}
<Group mt="md">
<Button
color="edr-green"
radius="md"
leftSection={<Check size={16} />}
onClick={onConfirm}
loading={confirmPending}
className="flex-1"
>
{confirmPending ? "Confirming…" : "Confirm"}
</Button>
<Button
variant="outline"
color="edr-green"
radius="md"
leftSection={<Send size={16} />}
onClick={onContinueLater}
className="flex-1"
>
Continue later
</Button>
<Button
variant="outline"
color="red"
radius="md"
leftSection={!abortPending ? <XCircle size={16} /> : undefined}
onClick={onAbort}
loading={abortPending}
>
Abort
</Button>
</Group>
</Stack>
</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>
);
}