mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1006 from Tria-plc/freight_feature/usermanagement
Add ContractCourtBadge component and integrate into contract pages
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 { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { eatDay } from './batch-window.util';
|
||||
import {
|
||||
TrainSchedulingService,
|
||||
effectiveWindowConfig,
|
||||
} from './train-scheduling.service';
|
||||
import { eatDay, listConfigBookingWindows } from './batch-window.util';
|
||||
import {
|
||||
BATCH_BOARD_STATUSES,
|
||||
BatchBoardQueryDto,
|
||||
@@ -167,6 +170,13 @@ export type BookingAllocationStatus =
|
||||
| "FAILED";
|
||||
|
||||
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;
|
||||
selectedForBatchAt: string | null;
|
||||
allocationStatus: BookingAllocationStatus;
|
||||
@@ -1321,10 +1331,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
const cycleOf = await this.windowCycleIndexer(s);
|
||||
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
|
||||
const need = this.needFor(b, wagonDims);
|
||||
const alloc = allocationByBooking.get(b.id);
|
||||
return {
|
||||
windowCycleNo: b.fullyExecutedAt ? cycleOf(b.fullyExecutedAt) : null,
|
||||
id: b.id,
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
company: b.isGovernment
|
||||
@@ -1632,7 +1644,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// Same bulk re-score as fillRouteDayInternal — the legacy per-schedule fill
|
||||
// must rank bulk bookings by their wagon-derived priority too.
|
||||
await this.recomputeBulkPriorities(pool, wagonDims);
|
||||
this.resortPoolByPriority(pool);
|
||||
this.resortPoolByPriority(pool, await this.windowCycleIndexer(schedule));
|
||||
const units = this.groupConsolidatedPool(pool);
|
||||
let armed = false;
|
||||
let preempted = false;
|
||||
@@ -1847,6 +1859,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
armed: 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) {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||
@@ -1857,6 +1872,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
cycleSchedule ??= schedule;
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive);
|
||||
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
|
||||
// batch time — stamp it and re-rank before the fill consumes the pool.
|
||||
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 booking whose partner isn't ready this cycle is skipped.
|
||||
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(
|
||||
(a, b) =>
|
||||
Number(b.isGovernment) - Number(a.isGovernment) ||
|
||||
cycleOf(a.fullyExecutedAt) - cycleOf(b.fullyExecutedAt) ||
|
||||
Number(b.priorityScore ?? 0) - Number(a.priorityScore ?? 0) ||
|
||||
(a.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 ?? []) {
|
||||
if (!(step.requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION)) continue;
|
||||
if (!step.actedAt) continue;
|
||||
const hazard = step.requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION;
|
||||
items.push({
|
||||
key: `hazard-${step.id}`,
|
||||
key: `step-${step.id}`,
|
||||
at: step.actedAt,
|
||||
title: CONTRACT_APPROVAL_ROLE_LABELS[step.requiredRole] ?? step.requiredRole,
|
||||
detail: step.status === "REJECTED" ? "Rejected" : "Approved",
|
||||
color: step.status === "REJECTED" ? "red" : "orange",
|
||||
icon: Flame,
|
||||
title: `${CONTRACT_APPROVAL_ROLE_LABELS[step.requiredRole] ?? step.requiredRole} ${step.status === "REJECTED" ? "rejected" : "approved"}`,
|
||||
detail: step.note ?? undefined,
|
||||
color: step.status === "REJECTED" ? "red" : hazard ? "orange" : "edr-green",
|
||||
icon: hazard ? Flame : ShieldCheck,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Badge, Group } from "@mantine/core";
|
||||
import { Repeat } from "lucide-react";
|
||||
import { Building2, Repeat, UserRound } from "lucide-react";
|
||||
|
||||
import {
|
||||
CONTRACT_STATUS_COLOR,
|
||||
CONTRACT_STATUS_STYLES,
|
||||
contractCourt,
|
||||
} from "@/features/contracts/contract-status.config";
|
||||
|
||||
interface ContractStatusBadgeProps {
|
||||
@@ -69,3 +70,42 @@ export function ContractStatusBadge({
|
||||
</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";
|
||||
import { WindowPhasePill } from "./batchVisuals";
|
||||
import { ForecastPanel } from "./ForecastPanel";
|
||||
import { forecastIsLive } from "./batchForecast";
|
||||
import { forecastIsLive, rankBookings } from "./batchForecast";
|
||||
|
||||
/**
|
||||
* Priority Tracking tab — live, glanceable ranking of every booking on this
|
||||
* 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
|
||||
* list; during the PAYMENT phase selected bookings show a live pay-window
|
||||
* 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";
|
||||
|
||||
// Rank exactly as the batch engine does: government first, then priority score
|
||||
// desc, then oldest (fullyExecutedAt / selectedForBatchAt as the tiebreak the
|
||||
// backend uses). The board already returns them in this order, but re-sort
|
||||
// defensively so the tab is correct even if the source order ever changes.
|
||||
const ranked = useMemo(() => {
|
||||
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]);
|
||||
// Rank exactly as the batch engine does: government first, then window cycle
|
||||
// (bookings only compete within the cycle they arrived in — an earlier cycle
|
||||
// boards before a later one regardless of score), then priority desc, then
|
||||
// oldest. Shared with the forecast sim so both views agree.
|
||||
const ranked = useMemo(() => rankBookings(bookings), [bookings]);
|
||||
|
||||
const scoreMax = useMemo(() => maxScore(ranked), [ranked]);
|
||||
// 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}>
|
||||
<Text fw={700}>Priority ranking</Text>
|
||||
<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>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
@@ -61,7 +61,12 @@ export interface ForecastResult {
|
||||
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(
|
||||
bookings: BatchBoardBookingDetail[],
|
||||
): BatchBoardBookingDetail[] {
|
||||
@@ -69,8 +74,11 @@ export function rankBookings(
|
||||
b.fullyExecutedAt
|
||||
? new Date(b.fullyExecutedAt).getTime()
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const cycle = (b: BatchBoardBookingDetail) =>
|
||||
b.windowCycleNo ?? Number.MAX_SAFE_INTEGER;
|
||||
return [...bookings].sort((a, b) => {
|
||||
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)
|
||||
return b.priorityScore - a.priorityScore;
|
||||
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 = [
|
||||
{ key: "all", label: "All contracts", statuses: null as string[] | null },
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
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 { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
@@ -49,6 +51,34 @@ const BOOKING_STATUS_OPTIONS = [
|
||||
{ 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() {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -56,6 +86,11 @@ export default function ClearanceDocumentsPage() {
|
||||
const [bookingStatuses, setBookingStatuses] = useState(
|
||||
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 search = debouncedQuery.trim() || undefined;
|
||||
@@ -67,7 +102,18 @@ export default function ClearanceDocumentsPage() {
|
||||
const page = pagination.pageIndex + 1;
|
||||
|
||||
const bookingsQuery = useQuery({
|
||||
queryKey: ["clearance-documents", "bookings", bookingStatuses, page, search],
|
||||
queryKey: [
|
||||
"clearance-documents",
|
||||
"bookings",
|
||||
bookingStatuses,
|
||||
directionFilter,
|
||||
freightTypeFilter,
|
||||
ownershipFilter,
|
||||
createdFrom,
|
||||
createdTo,
|
||||
page,
|
||||
search,
|
||||
],
|
||||
queryFn: () =>
|
||||
// Self-clearance instances carry bookingType=ONE_TIME whatever their
|
||||
// contract kind, so customsClearingEnabled=false + the three per-booking
|
||||
@@ -78,6 +124,13 @@ export default function ClearanceDocumentsPage() {
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
search,
|
||||
...(directionFilter ? { tradeDirection: directionFilter } : {}),
|
||||
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
|
||||
...(ownershipFilter
|
||||
? { isGovernment: ownershipFilter as "true" | "false" }
|
||||
: {}),
|
||||
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
|
||||
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
@@ -115,9 +168,18 @@ export default function ClearanceDocumentsPage() {
|
||||
{
|
||||
id: "contractRef",
|
||||
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">{row.original.contractReference ?? "—"}</Text>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
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",
|
||||
@@ -238,6 +300,73 @@ export default function ClearanceDocumentsPage() {
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</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>
|
||||
|
||||
{showEmpty ? (
|
||||
|
||||
@@ -15,12 +15,14 @@ import {
|
||||
Files,
|
||||
Flame,
|
||||
History,
|
||||
Info,
|
||||
LayoutGrid,
|
||||
Milestone,
|
||||
Package,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Route as RouteIcon,
|
||||
ShieldCheck,
|
||||
Snowflake,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
@@ -48,7 +51,10 @@ import { PageContainer } from "@/components/page";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
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 { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
|
||||
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
||||
@@ -374,6 +380,7 @@ export default function ContractRequestDetailPage() {
|
||||
status={contract.status}
|
||||
isRenewal={Boolean(contract.renewalOfId)}
|
||||
/>
|
||||
<ContractCourtBadge status={contract.status} />
|
||||
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
|
||||
{contract.contractKind === "GENERAL" ? "General" : "One-time"}
|
||||
</Badge>
|
||||
@@ -553,6 +560,100 @@ export default function ContractRequestDetailPage() {
|
||||
<ContractCustomerCard contract={contract} />
|
||||
) : (
|
||||
<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">
|
||||
{routes.length === 0 ? (
|
||||
<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({
|
||||
icon: Icon,
|
||||
text,
|
||||
|
||||
@@ -34,7 +34,10 @@ import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ContractApprovalProgressCell } from "@/components/contracts/ContractApprovalProgressCell";
|
||||
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
||||
import {
|
||||
ContractCourtBadge,
|
||||
ContractStatusBadge,
|
||||
} from "@/components/contracts/ContractStatusBadge";
|
||||
import {
|
||||
ContractStatusTabs,
|
||||
type ContractStatusTabKey,
|
||||
@@ -334,6 +337,19 @@ export default function ContractRequestsPage() {
|
||||
</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",
|
||||
size: COLUMN_WIDTH,
|
||||
@@ -666,7 +682,7 @@ export default function ContractRequestsPage() {
|
||||
}}
|
||||
// table-fixed makes the per-column 120px widths stick; without
|
||||
// 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}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -242,23 +242,20 @@ export default function GlClearanceDetailPage() {
|
||||
|
||||
<Tabs.Panel value="workflow">
|
||||
{/* GL Ethiopia cannot file the import customs declaration until this
|
||||
desk names the officer handling the shipment in transit, so the
|
||||
ask sits above everything else on the page. Exports have no such
|
||||
gate — Djibouti's steps come after the declaration. */}
|
||||
{isImport ? (
|
||||
<Box mb="md">
|
||||
<TransitAssigneePanel
|
||||
entityId={id!}
|
||||
isBooking={data.kind === "booking"}
|
||||
transitAssignee={data.clearance.transitAssignee}
|
||||
side="DJ"
|
||||
readOnly={
|
||||
!hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
desk names the officer handling the shipment in transit. Exports also
|
||||
need transit assignment at the DJ stage after ET requests it. */}
|
||||
<Box mb="md">
|
||||
<TransitAssigneePanel
|
||||
entityId={id!}
|
||||
isBooking={data.kind === "booking"}
|
||||
transitAssignee={data.clearance.transitAssignee}
|
||||
side="DJ"
|
||||
readOnly={
|
||||
!hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
|
||||
@@ -411,6 +411,12 @@ export type BookingAllocationStatus =
|
||||
| "FAILED";
|
||||
|
||||
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;
|
||||
selectedForBatchAt: string | null;
|
||||
allocationStatus: BookingAllocationStatus;
|
||||
|
||||
@@ -422,11 +422,13 @@ export function Step8Review({
|
||||
label="Customs clearing"
|
||||
value={customsValue}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<Package size={18} />}
|
||||
label="Hazardous cargo"
|
||||
value={
|
||||
values.isHazardous ? (
|
||||
{/* Step-3 toggles appear only when the customer selected them —
|
||||
an off toggle is left off the summary entirely. */}
|
||||
{values.isHazardous && (
|
||||
<SummaryItem
|
||||
icon={<Package size={18} />}
|
||||
label="Hazardous cargo"
|
||||
value={
|
||||
<>
|
||||
Yes
|
||||
<Group gap={6} mt={6}>
|
||||
@@ -438,27 +440,24 @@ export function Step8Review({
|
||||
</Badge>
|
||||
</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>
|
||||
</Paper>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user