mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
add contract link
This commit is contained in:
@@ -28,6 +28,8 @@ describe("ConsolidationApprovalService", () => {
|
|||||||
approvals?: Partial<Record<string, jest.Mock>>;
|
approvals?: Partial<Record<string, jest.Mock>>;
|
||||||
bookingsRepository?: Partial<Record<string, jest.Mock>>;
|
bookingsRepository?: Partial<Record<string, jest.Mock>>;
|
||||||
bookingsService?: Partial<Record<string, jest.Mock>>;
|
bookingsService?: Partial<Record<string, jest.Mock>>;
|
||||||
|
/** Contract rows the id→reference lookup should return. */
|
||||||
|
contracts?: { id: string; reference: string }[];
|
||||||
/** Yard ids the caller is scoped to; null = unrestricted. */
|
/** Yard ids the caller is scoped to; null = unrestricted. */
|
||||||
yardScope?: string[] | null;
|
yardScope?: string[] | null;
|
||||||
} = {},
|
} = {},
|
||||||
@@ -68,8 +70,14 @@ describe("ConsolidationApprovalService", () => {
|
|||||||
consolidationRejectedToStaff: jest.fn(),
|
consolidationRejectedToStaff: jest.fn(),
|
||||||
operationRequestedToStaff: jest.fn(),
|
operationRequestedToStaff: jest.fn(),
|
||||||
};
|
};
|
||||||
|
const contractRepo = {
|
||||||
|
find: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(overrides.contracts ?? []),
|
||||||
|
};
|
||||||
const dataSource = {
|
const dataSource = {
|
||||||
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
|
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
|
||||||
|
getRepository: jest.fn(() => contractRepo),
|
||||||
};
|
};
|
||||||
const yardScope = {
|
const yardScope = {
|
||||||
getScopedYardIds: jest
|
getScopedYardIds: jest
|
||||||
@@ -85,7 +93,14 @@ describe("ConsolidationApprovalService", () => {
|
|||||||
dataSource as never,
|
dataSource as never,
|
||||||
yardScope as never,
|
yardScope as never,
|
||||||
);
|
);
|
||||||
return { service, approvals, bookingsRepository, notifier, yardScope };
|
return {
|
||||||
|
service,
|
||||||
|
approvals,
|
||||||
|
bookingsRepository,
|
||||||
|
notifier,
|
||||||
|
yardScope,
|
||||||
|
contractRepo,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
it("holds BOTH halves at the gate when a pairing is created", async () => {
|
it("holds BOTH halves at the gate when a pairing is created", async () => {
|
||||||
@@ -416,4 +431,51 @@ describe("ConsolidationApprovalService", () => {
|
|||||||
status: "OPERATION_REQUEST_PENDING",
|
status: "OPERATION_REQUEST_PENDING",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("attaches each half's contract reference for the queue link", async () => {
|
||||||
|
// Booking has no contract relation (contract–booking split), so the
|
||||||
|
// references are batch-loaded by id — one query for the whole page.
|
||||||
|
const { service, contractRepo } = makeService({
|
||||||
|
approvals: {
|
||||||
|
findQueuePage: jest.fn().mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
...PENDING,
|
||||||
|
booking: { id: "b-1", contractId: "c-1" },
|
||||||
|
partnerBooking: { id: "b-2", contractId: "c-2" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
contracts: [
|
||||||
|
{ id: "c-1", reference: "CT-001" },
|
||||||
|
{ id: "c-2", reference: "CT-002" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = await service.queue();
|
||||||
|
|
||||||
|
expect(items[0].contractReference).toBe("CT-001");
|
||||||
|
expect(items[0].partnerContractReference).toBe("CT-002");
|
||||||
|
expect(contractRepo.find).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the contract reference null when a half has no contract", async () => {
|
||||||
|
const { service, contractRepo } = makeService({
|
||||||
|
approvals: {
|
||||||
|
findQueuePage: jest.fn().mockResolvedValue({
|
||||||
|
items: [{ ...PENDING, booking: { id: "b-1" }, partnerBooking: null }],
|
||||||
|
total: 1,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = await service.queue();
|
||||||
|
|
||||||
|
expect(items[0].contractReference).toBeNull();
|
||||||
|
expect(items[0].partnerContractReference).toBeNull();
|
||||||
|
// Nothing to look up — no query at all.
|
||||||
|
expect(contractRepo.find).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { DataSource } from "typeorm";
|
import { DataSource, In } from "typeorm";
|
||||||
|
|
||||||
import { Booking } from "./entities/booking.entity";
|
import { Booking } from "./entities/booking.entity";
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +20,7 @@ import { BookingsRepository } from "./bookings.repository";
|
|||||||
import { BookingsService } from "./bookings.service";
|
import { BookingsService } from "./bookings.service";
|
||||||
import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service";
|
import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service";
|
||||||
import { YardScopeService } from "../rule-engine/services/yard-scope.service";
|
import { YardScopeService } from "../rule-engine/services/yard-scope.service";
|
||||||
|
import { Contract } from "../contracts/entities/contract.entity";
|
||||||
|
|
||||||
/** Where a rejected pair goes back to, so GL can fix and resubmit. */
|
/** Where a rejected pair goes back to, so GL can fix and resubmit. */
|
||||||
const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED";
|
const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED";
|
||||||
@@ -31,6 +32,9 @@ export const CONSOLIDATION_APPROVAL_PENDING = "CONSOLIDATION_APPROVAL_PENDING";
|
|||||||
export type ConsolidationApprovalView = ConsolidationApproval & {
|
export type ConsolidationApprovalView = ConsolidationApproval & {
|
||||||
requestedByName: string | null;
|
requestedByName: string | null;
|
||||||
decidedByName: string | null;
|
decidedByName: string | null;
|
||||||
|
/** Contract the booking half was created under — reviewers work by contract. */
|
||||||
|
contractReference: string | null;
|
||||||
|
partnerContractReference: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -281,12 +285,18 @@ export class ConsolidationApprovalService {
|
|||||||
const names = await this.bookingsRepository.resolveStaffNames(
|
const names = await this.bookingsRepository.resolveStaffNames(
|
||||||
rows.flatMap((r) => [r.requestedBy, r.decidedBy]),
|
rows.flatMap((r) => [r.requestedBy, r.decidedBy]),
|
||||||
);
|
);
|
||||||
|
const contractRefs = await this.contractReferences(rows);
|
||||||
|
const refOf = (contractId?: string | null) =>
|
||||||
|
contractId ? (contractRefs.get(contractId) ?? null) : null;
|
||||||
|
|
||||||
const items = rows.map((row) => ({
|
const items = rows.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
requestedByName: row.requestedBy
|
requestedByName: row.requestedBy
|
||||||
? (names.get(row.requestedBy) ?? null)
|
? (names.get(row.requestedBy) ?? null)
|
||||||
: null,
|
: null,
|
||||||
decidedByName: row.decidedBy ? (names.get(row.decidedBy) ?? null) : null,
|
decidedByName: row.decidedBy ? (names.get(row.decidedBy) ?? null) : null,
|
||||||
|
contractReference: refOf(row.booking?.contractId),
|
||||||
|
partnerContractReference: refOf(row.partnerBooking?.contractId),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / pageSize);
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
@@ -305,6 +315,32 @@ export class ConsolidationApprovalService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract id → reference for the bookings on this page.
|
||||||
|
*
|
||||||
|
* Booking has no contract relation (contract–booking split), so the
|
||||||
|
* references are batch-loaded by id rather than joined — one query per page,
|
||||||
|
* not one per row.
|
||||||
|
*/
|
||||||
|
private async contractReferences(
|
||||||
|
rows: ConsolidationApproval[],
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const ids = [
|
||||||
|
...new Set(
|
||||||
|
rows
|
||||||
|
.flatMap((r) => [r.booking?.contractId, r.partnerBooking?.contractId])
|
||||||
|
.filter((id): id is string => !!id),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (!ids.length) return new Map();
|
||||||
|
|
||||||
|
const contracts = await this.dataSource.getRepository(Contract).find({
|
||||||
|
where: { id: In(ids) },
|
||||||
|
select: { id: true, reference: true },
|
||||||
|
});
|
||||||
|
return new Map(contracts.map((c) => [c.id, c.reference]));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Yard ids the caller may see, or undefined for unrestricted.
|
* Yard ids the caller may see, or undefined for unrestricted.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
|
import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
|
||||||
import {
|
import { Box, Button, Card, Group, Modal, Stack, Text } from "@mantine/core";
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Group,
|
|
||||||
Modal,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Calendar,
|
Calendar,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
|
FileText,
|
||||||
LayoutList,
|
LayoutList,
|
||||||
Link2,
|
Link2,
|
||||||
Package,
|
Package,
|
||||||
@@ -23,13 +16,19 @@ import {
|
|||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
||||||
import { ExportButton } from "@/components/export/ExportButton";
|
import { ExportButton } from "@/components/export/ExportButton";
|
||||||
import { formatDate, humanize } from "@/lib/format";
|
import { formatDate, humanize } from "@/lib/format";
|
||||||
import { FilterBar, dateRangeParams, routeParams, useFilters, type FilterDef } from "@/components/filters";
|
import {
|
||||||
|
FilterBar,
|
||||||
|
dateRangeParams,
|
||||||
|
routeParams,
|
||||||
|
useFilters,
|
||||||
|
type FilterDef,
|
||||||
|
} from "@/components/filters";
|
||||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||||
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
|
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
|
||||||
@@ -150,36 +149,97 @@ export default function BookingRequestsPage() {
|
|||||||
// split), so a deep link can never land behind "More filters" unseen.
|
// split), so a deep link can never land behind "More filters" unseen.
|
||||||
const bookingFilterDefs: FilterDef[] = useMemo(
|
const bookingFilterDefs: FilterDef[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ key: "customerKind", label: "Booked by", type: "enum", multiple: false, options: CUSTOMER_KIND_OPTIONS },
|
|
||||||
{ key: "bookingType", label: "Kind", type: "enum", multiple: false, options: BOOKING_KIND_OPTIONS },
|
|
||||||
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
|
|
||||||
{
|
{
|
||||||
key: "tradeDirection", label: "Direction", type: "enum", multiple: false,
|
key: "customerKind",
|
||||||
|
label: "Booked by",
|
||||||
|
type: "enum",
|
||||||
|
multiple: false,
|
||||||
|
options: CUSTOMER_KIND_OPTIONS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "bookingType",
|
||||||
|
label: "Kind",
|
||||||
|
type: "enum",
|
||||||
|
multiple: false,
|
||||||
|
options: BOOKING_KIND_OPTIONS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "statuses",
|
||||||
|
label: "Status",
|
||||||
|
type: "enum",
|
||||||
|
options: STATUS_OPTIONS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tradeDirection",
|
||||||
|
label: "Direction",
|
||||||
|
type: "enum",
|
||||||
|
multiple: false,
|
||||||
options: filterOptions(TRADE_DIRECTION_OPTIONS),
|
options: filterOptions(TRADE_DIRECTION_OPTIONS),
|
||||||
},
|
},
|
||||||
{ key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS },
|
{
|
||||||
{ key: "serviceTypeId", label: "Service", type: "enum", multiple: false, options: serviceTypeOptions },
|
key: "freightType",
|
||||||
{ key: "paymentStatus", label: "Payment", type: "enum", multiple: false, options: PAYMENT_STATUS_OPTIONS, secondary: true },
|
label: "Freight",
|
||||||
|
type: "enum",
|
||||||
|
multiple: false,
|
||||||
|
options: FREIGHT_TYPE_OPTIONS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "serviceTypeId",
|
||||||
|
label: "Service",
|
||||||
|
type: "enum",
|
||||||
|
multiple: false,
|
||||||
|
options: serviceTypeOptions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "paymentStatus",
|
||||||
|
label: "Payment",
|
||||||
|
type: "enum",
|
||||||
|
multiple: false,
|
||||||
|
options: PAYMENT_STATUS_OPTIONS,
|
||||||
|
secondary: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// Wins over the `paymentStatus` filter above — the queue is by
|
// Wins over the `paymentStatus` filter above — the queue is by
|
||||||
// definition PAID — because it's later in this array: toApiParams
|
// definition PAID — because it's later in this array: toApiParams
|
||||||
// merges defs in order, so a later toParams overwrites an earlier one.
|
// merges defs in order, so a later toParams overwrites an earlier one.
|
||||||
key: "paidUnallocated", label: "Allocation", type: "boolean", secondary: true,
|
key: "paidUnallocated",
|
||||||
|
label: "Allocation",
|
||||||
|
type: "boolean",
|
||||||
|
secondary: true,
|
||||||
trueLabel: "Paid, not allocated",
|
trueLabel: "Paid, not allocated",
|
||||||
toParams: (v) => (v.v[0] === "true" ? { paymentStatus: "PAID", assignedToSchedule: "false" } : {}),
|
toParams: (v) =>
|
||||||
|
v.v[0] === "true"
|
||||||
|
? { paymentStatus: "PAID", assignedToSchedule: "false" }
|
||||||
|
: {},
|
||||||
},
|
},
|
||||||
{ key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS, secondary: true },
|
|
||||||
{
|
{
|
||||||
key: "route", label: "Route", type: "route", options: yardOptions,
|
key: "isGovernment",
|
||||||
|
label: "Ownership",
|
||||||
|
type: "enum",
|
||||||
|
multiple: false,
|
||||||
|
options: OWNERSHIP_OPTIONS,
|
||||||
|
secondary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "route",
|
||||||
|
label: "Route",
|
||||||
|
type: "route",
|
||||||
|
options: yardOptions,
|
||||||
toParams: routeParams("originYardId", "destinationYardId"),
|
toParams: routeParams("originYardId", "destinationYardId"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "created", label: "Created", type: "date", secondary: true,
|
key: "created",
|
||||||
|
label: "Created",
|
||||||
|
type: "date",
|
||||||
|
secondary: true,
|
||||||
operators: ["between", "before", "after"],
|
operators: ["between", "before", "after"],
|
||||||
toParams: dateRangeParams("createdFrom", "createdTo"),
|
toParams: dateRangeParams("createdFrom", "createdTo"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "scheduled", label: "Scheduled", type: "date", secondary: true,
|
key: "scheduled",
|
||||||
|
label: "Scheduled",
|
||||||
|
type: "date",
|
||||||
|
secondary: true,
|
||||||
operators: ["between", "before", "after"],
|
operators: ["between", "before", "after"],
|
||||||
toParams: dateRangeParams("scheduledFrom", "scheduledTo"),
|
toParams: dateRangeParams("scheduledFrom", "scheduledTo"),
|
||||||
},
|
},
|
||||||
@@ -187,19 +247,24 @@ export default function BookingRequestsPage() {
|
|||||||
[filterOptions, yardOptions, serviceTypeOptions],
|
[filterOptions, yardOptions, serviceTypeOptions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const controls = useFilters(bookingFilterDefs, { defaultSort: "createdAt:DESC", pageSize: 10 });
|
const controls = useFilters(bookingFilterDefs, {
|
||||||
|
defaultSort: "createdAt:DESC",
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
|
||||||
const filter: BookingListFilter = useMemo(
|
const filter: BookingListFilter = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
...(controls.params as unknown as BookingListFilter),
|
...(controls.params as unknown as BookingListFilter),
|
||||||
// React Query cache key per kind selection ("ALL" when unfiltered) —
|
// React Query cache key per kind selection ("ALL" when unfiltered) —
|
||||||
// kept as a param the API ignores, matching the pre-migration cache key.
|
// kept as a param the API ignores, matching the pre-migration cache key.
|
||||||
tab: (controls.values.bookingType?.v[0] as BookingKind | undefined) ?? "ALL",
|
tab:
|
||||||
|
(controls.values.bookingType?.v[0] as BookingKind | undefined) ?? "ALL",
|
||||||
}),
|
}),
|
||||||
[controls.params, controls.values.bookingType],
|
[controls.params, controls.values.bookingType],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
|
const { data, isLoading, isError, refetch, isFetching } =
|
||||||
|
useBookingList(filter);
|
||||||
const primaryAllocateId = allocateIds[0];
|
const primaryAllocateId = allocateIds[0];
|
||||||
const { data: allocateBooking } = useBookingDetail(
|
const { data: allocateBooking } = useBookingDetail(
|
||||||
allocateOpen ? primaryAllocateId : undefined,
|
allocateOpen ? primaryAllocateId : undefined,
|
||||||
@@ -262,8 +327,9 @@ export default function BookingRequestsPage() {
|
|||||||
async (row: BookingListRow) => {
|
async (row: BookingListRow) => {
|
||||||
setAllocatingId(row.id);
|
setAllocatingId(row.id);
|
||||||
try {
|
try {
|
||||||
const candidates =
|
const candidates = await trainSchedulingService.getAllocationCandidates(
|
||||||
await trainSchedulingService.getAllocationCandidates(row.id);
|
row.id,
|
||||||
|
);
|
||||||
if (candidates.sameDay.length > 0) {
|
if (candidates.sameDay.length > 0) {
|
||||||
const target = candidates.sameDay[0];
|
const target = candidates.sameDay[0];
|
||||||
await trainSchedulingService.allocatePaidBooking(row.id, target.id);
|
await trainSchedulingService.allocatePaidBooking(row.id, target.id);
|
||||||
@@ -330,7 +396,9 @@ export default function BookingRequestsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 max-w-[220px]">
|
<div className="min-w-0 max-w-[220px]">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<p className="truncate font-medium text-foreground">{b.reference}</p>
|
<p className="truncate font-medium text-foreground">
|
||||||
|
{b.reference}
|
||||||
|
</p>
|
||||||
<Badge
|
<Badge
|
||||||
variant={isGeneral ? "secondary" : "outline"}
|
variant={isGeneral ? "secondary" : "outline"}
|
||||||
className="h-5 shrink-0 px-1.5 text-[10px] font-medium"
|
className="h-5 shrink-0 px-1.5 text-[10px] font-medium"
|
||||||
@@ -338,6 +406,26 @@ export default function BookingRequestsPage() {
|
|||||||
{isGeneral ? "General" : "One-time"}
|
{isGeneral ? "General" : "One-time"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
{b.contractReference ? (
|
||||||
|
<p className="mt-0.5 flex items-center gap-1 truncate text-xs">
|
||||||
|
<FileText className="size-3 shrink-0 text-muted-foreground opacity-70" />
|
||||||
|
{b.contractId ? (
|
||||||
|
<Link
|
||||||
|
to={`/dashboard/contract-requests/${b.contractId}/view`}
|
||||||
|
// The row itself opens the booking — without this the
|
||||||
|
// contract link would never win the click.
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="truncate text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{b.contractReference}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="truncate text-muted-foreground">
|
||||||
|
{b.contractReference}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||||
{b.isShippingLine ? (
|
{b.isShippingLine ? (
|
||||||
<Ship className="size-3 shrink-0 opacity-70" />
|
<Ship className="size-3 shrink-0 opacity-70" />
|
||||||
@@ -346,7 +434,10 @@ export default function BookingRequestsPage() {
|
|||||||
)}
|
)}
|
||||||
{b.customerLabel}
|
{b.customerLabel}
|
||||||
{b.isShippingLine ? (
|
{b.isShippingLine ? (
|
||||||
<Badge variant="secondary" className="h-4 shrink-0 px-1 text-[9px] font-medium">
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="h-4 shrink-0 px-1 text-[9px] font-medium"
|
||||||
|
>
|
||||||
Shipping line
|
Shipping line
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -382,7 +473,9 @@ export default function BookingRequestsPage() {
|
|||||||
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||||
<span className="max-w-[8rem] truncate">{b.originLabel}</span>
|
<span className="max-w-[8rem] truncate">{b.originLabel}</span>
|
||||||
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
|
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
<span className="max-w-[8rem] truncate">{b.destinationLabel}</span>
|
<span className="max-w-[8rem] truncate">
|
||||||
|
{b.destinationLabel}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<Badge
|
<Badge
|
||||||
@@ -443,7 +536,8 @@ export default function BookingRequestsPage() {
|
|||||||
size: 140,
|
size: 140,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const b = row.original;
|
const b = row.original;
|
||||||
const needsAllocation = b.paymentStatus === "PAID" && !b.trainScheduleId;
|
const needsAllocation =
|
||||||
|
b.paymentStatus === "PAID" && !b.trainScheduleId;
|
||||||
return (
|
return (
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
{needsAllocation ? (
|
{needsAllocation ? (
|
||||||
@@ -474,61 +568,61 @@ export default function BookingRequestsPage() {
|
|||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Booking requests"
|
title="Booking requests"
|
||||||
subtitle="Review, approve, and schedule freight booking requests."
|
subtitle="Review, approve, and schedule freight booking requests."
|
||||||
action={
|
action={
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
leftSection={<Plus size={18} />}
|
leftSection={<Plus size={18} />}
|
||||||
onClick={() => navigate("/dashboard/booking-requests/new")}
|
onClick={() => navigate("/dashboard/booking-requests/new")}
|
||||||
>
|
>
|
||||||
Create booking
|
Create booking
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
leftSection={<RefreshCw size={16} />}
|
leftSection={<RefreshCw size={16} />}
|
||||||
loading={isFetching}
|
loading={isFetching}
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
>
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<KpiStrip
|
<KpiStrip
|
||||||
loading={summaryLoading}
|
loading={summaryLoading}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
label: "In queue",
|
label: "In queue",
|
||||||
value: metrics?.inQueue ?? 0,
|
value: metrics?.inQueue ?? 0,
|
||||||
icon: LayoutList,
|
icon: LayoutList,
|
||||||
color: "edr-green",
|
color: "edr-green",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Needs action",
|
label: "Needs action",
|
||||||
value: metrics?.needsAction ?? 0,
|
value: metrics?.needsAction ?? 0,
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
color: "yellow",
|
color: "yellow",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Urgent",
|
label: "Urgent",
|
||||||
value: metrics?.urgent ?? 0,
|
value: metrics?.urgent ?? 0,
|
||||||
icon: AlertTriangle,
|
icon: AlertTriangle,
|
||||||
color: "red",
|
color: "red",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Completed",
|
label: "Completed",
|
||||||
value: tabCounts?.completed ?? 0,
|
value: tabCounts?.completed ?? 0,
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
color: "edr-green",
|
color: "edr-green",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Status tabs replaced by booking-kind tabs (one-time / general). The
|
{/* Status tabs replaced by booking-kind tabs (one-time / general). The
|
||||||
old BookingStatusTabs is commented out — status is now a filter select.
|
old BookingStatusTabs is commented out — status is now a filter select.
|
||||||
<BookingStatusTabs
|
<BookingStatusTabs
|
||||||
active={activeTab}
|
active={activeTab}
|
||||||
@@ -540,92 +634,95 @@ export default function BookingRequestsPage() {
|
|||||||
/>
|
/>
|
||||||
*/}
|
*/}
|
||||||
|
|
||||||
<Card p={0}>
|
<Card p={0}>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Box px="md" pt="sm" pb="xs" w="100%">
|
<Box px="md" pt="sm" pb="xs" w="100%">
|
||||||
<FilterBar
|
<FilterBar
|
||||||
defs={bookingFilterDefs}
|
defs={bookingFilterDefs}
|
||||||
controls={controls}
|
controls={controls}
|
||||||
searchPlaceholder="Search booking, contract, customer or shipping line…"
|
searchPlaceholder="Search booking, contract, customer or shipping line…"
|
||||||
viewId="booking-requests"
|
viewId="booking-requests"
|
||||||
>
|
>
|
||||||
<ExportButton datasetKey="bookings" params={controls.params} />
|
<ExportButton datasetKey="bookings" params={controls.params} />
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{showEmpty ? (
|
||||||
|
<Box px="md" pb="md">
|
||||||
|
<BookingTableEmpty
|
||||||
|
isError={isError}
|
||||||
|
hasSearch={hasSearch}
|
||||||
|
onRetry={handleRefresh}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
) : (
|
||||||
{showEmpty ? (
|
<Box style={{ overflowX: "auto" }} w="100%">
|
||||||
<Box px="md" pb="md">
|
<DataTable
|
||||||
<BookingTableEmpty
|
columns={columns}
|
||||||
isError={isError}
|
data={rows}
|
||||||
hasSearch={hasSearch}
|
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||||
onRetry={handleRefresh}
|
onRowClick={handleRowClick}
|
||||||
/>
|
{...controls.tableProps(total)}
|
||||||
</Box>
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
) : (
|
footer={DataTableFooter}
|
||||||
<Box style={{ overflowX: "auto" }} w="100%">
|
/>
|
||||||
<DataTable
|
</Box>
|
||||||
columns={columns}
|
)}
|
||||||
data={rows}
|
|
||||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
|
||||||
onRowClick={handleRowClick}
|
|
||||||
{...controls.tableProps(total)}
|
|
||||||
containerClassName="border-0 shadow-none bg-transparent"
|
|
||||||
footer={DataTableFooter}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
opened={otherDayModal !== null}
|
|
||||||
onClose={() => setOtherDayModal(null)}
|
|
||||||
title="Allocate to another date"
|
|
||||||
centered
|
|
||||||
>
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
No train on {otherDayModal ? formatDate(otherDayModal.booking.scheduledDate) : "the booking's day"}{" "}
|
|
||||||
fits booking {otherDayModal?.booking.reference}. These trains on
|
|
||||||
other dates do — the customer will be notified of the date change.
|
|
||||||
</Text>
|
|
||||||
{otherDayModal?.candidates.map((c) => (
|
|
||||||
<Group key={c.id} justify="space-between" wrap="nowrap">
|
|
||||||
<div>
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
{c.reference ?? "Train"}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
Departs {formatDate(c.scheduledDepartureDate)}
|
|
||||||
{c.direction ? ` · ${c.direction}` : ""}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="compact-sm"
|
|
||||||
color="edr-green"
|
|
||||||
loading={allocatingId === otherDayModal.booking.id}
|
|
||||||
onClick={() => void handleAllocateOtherDay(c)}
|
|
||||||
>
|
|
||||||
Allocate
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
))}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
{allocateBooking ? (
|
<Modal
|
||||||
<AllocateBookingWizard
|
opened={otherDayModal !== null}
|
||||||
booking={allocateBooking}
|
onClose={() => setOtherDayModal(null)}
|
||||||
opened={allocateOpen}
|
title="Allocate to another date"
|
||||||
onClose={() => {
|
centered
|
||||||
setAllocateOpen(false);
|
>
|
||||||
setAllocateIds([]);
|
<Stack gap="sm">
|
||||||
void refetch();
|
<Text size="sm" c="dimmed">
|
||||||
}}
|
No train on{" "}
|
||||||
initialBookingIds={allocateIds}
|
{otherDayModal
|
||||||
/>
|
? formatDate(otherDayModal.booking.scheduledDate)
|
||||||
) : null}
|
: "the booking's day"}{" "}
|
||||||
|
fits booking {otherDayModal?.booking.reference}. These trains on
|
||||||
|
other dates do — the customer will be notified of the date change.
|
||||||
|
</Text>
|
||||||
|
{otherDayModal?.candidates.map((c) => (
|
||||||
|
<Group key={c.id} justify="space-between" wrap="nowrap">
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{c.reference ?? "Train"}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Departs {formatDate(c.scheduledDepartureDate)}
|
||||||
|
{c.direction ? ` · ${c.direction}` : ""}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
color="edr-green"
|
||||||
|
loading={allocatingId === otherDayModal.booking.id}
|
||||||
|
onClick={() => void handleAllocateOtherDay(c)}
|
||||||
|
>
|
||||||
|
Allocate
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{allocateBooking ? (
|
||||||
|
<AllocateBookingWizard
|
||||||
|
booking={allocateBooking}
|
||||||
|
opened={allocateOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setAllocateOpen(false);
|
||||||
|
setAllocateIds([]);
|
||||||
|
void refetch();
|
||||||
|
}}
|
||||||
|
initialBookingIds={allocateIds}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,15 @@ import {
|
|||||||
Textarea,
|
Textarea,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { AlertCircle, Check, Clock, Link2, User, X } from "lucide-react";
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Check,
|
||||||
|
Clock,
|
||||||
|
FileText,
|
||||||
|
Link2,
|
||||||
|
User,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from "@/components/page";
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
@@ -234,6 +242,8 @@ export default function ConsolidationApprovalsPage() {
|
|||||||
row.booking?.reference ?? row.bookingReference
|
row.booking?.reference ?? row.bookingReference
|
||||||
}
|
}
|
||||||
company={row.booking?.company?.name}
|
company={row.booking?.company?.name}
|
||||||
|
contractReference={row.contractReference}
|
||||||
|
contractId={row.booking?.contractId}
|
||||||
/>
|
/>
|
||||||
<BookingSide
|
<BookingSide
|
||||||
id={row.partnerBookingId}
|
id={row.partnerBookingId}
|
||||||
@@ -242,6 +252,8 @@ export default function ConsolidationApprovalsPage() {
|
|||||||
row.partnerBookingReference
|
row.partnerBookingReference
|
||||||
}
|
}
|
||||||
company={row.partnerBooking?.company?.name}
|
company={row.partnerBooking?.company?.name}
|
||||||
|
contractReference={row.partnerContractReference}
|
||||||
|
contractId={row.partnerBooking?.contractId}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -417,15 +429,23 @@ export default function ConsolidationApprovalsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One half of the wagon: its reference (linked) and whose cargo it is. */
|
/**
|
||||||
|
* One half of the wagon: its booking reference, the contract it was raised
|
||||||
|
* under, and whose cargo it is. Both references link out — a reviewer deciding
|
||||||
|
* a pairing usually wants the contract, not just the shipment.
|
||||||
|
*/
|
||||||
function BookingSide({
|
function BookingSide({
|
||||||
id,
|
id,
|
||||||
reference,
|
reference,
|
||||||
company,
|
company,
|
||||||
|
contractReference,
|
||||||
|
contractId,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
reference?: string | null;
|
reference?: string | null;
|
||||||
company?: string | null;
|
company?: string | null;
|
||||||
|
contractReference?: string | null;
|
||||||
|
contractId?: string | null;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Box style={{ minWidth: 0 }}>
|
<Box style={{ minWidth: 0 }}>
|
||||||
@@ -439,6 +459,32 @@ function BookingSide({
|
|||||||
>
|
>
|
||||||
{reference ?? "—"}
|
{reference ?? "—"}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{contractReference && (
|
||||||
|
<Group gap={4} wrap="nowrap" mt={2}>
|
||||||
|
<FileText
|
||||||
|
size={11}
|
||||||
|
className="shrink-0"
|
||||||
|
color="var(--mantine-color-dimmed)"
|
||||||
|
/>
|
||||||
|
{contractId ? (
|
||||||
|
<Text
|
||||||
|
component={Link}
|
||||||
|
to={`/dashboard/contract-requests/${contractId}/view`}
|
||||||
|
fz={12}
|
||||||
|
c="blue.7"
|
||||||
|
style={{ textDecoration: "none" }}
|
||||||
|
>
|
||||||
|
{contractReference}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text fz={12} c="dimmed">
|
||||||
|
{contractReference}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
<Text fz={12.5} c="dimmed">
|
<Text fz={12.5} c="dimmed">
|
||||||
{company ?? "—"}
|
{company ?? "—"}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -26,14 +26,19 @@ export interface ConsolidationApprovalRow {
|
|||||||
scheduledDate?: string | null;
|
scheduledDate?: string | null;
|
||||||
bookingReference?: string | null;
|
bookingReference?: string | null;
|
||||||
partnerBookingReference?: string | null;
|
partnerBookingReference?: string | null;
|
||||||
|
/** Contract each half was created under — reviewers work by contract. */
|
||||||
|
contractReference?: string | null;
|
||||||
|
partnerContractReference?: string | null;
|
||||||
booking?: {
|
booking?: {
|
||||||
id: string;
|
id: string;
|
||||||
reference?: string;
|
reference?: string;
|
||||||
|
contractId?: string | null;
|
||||||
company?: { name?: string } | null;
|
company?: { name?: string } | null;
|
||||||
} | null;
|
} | null;
|
||||||
partnerBooking?: {
|
partnerBooking?: {
|
||||||
id: string;
|
id: string;
|
||||||
reference?: string;
|
reference?: string;
|
||||||
|
contractId?: string | null;
|
||||||
company?: { name?: string } | null;
|
company?: { name?: string } | null;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user