mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
style: improved the review ui
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user