mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +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 =
|
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 =
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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 { 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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 { 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" : ""}
|
||||||
|
|||||||
@@ -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";
|
} 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"}
|
||||||
|
|||||||
@@ -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" }}>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user