style: rebuild the my bookings page with new ui

This commit is contained in:
Nathnael
2026-06-10 10:43:48 +00:00
parent 97ed1c4b85
commit b37e0cf7a7

View File

@@ -1,32 +1,19 @@
import { useMemo, useState } from "react"; import { useMemo } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
ActionIcon, ActionIcon,
Badge,
Box, Box,
Button, Button,
Card, Card,
Group, Group,
Menu, Menu,
SimpleGrid,
Stack, Stack,
Text, Text,
TextInput,
ThemeIcon, ThemeIcon,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { import { ArrowUpDown, Download, Filter, MoreVertical, Package, Plus } from "lucide-react";
ArrowRight,
Clock,
Eye,
Filter,
MoreHorizontal,
Package,
Plus,
Search,
Truck,
} from "lucide-react";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
@@ -37,63 +24,188 @@ import {
usePagination, usePagination,
} from "@edr/ui-common"; } from "@edr/ui-common";
// ── Status badge ──────────────────────────────────────────────────────────────
const STATUS_CONFIG: Record<string, { bg: string; dot: string; color: string; label: string }> = {
DRAFT: { bg: "#F1F4F7", dot: "#94A3B8", color: "#475569", label: "Draft" },
REVIEWING: { bg: "#E9F0F8", dot: "#3B6FB0", color: "#2E5B96", label: "Reviewing" },
AWAITING_PAYMENT: { bg: "#FDF3E0", dot: "#F2A516", color: "#9A5B00", label: "Awaiting Payment" },
CONFIRMED: { bg: "#ECF6F1", dot: "#0EA371", color: "#0A6F4D", label: "Confirmed" },
IN_TRANSIT: { bg: "#ECF6F1", dot: "#0EA371", color: "#0A6F4D", label: "In Transit" },
DELIVERED: { bg: "#E9F0F8", dot: "#3B6FB0", color: "#2E5B96", label: "Delivered" },
CANCELLED: { bg: "#FBEAE7", dot: "#C0392B", color: "#C0392B", label: "Cancelled" },
};
function StatusBadge({ status }: { status: string }) {
const cfg = STATUS_CONFIG[status] ?? {
bg: "#F1F4F7",
dot: "#94A3B8",
color: "#475569",
label: status.replace(/_/g, " "),
};
return (
<Group
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: cfg.bg,
padding: "5px 11px",
}}
>
<Box
style={{
width: 6,
height: 6,
borderRadius: "50%",
backgroundColor: cfg.dot,
flexShrink: 0,
}}
/>
<Text fz={11} fw={700} style={{ color: cfg.color, whiteSpace: "nowrap" }}>
{cfg.label}
</Text>
</Group>
);
}
// ── Context-sensitive action button ───────────────────────────────────────────
function PrimaryAction({
status,
id,
onNavigate,
}: {
status: string;
id: string;
onNavigate: (path: string) => void;
}) {
if (status === "DRAFT") {
return (
<Button
size="xs"
radius="md"
fw={700}
fz={13}
style={{ backgroundColor: "var(--mantine-color-edr-ink-0)", color: "#fff" }}
onClick={() => onNavigate(`/bookings/${id}`)}
>
Continue
</Button>
);
}
if (status === "AWAITING_PAYMENT") {
return (
<Button
size="xs"
radius="md"
fw={700}
fz={13}
style={{ backgroundColor: "var(--mantine-color-edr-accent-0)", color: "#fff" }}
onClick={() => onNavigate(`/bookings/${id}`)}
>
Pay
</Button>
);
}
if (status === "IN_TRANSIT") {
return (
<Button
size="xs"
radius="md"
variant="default"
fw={600}
fz={13}
onClick={() => onNavigate(`/bookings/${id}`)}
>
Track
</Button>
);
}
return (
<Button
size="xs"
radius="md"
variant="default"
fw={600}
fz={13}
onClick={() => onNavigate(`/bookings/${id}`)}
>
View
</Button>
);
}
// ── Column header label ───────────────────────────────────────────────────────
function ColHeader({ label }: { label: string }) {
return (
<Text
fz={11}
fw={700}
c="edr-muted"
style={{ letterSpacing: "0.6px", textTransform: "uppercase", whiteSpace: "nowrap" }}
>
{label}
</Text>
);
}
const hMeta = { headerClassName: "bg-[#F4F7FA]" };
// ── Main component ────────────────────────────────────────────────────────────
export default function MyBookings() { export default function MyBookings() {
const navigate = useNavigate(); const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [searchTerm, setSearchTerm] = useState("");
const { data, isLoading, isError } = useQuery(
api.bookings.list.queryOptions(),
);
const { data, isLoading, isError } = useQuery(api.bookings.list.queryOptions());
const bookings = data?.items ?? []; const bookings = data?.items ?? [];
const filteredData = useMemo(() => { const total = bookings.length;
return bookings.filter((b) => {
const term = searchTerm.toLowerCase();
return (
b.reference.toLowerCase().includes(term) ||
(b.originYard?.label ?? b.originYard?.code ?? "").toLowerCase().includes(term) ||
(b.destinationYard?.label ?? b.destinationYard?.code ?? "").toLowerCase().includes(term) ||
b.status.toLowerCase().includes(term)
);
});
}, [bookings, searchTerm]);
const total = filteredData.length;
const pageCount = Math.ceil(total / pagination.pageSize); const pageCount = Math.ceil(total / pagination.pageSize);
const start = pagination.pageIndex * pagination.pageSize; const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total); const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(() => bookings.slice(start, end), [bookings, start, end]);
const paginatedData = useMemo(() => filteredData.slice(start, end), [filteredData, start, end]);
const activeCount = useMemo(() => {
return bookings.filter(
(b) => b.status === "CONFIRMED" || b.status === "IN_TRANSIT",
).length;
}, [bookings]);
const pendingCount = useMemo(() => {
return bookings.filter((b) => b.status === "DRAFT").length;
}, [bookings]);
const columns: ColumnDef<Freight.IBooking>[] = [ const columns: ColumnDef<Freight.IBooking>[] = [
{ {
accessorKey: "reference", id: "booking",
header: "Reference", size: 244,
meta: hMeta,
header: () => <ColHeader label="Booking" />,
cell: ({ row }) => { cell: ({ row }) => {
const booking = row.original; const b = row.original;
const cargoLabel =
b.freightType === "BULK"
? "Bulk Cargo"
: b.freightType === "BREAK_BULK"
? "Break Bulk"
: "Cargo";
return ( return (
<Group gap="sm" wrap="nowrap"> <Group gap={12} wrap="nowrap" align="center">
<Box className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-700 text-white shadow-sm shadow-emerald-500/30"> <Box
<Package className="h-5 w-5" /> style={{
width: 36,
height: 36,
borderRadius: 9,
flexShrink: 0,
backgroundColor: "var(--mantine-color-edr-soft-0)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Package size={18} color="var(--mantine-color-edr-green-7)" strokeWidth={2} />
</Box> </Box>
<Box> <Box style={{ minWidth: 0 }}>
<Text fw={600} size="sm"> <Text fz={14} fw={700} c="edr-text" truncate>
{booking.reference} {b.reference}
</Text> </Text>
<Text size="xs" c="dimmed"> <Text fz={12} c="edr-muted">
{booking.scheduledDate ?? booking.createdAt} {cargoLabel}
</Text> </Text>
</Box> </Box>
</Group> </Group>
@@ -102,72 +214,80 @@ export default function MyBookings() {
}, },
{ {
id: "route", id: "route",
header: "Route", size: 196,
cell: ({ row }) => ( meta: hMeta,
<Group gap={6} wrap="nowrap"> header: () => <ColHeader label="Route" />,
<Text size="sm" fw={500}>
{row.original.originYard?.label ?? row.original.originYard?.code ?? "—"}
</Text>
<ArrowRight className="h-4 w-4 text-[var(--mantine-color-gray-4)]" />
<Text size="sm" fw={500}>
{row.original.destinationYard?.label ?? row.original.destinationYard?.code ?? "—"}
</Text>
</Group>
),
},
{
id: "cargo",
header: "Cargo",
cell: ({ row }) => { cell: ({ row }) => {
const b = row.original; const b = row.original;
const containerCount = b.containers?.reduce((sum, c) => sum + c.qty, 0) ?? 0; const origin = b.originYard?.label ?? b.originYard?.code ?? "—";
const containerType = b.containers?.[0]?.type ?? null; const dest = b.destinationYard?.label ?? b.destinationYard?.code ?? "—";
const sub = b.scheduledDate ?? b.createdAt ?? "";
return ( return (
<Box> <Box>
<Text size="sm" fw={500}> <Text fz={13} fw={600} c="edr-text">
{b.freightType === "BULK" ? "Bulk" : "Break Bulk"} {origin} {dest}
</Text>
<Text size="xs" c="dimmed">
{containerType && containerCount > 0 ? `${containerCount} × ${containerType} · ` : ""}
{b.cargoTotalWeightVgm}t
</Text> </Text>
{sub && (
<Text fz={12} c="edr-muted">
{sub}
</Text>
)}
</Box> </Box>
); );
}, },
}, },
{ {
id: "transportMode", id: "status",
header: "Transport", size: 190,
cell: ({ row }) => ( meta: hMeta,
<Text size="sm" c="dimmed"> header: () => <ColHeader label="Status" />,
{row.original.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail"}
</Text>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />, cell: ({ row }) => <StatusBadge status={row.original.status} />,
}, },
{
id: "amount",
size: 140,
meta: hMeta,
header: () => <ColHeader label="Amount" />,
cell: ({ row }) => {
const b = row.original as Freight.IBooking & { totalAmount?: number; amount?: number };
const amount = b.totalAmount ?? b.amount ?? null;
if (!amount) {
return (
<Text fz={14} fw={700} style={{ color: "#94A3B8" }}>
</Text>
);
}
return (
<Text fz={14} fw={700} c="edr-text">
ETB {amount.toLocaleString()}
</Text>
);
},
},
{ {
id: "actions", id: "actions",
size: 40, meta: hMeta,
header: () => null,
cell: ({ row }) => { cell: ({ row }) => {
const booking = row.original; const booking = row.original;
return ( return (
<Group justify="flex-end" onClick={(e) => e.stopPropagation()}> <Group justify="flex-end" gap={8} wrap="nowrap" onClick={(e) => e.stopPropagation()}>
<PrimaryAction status={booking.status} id={booking.id} onNavigate={navigate} />
<Menu position="bottom-end" withinPortal shadow="md" radius="md"> <Menu position="bottom-end" withinPortal shadow="md" radius="md">
<Menu.Target> <Menu.Target>
<ActionIcon variant="default" radius="md" aria-label="Row actions"> <ActionIcon
<MoreHorizontal size={16} /> variant="transparent"
size={30}
radius="md"
aria-label="More options"
>
<MoreVertical size={16} color="#9AA8B5" />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item <Menu.Item onClick={() => navigate(`/bookings/${booking.id}`)}>
leftSection={<Eye size={15} />} View Details
onClick={() => navigate(`/bookings/${booking.id}`)}
>
View
</Menu.Item> </Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
@@ -180,111 +300,85 @@ export default function MyBookings() {
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success"; const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
return ( return (
<Box className="min-h-screen bg-gray-50/50 p-6"> <Box style={{ padding: "28px 32px 32px" }}>
<Stack gap="lg" maw={1280} mx="auto"> <Stack gap="lg">
{/* ── Header band ─────────────────────────────────────────── */} {/* ── Page header ─────────────────────────────────────────────── */}
<Card <Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
radius="lg" <Box>
withBorder <Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
className="relative overflow-hidden border-emerald-100! bg-gradient-to-br from-emerald-50 via-white to-white" Bookings
> </Title>
<Box className="pointer-events-none absolute -right-10 -top-16 h-48 w-48 rounded-full bg-emerald-400/10 blur-2xl" /> <Text size="sm" c="edr-muted" mt={4}>
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md" className="relative"> Manage every cargo booking from draft to delivery.
<Box> </Text>
<Title order={1} className="tracking-tight"> </Box>
My Bookings <Group gap={12}>
</Title> <Button variant="default" radius="md" leftSection={<Download size={16} />}>
<Text size="sm" c="dimmed" mt={4}> Export
View and manage your freight booking requests. </Button>
</Text> <Button
</Box> component={Link}
to="/bookings/new"
<Group gap="sm" wrap="wrap"> color="edr-green"
<TextInput radius="md"
placeholder="Search bookings..." leftSection={<Plus size={16} />}
value={searchTerm} >
onChange={(e) => setSearchTerm(e.currentTarget.value)} New Booking
leftSection={<Search size={16} />} </Button>
radius="md"
className="w-full sm:w-80"
styles={{ input: { background: "white" } }}
/>
<Button
component={Link}
to="/bookings/new"
color="edr-green"
radius="md"
leftSection={<Plus size={16} />}
className="shadow-sm shadow-emerald-500/30"
>
New Booking
</Button>
</Group>
</Group> </Group>
</Card> </Group>
{/* ── Stat cards ──────────────────────────────────────────── */} {/* ── Bookings table card ──────────────────────────────────────── */}
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="md"> <Card p={0} style={{ overflow: "hidden" }}>
<StatCard {/* Toolbar */}
label="Total Bookings" <Group
value={bookings.length} justify="flex-end"
icon={<Package className="h-6 w-6" />} gap={8}
gradient="from-emerald-500 to-emerald-700 shadow-emerald-500/30" px={20}
/> py={14}
<StatCard style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}
label="Active Bookings" >
value={activeCount} <Button
icon={<Truck className="h-6 w-6" />} variant="default"
gradient="from-sky-500 to-blue-600 shadow-sky-500/30" size="sm"
/> radius="md"
<StatCard leftSection={<ArrowUpDown size={14} />}
label="Pending Approval" >
value={pendingCount} Sort
icon={<Clock className="h-6 w-6" />} </Button>
gradient="from-amber-400 to-orange-500 shadow-amber-500/30" <Button
/> variant="default"
</SimpleGrid> size="sm"
radius="md"
{/* ── Table ───────────────────────────────────────────────── */} leftSection={<Filter size={14} />}
<Card radius="lg" withBorder p={0} className="overflow-hidden"> >
<Group justify="space-between" align="flex-start" p="lg" className="border-b border-[var(--mantine-color-gray-2)] bg-gray-50/60">
<Box>
<Title order={4}>Recent Requests</Title>
<Text size="sm" c="dimmed">
A list of your recent freight bookings and their statuses.
</Text>
</Box>
<Button variant="default" size="sm" radius="md" leftSection={<Filter size={15} />}>
Filter Filter
</Button> </Button>
</Group> </Group>
{/* Empty state */}
{total === 0 && dataTableStatus === "success" ? ( {total === 0 && dataTableStatus === "success" ? (
<Stack align="center" gap={4} px="lg" py={64} className="text-center"> <Stack align="center" gap={4} px="lg" py={64} ta="center">
<ThemeIcon size={56} radius="lg" color="edr-green" variant="light" mb="xs"> <ThemeIcon size={56} radius="lg" color="edr-green" variant="light" mb="xs">
<Package size={28} /> <Package size={28} />
</ThemeIcon> </ThemeIcon>
<Text size="sm" fw={600}> <Text size="sm" fw={600} c="edr-text">
No bookings found No bookings yet
</Text> </Text>
<Text size="xs" c="dimmed" maw={320}> <Text size="xs" c="edr-muted" maw={320}>
{searchTerm You haven't made any booking requests yet. Create your first one to get started.
? "No bookings match your current search filter."
: "You haven't requested any bookings yet."}
</Text> </Text>
{!searchTerm && ( <Button
<Button component={Link}
component={Link} to="/bookings/new"
to="/bookings/new" size="sm"
size="sm" color="edr-green"
color="edr-green" radius="md"
radius="md" mt="md"
mt="md" leftSection={<Plus size={15} />}
leftSection={<Plus size={15} />} >
> Create first booking
Create your first booking </Button>
</Button>
)}
</Stack> </Stack>
) : ( ) : (
<DataTable <DataTable
@@ -295,14 +389,14 @@ export default function MyBookings() {
pagination={{ pagination={{
pageIndex: pagination.pageIndex, pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
pageCount: pageCount, pageCount,
totalCount: total, totalCount: total,
}} }}
tableOptions={{ tableOptions={{
state: { pagination }, state: { pagination },
onPaginationChange: setPagination, onPaginationChange: setPagination,
}} }}
containerClassName="border-0 shadow-none" containerClassName="border-0 shadow-none rounded-none"
footer={DataTableFooter} footer={DataTableFooter}
/> />
)} )}
@@ -311,60 +405,3 @@ export default function MyBookings() {
</Box> </Box>
); );
} }
function StatCard({
label,
value,
icon,
gradient,
}: {
label: string;
value: number;
icon: React.ReactNode;
gradient: string;
}) {
return (
<Card
radius="lg"
withBorder
className="group transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"
>
<Group justify="space-between" align="center" wrap="nowrap">
<Box>
<Text size="sm" fw={500} c="dimmed">
{label}
</Text>
<Text fz={30} fw={700} mt={6} className="tracking-tight">
{value}
</Text>
</Box>
<Box
className={`flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br text-white shadow-md transition-transform duration-200 group-hover:scale-105 ${gradient}`}
>
{icon}
</Box>
</Group>
</Card>
);
}
function StatusBadge({ status }: { status: string }) {
const colorMap: Record<string, string> = {
DRAFT: "amber",
CONFIRMED: "edr-green",
IN_TRANSIT: "blue",
DELIVERED: "edr-green",
CANCELLED: "red",
};
return (
<Badge
variant="light"
color={colorMap[status] ?? "gray"}
radius="sm"
size="sm"
>
{status.replace(/_/g, " ")}
</Badge>
);
}