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 { useQuery } from "@tanstack/react-query";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Group,
Menu,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import {
ArrowRight,
Clock,
Eye,
Filter,
MoreHorizontal,
Package,
Plus,
Search,
Truck,
} from "lucide-react";
import { ArrowUpDown, Download, Filter, MoreVertical, Package, Plus } from "lucide-react";
import { api } from "@/services/api";
import type { Freight } from "@edr/types";
@@ -37,63 +24,188 @@ import {
usePagination,
} 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() {
const navigate = useNavigate();
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 filteredData = useMemo(() => {
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 total = bookings.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 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 paginatedData = useMemo(() => bookings.slice(start, end), [bookings, start, end]);
const columns: ColumnDef<Freight.IBooking>[] = [
{
accessorKey: "reference",
header: "Reference",
id: "booking",
size: 244,
meta: hMeta,
header: () => <ColHeader label="Booking" />,
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 (
<Group gap="sm" wrap="nowrap">
<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">
<Package className="h-5 w-5" />
<Group gap={12} wrap="nowrap" align="center">
<Box
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>
<Text fw={600} size="sm">
{booking.reference}
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={700} c="edr-text" truncate>
{b.reference}
</Text>
<Text size="xs" c="dimmed">
{booking.scheduledDate ?? booking.createdAt}
<Text fz={12} c="edr-muted">
{cargoLabel}
</Text>
</Box>
</Group>
@@ -102,72 +214,80 @@ export default function MyBookings() {
},
{
id: "route",
header: "Route",
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<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",
size: 196,
meta: hMeta,
header: () => <ColHeader label="Route" />,
cell: ({ row }) => {
const b = row.original;
const containerCount = b.containers?.reduce((sum, c) => sum + c.qty, 0) ?? 0;
const containerType = b.containers?.[0]?.type ?? null;
const origin = b.originYard?.label ?? b.originYard?.code ?? "—";
const dest = b.destinationYard?.label ?? b.destinationYard?.code ?? "—";
const sub = b.scheduledDate ?? b.createdAt ?? "";
return (
<Box>
<Text size="sm" fw={500}>
{b.freightType === "BULK" ? "Bulk" : "Break Bulk"}
</Text>
<Text size="xs" c="dimmed">
{containerType && containerCount > 0 ? `${containerCount} × ${containerType} · ` : ""}
{b.cargoTotalWeightVgm}t
<Text fz={13} fw={600} c="edr-text">
{origin} {dest}
</Text>
{sub && (
<Text fz={12} c="edr-muted">
{sub}
</Text>
)}
</Box>
);
},
},
{
id: "transportMode",
header: "Transport",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{row.original.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail"}
</Text>
),
},
{
accessorKey: "status",
header: "Status",
id: "status",
size: 190,
meta: hMeta,
header: () => <ColHeader label="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",
size: 40,
meta: hMeta,
header: () => null,
cell: ({ row }) => {
const booking = row.original;
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.Target>
<ActionIcon variant="default" radius="md" aria-label="Row actions">
<MoreHorizontal size={16} />
<ActionIcon
variant="transparent"
size={30}
radius="md"
aria-label="More options"
>
<MoreVertical size={16} color="#9AA8B5" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<Eye size={15} />}
onClick={() => navigate(`/bookings/${booking.id}`)}
>
View
<Menu.Item onClick={() => navigate(`/bookings/${booking.id}`)}>
View Details
</Menu.Item>
</Menu.Dropdown>
</Menu>
@@ -180,111 +300,85 @@ export default function MyBookings() {
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
return (
<Box className="min-h-screen bg-gray-50/50 p-6">
<Stack gap="lg" maw={1280} mx="auto">
{/* ── Header band ─────────────────────────────────────────── */}
<Card
radius="lg"
withBorder
className="relative overflow-hidden border-emerald-100! bg-gradient-to-br from-emerald-50 via-white to-white"
>
<Box className="pointer-events-none absolute -right-10 -top-16 h-48 w-48 rounded-full bg-emerald-400/10 blur-2xl" />
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md" className="relative">
<Box>
<Title order={1} className="tracking-tight">
My Bookings
</Title>
<Text size="sm" c="dimmed" mt={4}>
View and manage your freight booking requests.
</Text>
</Box>
<Group gap="sm" wrap="wrap">
<TextInput
placeholder="Search bookings..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.currentTarget.value)}
leftSection={<Search size={16} />}
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>
<Box style={{ padding: "28px 32px 32px" }}>
<Stack gap="lg">
{/* ── Page header ─────────────────────────────────────────────── */}
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
<Box>
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
Bookings
</Title>
<Text size="sm" c="edr-muted" mt={4}>
Manage every cargo booking from draft to delivery.
</Text>
</Box>
<Group gap={12}>
<Button variant="default" radius="md" leftSection={<Download size={16} />}>
Export
</Button>
<Button
component={Link}
to="/bookings/new"
color="edr-green"
radius="md"
leftSection={<Plus size={16} />}
>
New Booking
</Button>
</Group>
</Card>
</Group>
{/* ── Stat cards ──────────────────────────────────────────── */}
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="md">
<StatCard
label="Total Bookings"
value={bookings.length}
icon={<Package className="h-6 w-6" />}
gradient="from-emerald-500 to-emerald-700 shadow-emerald-500/30"
/>
<StatCard
label="Active Bookings"
value={activeCount}
icon={<Truck className="h-6 w-6" />}
gradient="from-sky-500 to-blue-600 shadow-sky-500/30"
/>
<StatCard
label="Pending Approval"
value={pendingCount}
icon={<Clock className="h-6 w-6" />}
gradient="from-amber-400 to-orange-500 shadow-amber-500/30"
/>
</SimpleGrid>
{/* ── Table ───────────────────────────────────────────────── */}
<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} />}>
{/* ── Bookings table card ──────────────────────────────────────── */}
<Card p={0} style={{ overflow: "hidden" }}>
{/* Toolbar */}
<Group
justify="flex-end"
gap={8}
px={20}
py={14}
style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Button
variant="default"
size="sm"
radius="md"
leftSection={<ArrowUpDown size={14} />}
>
Sort
</Button>
<Button
variant="default"
size="sm"
radius="md"
leftSection={<Filter size={14} />}
>
Filter
</Button>
</Group>
{/* Empty state */}
{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">
<Package size={28} />
</ThemeIcon>
<Text size="sm" fw={600}>
No bookings found
<Text size="sm" fw={600} c="edr-text">
No bookings yet
</Text>
<Text size="xs" c="dimmed" maw={320}>
{searchTerm
? "No bookings match your current search filter."
: "You haven't requested any bookings yet."}
<Text size="xs" c="edr-muted" maw={320}>
You haven't made any booking requests yet. Create your first one to get started.
</Text>
{!searchTerm && (
<Button
component={Link}
to="/bookings/new"
size="sm"
color="edr-green"
radius="md"
mt="md"
leftSection={<Plus size={15} />}
>
Create your first booking
</Button>
)}
<Button
component={Link}
to="/bookings/new"
size="sm"
color="edr-green"
radius="md"
mt="md"
leftSection={<Plus size={15} />}
>
Create first booking
</Button>
</Stack>
) : (
<DataTable
@@ -295,14 +389,14 @@ export default function MyBookings() {
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount: pageCount,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-0 shadow-none"
containerClassName="border-0 shadow-none rounded-none"
footer={DataTableFooter}
/>
)}
@@ -311,60 +405,3 @@ export default function MyBookings() {
</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>
);
}