mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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"
|
||||
|
||||
55
apps/edr-freight-web/portal/src/components/ModeIndicator.tsx
Normal file
55
apps/edr-freight-web/portal/src/components/ModeIndicator.tsx
Normal 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;
|
||||
30
apps/edr-freight-web/portal/src/constants/profileMode.ts
Normal file
30
apps/edr-freight-web/portal/src/constants/profileMode.ts
Normal 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.`;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" : ""}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"}
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user