Implement comprehensive Global Logistics workflow for Unimodal Export and Import processes, including contract verification, documentation submission, compliance management, customs clearance, payment settlement, and last-mile transport handling.

This commit is contained in:
Marshal
2026-06-27 18:54:03 +00:00
parent 0a23ade118
commit e977893888
21 changed files with 1985 additions and 268 deletions

View File

@@ -235,13 +235,55 @@ export class ContractClearanceService {
}
}
/** GL ET reviews a single document: APPROVED or QUERIED (→ back to upload). */
/**
* Path A (no customs) — the customer clears the cargo himself and uploads his
* own clearance proof, reviewed by Operations rather than GL. True when a
* clearance doc set resolves for a non-customs contract.
*/
private isSelfClear(contract: Contract): boolean {
if (contract.customsClearingEnabled) return false;
return contractClearanceCodes(contract).inputCode != null;
}
/** GL ET (Path B) reviews a single document: APPROVED or QUERIED. */
async reviewDocument(
contractId: string,
fileKey: string,
status: 'APPROVED' | 'QUERIED',
staffId: string,
note?: string,
): Promise<Contract> {
return this.applyReview(contractId, fileKey, status, staffId, 'GL_ET', note);
}
/**
* Operations (Path A) reviews a customer self-clearance document. Identical
* approve/query loop to {@link reviewDocument}; rejects customs (Path B)
* contracts, which are GL-reviewed.
*/
async opsReviewDocument(
contractId: string,
fileKey: string,
status: 'APPROVED' | 'QUERIED',
staffId: string,
note?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (!this.isSelfClear(contract)) {
throw new ConflictException(
'Operations review applies only to self-clearance (non-customs) contracts.',
);
}
return this.applyReview(contractId, fileKey, status, staffId, 'OPERATIONS', note);
}
private async applyReview(
contractId: string,
fileKey: string,
status: 'APPROVED' | 'QUERIED',
staffId: string,
reviewerRole: 'GL_ET' | 'OPERATIONS',
note?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
@@ -280,7 +322,7 @@ export class ContractClearanceService {
`Document "${fileKey}" queried: ${note}`,
'CHANGES_REQUESTED',
staffId,
'GL_ET',
reviewerRole,
);
// Return the contract to the customer to re-upload the queried document.
await this.contractsRepository.update(contractId, {
@@ -325,11 +367,17 @@ export class ContractClearanceService {
}
/**
* GL ET finalizes pre-booking clearance: requires every customer document
* APPROVED (and required output docs present) → CLEARANCE_READY_FOR_BOOKING.
* GL ET finalizes Path B pre-booking clearance: requires every customer
* document APPROVED and required output docs present → CLEARANCE_READY_FOR_BOOKING
* (GL then creates the booking). Rejects self-clearance (Path A) contracts.
*/
async finalize(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (this.isSelfClear(contract)) {
throw new ConflictException(
'Self-clearance (Path A) contracts are finalized by Operations, not GL.',
);
}
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
throw new ConflictException(
`Cannot finalize clearance on status "${contract.status}".`,
@@ -376,8 +424,46 @@ export class ContractClearanceService {
}
/**
* GL ET queue: contracts awaiting pre-booking document review. Scoped to
* CLEARANCE_UNDER_REVIEW (customs contracts only).
* Operations finalizes Path A self-clearance: requires every customer document
* APPROVED, then the contract becomes bookable BY THE CUSTOMER. There is no GL
* output phase on Path A, so the contract goes straight to FULLY_EXECUTED
* (ONE_TIME) / CONTRACT_ACTIVE (GENERAL).
*/
async opsFinalize(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (!this.isSelfClear(contract)) {
throw new ConflictException(
'Operations finalize applies only to self-clearance (non-customs) contracts.',
);
}
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
throw new ConflictException(
`Cannot finalize clearance on status "${contract.status}".`,
);
}
const approved = await this.isClearanceFullyApproved(contract);
if (!approved) {
throw new BadRequestException(
'All required documents must be approved before clearance can be finalized',
);
}
const cycle = await this.contractsRepository.currentCycle(contractId);
await this.contractsRepository.update(contractId, {
status: contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED',
clearanceStatus: 'SELF_CLEARED',
} as never);
if (cycle) {
await this.contractsRepository.setCycleStatus(cycle.id, 'CLEARANCE_READY_FOR_BOOKING', {
clearanceReadyAt: new Date(),
});
}
return this.contractsService.findById(contractId);
}
/**
* GL ET queue: customs (Path B) contracts awaiting pre-booking document review.
*/
async queue(
filter: FilterContractDto,
@@ -388,6 +474,22 @@ export class ContractClearanceService {
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 100,
statuses: ['CLEARANCE_UNDER_REVIEW'],
customsClearingEnabled: true,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
}
/**
* Operations queue: self-clearance (Path A) contracts awaiting Operations
* review of the customer's own clearance documents.
*/
async opsQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
return this.contractsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 100,
statuses: ['CLEARANCE_UNDER_REVIEW'],
customsClearingEnabled: false,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});

View File

@@ -20,16 +20,28 @@ function freightFor(freightType: string): Freight {
return freightType === 'BULK' ? 'bulk' : 'container';
}
/** The customer-input clearance setting code, or null when no gate applies. */
/**
* The customer-input clearance setting code, or null when no gate applies.
*
* - Path B (customs bundled): the customer uploads the documents GL needs to do
* the clearance work → `contract_clearance_{op}_{freight}`.
* - Path A (no customs): the customer clears the cargo himself and uploads his
* own (smaller) clearance proof set → `contract_clearance_selfclear_{op}_{freight}`,
* reviewed by Operations rather than GL.
*
* DOMESTIC/intercity has no border, so no clearance gate applies on either path.
*/
export function contractClearanceSettingCode(
tradeDirection: string,
freightType: string,
includesCustoms: boolean,
): string | null {
if (!includesCustoms) return null;
const op = operationFor(tradeDirection);
if (!op) return null;
const freight = freightFor(freightType);
if (!includesCustoms) {
return `contract_clearance_selfclear_${op}_${freight}`;
}
return `contract_clearance_${op}_${freight}`;
}

View File

@@ -22,6 +22,7 @@ import { ContractPricingService } from './contract-pricing.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository';
import { ContractsService } from './contracts.service';
import { contractClearanceSettingCode } from './contract-clearance.util';
import { Contract } from './entities/contract.entity';
import { ContractSignerRole } from './entities/contract-signature.entity';
import { SignContractDto } from './dto/sign-contract.dto';
@@ -441,9 +442,14 @@ export class ContractTransitionService {
}
/**
* Staff/Director/CEO counter-sign → branch on customs:
* - customs: AWAITING_CLEARANCE_DOCUMENTS + clearance gate opened (Path B)
* - transport: FULLY_EXECUTED (ONE_TIME) / CONTRACT_ACTIVE (GENERAL)
* Staff/Director/CEO counter-sign → branch on the execution path. A customs
* border (IMPORT/EXPORT) always requires a clearance gate before any shipment;
* who reviews differs:
* - Path B (customs bundled): customer uploads GL-input docs, GL reviews, GL
* uploads output, then GL creates the booking.
* - Path A (no customs): the customer clears the cargo himself and uploads his
* own clearance proof; Operations reviews it; then the CUSTOMER books.
* DOMESTIC/intercity has no border, so it goes straight to executed.
*/
async counterSign(
contractId: string,
@@ -461,9 +467,19 @@ export class ContractTransitionService {
lockedAt: now,
};
if (contract.customsClearingEnabled) {
// Path B — open a clearance cycle, seed the pre-booking milestones, and
// route the customer to the document upload.
// A clearance gate applies whenever a clearance doc set resolves — Path B
// (customs) or Path A self-clearance (IMPORT/EXPORT without customs). DOMESTIC
// resolves to null on both paths and skips straight to executed.
const clearanceCode = contractClearanceSettingCode(
contract.tradeDirection,
contract.freightType,
contract.customsClearingEnabled ?? false,
);
if (clearanceCode) {
// Open a clearance cycle, seed the pre-booking milestones, and route the
// customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the
// distinction is enforced at the review/finalize endpoints, not here.
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
@@ -471,7 +487,7 @@ export class ContractTransitionService {
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
updates.clearanceCycleNumber = cycleNumber;
} else {
// Path A — transport only; ready for the customer to book.
// No clearance gate (DOMESTIC) — ready for the customer to book directly.
updates.status =
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
updates.clearanceStatus = 'NOT_APPLICABLE';

View File

@@ -405,6 +405,45 @@ export class ContractsController {
return this.clearanceService.finalize(id);
}
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
@Get('clearance/ops-queue')
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
@ApiOperation({
summary: 'Operations queue: self-clearance (non-customs) contracts awaiting review',
})
opsClearanceQueue(@Query() filter: FilterContractDto) {
return this.clearanceService.opsQueue(filter);
}
@Post(':id/clearance/ops-review')
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
@ApiOperation({
summary: 'Operations reviews a customer self-clearance document (Approve | Query)',
})
opsReviewClearanceDocument(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ReviewClearanceDocumentDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.opsReviewDocument(
id,
dto.fileKey,
dto.status,
resolveAuthUserId(user),
dto.note,
);
}
@Post(':id/clearance/ops-finalize')
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
@ApiOperation({
summary: 'Operations finalizes self-clearance → customer may create the booking',
})
opsFinalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
return this.clearanceService.opsFinalize(id);
}
// ── Booking under contract (Path A customer / Path B GL ET) ────────────────
@Post(':id/bookings')

View File

@@ -25,6 +25,7 @@ export interface ContractListFilterOptions {
freightType?: string;
tradeDirection?: string;
paymentCurrency?: string;
customsClearingEnabled?: boolean;
createdFrom?: string;
createdTo?: string;
}
@@ -209,6 +210,11 @@ export class ContractsRepository extends BaseRepository<Contract> {
contractKind: options.contractKind,
});
}
if (options.customsClearingEnabled !== undefined) {
qb.andWhere('contract.customs_clearing_enabled = :customsClearingEnabled', {
customsClearingEnabled: options.customsClearingEnabled,
});
}
if (options.serviceTypeId) {
qb.andWhere('contract.service_type_id = :serviceTypeId', {
serviceTypeId: options.serviceTypeId,

View File

@@ -49,7 +49,8 @@ export const CONTRACT_CLEARANCE_STATUSES = [
'NOT_APPLICABLE',
'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking
'SELF_CLEARED', // Path A — Operations approved self-clearance; customer may book
'ACTIVE_SHIPMENT_IN_PROGRESS',
] as const;
export type ContractClearanceStatusValue =

View File

@@ -400,6 +400,61 @@ const CONTRACT_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [
},
];
// ── Path A self-clearance settings (no EDR customs service) ──────────────────
// When the contract does NOT bundle customs clearance, the customer clears the
// cargo himself and uploads his OWN clearance proof on the contract. Operations
// (not GL) reviews this smaller set before the customer may create the booking.
// Resolved by contract-clearance.util.ts as contract_clearance_selfclear_{op}_{freight}.
const SELF_CLEARANCE_IMPORT_FIELDS: OnboardingField[] = [
clearanceField("customs_declaration", "Customs Declaration (IM4/IM5)", 1),
clearanceField("import_release", "Import Release Permit", 2),
clearanceField("duty_tax_receipt", "Duty & Tax Payment Receipt", 3, {
required: false,
}),
clearanceField("delivery_order", "Delivery Order", 4, { required: false }),
clearanceField("supporting_document", "Other Clearance Document", 5, {
required: false,
}),
];
const SELF_CLEARANCE_EXPORT_FIELDS: OnboardingField[] = [
clearanceField("customs_declaration", "Customs Declaration (EX3/EX8)", 1),
clearanceField("export_release", "Export Release", 2),
clearanceField("transit_document", "Transit Document (T1)", 3, {
required: false,
}),
clearanceField("supporting_document", "Other Clearance Document", 4, {
required: false,
}),
];
const SELF_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [
{
code: "contract_clearance_selfclear_import_container",
label: "Self-clearance documents (import container)",
entity: CONTRACT_CLEARANCE_ENTITY,
fields: SELF_CLEARANCE_IMPORT_FIELDS,
},
{
code: "contract_clearance_selfclear_export_container",
label: "Self-clearance documents (export container)",
entity: CONTRACT_CLEARANCE_ENTITY,
fields: SELF_CLEARANCE_EXPORT_FIELDS,
},
{
code: "contract_clearance_selfclear_import_bulk",
label: "Self-clearance documents (import bulk)",
entity: CONTRACT_CLEARANCE_ENTITY,
fields: SELF_CLEARANCE_IMPORT_FIELDS,
},
{
code: "contract_clearance_selfclear_export_bulk",
label: "Self-clearance documents (export bulk)",
entity: CONTRACT_CLEARANCE_ENTITY,
fields: SELF_CLEARANCE_EXPORT_FIELDS,
},
];
// ── Contract intake settings ────────────────────────────────────────────────
// Commercial/framework documents attached at contract submission (wizard step 5),
// distinct from the post-sign clearance docs above.
@@ -456,6 +511,11 @@ export class FileUploadSettingsSeeder {
description:
"Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.",
})),
...SELF_CLEARANCE_SETTINGS.map((s) => ({
...s,
description:
"Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.",
})),
...CONTRACT_INTAKE_SETTINGS.map((s) => ({
...s,
description:

View File

@@ -79,6 +79,7 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
perm('a3000001-0001-4000-8000-00000000000a', 'edr_freight_app:contracts:clearance_review', 'Review pre-booking clearance docs'),
perm('a3000001-0001-4000-8000-00000000000b', 'edr_freight_app:contracts:finalize_clearance', 'Finalize pre-booking clearance'),
perm('a3000001-0001-4000-8000-00000000000c', 'edr_freight_app:contracts:create_booking', 'GL ET create booking under contract'),
perm('a3000001-0001-4000-8000-00000000000d', 'edr_freight_app:contracts:ops_clearance_review', 'Operations review of self-clearance docs (Path A)'),
];
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
@@ -147,6 +148,7 @@ export const FREIGHT_PERMS = {
clearanceReview: 'edr_freight_app:contracts:clearance_review',
finalizeClearance: 'edr_freight_app:contracts:finalize_clearance',
createBooking: 'edr_freight_app:contracts:create_booking',
opsClearanceReview: 'edr_freight_app:contracts:ops_clearance_review',
},
trainScheduling: {
view: 'edr_freight_app:train_scheduling:view',
@@ -196,6 +198,10 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.trainScheduling.manage,
FREIGHT_PERMS.fleet.view,
FREIGHT_PERMS.fleet.manage,
// Path A (no customs): Operations reviews the customer's self-clearance docs
// on the contract before the customer may create a shipment booking.
FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.contracts.opsClearanceReview,
...allRuleEngineViewKeys(),
],
director: [

View File

@@ -140,6 +140,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.contracts.clearanceReview,
},
{
label: "Self-Clearance Review",
href: "/dashboard/contracts/ops-clearance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
},
{
label: "Train Schedules",
href: "/dashboard/operations/train-scheduling-v2",
@@ -467,10 +473,25 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="contracts/ops-clearance"
element={
<RequirePermission
permission={FREIGHT_PERMS.contracts.opsClearanceReview}
>
<ContractClearanceListPage opsMode />
</RequirePermission>
}
/>
<Route
path="contracts/clearance/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceReview}>
<RequirePermission
permission={[
FREIGHT_PERMS.contracts.clearanceReview,
FREIGHT_PERMS.contracts.opsClearanceReview,
]}
>
<ContractClearanceDetailPage />
</RequirePermission>
}

View File

@@ -39,6 +39,11 @@ export interface ContractClearanceReviewSectionProps {
onChanged?: () => void;
/** Hide the inline progress summary (e.g. when the parent renders its own). */
hideSummary?: boolean;
/**
* Path A (non-customs): the reviewer is Operations, not GL, and there is no GL
* output upload step. Routes review/finalize to the Operations endpoints.
*/
selfClear?: boolean;
}
const STATUS_META: Record<
@@ -59,6 +64,7 @@ export function ContractClearanceReviewSection({
contractId,
onChanged,
hideSummary,
selfClear = false,
}: ContractClearanceReviewSectionProps) {
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
@@ -70,7 +76,7 @@ export function ContractClearanceReviewSection({
});
const { reviewDocument, uploadOutputDocuments, finalizeClearance } =
useContractClearanceMutations(contractId);
useContractClearanceMutations(contractId, selfClear);
const customerDocs = useMemo(
() =>

View File

@@ -143,6 +143,12 @@ export const URL_CONSTANTS = {
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
`/contracts/${id}/clearance/output-documents`,
CLEARANCE_FINALIZE: (id: string) => `/contracts/${id}/clearance/finalize`,
// Path A self-clearance — Operations reviews the customer's own clearance docs.
OPS_CLEARANCE_QUEUE: "/contracts/clearance/ops-queue",
OPS_CLEARANCE_REVIEW: (id: string) =>
`/contracts/${id}/clearance/ops-review`,
OPS_CLEARANCE_FINALIZE: (id: string) =>
`/contracts/${id}/clearance/ops-finalize`,
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
MILESTONES: (id: string) => `/contracts/${id}/milestones`,
BOOKING_MILESTONES: (bookingId: string) =>

View File

@@ -52,6 +52,15 @@ export function useContractClearanceQueue(region = "ET", enabled = true) {
});
}
/** Path A self-clearance queue (Operations reviews non-customs contracts). */
export function useOpsClearanceQueue(enabled = true) {
return useQuery({
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("OPS"),
queryFn: () => contractsService.getOpsClearanceQueue(),
enabled,
});
}
export function useContractMilestones(id: string | undefined) {
return useQuery({
queryKey: QUERY_KEYS.CONTRACTS.milestones(id ?? ""),
@@ -177,8 +186,15 @@ export function useContractMutations(contractId: string) {
};
}
/** Pre-booking clearance mutations (GL ET) keyed on a contract. */
export function useContractClearanceMutations(contractId: string) {
/**
* Pre-booking clearance mutations keyed on a contract. Pass `selfClear = true`
* for Path A (non-customs) contracts so review/finalize hit the Operations
* endpoints instead of the GL ET ones. Path A has no GL output upload step.
*/
export function useContractClearanceMutations(
contractId: string,
selfClear = false,
) {
const qc = useQueryClient();
const refresh = () => {
@@ -196,7 +212,10 @@ export function useContractClearanceMutations(contractId: string) {
fileKey: string;
status: "APPROVED" | "QUERIED";
note?: string;
}) => contractsService.reviewClearanceDocument(contractId, p),
}) =>
selfClear
? contractsService.opsReviewClearanceDocument(contractId, p)
: contractsService.reviewClearanceDocument(contractId, p),
onSuccess: (_d, p) => {
toast.success(
p.status === "APPROVED"
@@ -219,9 +238,16 @@ export function useContractClearanceMutations(contractId: string) {
});
const finalizeClearance = useMutation({
mutationFn: () => contractsService.finalizeClearance(contractId),
mutationFn: () =>
selfClear
? contractsService.opsFinalizeClearance(contractId)
: contractsService.finalizeClearance(contractId),
onSuccess: () => {
toast.success("Clearance finalized — ready for booking");
toast.success(
selfClear
? "Clearance approved — customer can now book"
: "Clearance finalized — ready for booking",
);
refresh();
},
onError: (e) =>

View File

@@ -33,6 +33,7 @@ export const FREIGHT_PERMS = {
clearanceReview: "edr_freight_app:contracts:clearance_review",
finalizeClearance: "edr_freight_app:contracts:finalize_clearance",
createBooking: "edr_freight_app:contracts:create_booking",
opsClearanceReview: "edr_freight_app:contracts:ops_clearance_review",
},
trainScheduling: {
view: "edr_freight_app:train_scheduling:view",
@@ -115,6 +116,13 @@ export function canCreateContractBooking(
return hasPermission(user, FREIGHT_PERMS.contracts.createBooking);
}
/** Operations: can review the Path A self-clearance queue (non-customs). */
export function canReviewSelfClearance(
user: AuthUser | null | undefined,
): boolean {
return hasPermission(user, FREIGHT_PERMS.contracts.opsClearanceReview);
}
/** Can see/manage the customs document-clearance queue (Global Logistics). */
export function canViewClearance(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.bookings.reviewDocuments);

View File

@@ -66,7 +66,11 @@ export default function ContractClearanceDetailPage() {
}, [clearance]);
const reference = contract?.reference ?? "Clearance";
// Path A (no customs): Operations reviews; the customer books in the portal —
// there is no "Create booking" action here.
const selfClear = clearance?.includesCustoms === false;
const canBook =
!selfClear &&
clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" &&
canCreateContractBooking(user);
@@ -155,7 +159,11 @@ export default function ContractClearanceDetailPage() {
<Grid gap="lg">
<Grid.Col span={{ base: 12, lg: 8 }}>
<ContractClearanceReviewSection contractId={id!} hideSummary />
<ContractClearanceReviewSection
contractId={id!}
hideSummary
selfClear={selfClear}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>

View File

@@ -40,7 +40,10 @@ import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { KpiStrip } from "@/components/page/KpiStrip";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { useContractClearanceQueue } from "@/hooks/contracts/useContracts";
import {
useContractClearanceQueue,
useOpsClearanceQueue,
} from "@/hooks/contracts/useContracts";
type ViewMode = "table" | "cards";
type Region = "ET" | "DJ";
@@ -103,15 +106,28 @@ function DirectionIcon({ direction }: { direction: string }) {
);
}
export default function ContractClearanceListPage() {
/**
* Pre-booking clearance queue. In `opsMode` it lists Path A self-clearance
* contracts for the Operations team (non-customs); otherwise the GL ET/DJ
* customs queue (Path B). Both route to the same detail page, which detects the
* path from the contract.
*/
export default function ContractClearanceListPage({
opsMode = false,
}: {
opsMode?: boolean;
} = {}) {
const navigate = useNavigate();
const [region, setRegion] = useState<Region>("ET");
const [query, setQuery] = useState("");
const [view, setView] = useState<ViewMode>("table");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const { data, isLoading, isError, isFetching, refetch } =
useContractClearanceQueue(region);
const glQueue = useContractClearanceQueue(region, !opsMode);
const opsQueue = useOpsClearanceQueue(opsMode);
const { data, isLoading, isError, isFetching, refetch } = opsMode
? opsQueue
: glQueue;
const allRows = useMemo(
() => (data?.items ?? []).map(toClearanceRow),
@@ -238,8 +254,12 @@ export default function ContractClearanceListPage() {
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Contract Clearance"
subtitle="Review pre-booking clearance documents on contracts before Global Logistics creates the shipment booking."
title={opsMode ? "Self-Clearance Review" : "Contract Clearance"}
subtitle={
opsMode
? "Review the customer's own clearance documents (no EDR customs service) before they create a shipment booking."
: "Review pre-booking clearance documents on contracts before Global Logistics creates the shipment booking."
}
meta={
<Badge
variant="light"
@@ -320,22 +340,24 @@ export default function ContractClearanceListPage() {
style={{ flex: 1, minWidth: 220 }}
/>
<Group gap="sm" wrap="nowrap">
<SegmentedControl
size="sm"
radius="md"
value={region}
onChange={(v) => {
setRegion(v as Region);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
data={[
{ value: "ET", label: "Ethiopia" },
{ value: "DJ", label: "Djibouti" },
]}
/>
{!opsMode && (
<SegmentedControl
size="sm"
radius="md"
value={region}
onChange={(v) => {
setRegion(v as Region);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
data={[
{ value: "ET", label: "Ethiopia" },
{ value: "DJ", label: "Djibouti" },
]}
/>
)}
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>

View File

@@ -204,6 +204,26 @@ export const contractsService = {
finalizeClearance: (id: string) =>
postContract<Freight.IContract>(C.CLEARANCE_FINALIZE(id)),
// ── Path A self-clearance (Operations review) ──
getOpsClearanceQueue: async (): Promise<PaginatedContracts> => {
const response = await client.get<PaginatedContracts>(
C.OPS_CLEARANCE_QUEUE,
);
const data = unwrap(response.data);
return {
items: (data.items ?? []) as Freight.IContract[],
total: data.total ?? 0,
};
},
opsReviewClearanceDocument: (
id: string,
payload: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string },
) => postContract<Freight.IContract>(C.OPS_CLEARANCE_REVIEW(id), payload),
opsFinalizeClearance: (id: string) =>
postContract<Freight.IContract>(C.OPS_CLEARANCE_FINALIZE(id)),
// ── Booking under contract (GL ET — Path B) ──
createBookingUnderContract: (
id: string,

View File

@@ -129,10 +129,17 @@ export default function ContractClearanceFlow() {
const isUnderReview = status === "DOCUMENTS_UNDER_REVIEW";
const isReady =
status === "CLEARANCE_READY_FOR_BOOKING" ||
status === "SELF_CLEARED" ||
status === "ACTIVE_SHIPMENT_IN_PROGRESS";
const canUpload = status === "AWAITING_DOCUMENTS" || isUnderReview;
const isInitialUpload = status === "AWAITING_DOCUMENTS";
// Path B (customs) is reviewed by Global Logistics and GL creates the booking;
// Path A self-clearance is reviewed by the Operations team and the customer
// creates the booking himself afterward.
const customsPath = clearance?.includesCustoms ?? true;
const reviewer = customsPath ? "Global Logistics" : "the Operations team";
const missingRequired = useMemo(
() => customerDocs.filter((d) => d.required && !d.file && !pending[d.fileKey]),
[customerDocs, pending],
@@ -209,8 +216,9 @@ export default function ContractClearanceFlow() {
icon={<CheckCircle2 size={18} />}
mb="md"
>
Your clearance documents are approved. Global Logistics will
create your booking you will be notified when payment is due.
{customsPath
? "Your clearance documents are approved. Global Logistics will create your booking — you will be notified when payment is due."
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
</Alert>
) : isUnderReview ? (
<Alert
@@ -219,9 +227,9 @@ export default function ContractClearanceFlow() {
icon={<Clock size={18} />}
mb="md"
>
Global Logistics is reviewing your documents. Only re-upload the
documents flagged with a query below approved documents stay as
they are.
{reviewer.charAt(0).toUpperCase() + reviewer.slice(1)} is
reviewing your documents. Only re-upload the documents flagged
with a query below approved documents stay as they are.
</Alert>
) : (
<Alert
@@ -230,9 +238,9 @@ export default function ContractClearanceFlow() {
icon={<AlertCircle size={18} />}
mb="md"
>
Upload every required clearance document (marked *) below to start
the review. Global Logistics will clear your shipment and create
the booking for you.
{customsPath
? "Upload every required clearance document (marked *) below to start the review. Global Logistics will clear your shipment and create the booking for you."
: "This service does not include EDR customs clearance — clear the cargo yourself and upload every required clearance document (marked *) below. The Operations team will review them before you can book a shipment."}
</Alert>
)}

View File

@@ -46,10 +46,14 @@ import {
MUTED,
} from "./contract-ui";
// Statuses where Path A customers may create a shipment booking themselves.
// Statuses where a customer may create a shipment booking themselves. Reached
// only after self-clearance is approved by Operations (Path A) or, for DOMESTIC,
// directly at counter-sign.
const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
// Statuses where Path B customers upload clearance docs on the contract.
const PATH_B_CLEARANCE = [
// Statuses where the customer uploads clearance documents on the contract. Used
// by both paths: Path B (customs, GL-reviewed) and Path A self-clearance
// (non-customs IMPORT/EXPORT, Operations-reviewed).
const CLEARANCE_UPLOAD_STATUSES = [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
@@ -120,12 +124,14 @@ export default function ContractDetailPage() {
const canSign = contract.status === "CONTRACT_READY";
const customsPath = contract.customsClearingEnabled;
// Path A — transport only, customer may book a shipment directly.
// The customer creates the booking himself unless GL owns it (customs / Path B).
// Reached only once the contract is executed (after self-clearance on Path A).
const canBookShipment =
!customsPath && PATH_A_BOOKABLE.includes(contract.status);
// Path B — customs clearance, customer uploads clearance documents.
// The customer uploads clearance documents on the contract while in a clearance
// status — Path B (customs, GL-reviewed) or Path A self-clearance (Operations).
const canUploadClearance =
customsPath && PATH_B_CLEARANCE.includes(contract.status);
CLEARANCE_UPLOAD_STATUSES.includes(contract.status);
return (
<Box style={{ padding: "28px 32px 40px" }}>
@@ -326,8 +332,8 @@ export default function ContractDetailPage() {
</Paper>
{/* Path B notice */}
{customsPath && PATH_B_CLEARANCE.includes(contract.status) && (
{/* Clearance notice (both paths) */}
{canUploadClearance && (
<Paper
withBorder
radius="lg"
@@ -352,15 +358,23 @@ export default function ContractDetailPage() {
<Group gap={10} align="center" mb={6}>
<Upload size={16} color={GREEN} />
<Text fw={700} fz={15} c={INK}>
Customs clearance shipment
{customsPath
? "Customs clearance shipment"
: "Customs clearance required"}
</Text>
</Group>
<Text fz={13} c="dimmed">
{contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
? "Upload your clearance documents so Global Logistics can review them. After approval, Global Logistics creates your booking — you only pay the freight."
: contract.status === "CLEARANCE_UNDER_REVIEW"
? "Global Logistics is reviewing your clearance documents. Re-upload any queried documents to proceed."
: "Your documents are cleared. Global Logistics will create your booking shortly — you will be notified when payment is due."}
{customsPath
? contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
? "Upload your clearance documents so Global Logistics can review them. After approval, Global Logistics creates your booking — you only pay the freight."
: contract.status === "CLEARANCE_UNDER_REVIEW"
? "Global Logistics is reviewing your clearance documents. Re-upload any queried documents to proceed."
: "Your documents are cleared. Global Logistics will create your booking shortly — you will be notified when payment is due."
: contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
? "This service does not include EDR customs clearance. Clear the cargo yourself and upload your clearance documents so the Operations team can review them before you book a shipment."
: contract.status === "CLEARANCE_UNDER_REVIEW"
? "The Operations team is reviewing your clearance documents. Re-upload any queried documents to proceed."
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
</Text>
</Paper>
)}
@@ -676,7 +690,9 @@ export default function ContractDetailPage() {
? "No bookings yet. Use “New booking” to ship against this contract."
: customsPath
? "No bookings yet. After your clearance documents are approved, Global Logistics creates the booking on your behalf."
: "Bookings appear here once the contract is fully executed."}
: canUploadClearance
? "No bookings yet. After the Operations team approves your clearance documents, you can create a booking here."
: "Bookings appear here once the contract is fully executed."}
</Text>
</Stack>
) : (

View File

@@ -4,18 +4,23 @@ import { useQuery } from "@tanstack/react-query";
import {
Box,
Button,
Card,
Center,
Group,
Loader,
Paper,
Select,
Stack,
Table,
Text,
TextInput,
Title,
} from "@mantine/core";
import {
ChevronLeft,
ChevronRight,
CheckCircle2,
FileStack,
Inbox,
Package,
Plus,
Search,
@@ -27,13 +32,15 @@ import {
import { api } from "@/services/api";
import type { ContractListFilter } from "@/services/contracts.service";
import type { Freight } from "@edr/types";
import { usePagination } from "@edr/ui-common";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
} from "@edr/ui-common";
import { BORDER, ContractStatusBadge, INK, MUTED, StatCard } from "./contract-ui";
BORDER,
ContractStatusBadge,
GREEN,
INK,
MUTED,
StatCard,
} from "./contract-ui";
function primaryRoute(contract: Freight.IContract) {
const route = contract.routes?.[0];
@@ -54,27 +61,35 @@ const PATH_B_CLEARANCE_STATUSES = [
/** The single most relevant next action for a customer's contract row. */
function getCustomerRowAction(
contract: Freight.IContract,
): { label: string; to: string } {
): { label: string; to: string; primary: boolean } {
const id = contract.id;
if (contract.status === "CONTRACT_READY") {
return { label: "View & sign", to: `/contracts/${id}/view` };
return { label: "View & sign", to: `/contracts/${id}/view`, primary: true };
}
if (contract.status === "CHANGES_REQUESTED") {
return { label: "Edit & resubmit", to: `/contracts/${id}` };
return { label: "Edit & resubmit", to: `/contracts/${id}`, primary: true };
}
if (
contract.customsClearingEnabled &&
PATH_B_CLEARANCE_STATUSES.includes(contract.status)
) {
return { label: "Upload clearance", to: `/contracts/${id}/clearance` };
return {
label: "Upload clearance",
to: `/contracts/${id}/clearance`,
primary: true,
};
}
if (
!contract.customsClearingEnabled &&
PATH_A_BOOKABLE_STATUSES.includes(contract.status)
) {
return { label: "Book shipment", to: `/contracts/${id}/bookings/new` };
return {
label: "Book shipment",
to: `/contracts/${id}/bookings/new`,
primary: true,
};
}
return { label: "View", to: `/contracts/${id}` };
return { label: "View", to: `/contracts/${id}`, primary: false };
}
export default function ContractsList() {
@@ -161,176 +176,33 @@ export default function ContractsList() {
return { active, pending, total };
}, [data]);
const columns: ColumnDef<Freight.IContract>[] = [
{
id: "reference",
header: () => <ColHeader label="Contract" />,
cell: ({ row }) => {
const c = row.original;
const isGeneral = c.contractKind === "GENERAL";
return (
<div>
<Text fz={14} fw={700} style={{ color: INK }}>
{c.reference}
</Text>
<Text fz={12} c="dimmed">
{isGeneral ? "General" : "One-Time"} ·{" "}
{c.freightType === "CONTAINER" ? "Containerised" : "Bulk"}
</Text>
</div>
);
},
},
{
id: "cargo",
header: () => <ColHeader label="Cargo" />,
cell: ({ row }) => {
const isContainer = row.original.freightType === "CONTAINER";
return (
<Group gap={7} wrap="nowrap" align="center">
{isContainer ? (
<Package size={15} color={MUTED} />
) : (
<Weight size={15} color={MUTED} />
)}
<Text fz={13} style={{ color: INK }}>
{isContainer ? "Container" : "Bulk"}
</Text>
</Group>
);
},
},
{
id: "route",
header: () => <ColHeader label="Route" />,
cell: ({ row }) => {
const { origin, destination, count } = primaryRoute(row.original);
return (
<Text fz={13} style={{ color: INK }}>
{origin}{" "}
<Text span c="dimmed">
</Text>{" "}
{destination}
{count > 1 && (
<Text span c="dimmed" fz={12}>
{" "}
+{count - 1}
</Text>
)}
</Text>
);
},
},
{
id: "trade",
header: () => <ColHeader label="Trade" />,
cell: ({ row }) => {
const dir = row.original.tradeDirection;
const label = dir
? dir.charAt(0) + dir.slice(1).toLowerCase()
: "—";
return (
<Text fz={13} c={dir ? undefined : "dimmed"} style={{ color: dir ? INK : undefined }}>
{label}
</Text>
);
},
},
{
id: "currency",
header: () => <ColHeader label="Currency" />,
cell: ({ row }) => (
<Text fz={13} style={{ color: INK }}>
{row.original.paymentCurrency ?? "—"}
</Text>
),
},
{
id: "created",
header: () => <ColHeader label="Created" />,
cell: ({ row }) => {
const created = row.original.createdAt;
return (
<Text fz={13} c={created ? undefined : "dimmed"} style={{ color: created ? INK : undefined }}>
{created ? new Date(created).toLocaleDateString() : "—"}
</Text>
);
},
},
{
id: "validUntil",
header: () => <ColHeader label="Valid Until" />,
cell: ({ row }) => {
const until = row.original.contractValidUntil;
return (
<Text
fz={13}
c={until ? undefined : "dimmed"}
style={{ color: until ? INK : undefined }}
>
{until ? new Date(until).toLocaleDateString() : "—"}
</Text>
);
},
},
{
id: "status",
header: () => <ColHeader label="Status" />,
cell: ({ row }) => <ContractStatusBadge status={row.original.status} />,
},
{
id: "actions",
header: () => <ColHeader label="Action" />,
cell: ({ row }) => {
const action = getCustomerRowAction(row.original);
return (
<Button
size="compact-sm"
radius="md"
variant={action.label === "View" ? "light" : "filled"}
color="edr-green"
onClick={(e) => {
e.stopPropagation();
navigate(action.to);
}}
>
{action.label}
</Button>
);
},
},
];
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
const total = data?.meta?.total ?? (data?.items?.length ?? 0);
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const pageIndex = pagination.pageIndex;
const start = total === 0 ? 0 : pageIndex * pagination.pageSize + 1;
const end = Math.min((pageIndex + 1) * pagination.pageSize, total);
const goToPage = (i: number) =>
setPagination({
pageIndex: Math.max(0, Math.min(i, pageCount - 1)),
pageSize: pagination.pageSize,
});
return (
<Box style={{ padding: "28px 32px 32px" }}>
<Stack gap="lg">
{/* Header */}
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
<Box>
<Title
order={1}
fw={800}
fz={26}
style={{ letterSpacing: "-0.01em" }}
>
Contracts
</Title>
<Text size="sm" c="edr-muted" mt={4} maw={520}>
Your freight agreements one-time and general. Sign a contract,
then ship against it over its validity window.
</Text>
</Box>
<Group justify="space-between" align="center" wrap="wrap" gap="md">
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
Contracts
</Title>
<Button
color="edr-green"
radius="md"
size="md"
leftSection={<Plus size={16} />}
onClick={() => navigate("/contracts/new", { state: { fresh: true } })}
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
>
New Contract
</Button>
@@ -439,6 +311,7 @@ export default function ContractsList() {
radius="md"
leftSection={<X size={14} />}
onClick={clearExtraFilters}
styles={{ root: { fontWeight: 600 } }}
>
Clear
</Button>
@@ -447,39 +320,351 @@ export default function ContractsList() {
</Paper>
{/* Table */}
<Card p={0} style={{ overflow: "hidden" }}>
<DataTable
columns={columns}
data={rows}
status={dataTableStatus}
onRowClick={(row) =>
navigate(`/contracts/${(row as Freight.IContract).id}`)
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
footer={DataTableFooter}
emptyMessage="No contracts yet. Create one from New Contract."
/>
</Card>
<Paper
withBorder
radius="lg"
style={{ borderColor: BORDER, overflow: "hidden" }}
>
<Box style={{ overflowX: "auto" }}>
<Table
verticalSpacing={14}
horizontalSpacing={20}
highlightOnHover
styles={{
th: {
fontSize: 11,
fontWeight: 700,
letterSpacing: "0.05em",
textTransform: "uppercase",
color: MUTED,
background: "#F8FAFC",
borderBottom: `1px solid ${BORDER}`,
whiteSpace: "nowrap",
},
td: {
borderBottom: `1px solid ${BORDER}`,
verticalAlign: "middle",
},
}}
>
<Table.Thead>
<Table.Tr>
<Table.Th>Contract</Table.Th>
<Table.Th>Cargo</Table.Th>
<Table.Th>Route</Table.Th>
<Table.Th>Trade</Table.Th>
<Table.Th>Currency</Table.Th>
<Table.Th>Created</Table.Th>
<Table.Th>Valid Until</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="right">Action</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading && (
<Table.Tr>
<Table.Td colSpan={9}>
<Center py={48}>
<Loader color="edr-green" size="sm" />
</Center>
</Table.Td>
</Table.Tr>
)}
{!isLoading && isError && (
<Table.Tr>
<Table.Td colSpan={9}>
<Center py={48}>
<Text fz={13} c="red">
Failed to load contracts. Please try again.
</Text>
</Center>
</Table.Td>
</Table.Tr>
)}
{!isLoading && !isError && rows.length === 0 && (
<Table.Tr>
<Table.Td colSpan={9}>
<Stack align="center" gap={8} py={48}>
<Inbox size={26} color={MUTED} style={{ opacity: 0.5 }} />
<Text fz={13} c="dimmed">
No contracts yet. Create one from New Contract.
</Text>
</Stack>
</Table.Td>
</Table.Tr>
)}
{!isLoading &&
!isError &&
rows.map((c) => {
const isGeneral = c.contractKind === "GENERAL";
const isContainer = c.freightType === "CONTAINER";
const { origin, destination, count } = primaryRoute(c);
const dir = c.tradeDirection;
const tradeLabel = dir
? dir.charAt(0) + dir.slice(1).toLowerCase()
: "—";
const action = getCustomerRowAction(c);
return (
<Table.Tr
key={c.id}
style={{ cursor: "pointer" }}
onClick={() => navigate(`/contracts/${c.id}`)}
>
<Table.Td>
<Text fz={14} fw={700} style={{ color: INK }}>
{c.reference}
</Text>
<Text fz={12} c="dimmed">
{isGeneral ? "General" : "One-Time"} ·{" "}
{isContainer ? "Containerised" : "Bulk"}
</Text>
</Table.Td>
<Table.Td>
<Group gap={7} wrap="nowrap" align="center">
{isContainer ? (
<Package size={15} color={MUTED} />
) : (
<Weight size={15} color={MUTED} />
)}
<Text fz={13} style={{ color: INK }}>
{isContainer ? "Container" : "Bulk"}
</Text>
</Group>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{origin}{" "}
<Text span c="dimmed">
</Text>{" "}
{destination}
{count > 1 && (
<Text span c="dimmed" fz={12}>
{" "}
+{count - 1}
</Text>
)}
</Text>
</Table.Td>
<Table.Td>
<Text
fz={13}
c={dir ? undefined : "dimmed"}
style={{ color: dir ? INK : undefined }}
>
{tradeLabel}
</Text>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{c.paymentCurrency ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Text
fz={13}
c={c.createdAt ? undefined : "dimmed"}
style={{ color: c.createdAt ? INK : undefined }}
>
{c.createdAt
? new Date(c.createdAt).toLocaleDateString()
: "—"}
</Text>
</Table.Td>
<Table.Td>
<Text
fz={13}
c={c.contractValidUntil ? undefined : "dimmed"}
style={{
color: c.contractValidUntil ? INK : undefined,
}}
>
{c.contractValidUntil
? new Date(
c.contractValidUntil,
).toLocaleDateString()
: "—"}
</Text>
</Table.Td>
<Table.Td>
<ContractStatusBadge status={c.status} />
</Table.Td>
<Table.Td>
<Group justify="flex-end">
<Button
size="compact-sm"
radius="md"
variant={action.primary ? "filled" : "light"}
color="edr-green"
onClick={(e) => {
e.stopPropagation();
navigate(action.to);
}}
styles={{
root: { fontWeight: 600, paddingInline: 14 },
}}
>
{action.label}
</Button>
</Group>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Box>
{/* Pagination footer */}
{!isLoading && !isError && rows.length > 0 && (
<Group
justify="space-between"
align="center"
wrap="wrap"
gap="md"
px={20}
py={14}
style={{ borderTop: `1px solid ${BORDER}`, background: "#FCFDFE" }}
>
<Group gap={10} align="center">
<Text fz={13} c="dimmed">
Rows
</Text>
<Select
data={["10", "25", "50"]}
value={String(pagination.pageSize)}
onChange={(v) =>
v &&
setPagination({ pageIndex: 0, pageSize: Number(v) })
}
radius="md"
size="xs"
comboboxProps={{ withinPortal: true }}
style={{ width: 76 }}
allowDeselect={false}
/>
<Text fz={13} c="dimmed">
{start}{end} of {total}
</Text>
</Group>
<Group gap={6} align="center">
<PagerButton
icon={<ChevronLeft size={16} />}
disabled={pageIndex === 0}
onClick={() => goToPage(pageIndex - 1)}
ariaLabel="Previous page"
/>
{pageNumbers(pageIndex, pageCount).map((p, i) =>
p === "…" ? (
<Text key={`gap-${i}`} fz={13} c="dimmed" px={4}>
</Text>
) : (
<PageChip
key={p}
page={p}
active={p === pageIndex}
onClick={() => goToPage(p)}
/>
),
)}
<PagerButton
icon={<ChevronRight size={16} />}
disabled={pageIndex >= pageCount - 1}
onClick={() => goToPage(pageIndex + 1)}
ariaLabel="Next page"
/>
</Group>
</Group>
)}
</Paper>
</Stack>
</Box>
);
}
function ColHeader({ label }: { label: string }) {
/** Compact page-number window with ellipses: 1 … 4 5 6 … 12. */
function pageNumbers(active: number, count: number): (number | "…")[] {
if (count <= 7) return Array.from({ length: count }, (_, i) => i);
const out: (number | "…")[] = [0];
const lo = Math.max(1, active - 1);
const hi = Math.min(count - 2, active + 1);
if (lo > 1) out.push("…");
for (let i = lo; i <= hi; i++) out.push(i);
if (hi < count - 2) out.push("…");
out.push(count - 1);
return out;
}
function PageChip({
page,
active,
onClick,
}: {
page: number;
active: boolean;
onClick: () => void;
}) {
return (
<Text fz={12} fw={700} c="dimmed" style={{ letterSpacing: 0.3 }}>
{label}
</Text>
<Box
component="button"
onClick={onClick}
style={{
minWidth: 32,
height: 32,
padding: "0 8px",
borderRadius: 9,
border: `1px solid ${active ? GREEN : BORDER}`,
background: active ? GREEN : "#FFFFFF",
color: active ? "#FFFFFF" : INK,
fontSize: 13,
fontWeight: active ? 700 : 600,
cursor: "pointer",
transition: "all 120ms ease",
}}
>
{page + 1}
</Box>
);
}
function PagerButton({
icon,
disabled,
onClick,
ariaLabel,
}: {
icon: React.ReactNode;
disabled: boolean;
onClick: () => void;
ariaLabel: string;
}) {
return (
<Box
component="button"
aria-label={ariaLabel}
onClick={onClick}
disabled={disabled}
style={{
width: 32,
height: 32,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 9,
border: `1px solid ${BORDER}`,
background: "#FFFFFF",
color: disabled ? "#C2CCD6" : INK,
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.6 : 1,
transition: "all 120ms ease",
}}
>
{icon}
</Box>
);
}

1138
docs/docs-new Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -69,7 +69,8 @@ export const CONTRACT_CLEARANCE_STATUSES = [
"NOT_APPLICABLE",
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
"CLEARANCE_READY_FOR_BOOKING", // Path B — GL may create the booking
"SELF_CLEARED", // Path A — Operations approved; customer may book
"ACTIVE_SHIPMENT_IN_PROGRESS",
] as const;
@@ -225,8 +226,18 @@ export interface ContractClearanceDocument {
export interface ContractClearanceView {
contractId: string;
/** Overall contract status (e.g. CLEARANCE_UNDER_REVIEW). */
status?: string;
clearanceStatus: ContractClearanceStatus;
cycleNumber: number;
/**
* True when the contract bundles EDR customs clearance (Path B, GL-reviewed).
* False for Path A self-clearance, reviewed by the Operations team.
*/
includesCustoms?: boolean;
/** Resolved customer-input / GL-output clearance setting codes. */
inputCode?: string | null;
outputCode?: string | null;
documents: ContractClearanceDocument[];
/** True once every required customer document is APPROVED. */
allApproved: boolean;