Implement user-based booking access control and enhance booking filtering options

This commit is contained in:
Marshal
2026-06-17 12:35:29 +00:00
parent 31710494d4
commit cd31f3612c
9 changed files with 530 additions and 137 deletions

View File

@@ -11,6 +11,7 @@ import {
Query,
Request,
Res,
UnauthorizedException,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
@@ -117,8 +118,22 @@ export class BookingsController {
@Get()
@ApiOperation({ summary: 'List freight bookings (paginated)' })
findAll(@Query() filter: FilterBookingDto) {
return this.bookingsService.findAll(filter);
async findAll(
@Query() filter: FilterBookingDto,
@CurrentUser() user: TCurrentUser,
) {
// Staff (backoffice) see every booking. Customers (portal) are always
// force-scoped to their own company, regardless of any companyId they pass.
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
return this.bookingsService.findAll(filter);
}
const userId = user?.id;
if (!userId) throw new UnauthorizedException('Authentication required');
const companyId =
await this.bookingsService.resolveCustomerCompanyId(userId);
// No linked company yet → no bookings to show (avoids leaking all bookings).
if (!companyId) return { items: [], total: 0 };
return this.bookingsService.findAll(filter, companyId);
}
@Get('list-summary')
@@ -166,15 +181,35 @@ export class BookingsController {
@Get('by-reference/:reference')
@ApiOperation({ summary: 'Get booking by reference' })
async findByReference(@Param('reference') reference: string) {
async findByReference(
@Param('reference') reference: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findByReference(reference);
// Staff see any booking; customers only their own company's.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id')
@ApiOperation({ summary: 'Get booking by ID' })
async findOne(@Param('id', ParseUUIDPipe) id: string) {
async findOne(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
// Staff see any booking; customers only their own company's.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.transitionService.enrichBookingResponse(booking);
}

View File

@@ -1,6 +1,7 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -575,6 +576,7 @@ export class BookingsService {
/** Return a paginated list of bookings matching the filter. */
async findAll(
filter: FilterBookingDto,
forceCompanyId?: string,
): Promise<{ items: Booking[]; total: number }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
@@ -587,7 +589,9 @@ export class BookingsService {
...statusFilter,
...schedulingStatusFilter,
assignedToSchedule: filter.assignedToSchedule,
companyId: filter.companyId,
// A forced company scope (portal/customer) overrides any caller-provided
// companyId so a customer can only ever see their own company's bookings.
companyId: forceCompanyId ?? filter.companyId,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
@@ -631,6 +635,40 @@ export class BookingsService {
});
}
/**
* Resolve the company a customer user belongs to, for scoping their own
* bookings. Returns null when no profile/company is linked yet.
*/
async resolveCustomerCompanyId(userId: string): Promise<string | null> {
try {
const { company } =
await this.companiesService.getCompanyInfoByUserId(userId);
return company?.id ?? null;
} catch {
return null;
}
}
/**
* Authorize a customer's access to a single booking. Staff are scoped at the
* controller (they pass `isStaff`); for a customer, the booking must belong
* to the company the authenticated user is linked to — otherwise it is hidden
* behind a NotFound so booking IDs can't be probed.
*/
async assertCustomerCanAccessBooking(
userId: string | undefined,
booking: Booking,
): Promise<void> {
if (!userId) {
throw new ForbiddenException('Authentication required');
}
const companyId = await this.resolveCustomerCompanyId(userId);
if (!companyId || booking.companyId !== companyId) {
// Don't reveal that the booking exists for another company.
throw new NotFoundException(`Booking ${booking.id} not found`);
}
}
/** Aggregate metrics and tab counts for the backoffice booking list. */
async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> {
const page = filter.page ?? 1;

View File

@@ -1,3 +1,3 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
// export const API_BASE_URL = 'http://localhost:3001';
export const API_BASE_URL = 'http://localhost:3001';

View File

@@ -1,3 +1,3 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
// export const API_BASE_URL = 'http://localhost:3001';
// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
export const API_BASE_URL = 'http://localhost:3001';

View File

@@ -69,11 +69,18 @@ export function PaymentDeadlineCard({
return () => clearInterval(interval);
}, [deadlineMs]);
const accentBg = remaining.expired ? "#FBEAE7" : "#FDF3E0";
const accentFg = remaining.expired ? "#C0392B" : "#9A5B00";
const accentBg = remaining.expired ? "#FBEAE7" : "#FEF6E6";
const accentFg = remaining.expired ? "#C0392B" : "#B07D14";
return (
<SectionCard p={22}>
<SectionCard
p={22}
style={
remaining.expired
? undefined
: { borderColor: "#F2E4C4", boxShadow: "0 0 0 1px #FBEAC2" }
}
>
<Group justify="space-between" align="center">
<CardTitle>Payment deadline</CardTitle>
<Group

View File

@@ -1,12 +1,92 @@
import { Box, Group, Text } from "@mantine/core";
import { AlertTriangle, Check, FileText, History } from "lucide-react";
import {
AlertTriangle,
Check,
FileText,
History,
MapPin,
MoveRight,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { PROGRESS_STAGES, STATUS_MAP } from "../constants";
import { fmtDate, isDraftLike, isNegative } from "../utils";
import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils";
import { SectionCard } from "./layout";
const ACCENT = "#F2A516";
/** Origin → destination strip rendered above the progress tracker. */
function RouteStrip({ booking }: { booking: Freight.IBooking }) {
const origin = yardLabel(booking.originYard);
const destination = yardLabel(booking.destinationYard);
return (
<Box
mb={22}
px={18}
py={14}
className="rounded-2xl"
style={{
background:
"linear-gradient(135deg, #FEF8EC 0%, #FBFCFD 60%, #F4FAF7 100%)",
border: "1px solid #F2E4C4",
}}
>
<Group justify="space-between" align="center" wrap="nowrap" gap="md">
<RouteEndpoint label="Origin" value={origin} />
<Box
className="flex items-center justify-center rounded-full shrink-0"
style={{
width: 34,
height: 34,
backgroundColor: "#fff",
border: `1px solid ${ACCENT}33`,
color: ACCENT,
}}
>
<MoveRight size={18} />
</Box>
<RouteEndpoint label="Destination" value={destination} alignRight />
</Group>
</Box>
);
}
function RouteEndpoint({
label,
value,
alignRight,
}: {
label: string;
value: string;
alignRight?: boolean;
}) {
return (
<Box miw={0} style={{ textAlign: alignRight ? "right" : "left", flex: 1 }}>
<Group
gap={5}
align="center"
wrap="nowrap"
justify={alignRight ? "flex-end" : "flex-start"}
>
<MapPin size={12} color={ACCENT} />
<Text
fz="10.5px"
fw={700}
c="#B07D14"
tt="uppercase"
className="tracking-[0.6px]"
>
{label}
</Text>
</Group>
<Text mt={3} fz="15px" fw={800} c="#10202F" truncate>
{value}
</Text>
</Box>
);
}
export function StatusHero({
booking,
children,
@@ -91,6 +171,8 @@ export function StatusHero({
<Box my={26} h={1} w="100%" bg="#EEF2F6" />
{!negative && <RouteStrip booking={booking} />}
{children ?? (
<ProgressTracker
current={cfg.stage}

View File

@@ -70,11 +70,11 @@ export function EstimateCard({
mb={4}
style={{
borderRadius: 6,
backgroundColor: "#F1F4F7",
backgroundColor: "#FEF6E6",
padding: "3px 7px",
fontSize: 11,
fontWeight: 700,
color: "#6B7C8E",
color: "#B07D14",
}}
>
est.

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
@@ -8,14 +8,33 @@ import {
Card,
Group,
Menu,
Paper,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import { ArrowUpDown, Download, Filter, MoreVertical, Package, Plus } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import {
ArrowRight,
CheckCircle2,
CreditCard,
FileEdit,
LayoutList,
MoreVertical,
Package,
Plus,
Search,
Wallet,
X,
} from "lucide-react";
import { api } from "@/services/api";
import type { BookingListFilter } from "@/services/bookings.service";
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
import type { Freight } from "@edr/types";
import {
DataTable,
@@ -24,25 +43,86 @@ import {
usePagination,
} from "@edr/ui-common";
// ── Status badge ──────────────────────────────────────────────────────────────
// ── Status filter options (grouped by lifecycle) ──────────────────────────────
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" },
};
const STATUS_FILTERS = [
{ key: "all", label: "All bookings", statuses: undefined as string | undefined },
{
key: "active",
label: "In progress",
statuses:
"SUBMITTED,CHANGES_REQUESTED,PENDING_APPROVAL,APPROVED_PENDING_SIGNATURE,APPROVED,CONTRACT_READY,SIGNED_CUSTOMER,FULLY_EXECUTED,PENDING_CONSOLIDATION,CONSOLIDATED",
},
{ key: "draft", label: "Drafts", statuses: "DRAFT" },
{
key: "payment",
label: "Awaiting payment",
statuses:
"SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED",
},
{ key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT" },
{ key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" },
{ key: "closed", label: "Cancelled / rejected", statuses: "CANCELLED,REJECTED" },
] as const;
type StatusFilterKey = (typeof STATUS_FILTERS)[number]["key"];
const SELECT_DATA = STATUS_FILTERS.map((f) => ({ value: f.key, label: f.label }));
// ── Summary stat cards (clickable lifecycle filters) ──────────────────────────
const STAT_CARDS: Array<{
key: StatusFilterKey;
label: string;
icon: LucideIcon;
iconBg: string;
iconColor: string;
}> = [
{
key: "all",
label: "All bookings",
icon: LayoutList,
iconBg: "#ECF6F1",
iconColor: "#0A8A5F",
},
{
key: "active",
label: "In progress",
icon: Package,
iconBg: "#FDF3E0",
iconColor: "#C77F09",
},
{
key: "payment",
label: "Awaiting payment",
icon: Wallet,
iconBg: "#FEF6E6",
iconColor: "#F2A516",
},
{
key: "draft",
label: "Drafts",
icon: FileEdit,
iconBg: "#F1F4F7",
iconColor: "#475569",
},
{
key: "done",
label: "Completed",
icon: CheckCircle2,
iconBg: "#ECF6F1",
iconColor: "#0A8A5F",
},
];
// ── Status badge (reuses the shared portal status config) ─────────────────────
function StatusBadge({ status }: { status: string }) {
const cfg = STATUS_CONFIG[status] ?? {
bg: "#F1F4F7",
dot: "#94A3B8",
color: "#475569",
label: status.replace(/_/g, " "),
};
const cfg = STATUS_CONFIG[status];
const label = cfg?.badgeLabel ?? status.replace(/_/g, " ");
const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7";
const text = cfg ? `var(--mantine-color-${cfg.badgeText}-7, #475569)` : "#475569";
const dot = cfg ? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)` : "#94A3B8";
return (
<Group
gap={6}
@@ -51,7 +131,7 @@ function StatusBadge({ status }: { status: string }) {
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: cfg.bg,
backgroundColor: bg,
padding: "5px 11px",
}}
>
@@ -60,12 +140,12 @@ function StatusBadge({ status }: { status: string }) {
width: 6,
height: 6,
borderRadius: "50%",
backgroundColor: cfg.dot,
backgroundColor: dot,
flexShrink: 0,
}}
/>
<Text fz={11} fw={700} style={{ color: cfg.color, whiteSpace: "nowrap" }}>
{cfg.label}
<Text fz={11} fw={700} style={{ color: text, whiteSpace: "nowrap" }}>
{label}
</Text>
</Group>
);
@@ -82,6 +162,7 @@ function PrimaryAction({
id: string;
onNavigate: (path: string) => void;
}) {
const go = () => onNavigate(`/bookings/${id}`);
if (status === "DRAFT") {
return (
<Button
@@ -89,57 +170,36 @@ function PrimaryAction({
radius="md"
fw={700}
fz={13}
rightSection={<ArrowRight size={14} />}
style={{ backgroundColor: "var(--mantine-color-edr-ink-0)", color: "#fff" }}
onClick={() => onNavigate(`/bookings/${id}`)}
onClick={go}
>
Continue
</Button>
);
}
if (status === "AWAITING_PAYMENT") {
if (status === "SELECTED_FOR_BATCH") {
return (
<Button
size="xs"
radius="md"
fw={700}
fz={13}
style={{ backgroundColor: "var(--mantine-color-edr-accent-0)", color: "#fff" }}
onClick={() => onNavigate(`/bookings/${id}`)}
leftSection={<CreditCard size={14} />}
color="edr-green"
onClick={go}
>
Pay
</Button>
);
}
if (status === "IN_TRANSIT") {
return (
<Button
size="xs"
radius="md"
variant="default"
fw={600}
fz={13}
onClick={() => onNavigate(`/bookings/${id}`)}
>
Track
Pay now
</Button>
);
}
return (
<Button
size="xs"
radius="md"
variant="default"
fw={600}
fz={13}
onClick={() => onNavigate(`/bookings/${id}`)}
>
<Button size="xs" radius="md" variant="default" fw={600} fz={13} onClick={go}>
View
</Button>
);
}
// ── Column header label ───────────────────────────────────────────────────────
function ColHeader({ label }: { label: string }) {
return (
<Text
@@ -155,20 +215,161 @@ function ColHeader({ label }: { label: string }) {
const hMeta = { headerClassName: "bg-[#F4F7FA]" };
function fmtDate(iso?: string | null): string {
if (!iso) return "";
const d = new Date(iso);
return Number.isNaN(d.getTime())
? ""
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
// ── Main component ────────────────────────────────────────────────────────────
// Lightweight count query for a single lifecycle filter (reads only `total`).
function useStatusCount(statuses: string | undefined): number | undefined {
const { data } = useQuery(
api.bookings.list.queryOptions({
input: { statuses, page: 1, pageSize: 1 },
staleTime: 30_000,
}),
);
return data?.total;
}
function StatCard({
card,
active,
count,
onSelect,
}: {
card: (typeof STAT_CARDS)[number];
active: boolean;
count: number | undefined;
onSelect: () => void;
}) {
const Icon = card.icon;
return (
<Paper
role="button"
tabIndex={0}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect();
}
}}
p="md"
radius="lg"
withBorder
style={{
cursor: "pointer",
transition: "box-shadow 140ms ease, border-color 140ms ease",
borderColor: active ? "#F2A516" : "var(--mantine-color-edr-border-0)",
boxShadow: active ? "0 0 0 1px #F2A516" : "none",
}}
>
<Group gap={12} wrap="nowrap" align="center">
<Box
style={{
width: 42,
height: 42,
borderRadius: 11,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: card.iconBg,
color: card.iconColor,
}}
>
<Icon size={20} strokeWidth={2} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz={24} fw={800} lh={1.05} c="edr-text">
{count ?? "—"}
</Text>
<Text fz={12} fw={600} c="edr-muted" truncate>
{card.label}
</Text>
</Box>
</Group>
</Paper>
);
}
export default function MyBookings() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [statusFilter, setStatusFilter] = useState<StatusFilterKey>("all");
const [query, setQuery] = useState("");
const { data, isLoading, isError } = useQuery(api.bookings.list.queryOptions());
const bookings = data?.items ?? [];
const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
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(() => bookings.slice(start, end), [bookings, start, end]);
const selectFilter = (key: StatusFilterKey) => {
setStatusFilter(key);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
};
const filter: BookingListFilter = useMemo(
() => ({
statuses,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
}),
[statuses, pagination.pageIndex, pagination.pageSize],
);
const { data, isLoading, isError } = useQuery(
api.bookings.list.queryOptions({ input: filter }),
);
// Per-card lifecycle counts (one cheap query each, total-only).
const allCount = useStatusCount(undefined);
const activeCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "active")!.statuses,
);
const paymentCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "payment")!.statuses,
);
const draftCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "draft")!.statuses,
);
const doneCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "done")!.statuses,
);
const cardCounts: Record<StatusFilterKey, number | undefined> = {
all: allCount,
active: activeCount,
payment: paymentCount,
draft: draftCount,
done: doneCount,
transit: undefined,
closed: undefined,
};
const allItems = data?.items ?? [];
const total = data?.total ?? allItems.length;
// Server handles status + pagination; reference search is applied on the page.
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return allItems;
return allItems.filter((b) =>
[b.reference, b.originYard?.label, b.destinationYard?.label]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(q)),
);
}, [allItems, query]);
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
const showEmpty =
!isLoading && !isError && rows.length === 0;
const columns: ColumnDef<Freight.IBooking>[] = [
{
@@ -178,8 +379,7 @@ export default function MyBookings() {
header: () => <ColHeader label="Booking" />,
cell: ({ row }) => {
const b = row.original;
const cargoLabel =
b.freightType === "BULK" ? "Bulk Cargo" : "Cargo";
const cargoLabel = b.freightType === "BULK" ? "Bulk cargo" : "Container";
return (
<Group gap={12} wrap="nowrap" align="center">
<Box
@@ -217,7 +417,7 @@ export default function MyBookings() {
const b = row.original;
const origin = b.originYard?.label ?? b.originYard?.code ?? "—";
const dest = b.destinationYard?.label ?? b.destinationYard?.code ?? "—";
const sub = b.scheduledDate ?? b.createdAt ?? "";
const sub = fmtDate(b.scheduledDate ?? b.createdAt);
return (
<Box>
<Text fz={13} fw={600} c="edr-text">
@@ -245,7 +445,10 @@ export default function MyBookings() {
meta: hMeta,
header: () => <ColHeader label="Amount" />,
cell: ({ row }) => {
const b = row.original as Freight.IBooking & { totalAmount?: number; amount?: number };
const b = row.original as Freight.IBooking & {
totalAmount?: number;
amount?: number;
};
const amount = b.totalAmount ?? b.amount ?? null;
if (!amount) {
return (
@@ -272,18 +475,13 @@ export default function MyBookings() {
<PrimaryAction status={booking.status} id={booking.id} onNavigate={navigate} />
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
<Menu.Target>
<ActionIcon
variant="transparent"
size={30}
radius="md"
aria-label="More options"
>
<ActionIcon variant="transparent" size={30} radius="md" aria-label="More options">
<MoreVertical size={16} color="#9AA8B5" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => navigate(`/bookings/${booking.id}`)}>
View Details
View details
</Menu.Item>
</Menu.Dropdown>
</Menu>
@@ -293,8 +491,6 @@ export default function MyBookings() {
},
];
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
return (
<Box style={{ padding: "28px 32px 32px" }}>
<Stack gap="lg">
@@ -305,81 +501,112 @@ export default function MyBookings() {
Bookings
</Title>
<Text size="sm" c="edr-muted" mt={4}>
Manage every cargo booking from draft to delivery.
Track 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>
<Button
component={Link}
to="/bookings/new"
color="edr-green"
radius="md"
leftSection={<Plus size={16} />}
>
New booking
</Button>
</Group>
{/* ── Summary stat cards ──────────────────────────────────────── */}
<SimpleGrid cols={{ base: 2, sm: 3, lg: 5 }} spacing="md">
{STAT_CARDS.map((card) => (
<StatCard
key={card.key}
card={card}
active={statusFilter === card.key}
count={cardCounts[card.key]}
onSelect={() => selectFilter(card.key)}
/>
))}
</SimpleGrid>
{/* ── Bookings table card ──────────────────────────────────────── */}
<Card p={0} style={{ overflow: "hidden" }}>
{/* Toolbar */}
<Group
justify="flex-end"
gap={8}
justify="space-between"
gap={12}
px={20}
py={14}
wrap="wrap"
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 gap={10} wrap="wrap" style={{ flex: 1, minWidth: 260 }}>
<TextInput
placeholder="Search reference or route…"
leftSection={<Search size={16} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
variant="transparent"
color="gray"
onClick={() => setQuery("")}
>
<X size={14} />
</ActionIcon>
) : null
}
radius="md"
style={{ flex: 1, minWidth: 200, maxWidth: 340 }}
/>
<Select
data={SELECT_DATA}
value={statusFilter}
onChange={(value) => selectFilter((value as StatusFilterKey) ?? "all")}
allowDeselect={false}
radius="md"
checkIconPosition="right"
comboboxProps={{ withinPortal: true }}
style={{ width: 200 }}
aria-label="Filter by status"
/>
</Group>
<Text fz={12} c="edr-muted">
{total} booking{total !== 1 ? "s" : ""}
</Text>
</Group>
{/* Empty state */}
{total === 0 && dataTableStatus === "success" ? (
{showEmpty ? (
<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} c="edr-text">
No bookings yet
{query ? "No bookings match your search" : "No bookings here yet"}
</Text>
<Text size="xs" c="edr-muted" maw={320}>
You haven't made any booking requests yet. Create your first one to get started.
{query
? "Try a different reference or clear the search."
: "Create your first booking to get started."}
</Text>
<Button
component={Link}
to="/bookings/new"
size="sm"
color="edr-green"
radius="md"
mt="md"
leftSection={<Plus size={15} />}
>
Create first booking
</Button>
{!query && (
<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
columns={columns}
data={paginatedData}
data={rows}
status={dataTableStatus}
onRowClick={(row) => navigate(`/bookings/${(row as Freight.IBooking).id}`)}
pagination={{
@@ -391,6 +618,8 @@ export default function MyBookings() {
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none rounded-none"
footer={DataTableFooter}

View File

@@ -54,6 +54,8 @@ export interface SignContractPayload {
export interface BookingListFilter {
status?: string;
/** Comma-separated statuses (overrides `status` when set). */
statuses?: string;
page?: number;
pageSize?: number;
sortBy?: string;