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 =
await this.bookingsService.resolveCustomerCompanyId(userId);
// 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
// resolves; otherwise fall back to company-level scoping.
const companyProfileId =

View File

@@ -35,6 +35,8 @@ export interface BookingListFilterOptions {
paymentCurrency?: string;
paymentStatus?: string;
excludePaymentStatus?: string;
createdFrom?: string;
createdTo?: string;
allowConsolidation?: boolean;
consolidationPaired?: string;
}
@@ -436,7 +438,18 @@ export class BookingsRepository extends BaseRepository<Booking> {
pageSize: number;
sortBy?: string;
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 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>> {
@@ -591,6 +619,17 @@ export class BookingsRepository extends BaseRepository<Booking> {
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) {
qb.andWhere('booking.trade_direction = :tradeDirection', {
tradeDirection: options.tradeDirection,

View File

@@ -42,6 +42,20 @@ import {
import { Booking } from './entities/booking.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 NEEDS_ACTION_STATUSES = [
'SUBMITTED',
@@ -628,7 +642,7 @@ export class BookingsService {
filter: FilterBookingDto,
forceCompanyId?: string,
forceCompanyProfileId?: string,
): Promise<{ items: Booking[]; total: number }> {
): Promise<PaginatedBookings> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const statusFilter = this.parseStatusFilter(filter);
@@ -654,6 +668,8 @@ export class BookingsService {
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
paymentStatus: filter.paymentStatus,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
allowConsolidation: filter.allowConsolidation,
consolidationPaired: filter.consolidationPaired,
sortBy: filter.sortBy,
@@ -676,7 +692,7 @@ export class BookingsService {
async findMyPayable(
userId: string,
filter: FilterBookingDto,
): Promise<{ items: Booking[]; total: number }> {
): Promise<PaginatedBookings> {
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
// Scope to the active operational profile when one resolves; fall back to
// company-level so not-yet-onboarded customers still see their payables.
@@ -823,6 +839,8 @@ export class BookingsService {
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
paymentStatus: filter.paymentStatus,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
allowConsolidation: filter.allowConsolidation,
consolidationPaired: filter.consolidationPaired,
};

View File

@@ -1,6 +1,6 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsOptional, IsUUID } from 'class-validator';
import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
import {
BOOKING_STATUSES,
BOOKING_TYPES,
@@ -62,6 +62,16 @@ export class FilterBookingDto {
@IsIn([...BOOKING_TYPES])
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 })
@IsOptional()
@IsIn([...TRADE_DIRECTIONS])

View File

@@ -41,6 +41,7 @@ import {
type ReactNode,
useState,
} from "react";
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
export interface SidebarItem {
label: string;
@@ -79,14 +80,6 @@ type SwitchResult =
| { success: true; data?: unknown }
| { 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 {
return name
.split(" ")
@@ -813,8 +806,7 @@ export function AppLayout({
<Text size="sm" c="dimmed">
You don't have an {modeLabel(targetMode).toLowerCase()} profile yet.
Add your business license to create one and switch to{" "}
{modeLabel(targetMode).toLowerCase()} mode. A new reference will be
generated automatically.
{modeLabel(targetMode).toLowerCase()} mode.
</Text>
<FileInput
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 { memo } from "react";
import { Link } from "react-router-dom";
import { ModeIndicator } from "@/components/ModeIndicator";
import { cv } from "../constants";
interface HelloSectionProps {
@@ -19,9 +20,12 @@ export const HelloSection = memo(function HelloSection({
<Text size="sm" c="edr-muted">
{greeting}
</Text>
<Text fz={26} fw={800} mt={2} c="edr-text" className="tracking-tight">
{companyName} 👋
</Text>
<Group gap={12} align="center" mt={2} wrap="wrap">
<Text fz={26} fw={800} c="edr-text" className="tracking-tight">
{companyName} 👋
</Text>
<ModeIndicator />
</Group>
</Box>
<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 { ActivityCard } from "./components/ActivityCard";
import { ContainersCard } from "./components/ContainersCard";
import { ContractCard } from "./components/ContractCard";
import { DocRow, IconSquare } from "./components/Documents";
import { KeyFactsStrip } from "./components/KeyFactsStrip";
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
import {
CancelledBanner,
@@ -113,6 +115,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
{showPairedNotice && <ConsolidationPairedNotice />}
<KeyFactsStrip booking={booking} />
<ContractCard booking={booking} navigate={navigate} />
<BodyGrid
@@ -120,6 +124,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
<>
<ShipmentDetailsCard booking={booking} />
<ContainersCard booking={booking} />
{booking.files && booking.files.length > 0 && (
<SectionCard>
<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 { 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.
const TRACKABLE_STATUSES = new Set([
@@ -320,24 +327,54 @@ export default function MyBookings() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [statusFilter, setStatusFilter] = useState<StatusFilterKey>("all");
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>(
null,
);
const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
const selectFilter = (key: StatusFilterKey) => {
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(
() => ({
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,
pageSize: pagination.pageSize,
}),
[statuses, pagination.pageIndex, pagination.pageSize],
[
statuses,
typeFilter,
freightFilter,
createdFrom,
createdTo,
pagination.pageIndex,
pagination.pageSize,
],
);
const { data, isLoading, isError } = useQuery(
@@ -358,14 +395,20 @@ export default function MyBookings() {
const doneCount = useStatusCount(
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> = {
all: allCount,
active: activeCount,
payment: paymentCount,
draft: draftCount,
done: doneCount,
transit: undefined,
closed: undefined,
transit: transitCount,
closed: closedCount,
};
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",
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",
size: 190,
@@ -536,9 +607,12 @@ export default function MyBookings() {
{/* ── 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>
<Group gap={10} align="center">
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
Bookings
</Title>
<ModeIndicator />
</Group>
<Text size="sm" c="edr-muted" mt={4}>
Track every cargo booking from draft to delivery.
</Text>
@@ -606,9 +680,79 @@ export default function MyBookings() {
radius="md"
checkIconPosition="right"
comboboxProps={{ withinPortal: true }}
style={{ width: 200 }}
style={{ width: 190 }}
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>
<Text fz={12} c="edr-muted">
{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";
import { api } from "@/services/api";
import { ModeIndicator } from "@/components/ModeIndicator";
import { PayNowButton } from "../bookings/payments/PayNowButton";
import {
BORDER,
@@ -120,6 +121,7 @@ export default function ContractDetailPage() {
{contract.reference}
</Title>
<ContractStatusBadge status={contract.status} />
<ModeIndicator size="sm" />
</Group>
<Text size="sm" c="dimmed" mt={2}>
General contract · {isContainer ? "Containerised" : "Bulk"}

View File

@@ -7,13 +7,14 @@ import {
Card,
Group,
Paper,
Select,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import { Layers, Plus, Search } from "lucide-react";
import { Layers, Plus, Search, X } from "lucide-react";
import { api } from "@/services/api";
import type { BookingListFilter } from "@/services/bookings.service";
@@ -24,22 +25,46 @@ import {
type ColumnDef,
usePagination,
} from "@edr/ui-common";
import { ModeIndicator } from "@/components/ModeIndicator";
import { CargoModeCell, PaymentBadge } from "../bookings/booking-display";
import { ContractStatusBadge, GREEN, INK, MUTED } from "./contract-ui";
export default function ContractsList() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
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(
() => ({
bookingType: "GENERAL_CONTRACT",
freightType: freightFilter ?? undefined,
createdFrom: createdFrom || undefined,
createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
sortBy: "createdAt",
sortOrder: "DESC",
}),
[pagination.pageIndex, pagination.pageSize],
[
freightFilter,
createdFrom,
createdTo,
pagination.pageIndex,
pagination.pageSize,
],
);
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",
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",
header: () => <ColHeader label="Ordering Until" />,
@@ -138,9 +173,12 @@ export default function ContractsList() {
{/* Header */}
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
<Box>
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
General Contracts
</Title>
<Group gap={10} align="center">
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
General Contracts
</Title>
<ModeIndicator />
</Group>
<Text size="sm" c="edr-muted" mt={4}>
Reserve a quantity once, then place orders against it until the
contract runs out or its window closes.
@@ -163,16 +201,71 @@ export default function ContractsList() {
hint="accepting orders"
/>
{/* Search */}
<TextInput
placeholder="Search by reference or route…"
leftSection={<Search size={16} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
radius="md"
styles={{ input: { height: 44 } }}
maw={420}
/>
{/* Search + filters */}
<Group gap={10} wrap="wrap" align="center">
<TextInput
placeholder="Search by reference or route…"
leftSection={<Search size={16} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
radius="md"
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 */}
<Card p={0} style={{ overflow: "hidden" }}>

View File

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