feat(bookings): enhance booking filtering and pagination

- Added createdFrom and createdTo filters to BookingListFilterOptions and FilterBookingDto for date range filtering.
- Updated BookingsService and BookingsRepository to handle pagination metadata in responses.
- Enhanced BookingsController to return pagination metadata when no company is linked.
- Introduced ModeIndicator component to display current operational mode across various pages.
- Added ContainersCard and KeyFactsStrip components for detailed booking views.
- Implemented CargoModeCell, BookingTypeBadge, and PaymentBadge for consistent display of booking attributes.
- Updated MyBookings and ContractsList pages to include new filters and display enhancements.
This commit is contained in:
Marshal
2026-06-21 07:41:04 +00:00
parent f82dad3657
commit 171e02cf7f
16 changed files with 763 additions and 42 deletions

View File

@@ -132,7 +132,22 @@ export class BookingsController {
const companyId = const companyId =
await this.bookingsService.resolveCustomerCompanyId(userId); await this.bookingsService.resolveCustomerCompanyId(userId);
// No linked company yet → no bookings to show (avoids leaking all bookings). // No linked company yet → no bookings to show (avoids leaking all bookings).
if (!companyId) return { items: [], total: 0 }; if (!companyId) {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
return {
items: [],
total: 0,
meta: {
page,
pageSize,
total: 0,
totalPages: 0,
hasNextPage: false,
hasPreviousPage: false,
},
};
}
// Scope to the active operational profile (importer/exporter) when one // Scope to the active operational profile (importer/exporter) when one
// resolves; otherwise fall back to company-level scoping. // resolves; otherwise fall back to company-level scoping.
const companyProfileId = const companyProfileId =

View File

@@ -35,6 +35,8 @@ export interface BookingListFilterOptions {
paymentCurrency?: string; paymentCurrency?: string;
paymentStatus?: string; paymentStatus?: string;
excludePaymentStatus?: string; excludePaymentStatus?: string;
createdFrom?: string;
createdTo?: string;
allowConsolidation?: boolean; allowConsolidation?: boolean;
consolidationPaired?: string; consolidationPaired?: string;
} }
@@ -436,7 +438,18 @@ export class BookingsRepository extends BaseRepository<Booking> {
pageSize: number; pageSize: number;
sortBy?: string; sortBy?: string;
sortOrder?: 'ASC' | 'DESC'; sortOrder?: 'ASC' | 'DESC';
}): Promise<{ items: Booking[]; total: number }> { }): Promise<{
items: Booking[];
total: number;
meta: {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}> {
const page = options.page; const page = options.page;
const pageSize = options.pageSize; const pageSize = options.pageSize;
@@ -483,7 +496,22 @@ export class BookingsRepository extends BaseRepository<Booking> {
} }
} }
return { items, total }; const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
// Return both the flat `total` (consumed by the backoffice list) and a
// `meta` block (consumed by the portal, matching PaginationMeta) so neither
// app needs to change its read shape.
return {
items,
total,
meta: {
page,
pageSize,
total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
};
} }
async getStatusCounts(): Promise<Record<string, number>> { async getStatusCounts(): Promise<Record<string, number>> {
@@ -591,6 +619,17 @@ export class BookingsRepository extends BaseRepository<Booking> {
bookingType: options.bookingType, bookingType: options.bookingType,
}); });
} }
if (options.createdFrom) {
qb.andWhere('booking.created_at >= :createdFrom', {
createdFrom: options.createdFrom,
});
}
if (options.createdTo) {
// Inclusive end-of-day: callers pass a date; include the whole day.
qb.andWhere('booking.created_at <= :createdTo', {
createdTo: options.createdTo,
});
}
if (options.tradeDirection) { if (options.tradeDirection) {
qb.andWhere('booking.trade_direction = :tradeDirection', { qb.andWhere('booking.trade_direction = :tradeDirection', {
tradeDirection: options.tradeDirection, tradeDirection: options.tradeDirection,

View File

@@ -42,6 +42,20 @@ import {
import { Booking } from './entities/booking.entity'; import { Booking } from './entities/booking.entity';
import { FileRecord } from '../files/entities/file.entity'; import { FileRecord } from '../files/entities/file.entity';
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
export interface PaginatedBookings {
items: Booking[];
total: number;
meta: {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
const URGENT_PRIORITY_THRESHOLD = 1000; const URGENT_PRIORITY_THRESHOLD = 1000;
const NEEDS_ACTION_STATUSES = [ const NEEDS_ACTION_STATUSES = [
'SUBMITTED', 'SUBMITTED',
@@ -628,7 +642,7 @@ export class BookingsService {
filter: FilterBookingDto, filter: FilterBookingDto,
forceCompanyId?: string, forceCompanyId?: string,
forceCompanyProfileId?: string, forceCompanyProfileId?: string,
): Promise<{ items: Booking[]; total: number }> { ): Promise<PaginatedBookings> {
const page = filter.page ?? 1; const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20; const pageSize = filter.pageSize ?? 20;
const statusFilter = this.parseStatusFilter(filter); const statusFilter = this.parseStatusFilter(filter);
@@ -654,6 +668,8 @@ export class BookingsService {
tradeDirection: filter.tradeDirection, tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency, paymentCurrency: filter.paymentCurrency,
paymentStatus: filter.paymentStatus, paymentStatus: filter.paymentStatus,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
allowConsolidation: filter.allowConsolidation, allowConsolidation: filter.allowConsolidation,
consolidationPaired: filter.consolidationPaired, consolidationPaired: filter.consolidationPaired,
sortBy: filter.sortBy, sortBy: filter.sortBy,
@@ -676,7 +692,7 @@ export class BookingsService {
async findMyPayable( async findMyPayable(
userId: string, userId: string,
filter: FilterBookingDto, filter: FilterBookingDto,
): Promise<{ items: Booking[]; total: number }> { ): Promise<PaginatedBookings> {
const { company } = await this.companiesService.getCompanyInfoByUserId(userId); const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
// Scope to the active operational profile when one resolves; fall back to // Scope to the active operational profile when one resolves; fall back to
// company-level so not-yet-onboarded customers still see their payables. // company-level so not-yet-onboarded customers still see their payables.
@@ -823,6 +839,8 @@ export class BookingsService {
tradeDirection: filter.tradeDirection, tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency, paymentCurrency: filter.paymentCurrency,
paymentStatus: filter.paymentStatus, paymentStatus: filter.paymentStatus,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
allowConsolidation: filter.allowConsolidation, allowConsolidation: filter.allowConsolidation,
consolidationPaired: filter.consolidationPaired, consolidationPaired: filter.consolidationPaired,
}; };

View File

@@ -1,6 +1,6 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsIn, IsOptional, IsUUID } from 'class-validator'; import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
import { import {
BOOKING_STATUSES, BOOKING_STATUSES,
BOOKING_TYPES, BOOKING_TYPES,
@@ -62,6 +62,16 @@ export class FilterBookingDto {
@IsIn([...BOOKING_TYPES]) @IsIn([...BOOKING_TYPES])
bookingType?: string; bookingType?: string;
@ApiPropertyOptional({ description: 'Filter bookings created on/after this date (ISO)' })
@IsOptional()
@IsDateString()
createdFrom?: string;
@ApiPropertyOptional({ description: 'Filter bookings created on/before this date (ISO)' })
@IsOptional()
@IsDateString()
createdTo?: string;
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS }) @ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
@IsOptional() @IsOptional()
@IsIn([...TRADE_DIRECTIONS]) @IsIn([...TRADE_DIRECTIONS])

View File

@@ -41,6 +41,7 @@ import {
type ReactNode, type ReactNode,
useState, useState,
} from "react"; } from "react";
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
export interface SidebarItem { export interface SidebarItem {
label: string; label: string;
@@ -79,14 +80,6 @@ type SwitchResult =
| { success: true; data?: unknown } | { success: true; data?: unknown }
| { success: false; error?: { message?: string } }; | { success: false; error?: { message?: string } };
const PROFILE_TYPE_LABELS: Record<string, string> = {
importer: "Importer",
exporter: "Exporter",
freight_forwarder: "Freight Forwarder",
dj_freight_forwarder: "DJ Freight Forwarder",
transporter: "Transporter",
};
function getInitials(name: string): string { function getInitials(name: string): string {
return name return name
.split(" ") .split(" ")
@@ -813,8 +806,7 @@ export function AppLayout({
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
You don't have an {modeLabel(targetMode).toLowerCase()} profile yet. You don't have an {modeLabel(targetMode).toLowerCase()} profile yet.
Add your business license to create one and switch to{" "} Add your business license to create one and switch to{" "}
{modeLabel(targetMode).toLowerCase()} mode. A new reference will be {modeLabel(targetMode).toLowerCase()} mode.
generated automatically.
</Text> </Text>
<FileInput <FileInput
label="Business license" label="Business license"

View File

@@ -0,0 +1,55 @@
import { Badge, Tooltip } from "@mantine/core";
import { ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
import useAuth from "@/hooks/useAuth";
import { modeDataDescription, modeDataLabel } from "@/constants/profileMode";
interface ModeIndicatorProps {
/** Mantine size token for the badge. */
size?: "sm" | "md" | "lg";
}
/**
* Small pill showing which operational mode's data is currently on screen
* (Import / Export). The data itself is scoped server-side by the active
* profile; this just makes the scope visible. Switching is done via the header
* button — this is read-only.
*
* Renders nothing for non-customer companies or when no import/export mode is
* active, so it never interferes with forwarders or not-yet-onboarded users.
*/
export function ModeIndicator({ size = "md" }: ModeIndicatorProps) {
const { companyType, activeProfileType } = useAuth();
if (companyType !== "customer") return null;
const label = modeDataLabel(activeProfileType);
if (!label) return null;
const isImport = activeProfileType === "importer";
return (
<Tooltip label={modeDataDescription(activeProfileType)} withArrow>
<Badge
size={size}
radius="sm"
variant="light"
color={isImport ? "edr-green" : "blue"}
leftSection={
isImport ? (
<ArrowDownToLine size={13} />
) : (
<ArrowUpFromLine size={13} />
)
}
styles={{
root: { textTransform: "none", letterSpacing: 0, fontWeight: 600 },
}}
>
Viewing: {label}
</Badge>
</Tooltip>
);
}
export default ModeIndicator;

View File

@@ -0,0 +1,30 @@
/**
* Operational-mode (importer/exporter/…) labels and helpers, shared by the app
* header and the per-page mode indicator so there is a single source of truth.
*/
export const PROFILE_TYPE_LABELS: Record<string, string> = {
importer: "Importer",
exporter: "Exporter",
freight_forwarder: "Freight Forwarder",
dj_freight_forwarder: "DJ Freight Forwarder",
transporter: "Transporter",
};
/** The data-scope label shown to the user (importer ⇒ "Import", exporter ⇒ "Export"). */
export function modeDataLabel(
activeProfileType?: string | null,
): string | null {
if (activeProfileType === "importer") return "Import";
if (activeProfileType === "exporter") return "Export";
return null;
}
/** Short helper sentence describing what the active mode scopes. */
export function modeDataDescription(
activeProfileType?: string | null,
): string {
const label = modeDataLabel(activeProfileType);
if (!label) return "";
return `Showing your ${label.toLowerCase()} data — switch in the header.`;
}

View File

@@ -2,6 +2,7 @@ import { Box, Group, Text } from "@mantine/core";
import { ArrowRight, Truck } from "lucide-react"; import { ArrowRight, Truck } from "lucide-react";
import { memo } from "react"; import { memo } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { ModeIndicator } from "@/components/ModeIndicator";
import { cv } from "../constants"; import { cv } from "../constants";
interface HelloSectionProps { interface HelloSectionProps {
@@ -19,9 +20,12 @@ export const HelloSection = memo(function HelloSection({
<Text size="sm" c="edr-muted"> <Text size="sm" c="edr-muted">
{greeting} {greeting}
</Text> </Text>
<Text fz={26} fw={800} mt={2} c="edr-text" className="tracking-tight"> <Group gap={12} align="center" mt={2} wrap="wrap">
{companyName} 👋 <Text fz={26} fw={800} c="edr-text" className="tracking-tight">
</Text> {companyName} 👋
</Text>
<ModeIndicator />
</Group>
</Box> </Box>
<Link to="/bookings/new"> <Link to="/bookings/new">

View File

@@ -9,8 +9,10 @@ import { paymentsService, type PaymentMethod } from "@/services/payments.service
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { ActivityCard } from "./components/ActivityCard"; import { ActivityCard } from "./components/ActivityCard";
import { ContainersCard } from "./components/ContainersCard";
import { ContractCard } from "./components/ContractCard"; import { ContractCard } from "./components/ContractCard";
import { DocRow, IconSquare } from "./components/Documents"; import { DocRow, IconSquare } from "./components/Documents";
import { KeyFactsStrip } from "./components/KeyFactsStrip";
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout"; import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
import { import {
CancelledBanner, CancelledBanner,
@@ -113,6 +115,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
{showPairedNotice && <ConsolidationPairedNotice />} {showPairedNotice && <ConsolidationPairedNotice />}
<KeyFactsStrip booking={booking} />
<ContractCard booking={booking} navigate={navigate} /> <ContractCard booking={booking} navigate={navigate} />
<BodyGrid <BodyGrid
@@ -120,6 +124,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
<> <>
<ShipmentDetailsCard booking={booking} /> <ShipmentDetailsCard booking={booking} />
<ContainersCard booking={booking} />
{booking.files && booking.files.length > 0 && ( {booking.files && booking.files.length > 0 && (
<SectionCard> <SectionCard>
<Group justify="space-between" align="center" mb="md"> <Group justify="space-between" align="center" mb="md">

View File

@@ -0,0 +1,90 @@
import { Box, Group, Table, Text } from "@mantine/core";
import { Boxes } from "lucide-react";
import type { Freight } from "@edr/types";
import { CardTitle, SectionCard } from "./layout";
/**
* Per-container-type breakdown for container bookings (count, type, VGM).
* Renders nothing for bulk bookings, which have no container lines.
*/
export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
const containers = booking.containers ?? [];
if (booking.freightType === "BULK" || containers.length === 0) return null;
const totalUnits = containers.reduce((sum, c) => sum + Number(c.qty || 0), 0);
const totalVgm = containers.reduce(
(sum, c) => sum + Number(c.vgm || 0) * Number(c.qty || 0),
0,
);
return (
<SectionCard>
<Group justify="space-between" align="center" mb="md">
<Group gap={8} align="center">
<Boxes size={18} color="#0A6F4D" />
<CardTitle>Containers</CardTitle>
</Group>
<Text fz="12.5px" fw={600} c="#9AA8B5">
{totalUnits} unit{totalUnits !== 1 ? "s" : ""}
</Text>
</Group>
<Table verticalSpacing="sm" horizontalSpacing={0}>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ color: "#9AA8B5", fontSize: 11.5 }}>Type</Table.Th>
<Table.Th style={{ color: "#9AA8B5", fontSize: 11.5 }}>Qty</Table.Th>
<Table.Th style={{ color: "#9AA8B5", fontSize: 11.5 }}>VGM / unit</Table.Th>
<Table.Th style={{ color: "#9AA8B5", fontSize: 11.5, textAlign: "right" }}>
Total VGM
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.map((c, i) => {
const lineVgm = Number(c.vgm || 0) * Number(c.qty || 0);
return (
<Table.Tr key={`${c.type}-${i}`}>
<Table.Td>
<Text fz={14} fw={700} c="#10202F">
{c.type}
</Text>
</Table.Td>
<Table.Td>
<Text fz={14} c="#10202F">
{c.qty}
</Text>
</Table.Td>
<Table.Td>
<Text fz={14} c="#475569">
{c.vgm ? `${c.vgm} t` : "—"}
</Text>
</Table.Td>
<Table.Td>
<Text fz={14} fw={700} c="#10202F" ta="right">
{lineVgm ? `${lineVgm.toLocaleString()} t` : "—"}
</Text>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
<Box
mt="sm"
pt="sm"
style={{ borderTop: "1px solid #F2F5F8", display: "flex", justifyContent: "space-between" }}
>
<Text fz={13} fw={600} c="#475569">
Total weight (VGM)
</Text>
<Text fz={14} fw={800} c="#0A6F4D">
{totalVgm.toLocaleString()} t
</Text>
</Box>
</SectionCard>
);
}

View File

@@ -0,0 +1,107 @@
import { Box, Group, SimpleGrid, Text } from "@mantine/core";
import {
CalendarClock,
CreditCard,
MapPin,
Package,
Tag,
Train,
} from "lucide-react";
import type { ReactNode } from "react";
import type { Freight } from "@edr/types";
import { fmtDate, yardLabel } from "../utils";
import { SectionCard } from "./layout";
type BookingLike = Freight.IBooking & {
bookingType?: string;
paymentStatus?: string;
trainScheduleId?: string | null;
};
function Fact({
icon,
label,
value,
}: {
icon: ReactNode;
label: string;
value: ReactNode;
}) {
return (
<Group gap={10} wrap="nowrap" align="flex-start">
<Box
style={{
width: 34,
height: 34,
borderRadius: 9,
flexShrink: 0,
background: "#F1F6FA",
color: "#0A6F4D",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{icon}
</Box>
<Box miw={0}>
<Text fz="11.5px" fw={600} c="#9AA8B5">
{label}
</Text>
<Text mt={2} fz="14px" fw={700} c="#10202F" truncate>
{value}
</Text>
</Box>
</Group>
);
}
/**
* Compact at-a-glance facts strip at the top of the booking detail page — gives
* a fast scan of the key attributes before the deeper cards below.
*/
export function KeyFactsStrip({ booking }: { booking: BookingLike }) {
const isContract = booking.bookingType === "GENERAL_CONTRACT";
const freight = booking.freightType === "BULK" ? "Bulk" : "Container";
const payment = booking.paymentStatus
? booking.paymentStatus
.replace(/_/g, " ")
.toLowerCase()
.replace(/^\w/, (c) => c.toUpperCase())
: "—";
return (
<SectionCard p="md">
<SimpleGrid cols={{ base: 1, xs: 2, md: 3, xl: 6 }} spacing="lg">
<Fact
icon={<Tag size={17} />}
label="Type"
value={isContract ? "General Contract" : "One-Time"}
/>
<Fact icon={<Package size={17} />} label="Cargo" value={freight} />
<Fact
icon={<MapPin size={17} />}
label="Route"
value={`${yardLabel(booking.originYard)}${yardLabel(booking.destinationYard)}`}
/>
<Fact icon={<CreditCard size={17} />} label="Payment" value={payment} />
<Fact
icon={<Train size={17} />}
label="Train"
value={booking.trainScheduleId ? "Assigned" : "Not assigned"}
/>
<Fact
icon={<CalendarClock size={17} />}
label={isContract ? "Ordering until" : "Scheduled"}
value={
isContract
? fmtDate(booking.expiresAt ?? null)
: fmtDate(booking.scheduledDate)
}
/>
</SimpleGrid>
</SectionCard>
);
}

View File

@@ -34,6 +34,13 @@ import {
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal"; import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
import { PayNowButton } from "./payments/PayNowButton"; import { PayNowButton } from "./payments/PayNowButton";
import { ModeIndicator } from "@/components/ModeIndicator";
import {
BookingTypeBadge,
CargoModeCell,
PaymentBadge,
SchedulingCell,
} from "./booking-display";
// Bookings that have left (or are leaving) the yard can be tracked live. // Bookings that have left (or are leaving) the yard can be tracked live.
const TRACKABLE_STATUSES = new Set([ const TRACKABLE_STATUSES = new Set([
@@ -320,24 +327,54 @@ export default function MyBookings() {
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [statusFilter, setStatusFilter] = useState<StatusFilterKey>("all"); const [statusFilter, setStatusFilter] = useState<StatusFilterKey>("all");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [typeFilter, setTypeFilter] = useState<string | null>(null);
const [freightFilter, setFreightFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<string>("");
const [createdTo, setCreatedTo] = useState<string>("");
const [trackingBooking, setTrackingBooking] = useState<Freight.IBooking | null>( const [trackingBooking, setTrackingBooking] = useState<Freight.IBooking | null>(
null, null,
); );
const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses; const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
const selectFilter = (key: StatusFilterKey) => { const selectFilter = (key: StatusFilterKey) => {
setStatusFilter(key); setStatusFilter(key);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); resetPage();
};
const hasExtraFilters =
!!typeFilter || !!freightFilter || !!createdFrom || !!createdTo;
const clearExtraFilters = () => {
setTypeFilter(null);
setFreightFilter(null);
setCreatedFrom("");
setCreatedTo("");
resetPage();
}; };
const filter: BookingListFilter = useMemo( const filter: BookingListFilter = useMemo(
() => ({ () => ({
statuses, statuses,
bookingType: typeFilter ?? undefined,
freightType: freightFilter ?? undefined,
createdFrom: createdFrom || undefined,
// include the whole selected end day
createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined,
page: pagination.pageIndex + 1, page: pagination.pageIndex + 1,
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
}), }),
[statuses, pagination.pageIndex, pagination.pageSize], [
statuses,
typeFilter,
freightFilter,
createdFrom,
createdTo,
pagination.pageIndex,
pagination.pageSize,
],
); );
const { data, isLoading, isError } = useQuery( const { data, isLoading, isError } = useQuery(
@@ -358,14 +395,20 @@ export default function MyBookings() {
const doneCount = useStatusCount( const doneCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "done")!.statuses, STATUS_FILTERS.find((f) => f.key === "done")!.statuses,
); );
const transitCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "transit")!.statuses,
);
const closedCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "closed")!.statuses,
);
const cardCounts: Record<StatusFilterKey, number | undefined> = { const cardCounts: Record<StatusFilterKey, number | undefined> = {
all: allCount, all: allCount,
active: activeCount, active: activeCount,
payment: paymentCount, payment: paymentCount,
draft: draftCount, draft: draftCount,
done: doneCount, done: doneCount,
transit: undefined, transit: transitCount,
closed: undefined, closed: closedCount,
}; };
const allItems = data?.items ?? []; const allItems = data?.items ?? [];
@@ -424,6 +467,20 @@ export default function MyBookings() {
); );
}, },
}, },
{
id: "type",
size: 150,
meta: hMeta,
header: () => <ColHeader label="Type" />,
cell: ({ row }) => <BookingTypeBadge booking={row.original} />,
},
{
id: "cargo",
size: 168,
meta: hMeta,
header: () => <ColHeader label="Cargo" />,
cell: ({ row }) => <CargoModeCell booking={row.original} />,
},
{ {
id: "route", id: "route",
size: 196, size: 196,
@@ -448,6 +505,20 @@ export default function MyBookings() {
); );
}, },
}, },
{
id: "payment",
size: 130,
meta: hMeta,
header: () => <ColHeader label="Payment" />,
cell: ({ row }) => <PaymentBadge status={row.original.paymentStatus} />,
},
{
id: "scheduling",
size: 140,
meta: hMeta,
header: () => <ColHeader label="Train" />,
cell: ({ row }) => <SchedulingCell booking={row.original} />,
},
{ {
id: "status", id: "status",
size: 190, size: 190,
@@ -536,9 +607,12 @@ export default function MyBookings() {
{/* ── Page header ─────────────────────────────────────────────── */} {/* ── Page header ─────────────────────────────────────────────── */}
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md"> <Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
<Box> <Box>
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}> <Group gap={10} align="center">
Bookings <Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
</Title> Bookings
</Title>
<ModeIndicator />
</Group>
<Text size="sm" c="edr-muted" mt={4}> <Text size="sm" c="edr-muted" mt={4}>
Track every cargo booking from draft to delivery. Track every cargo booking from draft to delivery.
</Text> </Text>
@@ -606,9 +680,79 @@ export default function MyBookings() {
radius="md" radius="md"
checkIconPosition="right" checkIconPosition="right"
comboboxProps={{ withinPortal: true }} comboboxProps={{ withinPortal: true }}
style={{ width: 200 }} style={{ width: 190 }}
aria-label="Filter by status" aria-label="Filter by status"
/> />
<Select
placeholder="Any type"
data={[
{ value: "ONE_TIME", label: "One-time" },
{ value: "GENERAL_CONTRACT", label: "General contract" },
]}
value={typeFilter}
onChange={(v) => {
setTypeFilter(v);
resetPage();
}}
clearable
radius="md"
comboboxProps={{ withinPortal: true }}
style={{ width: 170 }}
aria-label="Filter by booking type"
/>
<Select
placeholder="Any cargo"
data={[
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
]}
value={freightFilter}
onChange={(v) => {
setFreightFilter(v);
resetPage();
}}
clearable
radius="md"
comboboxProps={{ withinPortal: true }}
style={{ width: 150 }}
aria-label="Filter by cargo type"
/>
<TextInput
type="date"
value={createdFrom}
onChange={(e) => {
setCreatedFrom(e.currentTarget.value);
resetPage();
}}
radius="md"
style={{ width: 150 }}
aria-label="Created from"
placeholder="From"
/>
<TextInput
type="date"
value={createdTo}
onChange={(e) => {
setCreatedTo(e.currentTarget.value);
resetPage();
}}
radius="md"
style={{ width: 150 }}
aria-label="Created to"
placeholder="To"
/>
{hasExtraFilters && (
<Button
variant="subtle"
color="gray"
radius="md"
size="sm"
leftSection={<X size={14} />}
onClick={clearExtraFilters}
>
Clear
</Button>
)}
</Group> </Group>
<Text fz={12} c="edr-muted"> <Text fz={12} c="edr-muted">
{total} booking{total !== 1 ? "s" : ""} {total} booking{total !== 1 ? "s" : ""}

View File

@@ -0,0 +1,109 @@
import { Badge, Group, Text } from "@mantine/core";
import type { Freight } from "@edr/types";
/**
* Shared presentation helpers for booking-like rows (one-time bookings AND
* general contracts). Kept in one place so the bookings list, contracts list,
* and detail page render type/freight/mode/payment consistently.
*/
type BookingLike = Freight.IBooking & {
bookingType?: string;
freightType?: string;
tradeDirection?: string;
paymentStatus?: string;
};
/** One-Time vs General Contract. */
export function BookingTypeBadge({ booking }: { booking: BookingLike }) {
const isContract = booking.bookingType === "GENERAL_CONTRACT";
return (
<Badge
variant="light"
radius="sm"
color={isContract ? "violet" : "gray"}
styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }}
>
{isContract ? "General Contract" : "One-Time"}
</Badge>
);
}
/** Containerised vs Bulk, plus the trade direction (Import/Export/Domestic). */
export function CargoModeCell({ booking }: { booking: BookingLike }) {
const freight =
booking.freightType === "BULK" ? "Bulk" : "Container";
const dir = booking.tradeDirection
? booking.tradeDirection.charAt(0) + booking.tradeDirection.slice(1).toLowerCase()
: null;
return (
<Group gap={6} wrap="nowrap">
<Badge
variant="light"
radius="sm"
color={booking.freightType === "BULK" ? "orange" : "teal"}
styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }}
>
{freight}
</Badge>
{dir && (
<Text fz={12} c="edr-muted">
{dir}
</Text>
)}
</Group>
);
}
const PAYMENT_COLORS: Record<string, string> = {
PAID: "green",
PENDING: "gray",
PNR_GENERATED: "blue",
VERIFICATION_IN_PROGRESS: "yellow",
FAILED: "red",
};
const PAYMENT_LABELS: Record<string, string> = {
PAID: "Paid",
PENDING: "Pending",
PNR_GENERATED: "PNR generated",
VERIFICATION_IN_PROGRESS: "Verifying",
FAILED: "Failed",
};
/** Payment status pill. */
export function PaymentBadge({ status }: { status?: string | null }) {
if (!status) return <Text fz={13} c="dimmed"></Text>;
return (
<Badge
variant="light"
radius="sm"
color={PAYMENT_COLORS[status] ?? "gray"}
styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }}
>
{PAYMENT_LABELS[status] ?? status.replace(/_/g, " ")}
</Badge>
);
}
/** Whether a booking is assigned to a train yet (scheduling progress). */
export function SchedulingCell({ booking }: { booking: BookingLike & { trainScheduleId?: string | null; schedulingStatus?: string } }) {
const assigned = !!booking.trainScheduleId;
const label = assigned
? "Assigned"
: booking.schedulingStatus === "HOLDING"
? "Holding"
: booking.schedulingStatus === "ELIGIBLE"
? "Eligible"
: "Not scheduled";
return (
<Badge
variant="dot"
radius="sm"
color={assigned ? "green" : "gray"}
styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }}
>
{label}
</Badge>
);
}

View File

@@ -25,6 +25,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { ModeIndicator } from "@/components/ModeIndicator";
import { PayNowButton } from "../bookings/payments/PayNowButton"; import { PayNowButton } from "../bookings/payments/PayNowButton";
import { import {
BORDER, BORDER,
@@ -120,6 +121,7 @@ export default function ContractDetailPage() {
{contract.reference} {contract.reference}
</Title> </Title>
<ContractStatusBadge status={contract.status} /> <ContractStatusBadge status={contract.status} />
<ModeIndicator size="sm" />
</Group> </Group>
<Text size="sm" c="dimmed" mt={2}> <Text size="sm" c="dimmed" mt={2}>
General contract · {isContainer ? "Containerised" : "Bulk"} General contract · {isContainer ? "Containerised" : "Bulk"}

View File

@@ -7,13 +7,14 @@ import {
Card, Card,
Group, Group,
Paper, Paper,
Select,
Stack, Stack,
Text, Text,
TextInput, TextInput,
ThemeIcon, ThemeIcon,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { Layers, Plus, Search } from "lucide-react"; import { Layers, Plus, Search, X } from "lucide-react";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { BookingListFilter } from "@/services/bookings.service"; import type { BookingListFilter } from "@/services/bookings.service";
@@ -24,22 +25,46 @@ import {
type ColumnDef, type ColumnDef,
usePagination, usePagination,
} from "@edr/ui-common"; } from "@edr/ui-common";
import { ModeIndicator } from "@/components/ModeIndicator";
import { CargoModeCell, PaymentBadge } from "../bookings/booking-display";
import { ContractStatusBadge, GREEN, INK, MUTED } from "./contract-ui"; import { ContractStatusBadge, GREEN, INK, MUTED } from "./contract-ui";
export default function ContractsList() { export default function ContractsList() {
const navigate = useNavigate(); const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [freightFilter, setFreightFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<string>("");
const [createdTo, setCreatedTo] = useState<string>("");
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
const hasExtraFilters = !!freightFilter || !!createdFrom || !!createdTo;
const clearExtraFilters = () => {
setFreightFilter(null);
setCreatedFrom("");
setCreatedTo("");
resetPage();
};
const filter: BookingListFilter = useMemo( const filter: BookingListFilter = useMemo(
() => ({ () => ({
bookingType: "GENERAL_CONTRACT", bookingType: "GENERAL_CONTRACT",
freightType: freightFilter ?? undefined,
createdFrom: createdFrom || undefined,
createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined,
page: pagination.pageIndex + 1, page: pagination.pageIndex + 1,
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
sortBy: "createdAt", sortBy: "createdAt",
sortOrder: "DESC", sortOrder: "DESC",
}), }),
[pagination.pageIndex, pagination.pageSize], [
freightFilter,
createdFrom,
createdTo,
pagination.pageIndex,
pagination.pageSize,
],
); );
const { data, isLoading, isError } = useQuery( const { data, isLoading, isError } = useQuery(
@@ -93,6 +118,11 @@ export default function ContractsList() {
); );
}, },
}, },
{
id: "cargo",
header: () => <ColHeader label="Cargo" />,
cell: ({ row }) => <CargoModeCell booking={row.original} />,
},
{ {
id: "route", id: "route",
header: () => <ColHeader label="Route" />, header: () => <ColHeader label="Route" />,
@@ -109,6 +139,11 @@ export default function ContractsList() {
); );
}, },
}, },
{
id: "payment",
header: () => <ColHeader label="Payment" />,
cell: ({ row }) => <PaymentBadge status={row.original.paymentStatus} />,
},
{ {
id: "expires", id: "expires",
header: () => <ColHeader label="Ordering Until" />, header: () => <ColHeader label="Ordering Until" />,
@@ -138,9 +173,12 @@ export default function ContractsList() {
{/* Header */} {/* Header */}
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md"> <Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
<Box> <Box>
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}> <Group gap={10} align="center">
General Contracts <Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
</Title> General Contracts
</Title>
<ModeIndicator />
</Group>
<Text size="sm" c="edr-muted" mt={4}> <Text size="sm" c="edr-muted" mt={4}>
Reserve a quantity once, then place orders against it until the Reserve a quantity once, then place orders against it until the
contract runs out or its window closes. contract runs out or its window closes.
@@ -163,16 +201,71 @@ export default function ContractsList() {
hint="accepting orders" hint="accepting orders"
/> />
{/* Search */} {/* Search + filters */}
<TextInput <Group gap={10} wrap="wrap" align="center">
placeholder="Search by reference or route…" <TextInput
leftSection={<Search size={16} />} placeholder="Search by reference or route…"
value={query} leftSection={<Search size={16} />}
onChange={(e) => setQuery(e.currentTarget.value)} value={query}
radius="md" onChange={(e) => setQuery(e.currentTarget.value)}
styles={{ input: { height: 44 } }} radius="md"
maw={420} styles={{ input: { height: 44 } }}
/> style={{ flex: 1, minWidth: 220, maxWidth: 360 }}
/>
<Select
placeholder="Any cargo"
data={[
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
]}
value={freightFilter}
onChange={(v) => {
setFreightFilter(v);
resetPage();
}}
clearable
radius="md"
comboboxProps={{ withinPortal: true }}
style={{ width: 160 }}
styles={{ input: { height: 44 } }}
aria-label="Filter by cargo type"
/>
<TextInput
type="date"
value={createdFrom}
onChange={(e) => {
setCreatedFrom(e.currentTarget.value);
resetPage();
}}
radius="md"
style={{ width: 160 }}
styles={{ input: { height: 44 } }}
aria-label="Created from"
/>
<TextInput
type="date"
value={createdTo}
onChange={(e) => {
setCreatedTo(e.currentTarget.value);
resetPage();
}}
radius="md"
style={{ width: 160 }}
styles={{ input: { height: 44 } }}
aria-label="Created to"
/>
{hasExtraFilters && (
<Button
variant="subtle"
color="gray"
radius="md"
leftSection={<X size={14} />}
onClick={clearExtraFilters}
>
Clear
</Button>
)}
</Group>
{/* Table */} {/* Table */}
<Card p={0} style={{ overflow: "hidden" }}> <Card p={0} style={{ overflow: "hidden" }}>

View File

@@ -71,6 +71,13 @@ export interface BookingListFilter {
statuses?: string; statuses?: string;
/** ONE_TIME or GENERAL_CONTRACT. */ /** ONE_TIME or GENERAL_CONTRACT. */
bookingType?: string; bookingType?: string;
/** CONTAINER or BULK. */
freightType?: string;
/** IMPORT / EXPORT / DOMESTIC. */
tradeDirection?: string;
/** Created-date range (ISO). */
createdFrom?: string;
createdTo?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
sortBy?: string; sortBy?: string;