mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
Add ContractCourtBadge component and integrate into contract pages
- Introduced ContractCourtBadge to display the responsible party for contract actions. - Updated ContractStatusBadge to include new court badge. - Enhanced ClearanceDocumentsPage with additional filters for trade direction, freight type, and ownership. - Modified ContractRequestDetailPage and ContractRequestsPage to utilize ContractCourtBadge.
This commit is contained in:
@@ -32,8 +32,11 @@ import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule
|
|||||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||||
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
||||||
import { BookingNotifierService } from './booking-notifier.service';
|
import { BookingNotifierService } from './booking-notifier.service';
|
||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import {
|
||||||
import { eatDay } from './batch-window.util';
|
TrainSchedulingService,
|
||||||
|
effectiveWindowConfig,
|
||||||
|
} from './train-scheduling.service';
|
||||||
|
import { eatDay, listConfigBookingWindows } from './batch-window.util';
|
||||||
import {
|
import {
|
||||||
BATCH_BOARD_STATUSES,
|
BATCH_BOARD_STATUSES,
|
||||||
BatchBoardQueryDto,
|
BatchBoardQueryDto,
|
||||||
@@ -167,6 +170,13 @@ export type BookingAllocationStatus =
|
|||||||
| "FAILED";
|
| "FAILED";
|
||||||
|
|
||||||
export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
||||||
|
/**
|
||||||
|
* 0-based booking-window cycle this booking entered the pool in (derived from
|
||||||
|
* `fullyExecutedAt` against the schedule's window cycles). Ranking compares
|
||||||
|
* bookings within a cycle only — an earlier cycle always boards before a later
|
||||||
|
* one regardless of score. Null while the contract is still pending.
|
||||||
|
*/
|
||||||
|
windowCycleNo: number | null;
|
||||||
fullyExecutedAt: string | null;
|
fullyExecutedAt: string | null;
|
||||||
selectedForBatchAt: string | null;
|
selectedForBatchAt: string | null;
|
||||||
allocationStatus: BookingAllocationStatus;
|
allocationStatus: BookingAllocationStatus;
|
||||||
@@ -1321,10 +1331,12 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cycleOf = await this.windowCycleIndexer(s);
|
||||||
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
|
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
|
||||||
const need = this.needFor(b, wagonDims);
|
const need = this.needFor(b, wagonDims);
|
||||||
const alloc = allocationByBooking.get(b.id);
|
const alloc = allocationByBooking.get(b.id);
|
||||||
return {
|
return {
|
||||||
|
windowCycleNo: b.fullyExecutedAt ? cycleOf(b.fullyExecutedAt) : null,
|
||||||
id: b.id,
|
id: b.id,
|
||||||
reference: b.reference ?? b.id.slice(0, 8),
|
reference: b.reference ?? b.id.slice(0, 8),
|
||||||
company: b.isGovernment
|
company: b.isGovernment
|
||||||
@@ -1632,7 +1644,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
// Same bulk re-score as fillRouteDayInternal — the legacy per-schedule fill
|
// Same bulk re-score as fillRouteDayInternal — the legacy per-schedule fill
|
||||||
// must rank bulk bookings by their wagon-derived priority too.
|
// must rank bulk bookings by their wagon-derived priority too.
|
||||||
await this.recomputeBulkPriorities(pool, wagonDims);
|
await this.recomputeBulkPriorities(pool, wagonDims);
|
||||||
this.resortPoolByPriority(pool);
|
this.resortPoolByPriority(pool, await this.windowCycleIndexer(schedule));
|
||||||
const units = this.groupConsolidatedPool(pool);
|
const units = this.groupConsolidatedPool(pool);
|
||||||
let armed = false;
|
let armed = false;
|
||||||
let preempted = false;
|
let preempted = false;
|
||||||
@@ -1847,6 +1859,9 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
armed: boolean;
|
armed: boolean;
|
||||||
changed: boolean;
|
changed: boolean;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
// The day group shares one booking window (route+day grouping), so any
|
||||||
|
// member's window grid stands for the pool's cycle derivation.
|
||||||
|
let cycleSchedule: TrainSchedule | null = null;
|
||||||
for (const id of scheduleIds) {
|
for (const id of scheduleIds) {
|
||||||
const schedule =
|
const schedule =
|
||||||
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||||
@@ -1857,6 +1872,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
cycleSchedule ??= schedule;
|
||||||
const limits = await this.capacityLimits(locomotive);
|
const limits = await this.capacityLimits(locomotive);
|
||||||
await this.syncScheduleMaxWagons(schedule, locomotive);
|
await this.syncScheduleMaxWagons(schedule, locomotive);
|
||||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||||
@@ -1877,7 +1893,10 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
// BULK bookings only get their real (wagon-derived) priority score now, at
|
// BULK bookings only get their real (wagon-derived) priority score now, at
|
||||||
// batch time — stamp it and re-rank before the fill consumes the pool.
|
// batch time — stamp it and re-rank before the fill consumes the pool.
|
||||||
await this.recomputeBulkPriorities(pool, wagonDims);
|
await this.recomputeBulkPriorities(pool, wagonDims);
|
||||||
this.resortPoolByPriority(pool);
|
this.resortPoolByPriority(
|
||||||
|
pool,
|
||||||
|
cycleSchedule ? await this.windowCycleIndexer(cycleSchedule) : undefined,
|
||||||
|
);
|
||||||
// Consolidated partners collapse into one atomic unit (both-or-neither); a
|
// Consolidated partners collapse into one atomic unit (both-or-neither); a
|
||||||
// consolidated booking whose partner isn't ready this cycle is skipped.
|
// consolidated booking whose partner isn't ready this cycle is skipped.
|
||||||
const units = this.groupConsolidatedPool(pool);
|
const units = this.groupConsolidatedPool(pool);
|
||||||
@@ -3257,11 +3276,66 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Restore the batch pool ordering (mirrors findBatchPool's ORDER BY) after scores changed. */
|
/**
|
||||||
private resortPoolByPriority(pool: Booking[]): void {
|
* Maps a booking's pool-entry time (`fullyExecutedAt`) to the 0-based
|
||||||
|
* booking-window cycle it arrived in: the last window whose open is at/before
|
||||||
|
* the timestamp (a timestamp in the doc-review/payment gap belongs to the
|
||||||
|
* cycle that just closed). The cycle grid comes from the schedule's frozen
|
||||||
|
* window-rule snapshot — the exact windows the cycle engine runs.
|
||||||
|
*/
|
||||||
|
private async windowCycleIndexer(
|
||||||
|
schedule: TrainSchedule,
|
||||||
|
): Promise<(ts: Date | null | undefined) => number> {
|
||||||
|
if (!schedule.scheduledDepartureDate) return () => 0;
|
||||||
|
let starts: number[];
|
||||||
|
try {
|
||||||
|
const liveCfg = await this.trainSchedulingService.getWindowConfig();
|
||||||
|
const cfg = effectiveWindowConfig(schedule, liveCfg);
|
||||||
|
const windows = listConfigBookingWindows(
|
||||||
|
schedule.direction,
|
||||||
|
schedule.scheduledDepartureDate,
|
||||||
|
{
|
||||||
|
...cfg,
|
||||||
|
reopenGapMinutes:
|
||||||
|
schedule.ruleReopenDelayMinutes ??
|
||||||
|
cfg.docReviewMinutes + cfg.paymentWindowMinutes,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
starts = windows.map((w) => w.start.getTime());
|
||||||
|
} catch (err) {
|
||||||
|
// A failed cycle derivation must never block the batch — fall back to one
|
||||||
|
// flat cycle (pure priority order, the old behaviour).
|
||||||
|
this.logger.warn(
|
||||||
|
`Window-cycle derivation failed for schedule ${schedule.id}: ` +
|
||||||
|
`${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
return () => 0;
|
||||||
|
}
|
||||||
|
return (ts) => {
|
||||||
|
if (!ts) return 0;
|
||||||
|
const ms = ts.getTime();
|
||||||
|
let idx = 0;
|
||||||
|
for (let i = 0; i < starts.length; i += 1) {
|
||||||
|
if (ms >= starts[i]) idx = i;
|
||||||
|
}
|
||||||
|
return idx;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rank the batch pool: government first, then WINDOW CYCLE (bookings compete
|
||||||
|
* only within the cycle they arrived in — an earlier cycle's booking always
|
||||||
|
* outranks a later cycle's, whatever the scores), then priority score, then
|
||||||
|
* oldest. `cycleOf` comes from {@link windowCycleIndexer}.
|
||||||
|
*/
|
||||||
|
private resortPoolByPriority(
|
||||||
|
pool: Booking[],
|
||||||
|
cycleOf: (ts: Date | null | undefined) => number = () => 0,
|
||||||
|
): void {
|
||||||
pool.sort(
|
pool.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
Number(b.isGovernment) - Number(a.isGovernment) ||
|
Number(b.isGovernment) - Number(a.isGovernment) ||
|
||||||
|
cycleOf(a.fullyExecutedAt) - cycleOf(b.fullyExecutedAt) ||
|
||||||
Number(b.priorityScore ?? 0) - Number(a.priorityScore ?? 0) ||
|
Number(b.priorityScore ?? 0) - Number(a.priorityScore ?? 0) ||
|
||||||
(a.fullyExecutedAt?.getTime() ?? Infinity) -
|
(a.fullyExecutedAt?.getTime() ?? Infinity) -
|
||||||
(b.fullyExecutedAt?.getTime() ?? Infinity) ||
|
(b.fullyExecutedAt?.getTime() ?? Infinity) ||
|
||||||
|
|||||||
@@ -99,16 +99,20 @@ export function ContractMilestonesTimeline({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every acted approval step, not just hazardous ones — this is the one
|
||||||
|
// place the approval-time record shows up in the page's main content
|
||||||
|
// (the sidebar's ContractApprovalStepsCard has the same times, but only
|
||||||
|
// there, and only while the chain is still actionable).
|
||||||
for (const step of contract.approvalSteps ?? []) {
|
for (const step of contract.approvalSteps ?? []) {
|
||||||
if (!(step.requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION)) continue;
|
|
||||||
if (!step.actedAt) continue;
|
if (!step.actedAt) continue;
|
||||||
|
const hazard = step.requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION;
|
||||||
items.push({
|
items.push({
|
||||||
key: `hazard-${step.id}`,
|
key: `step-${step.id}`,
|
||||||
at: step.actedAt,
|
at: step.actedAt,
|
||||||
title: CONTRACT_APPROVAL_ROLE_LABELS[step.requiredRole] ?? step.requiredRole,
|
title: `${CONTRACT_APPROVAL_ROLE_LABELS[step.requiredRole] ?? step.requiredRole} ${step.status === "REJECTED" ? "rejected" : "approved"}`,
|
||||||
detail: step.status === "REJECTED" ? "Rejected" : "Approved",
|
detail: step.note ?? undefined,
|
||||||
color: step.status === "REJECTED" ? "red" : "orange",
|
color: step.status === "REJECTED" ? "red" : hazard ? "orange" : "edr-green",
|
||||||
icon: Flame,
|
icon: hazard ? Flame : ShieldCheck,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Badge, Group } from "@mantine/core";
|
import { Badge, Group } from "@mantine/core";
|
||||||
import { Repeat } from "lucide-react";
|
import { Building2, Repeat, UserRound } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CONTRACT_STATUS_COLOR,
|
CONTRACT_STATUS_COLOR,
|
||||||
CONTRACT_STATUS_STYLES,
|
CONTRACT_STATUS_STYLES,
|
||||||
|
contractCourt,
|
||||||
} from "@/features/contracts/contract-status.config";
|
} from "@/features/contracts/contract-status.config";
|
||||||
|
|
||||||
interface ContractStatusBadgeProps {
|
interface ContractStatusBadgeProps {
|
||||||
@@ -69,3 +70,42 @@ export function ContractStatusBadge({
|
|||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whose court the contract sits in: customer, EDR, or nobody ("—"). */
|
||||||
|
export function ContractCourtBadge({ status }: { status: string }) {
|
||||||
|
const court = contractCourt(status);
|
||||||
|
if (!court) {
|
||||||
|
return (
|
||||||
|
<span className="text-sm text-muted-foreground" title="No party is awaited">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const isCustomer = court === "customer";
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color={isCustomer ? "orange" : "edr-green"}
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
tt="uppercase"
|
||||||
|
fw={600}
|
||||||
|
leftSection={
|
||||||
|
isCustomer ? <UserRound size={12} /> : <Building2 size={12} />
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
isCustomer
|
||||||
|
? "Waiting on the customer to act"
|
||||||
|
: "Waiting on EDR staff to act"
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
display: "inline-flex",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isCustomer ? "With customer" : "With EDR"}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,12 +34,13 @@ import type {
|
|||||||
} from "@/types/trainScheduling";
|
} from "@/types/trainScheduling";
|
||||||
import { WindowPhasePill } from "./batchVisuals";
|
import { WindowPhasePill } from "./batchVisuals";
|
||||||
import { ForecastPanel } from "./ForecastPanel";
|
import { ForecastPanel } from "./ForecastPanel";
|
||||||
import { forecastIsLive } from "./batchForecast";
|
import { forecastIsLive, rankBookings } from "./batchForecast";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Priority Tracking tab — live, glanceable ranking of every booking on this
|
* Priority Tracking tab — live, glanceable ranking of every booking on this
|
||||||
* schedule in the exact order the batch engine boards them (government first,
|
* schedule in the exact order the batch engine boards them (government first,
|
||||||
* then rule-engine priority score, then oldest). Bookings above the train's
|
* then window cycle — bookings compete only within their own cycle — then
|
||||||
|
* rule-engine priority score, then oldest). Bookings above the train's
|
||||||
* wagon-capacity line render as "selected" (green), below it as the waiting
|
* wagon-capacity line render as "selected" (green), below it as the waiting
|
||||||
* list; during the PAYMENT phase selected bookings show a live pay-window
|
* list; during the PAYMENT phase selected bookings show a live pay-window
|
||||||
* countdown. Purely presentational — data comes from the batch-board detail
|
* countdown. Purely presentational — data comes from the batch-board detail
|
||||||
@@ -286,19 +287,11 @@ export const PriorityTrackingTab = memo(function PriorityTrackingTab({
|
|||||||
);
|
);
|
||||||
const showForecast = forecastAvailable && view === "forecast";
|
const showForecast = forecastAvailable && view === "forecast";
|
||||||
|
|
||||||
// Rank exactly as the batch engine does: government first, then priority score
|
// Rank exactly as the batch engine does: government first, then window cycle
|
||||||
// desc, then oldest (fullyExecutedAt / selectedForBatchAt as the tiebreak the
|
// (bookings only compete within the cycle they arrived in — an earlier cycle
|
||||||
// backend uses). The board already returns them in this order, but re-sort
|
// boards before a later one regardless of score), then priority desc, then
|
||||||
// defensively so the tab is correct even if the source order ever changes.
|
// oldest. Shared with the forecast sim so both views agree.
|
||||||
const ranked = useMemo(() => {
|
const ranked = useMemo(() => rankBookings(bookings), [bookings]);
|
||||||
const time = (b: BatchBoardBookingDetail) =>
|
|
||||||
b.fullyExecutedAt ? new Date(b.fullyExecutedAt).getTime() : Number.MAX_SAFE_INTEGER;
|
|
||||||
return [...bookings].sort((a, b) => {
|
|
||||||
if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1;
|
|
||||||
if (b.priorityScore !== a.priorityScore) return b.priorityScore - a.priorityScore;
|
|
||||||
return time(a) - time(b);
|
|
||||||
});
|
|
||||||
}, [bookings]);
|
|
||||||
|
|
||||||
const scoreMax = useMemo(() => maxScore(ranked), [ranked]);
|
const scoreMax = useMemo(() => maxScore(ranked), [ranked]);
|
||||||
// Wagon-slot cap from the board DTO (derived from train length and the
|
// Wagon-slot cap from the board DTO (derived from train length and the
|
||||||
@@ -395,7 +388,8 @@ export const PriorityTrackingTab = memo(function PriorityTrackingTab({
|
|||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<Text fw={700}>Priority ranking</Text>
|
<Text fw={700}>Priority ranking</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Government first, then rule-engine score, then earliest booked.
|
Government first, then booking window (earlier cycles board
|
||||||
|
first), then rule-engine score, then earliest booked.
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -61,7 +61,12 @@ export interface ForecastResult {
|
|||||||
full: boolean;
|
full: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Engine rank order: government first, then priority desc, then oldest booked. */
|
/**
|
||||||
|
* Engine rank order: government first, then window cycle asc (bookings compete
|
||||||
|
* only within the cycle they arrived in — earlier cycles board first no matter
|
||||||
|
* the score; pending-contract rows sink last), then priority desc, then oldest
|
||||||
|
* booked.
|
||||||
|
*/
|
||||||
export function rankBookings(
|
export function rankBookings(
|
||||||
bookings: BatchBoardBookingDetail[],
|
bookings: BatchBoardBookingDetail[],
|
||||||
): BatchBoardBookingDetail[] {
|
): BatchBoardBookingDetail[] {
|
||||||
@@ -69,8 +74,11 @@ export function rankBookings(
|
|||||||
b.fullyExecutedAt
|
b.fullyExecutedAt
|
||||||
? new Date(b.fullyExecutedAt).getTime()
|
? new Date(b.fullyExecutedAt).getTime()
|
||||||
: Number.MAX_SAFE_INTEGER;
|
: Number.MAX_SAFE_INTEGER;
|
||||||
|
const cycle = (b: BatchBoardBookingDetail) =>
|
||||||
|
b.windowCycleNo ?? Number.MAX_SAFE_INTEGER;
|
||||||
return [...bookings].sort((a, b) => {
|
return [...bookings].sort((a, b) => {
|
||||||
if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1;
|
if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1;
|
||||||
|
if (cycle(a) !== cycle(b)) return cycle(a) - cycle(b);
|
||||||
if (b.priorityScore !== a.priorityScore)
|
if (b.priorityScore !== a.priorityScore)
|
||||||
return b.priorityScore - a.priorityScore;
|
return b.priorityScore - a.priorityScore;
|
||||||
return time(a) - time(b);
|
return time(a) - time(b);
|
||||||
|
|||||||
@@ -270,6 +270,41 @@ export const CONTRACT_STATUS_META: Record<string, StatusMeta> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Statuses where the next action sits with the customer (portal side). */
|
||||||
|
const WITH_CUSTOMER_STATUSES = new Set([
|
||||||
|
"DRAFT",
|
||||||
|
"PRICE_CHANGED_PENDING_CONFIRM",
|
||||||
|
"CHANGES_REQUESTED",
|
||||||
|
"CONTRACT_READY", // generated contract awaits the customer's signature
|
||||||
|
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||||
|
"RENEWAL_DRAFT",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Statuses where the next action sits with EDR staff. */
|
||||||
|
const WITH_EDR_STATUSES = new Set([
|
||||||
|
"SUBMITTED",
|
||||||
|
"PENDING_APPROVAL",
|
||||||
|
"APPROVED",
|
||||||
|
"APPROVED_PENDING_SIGNATURE",
|
||||||
|
"SIGNED_CUSTOMER",
|
||||||
|
"CLEARANCE_UNDER_REVIEW",
|
||||||
|
"CLEARANCE_READY_FOR_BOOKING",
|
||||||
|
"RENEWAL_SUBMITTED",
|
||||||
|
"RENEWAL_PENDING_APPROVAL",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whose court the contract is in. Null for states with no pending party
|
||||||
|
* (active, closed, rejected…).
|
||||||
|
*/
|
||||||
|
export function contractCourt(
|
||||||
|
status: ContractStatus | string,
|
||||||
|
): "customer" | "edr" | null {
|
||||||
|
if (WITH_CUSTOMER_STATUSES.has(status)) return "customer";
|
||||||
|
if (WITH_EDR_STATUSES.has(status)) return "edr";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export const CONTRACT_LIST_TABS = [
|
export const CONTRACT_LIST_TABS = [
|
||||||
{ key: "all", label: "All contracts", statuses: null as string[] | null },
|
{ key: "all", label: "All contracts", statuses: null as string[] | null },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { DateInput } from "@mantine/dates";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
import { FileText, Inbox, RefreshCw, Search, User, X } from "lucide-react";
|
import { FileText, Inbox, RefreshCw, Search, User, X } from "lucide-react";
|
||||||
@@ -16,6 +17,7 @@ import { useCallback, useMemo, useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||||
|
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
|
||||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||||
import { PageContainer, PageHeader } from "@/components/page";
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
@@ -49,6 +51,34 @@ const BOOKING_STATUS_OPTIONS = [
|
|||||||
{ value: "CLEARANCE_READY", label: "Clearance ready" },
|
{ value: "CLEARANCE_READY", label: "Clearance ready" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const TRADE_DIRECTION_OPTIONS = [
|
||||||
|
{ value: "IMPORT", label: "Import" },
|
||||||
|
{ value: "EXPORT", label: "Export" },
|
||||||
|
{ value: "DOMESTIC", label: "Domestic" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FREIGHT_TYPE_OPTIONS = [
|
||||||
|
{ value: "CONTAINER", label: "Container" },
|
||||||
|
{ value: "BULK", label: "Bulk" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const OWNERSHIP_OPTIONS = [
|
||||||
|
{ value: "true", label: "Government" },
|
||||||
|
{ value: "false", label: "Private" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function startOfDayIso(d: Date): string {
|
||||||
|
const x = new Date(d);
|
||||||
|
x.setHours(0, 0, 0, 0);
|
||||||
|
return x.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function endOfDayIso(d: Date): string {
|
||||||
|
const x = new Date(d);
|
||||||
|
x.setHours(23, 59, 59, 999);
|
||||||
|
return x.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
export default function ClearanceDocumentsPage() {
|
export default function ClearanceDocumentsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@@ -56,6 +86,11 @@ export default function ClearanceDocumentsPage() {
|
|||||||
const [bookingStatuses, setBookingStatuses] = useState(
|
const [bookingStatuses, setBookingStatuses] = useState(
|
||||||
BOOKING_STATUS_OPTIONS[0].value,
|
BOOKING_STATUS_OPTIONS[0].value,
|
||||||
);
|
);
|
||||||
|
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
|
||||||
|
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
|
||||||
|
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
|
||||||
|
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
|
||||||
|
const [createdTo, setCreatedTo] = useState<Date | null>(null);
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE });
|
const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE });
|
||||||
|
|
||||||
const search = debouncedQuery.trim() || undefined;
|
const search = debouncedQuery.trim() || undefined;
|
||||||
@@ -67,7 +102,18 @@ export default function ClearanceDocumentsPage() {
|
|||||||
const page = pagination.pageIndex + 1;
|
const page = pagination.pageIndex + 1;
|
||||||
|
|
||||||
const bookingsQuery = useQuery({
|
const bookingsQuery = useQuery({
|
||||||
queryKey: ["clearance-documents", "bookings", bookingStatuses, page, search],
|
queryKey: [
|
||||||
|
"clearance-documents",
|
||||||
|
"bookings",
|
||||||
|
bookingStatuses,
|
||||||
|
directionFilter,
|
||||||
|
freightTypeFilter,
|
||||||
|
ownershipFilter,
|
||||||
|
createdFrom,
|
||||||
|
createdTo,
|
||||||
|
page,
|
||||||
|
search,
|
||||||
|
],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
// Self-clearance instances carry bookingType=ONE_TIME whatever their
|
// Self-clearance instances carry bookingType=ONE_TIME whatever their
|
||||||
// contract kind, so customsClearingEnabled=false + the three per-booking
|
// contract kind, so customsClearingEnabled=false + the three per-booking
|
||||||
@@ -78,6 +124,13 @@ export default function ClearanceDocumentsPage() {
|
|||||||
page,
|
page,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
search,
|
search,
|
||||||
|
...(directionFilter ? { tradeDirection: directionFilter } : {}),
|
||||||
|
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
|
||||||
|
...(ownershipFilter
|
||||||
|
? { isGovernment: ownershipFilter as "true" | "false" }
|
||||||
|
: {}),
|
||||||
|
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
|
||||||
|
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
|
||||||
}),
|
}),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
@@ -115,9 +168,18 @@ export default function ClearanceDocumentsPage() {
|
|||||||
{
|
{
|
||||||
id: "contractRef",
|
id: "contractRef",
|
||||||
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<Text size="sm">{row.original.contractReference ?? "—"}</Text>
|
const b = row.original;
|
||||||
),
|
return b.contractId && b.contractReference ? (
|
||||||
|
<ContractReferenceLink
|
||||||
|
contractId={b.contractId}
|
||||||
|
contractReference={b.contractReference}
|
||||||
|
className="block truncate text-sm text-foreground underline underline-offset-2 hover:text-muted-foreground"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text size="sm">—</Text>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "shipment",
|
id: "shipment",
|
||||||
@@ -238,6 +300,73 @@ export default function ClearanceDocumentsPage() {
|
|||||||
{total} record{total !== 1 ? "s" : ""}
|
{total} record{total !== 1 ? "s" : ""}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
<Group gap="sm" mt="sm" wrap="wrap">
|
||||||
|
<Select
|
||||||
|
placeholder="Direction"
|
||||||
|
data={TRADE_DIRECTION_OPTIONS}
|
||||||
|
value={directionFilter}
|
||||||
|
onChange={(v) => {
|
||||||
|
setDirectionFilter(v);
|
||||||
|
resetPage();
|
||||||
|
}}
|
||||||
|
clearable
|
||||||
|
radius="lg"
|
||||||
|
style={{ minWidth: 130 }}
|
||||||
|
aria-label="Filter by direction"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="Freight type"
|
||||||
|
data={FREIGHT_TYPE_OPTIONS}
|
||||||
|
value={freightTypeFilter}
|
||||||
|
onChange={(v) => {
|
||||||
|
setFreightTypeFilter(v);
|
||||||
|
resetPage();
|
||||||
|
}}
|
||||||
|
clearable
|
||||||
|
radius="lg"
|
||||||
|
style={{ minWidth: 140 }}
|
||||||
|
aria-label="Filter by freight type"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="Gov / Private"
|
||||||
|
data={OWNERSHIP_OPTIONS}
|
||||||
|
value={ownershipFilter}
|
||||||
|
onChange={(v) => {
|
||||||
|
setOwnershipFilter(v);
|
||||||
|
resetPage();
|
||||||
|
}}
|
||||||
|
clearable
|
||||||
|
radius="lg"
|
||||||
|
style={{ minWidth: 140 }}
|
||||||
|
aria-label="Filter by ownership"
|
||||||
|
/>
|
||||||
|
<DateInput
|
||||||
|
placeholder="Created from"
|
||||||
|
value={createdFrom}
|
||||||
|
onChange={(v) => {
|
||||||
|
setCreatedFrom(v ? new Date(v) : null);
|
||||||
|
resetPage();
|
||||||
|
}}
|
||||||
|
maxDate={createdTo ?? undefined}
|
||||||
|
clearable
|
||||||
|
radius="lg"
|
||||||
|
style={{ minWidth: 140 }}
|
||||||
|
aria-label="Created from"
|
||||||
|
/>
|
||||||
|
<DateInput
|
||||||
|
placeholder="Created to"
|
||||||
|
value={createdTo}
|
||||||
|
onChange={(v) => {
|
||||||
|
setCreatedTo(v ? new Date(v) : null);
|
||||||
|
resetPage();
|
||||||
|
}}
|
||||||
|
minDate={createdFrom ?? undefined}
|
||||||
|
clearable
|
||||||
|
radius="lg"
|
||||||
|
style={{ minWidth: 140 }}
|
||||||
|
aria-label="Created to"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{showEmpty ? (
|
{showEmpty ? (
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ import {
|
|||||||
Files,
|
Files,
|
||||||
Flame,
|
Flame,
|
||||||
History,
|
History,
|
||||||
|
Info,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Milestone,
|
Milestone,
|
||||||
Package,
|
Package,
|
||||||
Receipt,
|
Receipt,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Route as RouteIcon,
|
Route as RouteIcon,
|
||||||
|
ShieldCheck,
|
||||||
Snowflake,
|
Snowflake,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -35,6 +37,7 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
@@ -48,7 +51,10 @@ import { PageContainer } from "@/components/page";
|
|||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
|
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
|
||||||
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
import {
|
||||||
|
ContractCourtBadge,
|
||||||
|
ContractStatusBadge,
|
||||||
|
} from "@/components/contracts/ContractStatusBadge";
|
||||||
import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
|
import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
|
||||||
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
|
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
|
||||||
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
||||||
@@ -374,6 +380,7 @@ export default function ContractRequestDetailPage() {
|
|||||||
status={contract.status}
|
status={contract.status}
|
||||||
isRenewal={Boolean(contract.renewalOfId)}
|
isRenewal={Boolean(contract.renewalOfId)}
|
||||||
/>
|
/>
|
||||||
|
<ContractCourtBadge status={contract.status} />
|
||||||
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
|
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
|
||||||
{contract.contractKind === "GENERAL" ? "General" : "One-time"}
|
{contract.contractKind === "GENERAL" ? "General" : "One-time"}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -553,6 +560,100 @@ export default function ContractRequestDetailPage() {
|
|||||||
<ContractCustomerCard contract={contract} />
|
<ContractCustomerCard contract={contract} />
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
|
<SectionCard
|
||||||
|
icon={Info}
|
||||||
|
title="Contract information"
|
||||||
|
subtitle="Full commercial and operational detail for this contract."
|
||||||
|
>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
||||||
|
<InfoRow
|
||||||
|
label="Service type"
|
||||||
|
value={contract.serviceType?.serviceName ?? "—"}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label="Payment currency"
|
||||||
|
value={contract.paymentCurrency ?? "—"}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label="Customs clearing"
|
||||||
|
value={
|
||||||
|
contract.customsClearingEnabled
|
||||||
|
? "Included automatically"
|
||||||
|
: contract.customsClearingAgent
|
||||||
|
? `Customer's agent — ${contract.customsClearingAgent}`
|
||||||
|
: "Not included"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{contract.equipmentReturn ? (
|
||||||
|
<InfoRow
|
||||||
|
label="Equipment return"
|
||||||
|
value={
|
||||||
|
contract.equipmentReturn === "WITH_RETURN"
|
||||||
|
? "With return"
|
||||||
|
: "Without return"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<InfoRow
|
||||||
|
label="Contract type"
|
||||||
|
value={contract.contractType ?? "Standard"}
|
||||||
|
/>
|
||||||
|
{contract.contractValidityDays != null ? (
|
||||||
|
<InfoRow
|
||||||
|
label="Validity period"
|
||||||
|
value={`${contract.contractValidityDays} days`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{contract.estimatedShipmentDate ? (
|
||||||
|
<InfoRow
|
||||||
|
label="Estimated shipment date"
|
||||||
|
value={formatDate(contract.estimatedShipmentDate)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{contract.firstMilePickupAddress ? (
|
||||||
|
<InfoRow
|
||||||
|
label="First-mile pickup"
|
||||||
|
value={contract.firstMilePickupAddress}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{contract.lastMileDeliveryAddress ? (
|
||||||
|
<InfoRow
|
||||||
|
label="Last-mile delivery"
|
||||||
|
value={contract.lastMileDeliveryAddress}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</SimpleGrid>
|
||||||
|
{contract.financialTerms ? (
|
||||||
|
<Box
|
||||||
|
mt="md"
|
||||||
|
pt="md"
|
||||||
|
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
c="dimmed"
|
||||||
|
fw={600}
|
||||||
|
tt="uppercase"
|
||||||
|
mb={4}
|
||||||
|
style={{ letterSpacing: 0.3 }}
|
||||||
|
>
|
||||||
|
Financial terms
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{contract.financialTerms}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard
|
||||||
|
icon={ShieldCheck}
|
||||||
|
title="Approval & signing timeline"
|
||||||
|
subtitle="Every dated step in this contract's approval chain, plus signatures — the same record kept in the sidebar, always visible here."
|
||||||
|
>
|
||||||
|
<ContractMilestonesTimeline contract={contract} />
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard icon={RouteIcon} title="Routes">
|
<SectionCard icon={RouteIcon} title="Routes">
|
||||||
{routes.length === 0 ? (
|
{routes.length === 0 ? (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
@@ -761,6 +862,25 @@ export default function ContractRequestDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
c="dimmed"
|
||||||
|
fw={600}
|
||||||
|
tt="uppercase"
|
||||||
|
style={{ letterSpacing: 0.3 }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={500} mt={2}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function MetaItem({
|
function MetaItem({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
text,
|
text,
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ import { useCallback, useMemo, useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { ContractApprovalProgressCell } from "@/components/contracts/ContractApprovalProgressCell";
|
import { ContractApprovalProgressCell } from "@/components/contracts/ContractApprovalProgressCell";
|
||||||
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
import {
|
||||||
|
ContractCourtBadge,
|
||||||
|
ContractStatusBadge,
|
||||||
|
} from "@/components/contracts/ContractStatusBadge";
|
||||||
import {
|
import {
|
||||||
ContractStatusTabs,
|
ContractStatusTabs,
|
||||||
type ContractStatusTabKey,
|
type ContractStatusTabKey,
|
||||||
@@ -334,6 +337,19 @@ export default function ContractRequestsPage() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "court",
|
||||||
|
size: COLUMN_WIDTH,
|
||||||
|
meta: COLUMN_META,
|
||||||
|
header: () => (
|
||||||
|
<span className={bookingTable.headerCell}>Waiting on</span>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="py-1">
|
||||||
|
<ContractCourtBadge status={row.original.status} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "approval",
|
id: "approval",
|
||||||
size: COLUMN_WIDTH,
|
size: COLUMN_WIDTH,
|
||||||
@@ -666,7 +682,7 @@ export default function ContractRequestsPage() {
|
|||||||
}}
|
}}
|
||||||
// table-fixed makes the per-column 120px widths stick; without
|
// table-fixed makes the per-column 120px widths stick; without
|
||||||
// it auto-layout re-widens columns once cells wrap.
|
// it auto-layout re-widens columns once cells wrap.
|
||||||
containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[840px]"
|
containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[960px]"
|
||||||
footer={DataTableFooter}
|
footer={DataTableFooter}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -242,23 +242,20 @@ export default function GlClearanceDetailPage() {
|
|||||||
|
|
||||||
<Tabs.Panel value="workflow">
|
<Tabs.Panel value="workflow">
|
||||||
{/* GL Ethiopia cannot file the import customs declaration until this
|
{/* GL Ethiopia cannot file the import customs declaration until this
|
||||||
desk names the officer handling the shipment in transit, so the
|
desk names the officer handling the shipment in transit. Exports also
|
||||||
ask sits above everything else on the page. Exports have no such
|
need transit assignment at the DJ stage after ET requests it. */}
|
||||||
gate — Djibouti's steps come after the declaration. */}
|
<Box mb="md">
|
||||||
{isImport ? (
|
<TransitAssigneePanel
|
||||||
<Box mb="md">
|
entityId={id!}
|
||||||
<TransitAssigneePanel
|
isBooking={data.kind === "booking"}
|
||||||
entityId={id!}
|
transitAssignee={data.clearance.transitAssignee}
|
||||||
isBooking={data.kind === "booking"}
|
side="DJ"
|
||||||
transitAssignee={data.clearance.transitAssignee}
|
readOnly={
|
||||||
side="DJ"
|
!hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||||
readOnly={
|
}
|
||||||
!hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
|
onChanged={() => void refetch()}
|
||||||
}
|
/>
|
||||||
onChanged={() => void refetch()}
|
</Box>
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||||
|
|||||||
@@ -411,6 +411,12 @@ export type BookingAllocationStatus =
|
|||||||
| "FAILED";
|
| "FAILED";
|
||||||
|
|
||||||
export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
||||||
|
/**
|
||||||
|
* 0-based booking-window cycle the booking entered the pool in. Ranking is
|
||||||
|
* per-cycle: an earlier cycle always boards before a later one regardless of
|
||||||
|
* priority score. Null while the contract is still pending.
|
||||||
|
*/
|
||||||
|
windowCycleNo: number | null;
|
||||||
fullyExecutedAt: string | null;
|
fullyExecutedAt: string | null;
|
||||||
selectedForBatchAt: string | null;
|
selectedForBatchAt: string | null;
|
||||||
allocationStatus: BookingAllocationStatus;
|
allocationStatus: BookingAllocationStatus;
|
||||||
|
|||||||
@@ -422,11 +422,13 @@ export function Step8Review({
|
|||||||
label="Customs clearing"
|
label="Customs clearing"
|
||||||
value={customsValue}
|
value={customsValue}
|
||||||
/>
|
/>
|
||||||
<SummaryItem
|
{/* Step-3 toggles appear only when the customer selected them —
|
||||||
icon={<Package size={18} />}
|
an off toggle is left off the summary entirely. */}
|
||||||
label="Hazardous cargo"
|
{values.isHazardous && (
|
||||||
value={
|
<SummaryItem
|
||||||
values.isHazardous ? (
|
icon={<Package size={18} />}
|
||||||
|
label="Hazardous cargo"
|
||||||
|
value={
|
||||||
<>
|
<>
|
||||||
Yes
|
Yes
|
||||||
<Group gap={6} mt={6}>
|
<Group gap={6} mt={6}>
|
||||||
@@ -438,27 +440,24 @@ export function Step8Review({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
</>
|
</>
|
||||||
) : (
|
|
||||||
"No"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SummaryItem
|
|
||||||
icon={<Package size={18} />}
|
|
||||||
label="Refrigerated"
|
|
||||||
value={values.isRefrigerated ? "Yes" : "No"}
|
|
||||||
/>
|
|
||||||
{values.cargoType === "container" && (
|
|
||||||
<SummaryItem
|
|
||||||
icon={<RotateCcw size={18} />}
|
|
||||||
label="Empty-container return"
|
|
||||||
value={
|
|
||||||
values.equipmentReturn === "with_return"
|
|
||||||
? "With return"
|
|
||||||
: "Without return"
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{values.isRefrigerated && (
|
||||||
|
<SummaryItem
|
||||||
|
icon={<Package size={18} />}
|
||||||
|
label="Refrigerated"
|
||||||
|
value="Yes"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{values.cargoType === "container" &&
|
||||||
|
values.equipmentReturn === "with_return" && (
|
||||||
|
<SummaryItem
|
||||||
|
icon={<RotateCcw size={18} />}
|
||||||
|
label="Empty-container return"
|
||||||
|
value="With return"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user