mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Implement user-based booking access control and enhance booking filtering options
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
Query,
|
||||
Request,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
@@ -117,9 +118,23 @@ export class BookingsController {
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List freight bookings (paginated)' })
|
||||
findAll(@Query() filter: FilterBookingDto) {
|
||||
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')
|
||||
@ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' })
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,13 +501,9 @@ 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"
|
||||
@@ -319,51 +511,85 @@ export default function MyBookings() {
|
||||
radius="md"
|
||||
leftSection={<Plus size={16} />}
|
||||
>
|
||||
New Booking
|
||||
New booking
|
||||
</Button>
|
||||
</Group>
|
||||
</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"
|
||||
<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"
|
||||
radius="md"
|
||||
leftSection={<ArrowUpDown size={14} />}
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
Sort
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
radius="md"
|
||||
leftSection={<Filter size={14} />}
|
||||
>
|
||||
Filter
|
||||
</Button>
|
||||
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>
|
||||
{!query && (
|
||||
<Button
|
||||
component={Link}
|
||||
to="/bookings/new"
|
||||
@@ -375,11 +601,12 @@ export default function MyBookings() {
|
||||
>
|
||||
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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user