mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
Implement intercity document handling and rejection notes for contracts
This commit is contained in:
@@ -168,6 +168,17 @@ export class BookingLifecycleNotifierService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Intercity documents approved → booking waits in the ride-along pool. */
|
||||||
|
intercityDocumentsApproved(b: Booking): void {
|
||||||
|
const msg =
|
||||||
|
`Documents for intercity booking ${b.reference} are approved. ` +
|
||||||
|
`Operations will assign your shipment to a passing train; payment opens once it is accepted.`;
|
||||||
|
void this.notifyContact(b, msg, 'DOCUMENTS APPROVED');
|
||||||
|
this.inApp(b, 'Documents approved', msg, {
|
||||||
|
type: NotificationType.CLEARANCE_DECISION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Operations returned the operation request for changes. */
|
/** Operations returned the operation request for changes. */
|
||||||
operationChangesRequested(b: Booking, note: string): void {
|
operationChangesRequested(b: Booking, note: string): void {
|
||||||
const msg =
|
const msg =
|
||||||
|
|||||||
@@ -840,6 +840,22 @@ export class BookingTransitionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Intercity: there is no shipment-day request step — an approved booking
|
||||||
|
// goes straight to FULLY_EXECUTED, which is what the intercity ride-along
|
||||||
|
// pool keys on. Staff then accept it onto a passing train (that accept
|
||||||
|
// opens the pay window).
|
||||||
|
if (booking.tradeDirection === "DOMESTIC") {
|
||||||
|
const now = new Date();
|
||||||
|
await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: "FULLY_EXECUTED",
|
||||||
|
fullyExecutedAt: now,
|
||||||
|
lockedAt: booking.lockedAt ?? now,
|
||||||
|
} as never);
|
||||||
|
const fresh = await this.bookingsService.findById(bookingId);
|
||||||
|
this.notifier.intercityDocumentsApproved(fresh);
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
status: "CLEARANCE_READY",
|
status: "CLEARANCE_READY",
|
||||||
} as never);
|
} as never);
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
clearanceSettingCode,
|
clearanceSettingCode,
|
||||||
clearanceOutputSettingCode,
|
clearanceOutputSettingCode,
|
||||||
|
clearanceCodesForBooking,
|
||||||
|
INTERCITY_DOCUMENTS_SETTING_CODE,
|
||||||
} from './clearance.util';
|
} from './clearance.util';
|
||||||
|
import type { Booking } from './entities/booking.entity';
|
||||||
|
|
||||||
describe('clearance.util — clearanceSettingCode', () => {
|
describe('clearance.util — clearanceSettingCode', () => {
|
||||||
it('resolves import container with/without customs', () => {
|
it('resolves import container with/without customs', () => {
|
||||||
@@ -24,9 +27,49 @@ describe('clearance.util — clearanceSettingCode', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null for DOMESTIC (no clearance gate)', () => {
|
it('resolves the intercity document set for DOMESTIC regardless of customs/freight', () => {
|
||||||
expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull();
|
expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBe(
|
||||||
expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBeNull();
|
INTERCITY_DOCUMENTS_SETTING_CODE,
|
||||||
|
);
|
||||||
|
expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBe(
|
||||||
|
INTERCITY_DOCUMENTS_SETTING_CODE,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearance.util — clearanceCodesForBooking (intercity)', () => {
|
||||||
|
const base = {
|
||||||
|
tradeDirection: 'DOMESTIC',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
serviceType: null,
|
||||||
|
customsClearingEnabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('GENERAL drawdowns and direct bookings carry the per-booking intercity set', () => {
|
||||||
|
const general = clearanceCodesForBooking({
|
||||||
|
...base,
|
||||||
|
contractId: 'c1',
|
||||||
|
contractKind: 'GENERAL',
|
||||||
|
} as unknown as Booking);
|
||||||
|
expect(general.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
|
||||||
|
expect(general.outputCode).toBeNull();
|
||||||
|
|
||||||
|
const direct = clearanceCodesForBooking({
|
||||||
|
...base,
|
||||||
|
contractId: null,
|
||||||
|
contractKind: null,
|
||||||
|
} as unknown as Booking);
|
||||||
|
expect(direct.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ONE_TIME contract drawdowns skip the per-booking set (contract collected it)', () => {
|
||||||
|
const drawdown = clearanceCodesForBooking({
|
||||||
|
...base,
|
||||||
|
contractId: 'c1',
|
||||||
|
contractKind: 'ONE_TIME',
|
||||||
|
} as unknown as Booking);
|
||||||
|
expect(drawdown.inputCode).toBeNull();
|
||||||
|
expect(drawdown.outputCode).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,19 @@ import { Booking } from './entities/booking.entity';
|
|||||||
type Op = 'import' | 'export';
|
type Op = 'import' | 'export';
|
||||||
type Freight = 'container' | 'bulk';
|
type Freight = 'container' | 'bulk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single (admin-configured) document set intercity shipments upload.
|
||||||
|
* DOMESTIC has no customs, so one shared set serves contracts and bookings:
|
||||||
|
* ONE_TIME collects it at contract level, GENERAL per booking — Operations
|
||||||
|
* reviews either way.
|
||||||
|
*/
|
||||||
|
export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents';
|
||||||
|
|
||||||
/** Trade direction → clearance operation. DOMESTIC has no customs clearance. */
|
/** Trade direction → clearance operation. DOMESTIC has no customs clearance. */
|
||||||
function operationFor(tradeDirection: string): Op | null {
|
function operationFor(tradeDirection: string): Op | null {
|
||||||
if (tradeDirection === 'IMPORT') return 'import';
|
if (tradeDirection === 'IMPORT') return 'import';
|
||||||
if (tradeDirection === 'EXPORT') return 'export';
|
if (tradeDirection === 'EXPORT') return 'export';
|
||||||
return null; // DOMESTIC / intercity — no clearance gate
|
return null; // DOMESTIC / intercity — no customs operation
|
||||||
}
|
}
|
||||||
|
|
||||||
function freightFor(freightType: string): Freight {
|
function freightFor(freightType: string): Freight {
|
||||||
@@ -26,6 +34,9 @@ export function clearanceSettingCode(
|
|||||||
freightType: string,
|
freightType: string,
|
||||||
includesCustoms: boolean,
|
includesCustoms: boolean,
|
||||||
): string | null {
|
): string | null {
|
||||||
|
// Intercity: no customs, but the admin-configured intercity document set is
|
||||||
|
// still collected and ops-reviewed before the shipment may board a train.
|
||||||
|
if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE;
|
||||||
const op = operationFor(tradeDirection);
|
const op = operationFor(tradeDirection);
|
||||||
if (!op) return null;
|
if (!op) return null;
|
||||||
const freight = freightFor(freightType);
|
const freight = freightFor(freightType);
|
||||||
@@ -66,6 +77,16 @@ export function clearanceCodesForBooking(booking: Booking): {
|
|||||||
const includesCustoms =
|
const includesCustoms =
|
||||||
Boolean(booking.serviceType?.includesCustoms) ||
|
Boolean(booking.serviceType?.includesCustoms) ||
|
||||||
Boolean(booking.customsClearingEnabled);
|
Boolean(booking.customsClearingEnabled);
|
||||||
|
// Intercity drawdowns under a ONE_TIME contract already cleared the intercity
|
||||||
|
// document set on the CONTRACT (post-signature); only GENERAL drawdowns and
|
||||||
|
// direct (contract-less) bookings carry the per-booking set.
|
||||||
|
if (
|
||||||
|
booking.tradeDirection === 'DOMESTIC' &&
|
||||||
|
booking.contractId &&
|
||||||
|
booking.contractKind === 'ONE_TIME'
|
||||||
|
) {
|
||||||
|
return { inputCode: null, outputCode: null, includesCustoms: false };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
inputCode: clearanceSettingCode(
|
inputCode: clearanceSettingCode(
|
||||||
booking.tradeDirection,
|
booking.tradeDirection,
|
||||||
|
|||||||
@@ -196,11 +196,12 @@ export class ContractBookingService {
|
|||||||
// GENERAL without customs (Path A) ALSO clears per booking: the customer
|
// GENERAL without customs (Path A) ALSO clears per booking: the customer
|
||||||
// uploads his own clearance proof on each booking and Operations reviews it
|
// uploads his own clearance proof on each booking and Operations reviews it
|
||||||
// (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
|
// (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
|
||||||
// requestOperation machine). DOMESTIC has no border, so no gate.
|
// requestOperation machine). GENERAL intercity (DOMESTIC) follows the same
|
||||||
|
// per-booking gate with the intercity document set — ops finalize then puts
|
||||||
|
// the booking straight into the ride-along pool (FULLY_EXECUTED), since
|
||||||
|
// intercity has no shipment-day request step.
|
||||||
const generalSelfClear =
|
const generalSelfClear =
|
||||||
contract.contractKind === 'GENERAL' &&
|
contract.contractKind === 'GENERAL' && !contract.customsClearingEnabled;
|
||||||
!contract.customsClearingEnabled &&
|
|
||||||
contract.tradeDirection !== 'DOMESTIC';
|
|
||||||
|
|
||||||
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
|
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
|
||||||
// there is no window and no date — staff accept them onto a train at
|
// there is no window and no date — staff accept them onto a train at
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
|
import { INTERCITY_DOCUMENTS_SETTING_CODE } from '../bookings/clearance.util';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves which seeded clearance FileUploadSetting applies to a contract during
|
* Resolves which seeded clearance FileUploadSetting applies to a contract during
|
||||||
@@ -29,13 +30,17 @@ function freightFor(freightType: string): Freight {
|
|||||||
* own (smaller) clearance proof set → `contract_clearance_selfclear_{op}_{freight}`,
|
* own (smaller) clearance proof set → `contract_clearance_selfclear_{op}_{freight}`,
|
||||||
* reviewed by Operations rather than GL.
|
* reviewed by Operations rather than GL.
|
||||||
*
|
*
|
||||||
* DOMESTIC/intercity has no border, so no clearance gate applies on either path.
|
* DOMESTIC/intercity has no border, but a ONE_TIME intercity contract still
|
||||||
|
* collects the admin-configured intercity document set after both signatures
|
||||||
|
* (ops-reviewed, like Path A). GENERAL intercity contracts skip the contract
|
||||||
|
* gate and collect the same set per booking instead.
|
||||||
*/
|
*/
|
||||||
export function contractClearanceSettingCode(
|
export function contractClearanceSettingCode(
|
||||||
tradeDirection: string,
|
tradeDirection: string,
|
||||||
freightType: string,
|
freightType: string,
|
||||||
includesCustoms: boolean,
|
includesCustoms: boolean,
|
||||||
): string | null {
|
): string | null {
|
||||||
|
if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE;
|
||||||
const op = operationFor(tradeDirection);
|
const op = operationFor(tradeDirection);
|
||||||
if (!op) return null;
|
if (!op) return null;
|
||||||
const freight = freightFor(freightType);
|
const freight = freightFor(freightType);
|
||||||
|
|||||||
@@ -1092,8 +1092,8 @@ export class ContractTransitionService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// A clearance gate applies whenever a clearance doc set resolves — Path B
|
// A clearance gate applies whenever a clearance doc set resolves — Path B
|
||||||
// (customs) or Path A self-clearance (IMPORT/EXPORT without customs). DOMESTIC
|
// (customs), Path A self-clearance (IMPORT/EXPORT without customs), or the
|
||||||
// resolves to null on both paths and skips straight to executed.
|
// intercity document set (DOMESTIC, ops-reviewed like Path A).
|
||||||
const clearanceCode = contractClearanceSettingCode(
|
const clearanceCode = contractClearanceSettingCode(
|
||||||
contract.tradeDirection,
|
contract.tradeDirection,
|
||||||
contract.freightType,
|
contract.freightType,
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
// direct download. Loaded separately to keep pagination counts correct.
|
// direct download. Loaded separately to keep pagination counts correct.
|
||||||
await this.attachContractFiles(items);
|
await this.attachContractFiles(items);
|
||||||
await this.attachClearancePhases(items);
|
await this.attachClearancePhases(items);
|
||||||
|
await this.attachRejectionNotes(items);
|
||||||
|
|
||||||
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
|
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
|
||||||
return {
|
return {
|
||||||
@@ -228,6 +229,31 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach the latest REJECTION review-note body to each REJECTED contract so
|
||||||
|
* list consumers (portal rows, backoffice queues) can show why without a
|
||||||
|
* per-contract detail fetch. One query per page, like `attachContractFiles`.
|
||||||
|
*/
|
||||||
|
private async attachRejectionNotes(contracts: Contract[]): Promise<void> {
|
||||||
|
const rejected = contracts.filter((c) => c.status === 'REJECTED');
|
||||||
|
if (rejected.length === 0) return;
|
||||||
|
const ids = rejected.map((c) => c.id);
|
||||||
|
const rows: Array<{ contract_id: string; body: string }> =
|
||||||
|
await this.dataSource.query(
|
||||||
|
`SELECT DISTINCT ON (contract_id) contract_id, body
|
||||||
|
FROM freight.contract_review_notes
|
||||||
|
WHERE contract_id = ANY($1)
|
||||||
|
AND note_type = 'REJECTION'
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
ORDER BY contract_id, created_at DESC`,
|
||||||
|
[ids],
|
||||||
|
);
|
||||||
|
const byContract = new Map(rows.map((r) => [r.contract_id, r.body]));
|
||||||
|
for (const contract of rejected) {
|
||||||
|
contract.latestRejectionNote = byContract.get(contract.id) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getStatusCounts(): Promise<Record<string, number>> {
|
async getStatusCounts(): Promise<Record<string, number>> {
|
||||||
const rows = await this.repository
|
const rows = await this.repository
|
||||||
.createQueryBuilder('contract')
|
.createQueryBuilder('contract')
|
||||||
|
|||||||
@@ -642,6 +642,47 @@ export class ContractsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Surface the rejection reason. The approval-step note is wiped on
|
||||||
|
// send-back resets, so the review-note trail is the only durable source.
|
||||||
|
if (contract.status === 'REJECTED') {
|
||||||
|
try {
|
||||||
|
const note = await this.contractsRepository.findLatestReviewNote(
|
||||||
|
contract.id,
|
||||||
|
'REJECTION',
|
||||||
|
);
|
||||||
|
contract.latestRejectionNote = note?.body ?? null;
|
||||||
|
} catch {
|
||||||
|
contract.latestRejectionNote = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surface the send-back reason to the returned-to approver, but only while
|
||||||
|
// it is still actionable: once any step acts after the send-back the note
|
||||||
|
// is stale and stays out of the response (the trail keeps it in the DB).
|
||||||
|
if (contract.status === 'PENDING_APPROVAL') {
|
||||||
|
try {
|
||||||
|
const note = await this.contractsRepository.findLatestReviewNote(
|
||||||
|
contract.id,
|
||||||
|
'STAFF_NOTE',
|
||||||
|
);
|
||||||
|
// Stale when any step acted after it (send-back resolved) or when the
|
||||||
|
// chain itself is newer than the note (fresh cycle after a resubmit).
|
||||||
|
const staleAfter = Math.max(
|
||||||
|
0,
|
||||||
|
...(contract.approvalSteps ?? []).flatMap((s) => [
|
||||||
|
s.actedAt ? new Date(s.actedAt).getTime() : 0,
|
||||||
|
s.createdAt ? new Date(s.createdAt).getTime() : 0,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
contract.latestSendBackNote =
|
||||||
|
note && new Date(note.createdAt).getTime() > staleAfter
|
||||||
|
? note.body
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
contract.latestSendBackNote = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return contract;
|
return contract;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -326,4 +326,19 @@ export class Contract extends BaseEntity {
|
|||||||
* asked them to fix. Lives in contract_review_notes, not a column here.
|
* asked them to fix. Lives in contract_review_notes, not a column here.
|
||||||
*/
|
*/
|
||||||
latestChangeRequestNote?: string | null;
|
latestChangeRequestNote?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of the most recent REJECTION review note, attached by
|
||||||
|
* ContractsService.findById when status is REJECTED so both backoffice and
|
||||||
|
* portal can show why. Lives in contract_review_notes, not a column here.
|
||||||
|
*/
|
||||||
|
latestRejectionNote?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of the most recent send-back STAFF_NOTE, attached by
|
||||||
|
* ContractsService.findById while the contract is PENDING_APPROVAL and no
|
||||||
|
* approval step has acted since the send-back. Lives in
|
||||||
|
* contract_review_notes, not a column here.
|
||||||
|
*/
|
||||||
|
latestSendBackNote?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -568,6 +568,20 @@ const DRIVER_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ── Intercity documents ─────────────────────────────────────────────────────
|
||||||
|
// One shared set for DOMESTIC (intercity) shipments, reviewed by Operations.
|
||||||
|
// ONE_TIME contracts collect it at contract level after both signatures;
|
||||||
|
// GENERAL contracts collect it per booking right after the booking is created.
|
||||||
|
// Fields start empty and are configured in the backoffice file-settings editor.
|
||||||
|
const INTERCITY_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
|
||||||
|
{
|
||||||
|
code: "intercity_documents",
|
||||||
|
label: "Intercity documents",
|
||||||
|
entity: "booking",
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FileUploadSettingsSeeder {
|
export class FileUploadSettingsSeeder {
|
||||||
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
|
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
|
||||||
@@ -619,6 +633,11 @@ export class FileUploadSettingsSeeder {
|
|||||||
description:
|
description:
|
||||||
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
|
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
|
||||||
})),
|
})),
|
||||||
|
...INTERCITY_DOCUMENT_SETTINGS.map((s) => ({
|
||||||
|
...s,
|
||||||
|
description:
|
||||||
|
"Intercity shipment documents — contract-level for ONE_TIME (after both signatures), per booking for GENERAL; reviewed by Operations.",
|
||||||
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Insert setting rows only — no FileUploadField rows. Fields start empty
|
// Insert setting rows only — no FileUploadField rows. Fields start empty
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { AlertTriangle, Check, ShieldCheck, X } from "lucide-react";
|
import { Check, ShieldCheck, X } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
Group,
|
Group,
|
||||||
@@ -51,6 +51,11 @@ export function ContractApprovalStepsCard({
|
|||||||
|
|
||||||
const nextPending = steps.find((s) => s.status === "PENDING");
|
const nextPending = steps.find((s) => s.status === "PENDING");
|
||||||
const summary = formatContractApprovalProgress(contract.status, steps);
|
const summary = formatContractApprovalProgress(contract.status, steps);
|
||||||
|
// The card also renders read-only trails (e.g. a REJECTED contract) — only
|
||||||
|
// offer approve/reject while the backend accepts step actions.
|
||||||
|
const actionable =
|
||||||
|
contract.status === "PENDING_APPROVAL" ||
|
||||||
|
contract.status === "APPROVED_PENDING_SIGNATURE";
|
||||||
|
|
||||||
// Approvers review a live preview of the document; there is no PDF to
|
// Approvers review a live preview of the document; there is no PDF to
|
||||||
// generate first — the final approval is what produces it.
|
// generate first — the final approval is what produces it.
|
||||||
@@ -160,7 +165,7 @@ export function ContractApprovalStepsCard({
|
|||||||
<StepRow
|
<StepRow
|
||||||
key={step.id}
|
key={step.id}
|
||||||
step={step}
|
step={step}
|
||||||
isNext={nextPending?.id === step.id}
|
isNext={actionable && nextPending?.id === step.id}
|
||||||
isPending={
|
isPending={
|
||||||
mutations.approveStep.isPending ||
|
mutations.approveStep.isPending ||
|
||||||
mutations.rejectStep.isPending
|
mutations.rejectStep.isPending
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
AlertTriangle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Box as BoxIcon,
|
Box as BoxIcon,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -264,7 +266,8 @@ export default function ContractRequestDetailPage() {
|
|||||||
const showApprovalCard =
|
const showApprovalCard =
|
||||||
contract.status === "PENDING_APPROVAL" ||
|
contract.status === "PENDING_APPROVAL" ||
|
||||||
contract.status === "APPROVED" ||
|
contract.status === "APPROVED" ||
|
||||||
contract.status === "APPROVED_PENDING_SIGNATURE";
|
contract.status === "APPROVED_PENDING_SIGNATURE" ||
|
||||||
|
contract.status === "REJECTED";
|
||||||
|
|
||||||
const showClearanceTab = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
|
const showClearanceTab = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
|
||||||
const phasedCustoms =
|
const phasedCustoms =
|
||||||
@@ -428,6 +431,32 @@ export default function ContractRequestDetailPage() {
|
|||||||
description={statusMeta.description}
|
description={statusMeta.description}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{contract.status === "REJECTED" && contract.latestRejectionNote ? (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
icon={<AlertTriangle size={18} />}
|
||||||
|
title="Rejection reason"
|
||||||
|
>
|
||||||
|
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{contract.latestRejectionNote}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{contract.status === "PENDING_APPROVAL" && contract.latestSendBackNote ? (
|
||||||
|
<Alert
|
||||||
|
color="orange"
|
||||||
|
radius="md"
|
||||||
|
icon={<AlertTriangle size={18} />}
|
||||||
|
title="Sent back in the approval chain"
|
||||||
|
>
|
||||||
|
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{contract.latestSendBackNote}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={currentTab}
|
value={currentTab}
|
||||||
onChange={(v) => setTab(v ?? "details")}
|
onChange={(v) => setTab(v ?? "details")}
|
||||||
|
|||||||
@@ -409,6 +409,10 @@ export default function ContractDetailPage() {
|
|||||||
|
|
||||||
const canSign = contract.status === "CONTRACT_READY";
|
const canSign = contract.status === "CONTRACT_READY";
|
||||||
const customsPath = contract.customsClearingEnabled;
|
const customsPath = contract.customsClearingEnabled;
|
||||||
|
// Intercity (DOMESTIC) has no customs — the document gate collects the
|
||||||
|
// admin-configured intercity set, reviewed by Operations.
|
||||||
|
const isIntercity = contract.tradeDirection === "DOMESTIC";
|
||||||
|
const docNoun = isIntercity ? "intercity documents" : "clearance documents";
|
||||||
// Only the NON-customs (Path A) customer books himself — once the contract is
|
// Only the NON-customs (Path A) customer books himself — once the contract is
|
||||||
// executed after self-clearance. Customs (Path B) bookings are created by
|
// executed after self-clearance. Customs (Path B) bookings are created by
|
||||||
// Global Logistics on the customer's behalf, so the customer gets no booking
|
// Global Logistics on the customer's behalf, so the customer gets no booking
|
||||||
@@ -586,8 +590,8 @@ export default function ContractDetailPage() {
|
|||||||
onClick={clearanceModal.open}
|
onClick={clearanceModal.open}
|
||||||
>
|
>
|
||||||
{contract.status === "CLEARANCE_UNDER_REVIEW"
|
{contract.status === "CLEARANCE_UNDER_REVIEW"
|
||||||
? "Manage clearance documents"
|
? `Manage ${docNoun}`
|
||||||
: "Upload clearance documents"}
|
: `Upload ${docNoun}`}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -852,7 +856,9 @@ export default function ContractDetailPage() {
|
|||||||
<Text fw={700} fz={15} c={INK}>
|
<Text fw={700} fz={15} c={INK}>
|
||||||
{customsPath
|
{customsPath
|
||||||
? "Customs clearance shipment"
|
? "Customs clearance shipment"
|
||||||
: "Customs clearance required"}
|
: isIntercity
|
||||||
|
? "Intercity documents required"
|
||||||
|
: "Customs clearance required"}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Text fz={13} c="dimmed">
|
<Text fz={13} c="dimmed">
|
||||||
@@ -862,11 +868,17 @@ export default function ContractDetailPage() {
|
|||||||
: contract.status === "CLEARANCE_UNDER_REVIEW"
|
: contract.status === "CLEARANCE_UNDER_REVIEW"
|
||||||
? "Global Logistics is reviewing your clearance documents. Re-upload any queried documents to proceed."
|
? "Global Logistics is reviewing your clearance documents. Re-upload any queried documents to proceed."
|
||||||
: "Your documents are cleared. You can now create a shipment booking under this contract."
|
: "Your documents are cleared. You can now create a shipment booking under this contract."
|
||||||
: contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
|
: isIntercity
|
||||||
? "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 === "AWAITING_CLEARANCE_DOCUMENTS"
|
||||||
: contract.status === "CLEARANCE_UNDER_REVIEW"
|
? "Upload the required intercity documents so the Operations team can review them before you book a shipment."
|
||||||
? "The Operations team is reviewing your clearance documents. Re-upload any queried documents to proceed."
|
: contract.status === "CLEARANCE_UNDER_REVIEW"
|
||||||
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
|
? "The Operations team is reviewing your intercity documents. Re-upload any queried documents to proceed."
|
||||||
|
: "Your intercity documents are approved. You can now create a shipment booking under this contract."
|
||||||
|
: 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>
|
||||||
{contract.status !== "CLEARANCE_READY_FOR_BOOKING" && (
|
{contract.status !== "CLEARANCE_READY_FOR_BOOKING" && (
|
||||||
<Button
|
<Button
|
||||||
@@ -1581,7 +1593,7 @@ export default function ContractDetailPage() {
|
|||||||
onClose={clearanceModal.close}
|
onClose={clearanceModal.close}
|
||||||
title={
|
title={
|
||||||
<Text fw={700} fz={16}>
|
<Text fw={700} fz={16}>
|
||||||
Clearance documents
|
{isIntercity ? "Intercity documents" : "Clearance documents"}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
size="xl"
|
size="xl"
|
||||||
|
|||||||
@@ -254,6 +254,14 @@ export function ContractStepBanner({ contract }: ContractStepBannerProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
{contract.status === "REJECTED" && contract.latestRejectionNote && (
|
||||||
|
<Text
|
||||||
|
fz={12.5}
|
||||||
|
style={{ color: "#B42318", whiteSpace: "pre-wrap", width: "100%" }}
|
||||||
|
>
|
||||||
|
Reason: {contract.latestRejectionNote}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
{expirySoon && (
|
{expirySoon && (
|
||||||
<Group gap={6} wrap="nowrap" align="center">
|
<Group gap={6} wrap="nowrap" align="center">
|
||||||
<AlertTriangle size={14} color="#9A6700" />
|
<AlertTriangle size={14} color="#9A6700" />
|
||||||
|
|||||||
@@ -654,6 +654,18 @@ export interface IContract extends BaseEntity {
|
|||||||
* exactly what to fix before resubmitting.
|
* exactly what to fix before resubmitting.
|
||||||
*/
|
*/
|
||||||
latestChangeRequestNote?: string | null;
|
latestChangeRequestNote?: string | null;
|
||||||
|
/**
|
||||||
|
* Body of the latest REJECTION review note (detail response only, when
|
||||||
|
* status is REJECTED). Shows staff and customer why the contract was
|
||||||
|
* rejected.
|
||||||
|
*/
|
||||||
|
latestRejectionNote?: string | null;
|
||||||
|
/**
|
||||||
|
* Body of the latest send-back STAFF_NOTE (detail response only, while the
|
||||||
|
* contract is PENDING_APPROVAL and no step has acted since the send-back).
|
||||||
|
* Tells the returned-to approver why the chain came back to them.
|
||||||
|
*/
|
||||||
|
latestSendBackNote?: string | null;
|
||||||
clearanceStatus: ContractClearanceStatus;
|
clearanceStatus: ContractClearanceStatus;
|
||||||
clearanceCycleNumber: number;
|
clearanceCycleNumber: number;
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user