feat: wire up the create booking form

This commit is contained in:
ghost2023
2026-05-23 11:54:13 +03:00
parent 8a5652d7d9
commit dc41da3731
10 changed files with 534 additions and 27 deletions

View File

@@ -22,6 +22,7 @@ import {
} from "lucide-react";
import BookingsPage from "./pages/bookings/BookingsPage";
import MyBookings from "./pages/bookings/MyBookings";
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import ConsignmentsPage from "./pages/consignments/ConsignmentsPage";
@@ -49,7 +50,7 @@ const sidebarItems: SidebarItem[] = [
{ label: "My Portal", href: "/portal", icon: <UserCircle /> },
{ label: "Dashboard", href: "/", icon: <LayoutDashboard /> },
{ label: "Customers", href: "/customers", icon: <Users /> },
{ label: "Bookings", href: "/bookings", icon: <CalendarCheck /> },
{ label: "My Bookings", href: "/bookings", icon: <CalendarCheck /> },
{ label: "Consignments", href: "/consignments", icon: <Package /> },
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
{ label: "Stations", href: "/stations", icon: <MapPinned /> },
@@ -115,7 +116,8 @@ const App = () => {
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/portal" element={<MyPortalPage />} />
<Route path="/bookings" element={<BookingsPage />} />
<Route path="/bookings" element={<MyBookings />} />
<Route path="/admin/bookings" element={<BookingsPage />} />
<Route path="/customers" element={<CustomersPage />} />
<Route path="/customers/:id" element={<CustomerDetailPage />} />
<Route path="/new-customer" element={<NewCustomerPage />} />
@@ -128,10 +130,7 @@ const App = () => {
<Route path="/trains" element={<TrainsPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/documents" element={<DocumentsPage />} />
<Route
path="/admin/dropdowns"
element={<DropdownSettingsPage />}
/>
<Route path="/admin/dropdowns" element={<DropdownSettingsPage />} />
<Route
path="/admin/file-uploads"
element={<FileUploadSettingsPage />}

View File

@@ -15,7 +15,7 @@ import {
import Breadcrumbs from "@/components/Breadcrumbs";
import DeleteBookingDialog from "./DeleteBookingDialog";
import { getBookingById, type BookingStatus } from "./bookings.mock";
import { getBookingById, deleteBooking, type BookingStatus } from "./bookings.mock";
import { Button, Card } from "@edr/ui-common";
export default function BookingDetailPage() {
@@ -88,7 +88,10 @@ export default function BookingDetailPage() {
<DeleteBookingDialog
bookingReference={booking.reference}
onConfirm={() => navigate("/bookings")}
onConfirm={() => {
deleteBooking(booking.id);
navigate("/bookings");
}}
>
<Button variant="outline">
<Trash2 />

View File

@@ -0,0 +1,333 @@
import { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import {
ArrowRight,
Clock,
Eye,
Filter,
MoreHorizontal,
Package,
Plus,
Search,
Trash2,
Truck,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import DeleteBookingDialog from "./DeleteBookingDialog";
import { getMyBookings } from "@/lib/currentCustomer";
import { deleteBooking, type Booking, type BookingStatus } from "./bookings.mock";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@edr/ui-common";
export default function MyBookings() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [searchTerm, setSearchTerm] = useState("");
const [myBookings, setMyBookings] = useState(() => getMyBookings());
const handleDeleteConfirm = (id: number) => {
deleteBooking(id);
setMyBookings(getMyBookings());
};
const filteredData = useMemo(() => {
return myBookings.filter((b) => {
const term = searchTerm.toLowerCase();
return (
b.reference.toLowerCase().includes(term) ||
b.originStation.toLowerCase().includes(term) ||
b.destinationStation.toLowerCase().includes(term) ||
b.cargoDescription.toLowerCase().includes(term) ||
b.status.toLowerCase().includes(term)
);
});
}, [myBookings, searchTerm]);
const total = filteredData.length;
const pageCount = Math.ceil(total / pagination.pageSize);
const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(() => filteredData.slice(start, end), [filteredData, start, end]);
const activeCount = useMemo(() => {
return myBookings.filter(
(b) => b.status === "Confirmed" || b.status === "In Transit",
).length;
}, [myBookings]);
const pendingCount = useMemo(() => {
return myBookings.filter((b) => b.status === "Pending").length;
}, [myBookings]);
const columns: ColumnDef<Booking>[] = [
{
accessorKey: "reference",
header: "Reference",
cell: ({ row }) => {
const booking = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Package className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-slate-900">{booking.reference}</p>
<p className="text-sm text-slate-500">{booking.requestedDate}</p>
</div>
</div>
);
},
},
{
id: "route",
header: "Route",
cell: ({ row }) => (
<div className="flex items-center gap-2 text-sm text-slate-700">
<span>{row.original.originStation}</span>
<ArrowRight className="text-slate-400" />
<span>{row.original.destinationStation}</span>
</div>
),
},
{
id: "cargo",
header: "Cargo",
cell: ({ row }) => {
const b = row.original;
return (
<div className="text-sm text-slate-700">
<p>{b.cargoType}</p>
<p className="text-xs text-slate-500">
{b.containerCount > 0 ? `${b.containerCount} × ${b.containerType} · ` : ""}{b.weightTons}t
</p>
</div>
);
},
},
{
accessorKey: "transportMode",
header: "Transport",
cell: ({ row }) => (
<span className="text-sm text-slate-700">
{row.original.transportMode}
</span>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "actions",
size: 40,
cell: ({ row }) => {
const booking = row.original;
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => navigate(`/bookings/${booking.id}`)}
>
<Eye />
View
</DropdownMenuItem>
<DropdownMenuSeparator />
<DeleteBookingDialog
bookingReference={booking.reference}
onConfirm={() => handleDeleteConfirm(booking.id)}
>
<DropdownMenuItem
onSelect={(e: Event) => e.preventDefault()}
variant="destructive"
>
<Trash2 />
Delete
</DropdownMenuItem>
</DeleteBookingDialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "My Bookings" }]} />
{/* Header Section Card */}
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
My Bookings
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
View and manage your freight booking requests.
</p>
</div>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type="search"
placeholder="Search bookings..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8!"
/>
</div>
<Link to="/bookings/new">
<Button>
<Plus />
New Booking
</Button>
</Link>
</div>
</Card>
{/* Stat Cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Total Bookings</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{myBookings.length}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Package />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Active Bookings</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{activeCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Truck />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Pending Approval</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{pendingCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Clock />
</div>
</CardContent>
</Card>
</div>
{/* Data Table */}
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>Recent Requests</CardTitle>
<CardDescription>
A list of your recent freight bookings and their statuses.
</CardDescription>
</div>
<Button variant="secondary" size="sm">
<Filter />
Filter
</Button>
</CardHeader>
<CardContent className="px-0">
{total === 0 ? (
<div className="flex flex-col items-center justify-center py-12 px-6 text-center">
<Package className="h-12 w-12 text-slate-300 mb-4" />
<h3 className="text-sm font-semibold text-slate-900">No bookings found</h3>
<p className="text-xs text-slate-500 mt-1 max-w-sm">
{searchTerm ? "No bookings match your current search filter." : "You haven't requested any bookings yet."}
</p>
</div>
) : (
<DataTable
columns={columns}
data={paginatedData}
status="success"
onRowClick={(row) => navigate(`/bookings/${(row as Booking).id}`)}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount: pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-b shadow-none"
footer={DataTableFooter}
/>
)}
</CardContent>
</Card>
</div>
</div>
);
}
function StatusBadge({ status }: { status: BookingStatus }) {
const styles: Record<BookingStatus, string> = {
Pending: "bg-amber-100 text-amber-700",
Confirmed: "bg-sky-100 text-sky-700",
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Cancelled: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -5,6 +5,10 @@ import { useNavigate } from "react-router-dom";
import { CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@edr/ui-common";
import Breadcrumbs from "@/components/Breadcrumbs";
import { addBooking } from "./bookings.mock";
import { getCurrentCustomer } from "@/lib/currentCustomer";
import { api } from "@/services/api";
import type { CreateBookingPayload } from "@/services/bookings.service";
import {
MOCK_VALID_CONTRACTS,
STEPS,
@@ -35,8 +39,8 @@ export default function NewBookingPage() {
const [submitted, setSubmitted] = useState(false);
const form = useForm<BookingFormValues>({
resolver: zodResolver(bookingFormSchema),
defaultValues: initialBookingFormValues,
resolver: zodResolver(bookingFormSchema),
mode: "onChange",
});
@@ -119,6 +123,123 @@ export default function NewBookingPage() {
return;
}
const me = getCurrentCustomer();
const reference =
data.draftContractId ||
data.previousContractRef ||
`EDR-DRAFT-${Date.now()}`;
const qtyCount =
data.cargoType === "container"
? data.containers.reduce((acc, c) => acc + Number(c.qty || 0), 0)
: 1;
const totalWeight =
data.cargoType === "container"
? data.containers.reduce(
(acc, c) => acc + Number(c.vgm || 0) * Number(c.qty || 0),
0,
)
: Number(data.cargoWeight || 0);
const description =
data.cargoType === "container"
? data.containers.map((c) => `${c.qty} × ${c.type}`).join(", ")
: data.freightType === "bulk"
? `Bulk - ${data.bulkCommodity === "Others" ? data.bulkCommodityOther : data.bulkCommodity}`
: `Break-Bulk - ${data.breakBulkType === "Others" ? data.breakBulkTypeOther : data.breakBulkType}`;
const newBooking = {
id: Date.now(),
reference,
customerId: me.id,
customer: me.company,
cargoType: (data.cargoType === "container"
? "Containerized"
: "Bulk") as any,
originStation: data.originYard,
destinationStation: data.destinationYard,
transportMode: (data.serviceType === "rail"
? "Rail"
: "Multimodal") as any,
containerType: (data.cargoType === "container" &&
data.containers[0]?.type === "40ft"
? "40FT"
: "20FT") as any,
containerCount: qtyCount,
weightTons: totalWeight,
requestedDate: new Date().toISOString().slice(0, 10),
priority: (data.isHazardous ? "High" : "Normal") as any,
cargoDescription: description,
specialInstructions: data.notes || "Standard handling required",
status: "Pending" as any,
};
addBooking(newBooking);
// Call API using api.bookings.create.call
const apiPayload = {
reference,
customerId: String(me.id),
scheduledDate: new Date().toISOString().slice(0, 10),
totalAmount: 0,
contractType: data.contractType.toUpperCase(),
previousContractId: data.previousContractRef || undefined,
serviceType:
data.serviceType === "rail" ? "RAIL_ONLY" : "RAIL_AND_FORWARDING",
firstMileEnabled: data.firstMileEnabled,
firstMilePickupAddress: data.firstMileEnabled
? data.pickUpAddress
: undefined,
lastMileEnabled: data.lastMileEnabled,
lastMileDeliveryAddress: data.lastMileEnabled
? data.deliveryAddress
: undefined,
equipmentReturn:
data.equipmentReturn === "with_return"
? "WITH_RETURN"
: "WITHOUT_RETURN",
originStation: data.originYard,
destinationStation: data.destinationYard,
cargoTotalWeightVgm: totalWeight,
freightType: data.cargoType === "container" ? "BREAK_BULK" : "BULK",
freightSubtype:
data.cargoType === "container"
? undefined
: data.freightType === "bulk"
? data.bulkCommodity
: data.breakBulkType,
isHazardous: data.isHazardous,
isRefrigerated: data.isRefrigerated,
tradeDirection:
getRouteDirection(data.originYard, data.destinationYard) === "export"
? "EXPORT"
: "IMPORT",
paymentCurrency: "USD",
allowConsolidation: data.consolidationEnabled,
...(data.cargoType === "container" && data.containers.length > 0
? {
containers: data.containers.map((c) => ({
type: c.type === "40ft" ? "40FT" as const : "20FT" as const,
qty: Number(c.qty || 1),
vgm: Number(c.vgm || 0),
})),
}
: {}),
};
api.bookings.create
.call(apiPayload as CreateBookingPayload)
.then((created) => {
console.log("Successfully created booking via API:", created);
})
.catch((err) => {
console.warn(
"API call failed (expected if API server is offline), falling back to mock storage:",
err,
);
});
setSubmitted(true);
setTimeout(() => navigate("/bookings"), 2500);
}

View File

@@ -83,7 +83,7 @@ function pickStation(i: number, offset: number) {
return stations[(i + offset) % stations.length] as string;
}
export const bookings: Booking[] = Array.from({ length: 22 }, (_, i) => {
const INITIAL_BOOKINGS: Booking[] = Array.from({ length: 22 }, (_, i) => {
const customer = customers[i % customers.length] as (typeof customers)[number];
const id = i + 1;
const requested = new Date(2026, 4, 1 + (i % 28));
@@ -125,6 +125,43 @@ export const bookings: Booking[] = Array.from({ length: 22 }, (_, i) => {
};
});
const getStoredBookings = (): Booking[] => {
if (typeof window === "undefined" || !window.localStorage) {
return INITIAL_BOOKINGS;
}
const data = localStorage.getItem("edr_bookings");
if (!data) {
localStorage.setItem("edr_bookings", JSON.stringify(INITIAL_BOOKINGS));
return INITIAL_BOOKINGS;
}
try {
return JSON.parse(data);
} catch (e) {
return INITIAL_BOOKINGS;
}
};
export const bookings: Booking[] = getStoredBookings();
export function saveBookingsToStorage() {
if (typeof window !== "undefined" && window.localStorage) {
localStorage.setItem("edr_bookings", JSON.stringify(bookings));
}
}
export function addBooking(booking: Booking) {
bookings.unshift(booking);
saveBookingsToStorage();
}
export function deleteBooking(id: number) {
const index = bookings.findIndex((b) => b.id === id);
if (index !== -1) {
bookings.splice(index, 1);
saveBookingsToStorage();
}
}
export function getBookingById(id: number | string): Booking | undefined {
const numericId = typeof id === "string" ? Number(id) : id;
return bookings.find((b) => b.id === numericId);

View File

@@ -46,7 +46,7 @@ export const REQUIRED_DOC_KEYS = [
"tin_certificate",
"business_license",
"business_registration",
"national_id",
// "national_id",
] as const;
export const STEPS = [
@@ -162,7 +162,7 @@ export const bookingFormSchema = z
destinationYard: z.string(),
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
cargoWeight: z.string(),
freightType: z.enum(["bulk", "break_bulk"]),
freightType: z.enum(["bulk", "break_bulk", ""]).default(""),
bulkCommodity: z.string(),
bulkCommodityOther: z.string(),
breakBulkType: z.string(),
@@ -480,4 +480,4 @@ export function calcWagons(containers: ContainerConfig[]): WagonCalcResult {
ft20Wagons: Ft20Wagons,
wagonLayout,
};
}
}

View File

@@ -8,7 +8,7 @@ import type {
UpdateFileUploadFieldDto,
UpdateFileUploadSettingDto,
} from "@/types/fileUploadSettings";
import { bookingsService } from "./bookings.service";
import { bookingsService, CreateBookingPayload } from "./bookings.service";
import { consignmentsService } from "./consignments.service";
import { trackingService } from "./tracking.service";
import { fileUploadSettingsService } from "./fileUploadSettings.service";
@@ -40,16 +40,11 @@ export const api = {
({ id }) => bookingsService.get(id),
),
create: endpoint<
{
reference: string;
customerId: string;
scheduledDate: string;
totalAmount: number;
trainId?: string;
},
Freight.IBooking
>("bookings", "create", (input) => bookingsService.create(input)),
create: endpoint<CreateBookingPayload, Freight.IBooking>(
"bookings",
"create",
bookingsService.create,
),
remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) =>
bookingsService.remove(id),

View File

@@ -14,7 +14,16 @@ export const bookingsService = {
return data.data;
},
create: async (payload: CreateBookingPayload): Promise<Freight.IBooking> => {
const { data } = await api.post("/bookings", payload);
const fd = new FormData();
for (const [key, value] of Object.entries(payload)) {
if (value === undefined || value === null) continue;
if (Array.isArray(value) || typeof value === "object") {
fd.append(key, JSON.stringify(value));
} else {
fd.append(key, String(value));
}
}
const { data } = await api.post("/api/bookings", fd);
return data.data;
},
remove: async (id: string): Promise<void> => {

View File

@@ -1,12 +1,12 @@
import type { Freight } from "@edr/types";
import { api } from "../utils/api";
import { client } from "../utils/api";
export const trackingService = {
forConsignment: async (
consignmentId: string,
): Promise<Freight.ITrackingEvent[]> => {
const { data } = await api.get(`/tracking/${consignmentId}`);
const { data } = await client.get(`/tracking/${consignmentId}`);
return data.data;
},
};

View File

@@ -155,4 +155,14 @@ export interface CreateBookingDto {
tradeDirection: "IMPORT" | "EXPORT";
paymentCurrency: string;
allowConsolidation?: boolean;
startDate?: string;
endDate?: string;
financialTerms?: string;
containers?: Array<{
type: "20FT" | "40FT";
qty: number;
vgm: number;
}>;
}