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( async reviewDocument(
contractId: string, contractId: string,
fileKey: string, fileKey: string,
status: 'APPROVED' | 'QUERIED', status: 'APPROVED' | 'QUERIED',
staffId: string, staffId: string,
note?: 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> { ): Promise<Contract> {
const contract = await this.contractsService.findById(contractId); const contract = await this.contractsService.findById(contractId);
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
@@ -280,7 +322,7 @@ export class ContractClearanceService {
`Document "${fileKey}" queried: ${note}`, `Document "${fileKey}" queried: ${note}`,
'CHANGES_REQUESTED', 'CHANGES_REQUESTED',
staffId, staffId,
'GL_ET', reviewerRole,
); );
// Return the contract to the customer to re-upload the queried document. // Return the contract to the customer to re-upload the queried document.
await this.contractsRepository.update(contractId, { await this.contractsRepository.update(contractId, {
@@ -325,11 +367,17 @@ export class ContractClearanceService {
} }
/** /**
* GL ET finalizes pre-booking clearance: requires every customer document * GL ET finalizes Path B pre-booking clearance: requires every customer
* APPROVED (and required output docs present) → CLEARANCE_READY_FOR_BOOKING. * 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> { async finalize(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId); 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') { if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
throw new ConflictException( throw new ConflictException(
`Cannot finalize clearance on status "${contract.status}".`, `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 * Operations finalizes Path A self-clearance: requires every customer document
* CLEARANCE_UNDER_REVIEW (customs contracts only). * 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( async queue(
filter: FilterContractDto, filter: FilterContractDto,
@@ -388,6 +474,22 @@ export class ContractClearanceService {
page: filter.page ?? 1, page: filter.page ?? 1,
pageSize: filter.pageSize ?? 100, pageSize: filter.pageSize ?? 100,
statuses: ['CLEARANCE_UNDER_REVIEW'], 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, sortBy: filter.sortBy,
sortOrder: filter.sortOrder, sortOrder: filter.sortOrder,
}); });

View File

@@ -20,16 +20,28 @@ function freightFor(freightType: string): Freight {
return freightType === 'BULK' ? 'bulk' : 'container'; 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( export function contractClearanceSettingCode(
tradeDirection: string, tradeDirection: string,
freightType: string, freightType: string,
includesCustoms: boolean, includesCustoms: boolean,
): string | null { ): string | null {
if (!includesCustoms) return null;
const op = operationFor(tradeDirection); const op = operationFor(tradeDirection);
if (!op) return null; if (!op) return null;
const freight = freightFor(freightType); const freight = freightFor(freightType);
if (!includesCustoms) {
return `contract_clearance_selfclear_${op}_${freight}`;
}
return `contract_clearance_${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 { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository'; import { ContractsRepository } from './contracts.repository';
import { ContractsService } from './contracts.service'; import { ContractsService } from './contracts.service';
import { contractClearanceSettingCode } from './contract-clearance.util';
import { Contract } from './entities/contract.entity'; import { Contract } from './entities/contract.entity';
import { ContractSignerRole } from './entities/contract-signature.entity'; import { ContractSignerRole } from './entities/contract-signature.entity';
import { SignContractDto } from './dto/sign-contract.dto'; import { SignContractDto } from './dto/sign-contract.dto';
@@ -441,9 +442,14 @@ export class ContractTransitionService {
} }
/** /**
* Staff/Director/CEO counter-sign → branch on customs: * Staff/Director/CEO counter-sign → branch on the execution path. A customs
* - customs: AWAITING_CLEARANCE_DOCUMENTS + clearance gate opened (Path B) * border (IMPORT/EXPORT) always requires a clearance gate before any shipment;
* - transport: FULLY_EXECUTED (ONE_TIME) / CONTRACT_ACTIVE (GENERAL) * 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( async counterSign(
contractId: string, contractId: string,
@@ -461,9 +467,19 @@ export class ContractTransitionService {
lockedAt: now, lockedAt: now,
}; };
if (contract.customsClearingEnabled) { // A clearance gate applies whenever a clearance doc set resolves — Path B
// Path B — open a clearance cycle, seed the pre-booking milestones, and // (customs) or Path A self-clearance (IMPORT/EXPORT without customs). DOMESTIC
// route the customer to the document upload. // 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 cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber); const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id); await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
@@ -471,7 +487,7 @@ export class ContractTransitionService {
updates.clearanceStatus = 'AWAITING_DOCUMENTS'; updates.clearanceStatus = 'AWAITING_DOCUMENTS';
updates.clearanceCycleNumber = cycleNumber; updates.clearanceCycleNumber = cycleNumber;
} else { } else {
// Path A — transport only; ready for the customer to book. // No clearance gate (DOMESTIC) — ready for the customer to book directly.
updates.status = updates.status =
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED'; contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
updates.clearanceStatus = 'NOT_APPLICABLE'; updates.clearanceStatus = 'NOT_APPLICABLE';

View File

@@ -405,6 +405,45 @@ export class ContractsController {
return this.clearanceService.finalize(id); 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) ──────────────── // ── Booking under contract (Path A customer / Path B GL ET) ────────────────
@Post(':id/bookings') @Post(':id/bookings')

View File

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

View File

@@ -49,7 +49,8 @@ export const CONTRACT_CLEARANCE_STATUSES = [
'NOT_APPLICABLE', 'NOT_APPLICABLE',
'AWAITING_DOCUMENTS', 'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW', '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', 'ACTIVE_SHIPMENT_IN_PROGRESS',
] as const; ] as const;
export type ContractClearanceStatusValue = 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 ──────────────────────────────────────────────── // ── Contract intake settings ────────────────────────────────────────────────
// Commercial/framework documents attached at contract submission (wizard step 5), // Commercial/framework documents attached at contract submission (wizard step 5),
// distinct from the post-sign clearance docs above. // distinct from the post-sign clearance docs above.
@@ -456,6 +511,11 @@ export class FileUploadSettingsSeeder {
description: description:
"Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.", "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) => ({ ...CONTRACT_INTAKE_SETTINGS.map((s) => ({
...s, ...s,
description: 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-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-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-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 }> = { 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', clearanceReview: 'edr_freight_app:contracts:clearance_review',
finalizeClearance: 'edr_freight_app:contracts:finalize_clearance', finalizeClearance: 'edr_freight_app:contracts:finalize_clearance',
createBooking: 'edr_freight_app:contracts:create_booking', createBooking: 'edr_freight_app:contracts:create_booking',
opsClearanceReview: 'edr_freight_app:contracts:ops_clearance_review',
}, },
trainScheduling: { trainScheduling: {
view: 'edr_freight_app:train_scheduling:view', view: 'edr_freight_app:train_scheduling:view',
@@ -196,6 +198,10 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.trainScheduling.manage, FREIGHT_PERMS.trainScheduling.manage,
FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.fleet.view,
FREIGHT_PERMS.fleet.manage, 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(), ...allRuleEngineViewKeys(),
], ],
director: [ director: [

View File

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

View File

@@ -39,6 +39,11 @@ export interface ContractClearanceReviewSectionProps {
onChanged?: () => void; onChanged?: () => void;
/** Hide the inline progress summary (e.g. when the parent renders its own). */ /** Hide the inline progress summary (e.g. when the parent renders its own). */
hideSummary?: boolean; 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< const STATUS_META: Record<
@@ -59,6 +64,7 @@ export function ContractClearanceReviewSection({
contractId, contractId,
onChanged, onChanged,
hideSummary, hideSummary,
selfClear = false,
}: ContractClearanceReviewSectionProps) { }: ContractClearanceReviewSectionProps) {
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({}); const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({}); const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
@@ -70,7 +76,7 @@ export function ContractClearanceReviewSection({
}); });
const { reviewDocument, uploadOutputDocuments, finalizeClearance } = const { reviewDocument, uploadOutputDocuments, finalizeClearance } =
useContractClearanceMutations(contractId); useContractClearanceMutations(contractId, selfClear);
const customerDocs = useMemo( const customerDocs = useMemo(
() => () =>

View File

@@ -143,6 +143,12 @@ export const URL_CONSTANTS = {
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) => CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
`/contracts/${id}/clearance/output-documents`, `/contracts/${id}/clearance/output-documents`,
CLEARANCE_FINALIZE: (id: string) => `/contracts/${id}/clearance/finalize`, 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`, BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
MILESTONES: (id: string) => `/contracts/${id}/milestones`, MILESTONES: (id: string) => `/contracts/${id}/milestones`,
BOOKING_MILESTONES: (bookingId: string) => 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) { export function useContractMilestones(id: string | undefined) {
return useQuery({ return useQuery({
queryKey: QUERY_KEYS.CONTRACTS.milestones(id ?? ""), 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 qc = useQueryClient();
const refresh = () => { const refresh = () => {
@@ -196,7 +212,10 @@ export function useContractClearanceMutations(contractId: string) {
fileKey: string; fileKey: string;
status: "APPROVED" | "QUERIED"; status: "APPROVED" | "QUERIED";
note?: string; note?: string;
}) => contractsService.reviewClearanceDocument(contractId, p), }) =>
selfClear
? contractsService.opsReviewClearanceDocument(contractId, p)
: contractsService.reviewClearanceDocument(contractId, p),
onSuccess: (_d, p) => { onSuccess: (_d, p) => {
toast.success( toast.success(
p.status === "APPROVED" p.status === "APPROVED"
@@ -219,9 +238,16 @@ export function useContractClearanceMutations(contractId: string) {
}); });
const finalizeClearance = useMutation({ const finalizeClearance = useMutation({
mutationFn: () => contractsService.finalizeClearance(contractId), mutationFn: () =>
selfClear
? contractsService.opsFinalizeClearance(contractId)
: contractsService.finalizeClearance(contractId),
onSuccess: () => { onSuccess: () => {
toast.success("Clearance finalized — ready for booking"); toast.success(
selfClear
? "Clearance approved — customer can now book"
: "Clearance finalized — ready for booking",
);
refresh(); refresh();
}, },
onError: (e) => onError: (e) =>

View File

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

View File

@@ -66,7 +66,11 @@ export default function ContractClearanceDetailPage() {
}, [clearance]); }, [clearance]);
const reference = contract?.reference ?? "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 = const canBook =
!selfClear &&
clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" && clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" &&
canCreateContractBooking(user); canCreateContractBooking(user);
@@ -155,7 +159,11 @@ export default function ContractClearanceDetailPage() {
<Grid gap="lg"> <Grid gap="lg">
<Grid.Col span={{ base: 12, lg: 8 }}> <Grid.Col span={{ base: 12, lg: 8 }}>
<ContractClearanceReviewSection contractId={id!} hideSummary /> <ContractClearanceReviewSection
contractId={id!}
hideSummary
selfClear={selfClear}
/>
</Grid.Col> </Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}> <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 { PageHeader } from "@/components/page/PageHeader";
import { KpiStrip } from "@/components/page/KpiStrip"; import { KpiStrip } from "@/components/page/KpiStrip";
import { bookingTable } from "@/components/bookings/booking-ui.styles"; 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 ViewMode = "table" | "cards";
type Region = "ET" | "DJ"; 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 navigate = useNavigate();
const [region, setRegion] = useState<Region>("ET"); const [region, setRegion] = useState<Region>("ET");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [view, setView] = useState<ViewMode>("table"); const [view, setView] = useState<ViewMode>("table");
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const { data, isLoading, isError, isFetching, refetch } = const glQueue = useContractClearanceQueue(region, !opsMode);
useContractClearanceQueue(region); const opsQueue = useOpsClearanceQueue(opsMode);
const { data, isLoading, isError, isFetching, refetch } = opsMode
? opsQueue
: glQueue;
const allRows = useMemo( const allRows = useMemo(
() => (data?.items ?? []).map(toClearanceRow), () => (data?.items ?? []).map(toClearanceRow),
@@ -238,8 +254,12 @@ export default function ContractClearanceListPage() {
<PageContainer> <PageContainer>
<Stack gap="lg"> <Stack gap="lg">
<PageHeader <PageHeader
title="Contract Clearance" title={opsMode ? "Self-Clearance Review" : "Contract Clearance"}
subtitle="Review pre-booking clearance documents on contracts before Global Logistics creates the shipment booking." 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={ meta={
<Badge <Badge
variant="light" variant="light"
@@ -320,22 +340,24 @@ export default function ContractClearanceListPage() {
style={{ flex: 1, minWidth: 220 }} style={{ flex: 1, minWidth: 220 }}
/> />
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
<SegmentedControl {!opsMode && (
size="sm" <SegmentedControl
radius="md" size="sm"
value={region} radius="md"
onChange={(v) => { value={region}
setRegion(v as Region); onChange={(v) => {
setPagination({ setRegion(v as Region);
pageIndex: 0, setPagination({
pageSize: pagination.pageSize, pageIndex: 0,
}); pageSize: pagination.pageSize,
}} });
data={[ }}
{ value: "ET", label: "Ethiopia" }, data={[
{ value: "DJ", label: "Djibouti" }, { value: "ET", label: "Ethiopia" },
]} { value: "DJ", label: "Djibouti" },
/> ]}
/>
)}
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""} {total} record{total !== 1 ? "s" : ""}
</Text> </Text>

View File

@@ -204,6 +204,26 @@ export const contractsService = {
finalizeClearance: (id: string) => finalizeClearance: (id: string) =>
postContract<Freight.IContract>(C.CLEARANCE_FINALIZE(id)), 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) ── // ── Booking under contract (GL ET — Path B) ──
createBookingUnderContract: ( createBookingUnderContract: (
id: string, id: string,

View File

@@ -129,10 +129,17 @@ export default function ContractClearanceFlow() {
const isUnderReview = status === "DOCUMENTS_UNDER_REVIEW"; const isUnderReview = status === "DOCUMENTS_UNDER_REVIEW";
const isReady = const isReady =
status === "CLEARANCE_READY_FOR_BOOKING" || status === "CLEARANCE_READY_FOR_BOOKING" ||
status === "SELF_CLEARED" ||
status === "ACTIVE_SHIPMENT_IN_PROGRESS"; status === "ACTIVE_SHIPMENT_IN_PROGRESS";
const canUpload = status === "AWAITING_DOCUMENTS" || isUnderReview; const canUpload = status === "AWAITING_DOCUMENTS" || isUnderReview;
const isInitialUpload = status === "AWAITING_DOCUMENTS"; 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( const missingRequired = useMemo(
() => customerDocs.filter((d) => d.required && !d.file && !pending[d.fileKey]), () => customerDocs.filter((d) => d.required && !d.file && !pending[d.fileKey]),
[customerDocs, pending], [customerDocs, pending],
@@ -209,8 +216,9 @@ export default function ContractClearanceFlow() {
icon={<CheckCircle2 size={18} />} icon={<CheckCircle2 size={18} />}
mb="md" mb="md"
> >
Your clearance documents are approved. Global Logistics will {customsPath
create your booking you will be notified when payment is due. ? "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> </Alert>
) : isUnderReview ? ( ) : isUnderReview ? (
<Alert <Alert
@@ -219,9 +227,9 @@ export default function ContractClearanceFlow() {
icon={<Clock size={18} />} icon={<Clock size={18} />}
mb="md" mb="md"
> >
Global Logistics is reviewing your documents. Only re-upload the {reviewer.charAt(0).toUpperCase() + reviewer.slice(1)} is
documents flagged with a query below approved documents stay as reviewing your documents. Only re-upload the documents flagged
they are. with a query below approved documents stay as they are.
</Alert> </Alert>
) : ( ) : (
<Alert <Alert
@@ -230,9 +238,9 @@ export default function ContractClearanceFlow() {
icon={<AlertCircle size={18} />} icon={<AlertCircle size={18} />}
mb="md" mb="md"
> >
Upload every required clearance document (marked *) below to start {customsPath
the review. Global Logistics will clear your shipment and create ? "Upload every required clearance document (marked *) below to start the review. Global Logistics will clear your shipment and create the booking for you."
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> </Alert>
)} )}

View File

@@ -46,10 +46,14 @@ import {
MUTED, MUTED,
} from "./contract-ui"; } 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"]; const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
// Statuses where Path B customers upload clearance docs on the contract. // Statuses where the customer uploads clearance documents on the contract. Used
const PATH_B_CLEARANCE = [ // 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", "AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW", "CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING", "CLEARANCE_READY_FOR_BOOKING",
@@ -120,12 +124,14 @@ export default function ContractDetailPage() {
const canSign = contract.status === "CONTRACT_READY"; const canSign = contract.status === "CONTRACT_READY";
const customsPath = contract.customsClearingEnabled; 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 = const canBookShipment =
!customsPath && PATH_A_BOOKABLE.includes(contract.status); !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 = const canUploadClearance =
customsPath && PATH_B_CLEARANCE.includes(contract.status); CLEARANCE_UPLOAD_STATUSES.includes(contract.status);
return ( return (
<Box style={{ padding: "28px 32px 40px" }}> <Box style={{ padding: "28px 32px 40px" }}>
@@ -326,8 +332,8 @@ export default function ContractDetailPage() {
</Paper> </Paper>
{/* Path B notice */} {/* Clearance notice (both paths) */}
{customsPath && PATH_B_CLEARANCE.includes(contract.status) && ( {canUploadClearance && (
<Paper <Paper
withBorder withBorder
radius="lg" radius="lg"
@@ -352,15 +358,23 @@ export default function ContractDetailPage() {
<Group gap={10} align="center" mb={6}> <Group gap={10} align="center" mb={6}>
<Upload size={16} color={GREEN} /> <Upload size={16} color={GREEN} />
<Text fw={700} fz={15} c={INK}> <Text fw={700} fz={15} c={INK}>
Customs clearance shipment {customsPath
? "Customs clearance shipment"
: "Customs clearance required"}
</Text> </Text>
</Group> </Group>
<Text fz={13} c="dimmed"> <Text fz={13} c="dimmed">
{contract.status === "AWAITING_CLEARANCE_DOCUMENTS" {customsPath
? "Upload your clearance documents so Global Logistics can review them. After approval, Global Logistics creates your booking — you only pay the freight." ? contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
: contract.status === "CLEARANCE_UNDER_REVIEW" ? "Upload your clearance documents so Global Logistics can review them. After approval, Global Logistics creates your booking — you only pay the freight."
? "Global Logistics is reviewing your clearance documents. Re-upload any queried documents to proceed." : contract.status === "CLEARANCE_UNDER_REVIEW"
: "Your documents are cleared. Global Logistics will create your booking shortly — you will be notified when payment is due."} ? "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> </Text>
</Paper> </Paper>
)} )}
@@ -676,7 +690,9 @@ export default function ContractDetailPage() {
? "No bookings yet. Use “New booking” to ship against this contract." ? "No bookings yet. Use “New booking” to ship against this contract."
: customsPath : customsPath
? "No bookings yet. After your clearance documents are approved, Global Logistics creates the booking on your behalf." ? "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> </Text>
</Stack> </Stack>
) : ( ) : (

View File

@@ -4,18 +4,23 @@ import { useQuery } from "@tanstack/react-query";
import { import {
Box, Box,
Button, Button,
Card, Center,
Group, Group,
Loader,
Paper, Paper,
Select, Select,
Stack, Stack,
Table,
Text, Text,
TextInput, TextInput,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { import {
ChevronLeft,
ChevronRight,
CheckCircle2, CheckCircle2,
FileStack, FileStack,
Inbox,
Package, Package,
Plus, Plus,
Search, Search,
@@ -27,13 +32,15 @@ import {
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { ContractListFilter } from "@/services/contracts.service"; import type { ContractListFilter } from "@/services/contracts.service";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { usePagination } from "@edr/ui-common";
import { import {
DataTable, BORDER,
DataTableFooter, ContractStatusBadge,
type ColumnDef, GREEN,
usePagination, INK,
} from "@edr/ui-common"; MUTED,
import { BORDER, ContractStatusBadge, INK, MUTED, StatCard } from "./contract-ui"; StatCard,
} from "./contract-ui";
function primaryRoute(contract: Freight.IContract) { function primaryRoute(contract: Freight.IContract) {
const route = contract.routes?.[0]; 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. */ /** The single most relevant next action for a customer's contract row. */
function getCustomerRowAction( function getCustomerRowAction(
contract: Freight.IContract, contract: Freight.IContract,
): { label: string; to: string } { ): { label: string; to: string; primary: boolean } {
const id = contract.id; const id = contract.id;
if (contract.status === "CONTRACT_READY") { 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") { if (contract.status === "CHANGES_REQUESTED") {
return { label: "Edit & resubmit", to: `/contracts/${id}` }; return { label: "Edit & resubmit", to: `/contracts/${id}`, primary: true };
} }
if ( if (
contract.customsClearingEnabled && contract.customsClearingEnabled &&
PATH_B_CLEARANCE_STATUSES.includes(contract.status) 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 ( if (
!contract.customsClearingEnabled && !contract.customsClearingEnabled &&
PATH_A_BOOKABLE_STATUSES.includes(contract.status) 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() { export default function ContractsList() {
@@ -161,176 +176,33 @@ export default function ContractsList() {
return { active, pending, total }; return { active, pending, total };
}, [data]); }, [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 total = data?.meta?.total ?? (data?.items?.length ?? 0);
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); 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 ( return (
<Box style={{ padding: "28px 32px 32px" }}> <Box style={{ padding: "28px 32px 32px" }}>
<Stack gap="lg"> <Stack gap="lg">
{/* Header */} {/* Header */}
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md"> <Group justify="space-between" align="center" wrap="wrap" gap="md">
<Box> <Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
<Title Contracts
order={1} </Title>
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>
<Button <Button
color="edr-green" color="edr-green"
radius="md" radius="md"
size="md" size="md"
leftSection={<Plus size={16} />} leftSection={<Plus size={16} />}
onClick={() => navigate("/contracts/new", { state: { fresh: true } })} onClick={() => navigate("/contracts/new", { state: { fresh: true } })}
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
> >
New Contract New Contract
</Button> </Button>
@@ -439,6 +311,7 @@ export default function ContractsList() {
radius="md" radius="md"
leftSection={<X size={14} />} leftSection={<X size={14} />}
onClick={clearExtraFilters} onClick={clearExtraFilters}
styles={{ root: { fontWeight: 600 } }}
> >
Clear Clear
</Button> </Button>
@@ -447,39 +320,351 @@ export default function ContractsList() {
</Paper> </Paper>
{/* Table */} {/* Table */}
<Card p={0} style={{ overflow: "hidden" }}> <Paper
<DataTable withBorder
columns={columns} radius="lg"
data={rows} style={{ borderColor: BORDER, overflow: "hidden" }}
status={dataTableStatus} >
onRowClick={(row) => <Box style={{ overflowX: "auto" }}>
navigate(`/contracts/${(row as Freight.IContract).id}`) <Table
} verticalSpacing={14}
pagination={{ horizontalSpacing={20}
pageIndex: pagination.pageIndex, highlightOnHover
pageSize: pagination.pageSize, styles={{
pageCount, th: {
totalCount: total, fontSize: 11,
}} fontWeight: 700,
tableOptions={{ letterSpacing: "0.05em",
state: { pagination }, textTransform: "uppercase",
onPaginationChange: setPagination, color: MUTED,
manualPagination: true, background: "#F8FAFC",
pageCount, borderBottom: `1px solid ${BORDER}`,
}} whiteSpace: "nowrap",
footer={DataTableFooter} },
emptyMessage="No contracts yet. Create one from New Contract." td: {
/> borderBottom: `1px solid ${BORDER}`,
</Card> 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> </Stack>
</Box> </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 ( return (
<Text fz={12} fw={700} c="dimmed" style={{ letterSpacing: 0.3 }}> <Box
{label} component="button"
</Text> 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", "NOT_APPLICABLE",
"AWAITING_DOCUMENTS", "AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW", "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", "ACTIVE_SHIPMENT_IN_PROGRESS",
] as const; ] as const;
@@ -225,8 +226,18 @@ export interface ContractClearanceDocument {
export interface ContractClearanceView { export interface ContractClearanceView {
contractId: string; contractId: string;
/** Overall contract status (e.g. CLEARANCE_UNDER_REVIEW). */
status?: string;
clearanceStatus: ContractClearanceStatus; clearanceStatus: ContractClearanceStatus;
cycleNumber: number; 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[]; documents: ContractClearanceDocument[];
/** True once every required customer document is APPROVED. */ /** True once every required customer document is APPROVED. */
allApproved: boolean; allApproved: boolean;