mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: enhance contract clearance process with linked booking details
- Added and to for better visibility of GL-created shipment bookings. - Implemented method in to fetch the latest clearance phase for contracts, improving list responses. - Introduced property in the entity to store the latest clearance cycle's phase. - Updated to surface linked booking information in the clearance view. - Created component to display detailed container information in booking details. - Refactored booking actions to remove contract-related actions from the booking request page. - Enhanced the component to reflect the current phase of clearance actions. - Updated UI components to provide clearer messaging regarding the status of clearance and linked bookings. - Adjusted action handling in to include duty payment actions. - Improved the to show hints for each phase of the clearance process.
This commit is contained in:
@@ -90,6 +90,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bc')
|
||||
.leftJoinAndSelect('bc.containerType', 'ct')
|
||||
.leftJoinAndSelect('bc.units', 'bcu')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
// .leftJoinAndSelect('booking.customer', 'customer')
|
||||
.leftJoinAndSelect('booking.train', 'train')
|
||||
@@ -104,6 +105,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
|
||||
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
|
||||
.where('booking.id = :id', { id })
|
||||
.addOrderBy('bcu.sort_order', 'ASC')
|
||||
.leftJoinAndMapMany(
|
||||
'booking.files',
|
||||
FileRecord,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
|
||||
import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity';
|
||||
import { Booking } from './booking.entity';
|
||||
import { BookingContainerUnit } from './booking-container-unit.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'booking_container' })
|
||||
@Index(['bookingId'])
|
||||
@@ -61,4 +62,8 @@ export class BookingContainer extends BaseEntity {
|
||||
|
||||
@Column({ name: 'overweight_excess_tons', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||
overweightExcessTons?: number | null;
|
||||
|
||||
/** The physical containers under this line — each with its own number + VGM. */
|
||||
@OneToMany(() => BookingContainerUnit, (u) => u.bookingContainer)
|
||||
units?: BookingContainerUnit[];
|
||||
}
|
||||
|
||||
@@ -334,15 +334,19 @@ export class CompaniesController {
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
) {
|
||||
const files = await this.filesService.findByResource(companyId, "companies");
|
||||
return files.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
code: f.code,
|
||||
mimeType: f.mimeType,
|
||||
size: f.size,
|
||||
uploadedAt: f.createdAt,
|
||||
url: f.url,
|
||||
}));
|
||||
return Promise.all(
|
||||
files.map(async (f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
code: f.code,
|
||||
mimeType: f.mimeType,
|
||||
size: f.size,
|
||||
uploadedAt: f.createdAt,
|
||||
// Raw `f.url` is an un-signed MinIO path the browser can't open — sign
|
||||
// it so the file previews/downloads in the client.
|
||||
url: f.url ? await this.filesService.signUrl(f.url) : f.url,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":companyId/documents")
|
||||
|
||||
@@ -77,6 +77,9 @@ export interface ContractClearanceView {
|
||||
/** Export post-booking clearance finalized (transit permit uploaded + GL confirmed). */
|
||||
exportClearanceFinalized?: boolean;
|
||||
linkedBookingId?: string | null;
|
||||
/** Reference + status of the GL-created shipment booking, once it exists. */
|
||||
linkedBookingReference?: string | null;
|
||||
linkedBookingStatus?: string | null;
|
||||
dutyAdvice?: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
@@ -284,13 +287,22 @@ export class ContractClearanceService {
|
||||
);
|
||||
|
||||
let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
|
||||
if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') {
|
||||
// Once GL creates the shipment booking, surface its reference + status so the
|
||||
// customer sees the concrete booking instead of a stale "will be created
|
||||
// shortly" message. Reuse the export booking load; fetch for import too.
|
||||
let linkedBookingReference: string | null = null;
|
||||
let linkedBookingStatus: string | null = null;
|
||||
if (cycle?.bookingId) {
|
||||
const booking = await this.bookingsService.findById(cycle.bookingId);
|
||||
if (booking) {
|
||||
nextAction = this.workflowService.computeNextActionForBooking(
|
||||
booking,
|
||||
bookingMilestones,
|
||||
);
|
||||
linkedBookingReference = booking.reference ?? null;
|
||||
linkedBookingStatus = booking.status ?? null;
|
||||
if (contract.tradeDirection === 'EXPORT') {
|
||||
nextAction = this.workflowService.computeNextActionForBooking(
|
||||
booking,
|
||||
bookingMilestones,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,6 +338,8 @@ export class ContractClearanceService {
|
||||
preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt),
|
||||
exportClearanceFinalized: Boolean(cycle?.completedAt),
|
||||
linkedBookingId: cycle?.bookingId ?? null,
|
||||
linkedBookingReference,
|
||||
linkedBookingStatus,
|
||||
dutyAdvice,
|
||||
workflowFiles,
|
||||
t1,
|
||||
|
||||
@@ -135,6 +135,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
// Attach the generated contract PDF to each row so list/home can offer a
|
||||
// direct download. Loaded separately to keep pagination counts correct.
|
||||
await this.attachContractFiles(items);
|
||||
await this.attachClearancePhases(items);
|
||||
|
||||
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
|
||||
return {
|
||||
@@ -173,6 +174,30 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach each contract's persisted clearance phase (latest cycle's
|
||||
* current_phase) so list consumers can show step-accurate customer actions
|
||||
* ("Pay duty & upload slip" vs generic "Update clearance") without a
|
||||
* per-contract clearance-view request. One query per page, like
|
||||
* `attachContractFiles`.
|
||||
*/
|
||||
private async attachClearancePhases(contracts: Contract[]): Promise<void> {
|
||||
if (contracts.length === 0) return;
|
||||
const ids = contracts.map((c) => c.id);
|
||||
const rows: Array<{ contract_id: string; current_phase: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT DISTINCT ON (contract_id) contract_id, current_phase
|
||||
FROM freight.contract_clearance_cycles
|
||||
WHERE contract_id = ANY($1)
|
||||
ORDER BY contract_id, cycle_number DESC`,
|
||||
[ids],
|
||||
);
|
||||
const byContract = new Map(rows.map((r) => [r.contract_id, r.current_phase]));
|
||||
for (const contract of contracts) {
|
||||
contract.clearancePhase = byContract.get(contract.id) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
async getStatusCounts(): Promise<Record<string, number>> {
|
||||
const rows = await this.repository
|
||||
.createQueryBuilder('contract')
|
||||
|
||||
@@ -254,4 +254,10 @@ export class Contract extends BaseEntity {
|
||||
createForeignKeyConstraints: false,
|
||||
})
|
||||
files?: FileRecord[];
|
||||
|
||||
/**
|
||||
* Latest clearance cycle's current_phase, attached by
|
||||
* ContractsRepository.attachClearancePhases for list responses. Not a column.
|
||||
*/
|
||||
clearancePhase?: string | null;
|
||||
}
|
||||
|
||||
@@ -123,6 +123,16 @@ export class FilesService {
|
||||
return this.filesRepository.findByResource(resourceId, resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-lived signed URL for a stored file's raw MinIO URL. The persisted
|
||||
* `url` is an un-signed object path that a browser cannot fetch directly;
|
||||
* callers that expose files for preview/download must sign them first.
|
||||
*/
|
||||
async signUrl(rawUrl: string, expirySeconds = 300): Promise<string> {
|
||||
const objectName = this.minioService.getObjectNameFromUrl(rawUrl);
|
||||
return this.minioService.getSignedUrl(objectName, expirySeconds);
|
||||
}
|
||||
|
||||
async findByCode(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Download, Zap, FileText, Clock } from "lucide-react";
|
||||
import { Stack, Text, Button } from "@mantine/core";
|
||||
import { Zap, Clock } from "lucide-react";
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { BookingActionsMenu } from "./BookingActionsMenu";
|
||||
@@ -14,21 +14,11 @@ interface BookingActionsToolbarProps {
|
||||
mutations: Mutations;
|
||||
}
|
||||
|
||||
/** Detail-page actions: primary toolbar + downloads. */
|
||||
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
|
||||
/** Detail-page actions: primary staff-action toolbar. */
|
||||
export function BookingActionsToolbar({ booking }: BookingActionsToolbarProps) {
|
||||
const row = toBookingListRow(booking);
|
||||
const { status } = booking;
|
||||
|
||||
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
|
||||
const blob = await fn();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
if (status === "REJECTED" || status === "CANCELLED" || status === "COMPLETED") {
|
||||
return null;
|
||||
}
|
||||
@@ -101,23 +91,6 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
|
||||
<BookingActionsMenu row={row} variant="toolbar" />
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
|
||||
{status === "CONTRACT_READY" && (
|
||||
<SectionCard icon={FileText} title="Documents">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<Download size={16} />}
|
||||
onClick={() =>
|
||||
downloadBlob(
|
||||
() => mutations.downloadContract(),
|
||||
`contract-${booking.reference}.txt`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Download contract
|
||||
</Button>
|
||||
</SectionCard>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useMemo } from "react";
|
||||
import { Boxes, Container as ContainerIcon, Snowflake, Flame } from "lucide-react";
|
||||
import { Badge, Box, Group, Stack, Table, Text, ThemeIcon } from "@mantine/core";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { SectionCard } from "./SectionCard";
|
||||
|
||||
export interface BookingContainerUnitsCardProps {
|
||||
booking: BookingDetail;
|
||||
}
|
||||
|
||||
interface FlatUnit {
|
||||
id: string;
|
||||
containerNumber: string;
|
||||
sealNumber?: string | null;
|
||||
vgmTons: number;
|
||||
isHazardous?: boolean;
|
||||
isReefer?: boolean;
|
||||
typeLabel: string;
|
||||
sizeFt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The physical container manifest: one row per container with its number, type,
|
||||
* seal, and weight (VGM). Per-unit numbers are only captured for contract-drawdown
|
||||
* bookings — when a line has no units the card falls back to the aggregate
|
||||
* type/qty/weight so it still renders something for plain bookings.
|
||||
*/
|
||||
export function BookingContainerUnitsCard({ booking }: BookingContainerUnitsCardProps) {
|
||||
const lines = booking.bookingContainers ?? [];
|
||||
|
||||
const units: FlatUnit[] = useMemo(
|
||||
() =>
|
||||
lines.flatMap((line) =>
|
||||
(line.units ?? []).map((u) => ({
|
||||
id: u.id,
|
||||
containerNumber: u.containerNumber,
|
||||
sealNumber: u.sealNumber,
|
||||
vgmTons: Number(u.vgmTons) || 0,
|
||||
isHazardous: u.isHazardous,
|
||||
isReefer: u.isReefer,
|
||||
typeLabel: line.containerType?.label ?? line.containerType?.code ?? "—",
|
||||
sizeFt: line.containerType?.sizeFt,
|
||||
})),
|
||||
),
|
||||
[lines],
|
||||
);
|
||||
|
||||
// Container bookings only — bulk has no container manifest.
|
||||
if (booking.freightType === "BULK" || lines.length === 0) return null;
|
||||
|
||||
const totalUnits = units.length;
|
||||
const totalVgm = units.reduce((sum, u) => sum + u.vgmTons, 0);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={Boxes}
|
||||
title="Containers"
|
||||
subtitle={
|
||||
totalUnits > 0
|
||||
? "Each physical container with its number and weight"
|
||||
: "Per-container numbers were not captured for this booking"
|
||||
}
|
||||
accent="teal"
|
||||
extra={
|
||||
totalUnits > 0 ? (
|
||||
<Badge color="teal" variant="light" radius="sm">
|
||||
{totalUnits} container{totalUnits === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="gray" variant="light" radius="sm">
|
||||
{lines.length} line{lines.length === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
>
|
||||
{totalUnits > 0 ? (
|
||||
<Stack gap="md">
|
||||
<Box style={{ overflowX: "auto" }}>
|
||||
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ width: 40 }}>#</Table.Th>
|
||||
<Table.Th>Container No.</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Seal</Table.Th>
|
||||
<Table.Th ta="right">Weight (VGM)</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{units.map((u, i) => (
|
||||
<Table.Tr key={u.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{i + 1}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={8} wrap="nowrap" align="center">
|
||||
<ThemeIcon size={26} radius="md" variant="light" color="teal">
|
||||
<ContainerIcon size={15} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={700} ff="monospace">
|
||||
{u.containerNumber}
|
||||
</Text>
|
||||
{u.isReefer ? (
|
||||
<ThemeIcon size={20} radius="sm" variant="light" color="blue" title="Reefer">
|
||||
<Snowflake size={12} />
|
||||
</ThemeIcon>
|
||||
) : null}
|
||||
{u.isHazardous ? (
|
||||
<ThemeIcon size={20} radius="sm" variant="light" color="red" title="Hazardous">
|
||||
<Flame size={12} />
|
||||
</ThemeIcon>
|
||||
) : null}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm">{u.typeLabel}</Text>
|
||||
{u.sizeFt ? (
|
||||
<Badge color="gray" variant="light" radius="sm" size="sm">
|
||||
{u.sizeFt}FT
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c={u.sealNumber ? undefined : "dimmed"}>
|
||||
{u.sealNumber || "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text size="sm" fw={700}>
|
||||
{u.vgmTons.toFixed(3)} t
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
<Group
|
||||
justify="space-between"
|
||||
pt="sm"
|
||||
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
Total weight (VGM)
|
||||
</Text>
|
||||
<Text size="sm" fw={800} c="teal.7">
|
||||
{totalVgm.toFixed(3)} t
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
// Fallback: no per-unit numbers — show the aggregate lines.
|
||||
<Box style={{ overflowX: "auto" }}>
|
||||
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Qty</Table.Th>
|
||||
<Table.Th>VGM / unit</Table.Th>
|
||||
<Table.Th ta="right">Total VGM</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{lines.map((line) => {
|
||||
const perUnit = Number(line.vgmPerUnitTons) || 0;
|
||||
return (
|
||||
<Table.Tr key={line.id}>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{line.containerType?.label ?? line.containerType?.code ?? "—"}
|
||||
</Text>
|
||||
{line.containerType?.sizeFt ? (
|
||||
<Badge color="gray" variant="light" radius="sm" size="sm">
|
||||
{line.containerType.sizeFt}FT
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>{line.quantity}</Table.Td>
|
||||
<Table.Td>{perUnit.toFixed(3)} t</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text size="sm" fw={700}>
|
||||
{(line.quantity * perUnit).toFixed(3)} t
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export * from "./BookingDetailHeader";
|
||||
export * from "./BookingLifecycleStepper";
|
||||
export * from "./BookingRouteCard";
|
||||
export * from "./BookingContainersCard";
|
||||
export * from "./BookingContainerUnitsCard";
|
||||
export * from "./BookingApprovalCard";
|
||||
export * from "./BookingReviewNotesCard";
|
||||
export * from "./BookingPaymentCard";
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Ban,
|
||||
Check,
|
||||
FileSignature,
|
||||
MessageSquareWarning,
|
||||
Play,
|
||||
ShieldCheck,
|
||||
@@ -211,29 +210,6 @@ const CANCEL_ACTION: BookingActionDef = {
|
||||
inputPlaceholder: "Reason for cancellation…",
|
||||
};
|
||||
|
||||
const VIEW_CONTRACT_ACTION: BookingActionDef = {
|
||||
id: "viewContract",
|
||||
label: "View contract",
|
||||
shortLabel: "Contract",
|
||||
description: "Open contract document and signatures",
|
||||
confirmTitle: "",
|
||||
confirmDescription: "",
|
||||
variant: "outline",
|
||||
icon: FileSignature,
|
||||
};
|
||||
|
||||
const SIGN_CONTRACT_STAFF_ACTION: BookingActionDef = {
|
||||
id: "signContractStaff",
|
||||
label: "Sign contract",
|
||||
shortLabel: "Sign",
|
||||
description: "Open contract page and apply staff counter-signature",
|
||||
confirmTitle: "",
|
||||
confirmDescription: "",
|
||||
variant: "default",
|
||||
icon: FileSignature,
|
||||
primary: true,
|
||||
};
|
||||
|
||||
// Opens the booking detail straight on the Clearance tab so Marketing can
|
||||
// review the customer's clearance documents (non-customs bookings only).
|
||||
const REVIEW_CLEARANCE_ACTION: BookingActionDef = {
|
||||
@@ -340,22 +316,14 @@ export function getBookingActions(
|
||||
actions = withCancel(approvalActions(approvalSteps));
|
||||
break;
|
||||
case "APPROVED":
|
||||
actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }, CANCEL_ACTION];
|
||||
actions = [CANCEL_ACTION];
|
||||
break;
|
||||
case "CONTRACT_READY":
|
||||
actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }];
|
||||
break;
|
||||
case "SIGNED_CUSTOMER":
|
||||
actions = [SIGN_CONTRACT_STAFF_ACTION, VIEW_CONTRACT_ACTION];
|
||||
break;
|
||||
case "FULLY_EXECUTED":
|
||||
actions = [
|
||||
{
|
||||
...VIEW_CONTRACT_ACTION,
|
||||
label: "View executed contract",
|
||||
primary: true,
|
||||
},
|
||||
];
|
||||
// Contract view/sign/executed buttons intentionally removed from the
|
||||
// booking-request page.
|
||||
actions = [];
|
||||
break;
|
||||
case "AWAITING_DOCUMENTS":
|
||||
case "DOCUMENTS_UNDER_REVIEW":
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileSignature,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
Milestone,
|
||||
@@ -36,31 +35,19 @@ import {
|
||||
BookingCargoCard,
|
||||
BookingCompanyCard,
|
||||
BookingContractSummaryCard,
|
||||
BookingDocumentsCard,
|
||||
BookingContainerUnitsCard,
|
||||
ClearanceReviewSection,
|
||||
ContractOrdersPanel,
|
||||
type BookingFileView,
|
||||
} from "@/components/bookings/detail";
|
||||
import { WarehouseInfoCard } from "@/components/warehouses";
|
||||
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import {
|
||||
useBookingDetail,
|
||||
useBookingMutations,
|
||||
} from "@/hooks/bookings/useBookings";
|
||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
// Signature / generated-contract files are surfaced on the contract page, not
|
||||
// in the booking's Documents list.
|
||||
const SIGNATURE_FILE_CODES = new Set([
|
||||
"signature",
|
||||
"signature_customer",
|
||||
"signature_staff",
|
||||
"contract",
|
||||
]);
|
||||
|
||||
export default function BookingRequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -77,14 +64,6 @@ export default function BookingRequestDetailPage() {
|
||||
} = useBookingDetail(id);
|
||||
const mutations = useBookingMutations(id ?? "");
|
||||
|
||||
const handleDownloadFile = async (file: BookingFileView) => {
|
||||
try {
|
||||
await downloadBookingFile(file.id, file.name);
|
||||
} catch {
|
||||
toast.error("Could not download file.");
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -149,11 +128,6 @@ export default function BookingRequestDetailPage() {
|
||||
|
||||
const row = toBookingListRow(booking);
|
||||
const statusMeta = getStatusMeta(booking.status);
|
||||
const showContractButton = [
|
||||
"CONTRACT_READY",
|
||||
"SIGNED_CUSTOMER",
|
||||
"FULLY_EXECUTED",
|
||||
].includes(booking.status);
|
||||
const showApprovalCard =
|
||||
booking.status === "PENDING_APPROVAL" ||
|
||||
booking.status === "APPROVED_PENDING_SIGNATURE";
|
||||
@@ -246,11 +220,7 @@ export default function BookingRequestDetailPage() {
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview">
|
||||
<OverviewPanel
|
||||
booking={booking}
|
||||
row={row}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
<OverviewPanel booking={booking} row={row} />
|
||||
</Tabs.Panel>
|
||||
{isGeneralContract && (
|
||||
<Tabs.Panel value="orders">
|
||||
@@ -270,11 +240,7 @@ export default function BookingRequestDetailPage() {
|
||||
)}
|
||||
</Tabs>
|
||||
) : (
|
||||
<OverviewPanel
|
||||
booking={booking}
|
||||
row={row}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
<OverviewPanel booking={booking} row={row} />
|
||||
)}
|
||||
</Grid.Col>
|
||||
|
||||
@@ -306,20 +272,6 @@ export default function BookingRequestDetailPage() {
|
||||
View document clearance
|
||||
</Button>
|
||||
)}
|
||||
{showContractButton && (
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<FileSignature size={16} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/dashboard/booking-requests/${booking.id}/contract`,
|
||||
)
|
||||
}
|
||||
>
|
||||
View & sign contract
|
||||
</Button>
|
||||
)}
|
||||
{showApprovalCard && (
|
||||
<ApprovalStepsCard booking={booking} mutations={mutations} />
|
||||
)}
|
||||
@@ -332,15 +284,13 @@ export default function BookingRequestDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** The booking's primary detail cards — route, services, cargo, contract, docs. */
|
||||
/** The booking's primary detail cards — route, services, cargo, containers. */
|
||||
function OverviewPanel({
|
||||
booking,
|
||||
row,
|
||||
onDownload,
|
||||
}: {
|
||||
booking: BookingDetail;
|
||||
row: ReturnType<typeof toBookingListRow>;
|
||||
onDownload: (file: BookingFileView) => void;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
@@ -351,15 +301,10 @@ function OverviewPanel({
|
||||
/>
|
||||
<BookingMileServicesCard booking={booking} />
|
||||
<BookingCargoCard booking={booking} />
|
||||
<BookingContainerUnitsCard booking={booking} />
|
||||
{booking.contractSummary && (
|
||||
<BookingContractSummaryCard summary={booking.contractSummary} />
|
||||
)}
|
||||
<BookingDocumentsCard
|
||||
files={(booking.files ?? []).filter(
|
||||
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
|
||||
)}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,17 @@ export interface BookingCompany {
|
||||
website?: string | null;
|
||||
}
|
||||
|
||||
/** One physical container under a line — its own number + verified gross mass. */
|
||||
export interface BookingContainerUnit {
|
||||
id: string;
|
||||
containerNumber: string;
|
||||
sealNumber?: string | null;
|
||||
vgmTons: number;
|
||||
isHazardous?: boolean;
|
||||
isReefer?: boolean;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface BookingContainerLine {
|
||||
id: string;
|
||||
containerTypeId: string;
|
||||
@@ -80,6 +91,8 @@ export interface BookingContainerLine {
|
||||
label?: string;
|
||||
sizeFt?: number;
|
||||
};
|
||||
/** Per-physical-container rows (number + weight). Empty when not captured. */
|
||||
units?: BookingContainerUnit[];
|
||||
}
|
||||
|
||||
export interface BookingApprovalStep {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Modal, Text, type ButtonProps } from "@mantine/core";
|
||||
import { AlertCircle, Upload } from "lucide-react";
|
||||
import { AlertCircle, Upload, type LucideIcon } from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { ContractClearancePanel } from "@/pages/contracts/ContractClearancePanel";
|
||||
@@ -13,6 +13,10 @@ interface ContractClearanceActionProps {
|
||||
label?: string;
|
||||
size?: ButtonProps["size"];
|
||||
urgent?: boolean;
|
||||
/** GL's turn — render as a calm status button, not a call to action. */
|
||||
waiting?: boolean;
|
||||
/** Icon override from the phase-aware action derivation. */
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
export function ContractClearanceAction({
|
||||
@@ -20,6 +24,8 @@ export function ContractClearanceAction({
|
||||
label: labelProp,
|
||||
size = "xs",
|
||||
urgent = false,
|
||||
waiting = false,
|
||||
icon: iconProp,
|
||||
}: ContractClearanceActionProps) {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
@@ -38,7 +44,13 @@ export function ContractClearanceAction({
|
||||
return urgent ? "Upload clearance" : "Manage clearance";
|
||||
}, [labelProp, clearance, urgent]);
|
||||
|
||||
const Icon = urgent || label.includes("Update") ? AlertCircle : Upload;
|
||||
const Icon =
|
||||
iconProp ?? (urgent || label.includes("Update") ? AlertCircle : Upload);
|
||||
|
||||
// Urgent (customer's turn) = filled orange so it stands out among the green
|
||||
// actions; waiting (GL's turn) = calm subtle gray; default = brand green.
|
||||
const color = urgent ? "orange" : waiting ? "gray" : "edr-green";
|
||||
const variant = waiting ? "light" : "filled";
|
||||
|
||||
return (
|
||||
<ModalSafeWrapper>
|
||||
@@ -47,7 +59,8 @@ export function ContractClearanceAction({
|
||||
radius="md"
|
||||
fw={700}
|
||||
fz={13}
|
||||
color="edr-green"
|
||||
color={color}
|
||||
variant={variant}
|
||||
leftSection={<Icon size={14} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -46,6 +46,8 @@ export function ContractCustomerAction({
|
||||
label={action.label}
|
||||
size={size}
|
||||
urgent={action.urgent}
|
||||
waiting={action.waiting}
|
||||
icon={action.icon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
CreditCard,
|
||||
Eye,
|
||||
FileSignature,
|
||||
Hourglass,
|
||||
PackagePlus,
|
||||
PencilLine,
|
||||
Receipt,
|
||||
RotateCcw,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
@@ -61,6 +63,8 @@ export type ContractCustomerAction =
|
||||
primary: boolean;
|
||||
icon: LucideIcon;
|
||||
urgent: boolean;
|
||||
/** True when it's GL's turn — render calm/informational, not a call to action. */
|
||||
waiting?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "pay";
|
||||
@@ -126,14 +130,56 @@ export function deriveContractCustomerAction(
|
||||
|
||||
const clr = contractNeedsClearanceAction(contract);
|
||||
if (clr.show) {
|
||||
return {
|
||||
type: "clearance",
|
||||
contractId: id,
|
||||
label: clr.urgent ? "Upload clearance" : "Update clearance",
|
||||
primary: true,
|
||||
icon: Upload,
|
||||
urgent: clr.urgent,
|
||||
};
|
||||
// Refine the generic clearance action by the persisted clearance phase so
|
||||
// the button says what the customer actually has to do right now (e.g.
|
||||
// "Pay duty & upload slip" during CUSTOMER_DUTY, not "Update clearance").
|
||||
const phase = contract.clearancePhase ?? null;
|
||||
switch (phase) {
|
||||
case "CUSTOMER_INTAKE":
|
||||
return {
|
||||
type: "clearance",
|
||||
contractId: id,
|
||||
label: "Upload clearance documents",
|
||||
primary: true,
|
||||
icon: Upload,
|
||||
urgent: true,
|
||||
};
|
||||
case "CUSTOMER_DUTY":
|
||||
return {
|
||||
type: "clearance",
|
||||
contractId: id,
|
||||
label: "Pay duty & upload slip",
|
||||
primary: true,
|
||||
icon: Receipt,
|
||||
urgent: true,
|
||||
};
|
||||
case "GL_ET_REVIEW":
|
||||
case "GL_DJ_COLLECTION":
|
||||
case "GL_ET_OUTPUT":
|
||||
case "GL_ET_POST_CLEARANCE":
|
||||
case "GL_DJ_LOADING":
|
||||
case "POST_TRANSIT":
|
||||
// GL's turn — nothing for the customer to do; show a calm status.
|
||||
return {
|
||||
type: "clearance",
|
||||
contractId: id,
|
||||
label: "Clearance in progress",
|
||||
primary: false,
|
||||
icon: Hourglass,
|
||||
urgent: false,
|
||||
waiting: true,
|
||||
};
|
||||
default:
|
||||
// No persisted phase (legacy / early cycles) — keep the status-derived label.
|
||||
return {
|
||||
type: "clearance",
|
||||
contractId: id,
|
||||
label: clr.urgent ? "Upload clearance" : "Update clearance",
|
||||
primary: true,
|
||||
icon: Upload,
|
||||
urgent: clr.urgent,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -6,7 +6,7 @@ import { contractNeedsClearanceAction } from "@/components/customer-actions/deri
|
||||
export interface ActionItem {
|
||||
id: string;
|
||||
/** What the customer must do — drives the icon, label and modal. */
|
||||
kind: "clearance" | "sign" | "book" | "pay";
|
||||
kind: "clearance" | "duty" | "sign" | "book" | "pay";
|
||||
/** The contract/booking reference for display. */
|
||||
reference: string;
|
||||
/** Short human description of the action. */
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FilePlus2,
|
||||
FileSignature,
|
||||
PackagePlus,
|
||||
Receipt,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
@@ -34,6 +35,7 @@ const KIND_META: Record<
|
||||
{ icon: typeof Upload; label: string; color: string }
|
||||
> = {
|
||||
clearance: { icon: Upload, label: "Clearance", color: "edr-green" },
|
||||
duty: { icon: Receipt, label: "Duty / tax", color: "orange" },
|
||||
sign: { icon: FileSignature, label: "Sign", color: "blue" },
|
||||
book: { icon: PackagePlus, label: "Book", color: "violet" },
|
||||
pay: { icon: CreditCard, label: "Payment", color: "orange" },
|
||||
@@ -96,6 +98,19 @@ export function ActionNeededSection({
|
||||
const awaiting =
|
||||
c.status === "AWAITING_CLEARANCE_DOCUMENTS" ||
|
||||
view?.clearanceStatus === "AWAITING_DOCUMENTS";
|
||||
// Duty phase: the customer's task is paying duty/tax and uploading the
|
||||
// slip — a distinct, money action, not a generic document upload.
|
||||
if (view?.phase === "CUSTOMER_DUTY") {
|
||||
out.push({
|
||||
id: `duty-${c.id}`,
|
||||
kind: "duty",
|
||||
reference: c.reference,
|
||||
description: "Duty / tax payment due — pay and upload the slip",
|
||||
targetId: c.id,
|
||||
urgent: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Only surface when there's something the customer can do: a query, or the
|
||||
// contract is awaiting their (re)upload.
|
||||
if (queried === 0 && !awaiting) return;
|
||||
@@ -162,6 +177,10 @@ export function ActionNeededSection({
|
||||
case "clearance":
|
||||
setClearanceId(item.targetId);
|
||||
break;
|
||||
case "duty":
|
||||
// Duty advice + payment-slip upload live on the contract detail page.
|
||||
navigate(`/contracts/${item.targetId}`);
|
||||
break;
|
||||
case "pay":
|
||||
setPayItem(item);
|
||||
break;
|
||||
@@ -249,6 +268,8 @@ export function ActionNeededSection({
|
||||
leftSection={
|
||||
item.kind === "clearance" ? (
|
||||
<Upload size={14} />
|
||||
) : item.kind === "duty" ? (
|
||||
<Receipt size={14} />
|
||||
) : (
|
||||
<FilePlus2 size={14} />
|
||||
)
|
||||
@@ -256,13 +277,15 @@ export function ActionNeededSection({
|
||||
>
|
||||
{item.kind === "pay"
|
||||
? "Pay now"
|
||||
: item.kind === "sign"
|
||||
? "Sign"
|
||||
: item.kind === "book"
|
||||
? "Book"
|
||||
: item.urgent
|
||||
? "Upload documents"
|
||||
: "Upload"}
|
||||
: item.kind === "duty"
|
||||
? "Pay duty & upload slip"
|
||||
: item.kind === "sign"
|
||||
? "Sign"
|
||||
: item.kind === "book"
|
||||
? "Book"
|
||||
: item.urgent
|
||||
? "Upload documents"
|
||||
: "Upload"}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,18 @@ const PHASE_LABELS: Record<string, string> = {
|
||||
POST_TRANSIT: "Transit",
|
||||
};
|
||||
|
||||
/** One-line hint under each phase label, for the vertical layout. */
|
||||
const PHASE_HINTS: Record<string, string> = {
|
||||
CUSTOMER_INTAKE: "You upload the required clearance documents",
|
||||
GL_ET_REVIEW: "Global Logistics reviews your documents in Ethiopia",
|
||||
GL_DJ_COLLECTION: "Delivery order collected in Djibouti",
|
||||
GL_ET_OUTPUT: "Customs declaration prepared",
|
||||
CUSTOMER_DUTY: "You pay the assessed duty / tax",
|
||||
GL_ET_POST_CLEARANCE: "Transit cleared and paperwork finalised",
|
||||
GL_DJ_LOADING: "Cargo loaded for departure",
|
||||
POST_TRANSIT: "In transit",
|
||||
};
|
||||
|
||||
const IMPORT_PHASES = [
|
||||
"CUSTOMER_INTAKE",
|
||||
"GL_ET_REVIEW",
|
||||
@@ -51,62 +63,92 @@ export function ClearancePhaseStepper({
|
||||
const current = clearance?.phase ?? phases[0];
|
||||
const activeIdx = phaseIndex(phases, current);
|
||||
|
||||
const dot = compact ? 26 : 30;
|
||||
const rowGap = compact ? 18 : 24;
|
||||
|
||||
// Vertical timeline: every phase is a row, so all steps stay visible on any
|
||||
// width without horizontal scrolling. The connector runs down between dots.
|
||||
return (
|
||||
<Group gap={0} wrap="nowrap" align="flex-start" style={{ overflowX: "auto" }}>
|
||||
<Stack gap={0}>
|
||||
{phases.map((phase, index) => {
|
||||
const isComplete = index < activeIdx;
|
||||
const isActive = index === activeIdx;
|
||||
const isLast = index === phases.length - 1;
|
||||
const doneOrActive = isComplete || isActive;
|
||||
|
||||
return (
|
||||
<Box key={phase} style={{ flex: isLast ? "0 0 auto" : 1, minWidth: compact ? 72 : 88 }}>
|
||||
<Group gap={0} wrap="nowrap" align="center">
|
||||
<Stack gap={4} align="center" style={{ flexShrink: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: compact ? 28 : 34,
|
||||
height: compact ? 28 : 34,
|
||||
borderRadius: "50%",
|
||||
background: isComplete ? BRAND_GREEN : isActive ? "white" : "var(--mantine-color-gray-1)",
|
||||
border: isActive
|
||||
? `2px solid ${BRAND_GREEN}`
|
||||
: isComplete
|
||||
? "2px solid transparent"
|
||||
: "2px solid var(--mantine-color-gray-3)",
|
||||
color: isComplete ? "white" : isActive ? BRAND_GREEN : "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
>
|
||||
{isComplete ? <Check size={compact ? 14 : 16} strokeWidth={3} /> : null}
|
||||
</Box>
|
||||
<Text
|
||||
size={compact ? "10px" : "xs"}
|
||||
fw={isActive ? 600 : 500}
|
||||
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
|
||||
ta="center"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{PHASE_LABELS[phase] ?? phase}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group key={phase} gap={12} wrap="nowrap" align="flex-start">
|
||||
{/* Dot + connector column */}
|
||||
<Stack gap={0} align="center" style={{ flexShrink: 0, alignSelf: "stretch" }}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: dot,
|
||||
height: dot,
|
||||
borderRadius: "50%",
|
||||
flexShrink: 0,
|
||||
background: isComplete
|
||||
? BRAND_GREEN
|
||||
: isActive
|
||||
? "white"
|
||||
: "var(--mantine-color-gray-1)",
|
||||
border: isActive
|
||||
? `2px solid ${BRAND_GREEN}`
|
||||
: isComplete
|
||||
? "2px solid transparent"
|
||||
: "2px solid var(--mantine-color-gray-3)",
|
||||
color: isComplete
|
||||
? "white"
|
||||
: isActive
|
||||
? BRAND_GREEN
|
||||
: "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
>
|
||||
{isComplete ? (
|
||||
<Check size={compact ? 14 : 16} strokeWidth={3} />
|
||||
) : (
|
||||
<Text size="xs" fw={700}>
|
||||
{index + 1}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{!isLast && (
|
||||
<Box
|
||||
style={{
|
||||
width: 2,
|
||||
flex: 1,
|
||||
height: 2,
|
||||
marginInline: 6,
|
||||
marginBottom: compact ? 16 : 20,
|
||||
minHeight: rowGap,
|
||||
marginBlock: 4,
|
||||
borderRadius: 2,
|
||||
background: isComplete ? BRAND_GREEN : "var(--mantine-color-gray-2)",
|
||||
background: isComplete
|
||||
? BRAND_GREEN
|
||||
: "var(--mantine-color-gray-2)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Label + hint */}
|
||||
<Box pb={isLast ? 0 : rowGap} style={{ minWidth: 0, paddingTop: 3 }}>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={isActive ? 700 : 500}
|
||||
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
|
||||
lh={1.2}
|
||||
>
|
||||
{PHASE_LABELS[phase] ?? phase}
|
||||
</Text>
|
||||
{PHASE_HINTS[phase] && (
|
||||
<Text size="xs" c="dimmed" mt={2} lh={1.3}>
|
||||
{PHASE_HINTS[phase]}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { AlertTriangle, Download, Receipt, Upload } from "lucide-react";
|
||||
import { AlertTriangle, ArrowRight, Download, PackageCheck, Receipt, Upload } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Freight } from "@edr/types";
|
||||
@@ -87,7 +87,36 @@ export function ContractClearanceWorkflowBanner({
|
||||
onDownload={downloadWorkflowFile}
|
||||
/>
|
||||
|
||||
{clearance.bookingReady ? (
|
||||
{clearance.linkedBookingId ? (
|
||||
<Alert color="green" variant="light" icon={<PackageCheck size={16} />}>
|
||||
<Stack gap={6}>
|
||||
<Text fz={13} fw={600} style={{ color: INK }}>
|
||||
Shipment booking created
|
||||
{clearance.linkedBookingReference
|
||||
? ` · ${clearance.linkedBookingReference}`
|
||||
: ""}
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed">
|
||||
Global Logistics has created your shipment booking
|
||||
{clearance.linkedBookingStatus
|
||||
? ` (${clearance.linkedBookingStatus.replace(/_/g, " ").toLowerCase()})`
|
||||
: ""}
|
||||
. Track its progress from the booking.
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<ArrowRight size={14} />}
|
||||
component="a"
|
||||
href={`/bookings/${clearance.linkedBookingId}`}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
View shipment booking
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : clearance.bookingReady ? (
|
||||
<Alert color="green" variant="light">
|
||||
Clearance is complete. Global Logistics will create your shipment booking shortly.
|
||||
</Alert>
|
||||
|
||||
@@ -15,7 +15,8 @@ import { AlertBox, AsyncComboboxField, fieldStyles } from "./shared";
|
||||
|
||||
const CONTRACT_TYPE_OPTIONS = [
|
||||
{ value: "new", label: "New Contract" },
|
||||
{ value: "renewal", label: "Contract Renewal" },
|
||||
// Renewal is disabled for now — not yet available to customers.
|
||||
{ value: "renewal", label: "Contract Renewal (coming soon)", disabled: true },
|
||||
];
|
||||
|
||||
type ContractForm = UseFormReturn<
|
||||
|
||||
@@ -356,6 +356,9 @@ export interface ContractClearanceView {
|
||||
/** Export post-booking clearance finalized after transit permit upload. */
|
||||
exportClearanceFinalized?: boolean;
|
||||
linkedBookingId?: string | null;
|
||||
/** Reference + status of the GL-created shipment booking, once it exists. */
|
||||
linkedBookingReference?: string | null;
|
||||
linkedBookingStatus?: string | null;
|
||||
dutyAdvice?: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
@@ -577,6 +580,12 @@ export interface IContract extends BaseEntity {
|
||||
status: ContractStatus;
|
||||
clearanceStatus: ContractClearanceStatus;
|
||||
clearanceCycleNumber: number;
|
||||
/**
|
||||
* Latest clearance cycle's current phase (list responses only). Lets list
|
||||
* consumers show step-accurate customer actions without fetching the full
|
||||
* clearance view per contract.
|
||||
*/
|
||||
clearancePhase?: ContractDocPhase | string | null;
|
||||
|
||||
pricingBreakdown?: ContractPricingBreakdown | null;
|
||||
pricingDisplayMode?: "UNIT_RATES";
|
||||
|
||||
Reference in New Issue
Block a user