mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
finilize gl
This commit is contained in:
@@ -699,6 +699,7 @@ export class BookingTransitionService {
|
|||||||
bookingId,
|
bookingId,
|
||||||
booking.tradeDirection ?? 'IMPORT',
|
booking.tradeDirection ?? 'IMPORT',
|
||||||
);
|
);
|
||||||
|
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
||||||
} as never);
|
} as never);
|
||||||
@@ -763,6 +764,15 @@ export class BookingTransitionService {
|
|||||||
if (status === 'QUERIED' && !note?.trim()) {
|
if (status === 'QUERIED' && !note?.trim()) {
|
||||||
throw new BadRequestException('A note is required when querying a document');
|
throw new BadRequestException('A note is required when querying a document');
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
status === 'QUERIED' &&
|
||||||
|
this.isPhasedGeneralCustoms(booking) &&
|
||||||
|
booking.preClearanceFinalizedAt
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Customer documents cannot be queried after pre-clearance is finalized.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await this.bookingsRepository.setDocumentReviewStatus(
|
await this.bookingsRepository.setDocumentReviewStatus(
|
||||||
bookingId,
|
bookingId,
|
||||||
@@ -779,10 +789,10 @@ export class BookingTransitionService {
|
|||||||
'CHANGES_REQUESTED',
|
'CHANGES_REQUESTED',
|
||||||
staffId,
|
staffId,
|
||||||
);
|
);
|
||||||
if (this.isPhasedGeneralCustoms(booking) && booking.preClearanceFinalizedAt) {
|
if (this.isPhasedGeneralCustoms(booking)) {
|
||||||
|
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
preClearanceFinalizedAt: null,
|
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
||||||
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
|
||||||
} as never);
|
} as never);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -543,16 +543,16 @@ export class BookingsController {
|
|||||||
|
|
||||||
@Post(':id/clearance/transit-permit')
|
@Post(':id/clearance/transit-permit')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
async uploadBookingTransitPermit(
|
async uploadBookingTransitPermit(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
const booking = await this.bookingClearanceService.uploadTransitPermit(
|
const booking = await this.bookingClearanceService.uploadTransitPermit(
|
||||||
id,
|
id,
|
||||||
file,
|
files ?? [],
|
||||||
resolveAuthUserId(user),
|
resolveAuthUserId(user),
|
||||||
);
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
|||||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||||
import { assertDeclarationFiles, buildWorkflowFiles } from './phased-clearance.util';
|
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
|
||||||
|
|
||||||
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
||||||
|
|
||||||
@@ -157,11 +157,9 @@ export class BookingClearanceService {
|
|||||||
booking.tradeDirection ?? 'IMPORT',
|
booking.tradeDirection ?? 'IMPORT',
|
||||||
);
|
);
|
||||||
const dutyAdvice = this.buildDutyAdvice(files, milestones);
|
const dutyAdvice = this.buildDutyAdvice(files, milestones);
|
||||||
const documentFileKeys = new Set(documents.map((d) => d.fileKey));
|
|
||||||
const workflowFiles = buildWorkflowFiles(
|
const workflowFiles = buildWorkflowFiles(
|
||||||
files,
|
files,
|
||||||
booking.tradeDirection ?? 'IMPORT',
|
booking.tradeDirection ?? 'IMPORT',
|
||||||
documentFileKeys,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -279,16 +277,8 @@ export class BookingClearanceService {
|
|||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
throw new BadRequestException('No declaration documents uploaded');
|
throw new BadRequestException('No declaration documents uploaded');
|
||||||
}
|
}
|
||||||
assertDeclarationFiles(files, tradeDirection);
|
|
||||||
|
|
||||||
for (const file of files) {
|
await persistDeclarationUploads(this.filesService, bookingId, 'bookings', files);
|
||||||
await this.filesService.upsertByCode({
|
|
||||||
resourceId: bookingId,
|
|
||||||
resource: 'bookings',
|
|
||||||
code: file.fieldname,
|
|
||||||
file,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.workflowService.onDeclarationUploadedForBooking(bookingId, userId);
|
await this.workflowService.onDeclarationUploadedForBooking(bookingId, userId);
|
||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
@@ -380,7 +370,7 @@ export class BookingClearanceService {
|
|||||||
|
|
||||||
async uploadTransitPermit(
|
async uploadTransitPermit(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
file: Express.Multer.File,
|
files: Express.Multer.File[],
|
||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.loadBooking(bookingId);
|
const booking = await this.loadBooking(bookingId);
|
||||||
@@ -392,14 +382,11 @@ export class BookingClearanceService {
|
|||||||
'IMPORT',
|
'IMPORT',
|
||||||
'TRANSIT_PERMIT_UPLOADED',
|
'TRANSIT_PERMIT_UPLOADED',
|
||||||
);
|
);
|
||||||
if (!file) throw new BadRequestException('No transit permit uploaded');
|
if (files.length === 0) {
|
||||||
|
throw new BadRequestException('No transit permit documents uploaded');
|
||||||
|
}
|
||||||
|
|
||||||
await this.filesService.upsertByCode({
|
await persistTransitPermitUploads(this.filesService, bookingId, 'bookings', files);
|
||||||
resourceId: bookingId,
|
|
||||||
resource: 'bookings',
|
|
||||||
code: 'transit_permitted',
|
|
||||||
file,
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.workflowService.completeMilestoneForBooking(
|
await this.workflowService.completeMilestoneForBooking(
|
||||||
bookingId,
|
bookingId,
|
||||||
@@ -598,32 +585,31 @@ export class BookingClearanceService {
|
|||||||
|
|
||||||
async etQueue(): Promise<Booking[]> {
|
async etQueue(): Promise<Booking[]> {
|
||||||
const candidates = await this.bookingsRepository.findByStatuses([
|
const candidates = await this.bookingsRepository.findByStatuses([
|
||||||
'AWAITING_DOCUMENTS',
|
...PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES,
|
||||||
'DOCUMENTS_UNDER_REVIEW',
|
|
||||||
'CLEARANCE_READY',
|
|
||||||
]);
|
]);
|
||||||
const filtered: Booking[] = [];
|
const filtered: Booking[] = [];
|
||||||
for (const b of candidates) {
|
for (const b of candidates) {
|
||||||
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
|
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
|
||||||
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
||||||
const next = this.workflowService.computeNextActionForBooking(b, milestones);
|
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
|
||||||
if (next?.actor === 'GL_ET') filtered.push(b);
|
|
||||||
}
|
}
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
async djQueue(): Promise<Booking[]> {
|
async djQueue(): Promise<Booking[]> {
|
||||||
const candidates = await this.bookingsRepository.findByStatuses([
|
const candidates = await this.bookingsRepository.findByStatuses([
|
||||||
'AWAITING_DOCUMENTS',
|
...DJ_BOOKING_QUEUE_STATUSES,
|
||||||
'DOCUMENTS_UNDER_REVIEW',
|
|
||||||
'CLEARANCE_READY',
|
|
||||||
]);
|
]);
|
||||||
const filtered: Booking[] = [];
|
const filtered: Booking[] = [];
|
||||||
for (const b of candidates) {
|
for (const b of candidates) {
|
||||||
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
|
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
|
||||||
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
||||||
const pending = this.workflowService.djPendingMilestoneCodes(milestones);
|
if (
|
||||||
if (b.roHoldReason || pending || this.workflowService.computeNextActionForBooking(b, milestones)?.actor === 'GL_DJ') {
|
belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, {
|
||||||
|
roHoldReason: b.roHoldReason,
|
||||||
|
preClearanceFinalizedAt: b.preClearanceFinalizedAt,
|
||||||
|
})
|
||||||
|
) {
|
||||||
filtered.push(b);
|
filtered.push(b);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,6 +197,26 @@ export class ClearanceMilestoneService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Skip optional milestones (e.g. duty when not required). */
|
/** Skip optional milestones (e.g. duty when not required). */
|
||||||
|
/** Reopen a completed contract milestone so review can continue after a query. */
|
||||||
|
async reopenForContract(contractId: string, code: string): Promise<void> {
|
||||||
|
const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } });
|
||||||
|
if (!milestone || milestone.status !== 'COMPLETED') return;
|
||||||
|
milestone.status = 'PENDING';
|
||||||
|
milestone.triggeredAt = null;
|
||||||
|
milestone.triggeredByUserId = null;
|
||||||
|
await this.repo.save(milestone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reopen a completed booking milestone so review can continue after a query. */
|
||||||
|
async reopenForBooking(bookingId: string, code: string): Promise<void> {
|
||||||
|
const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
|
||||||
|
if (!milestone || milestone.status !== 'COMPLETED') return;
|
||||||
|
milestone.status = 'PENDING';
|
||||||
|
milestone.triggeredAt = null;
|
||||||
|
milestone.triggeredByUserId = null;
|
||||||
|
await this.repo.save(milestone);
|
||||||
|
}
|
||||||
|
|
||||||
async skipForContract(contractId: string, code: string): Promise<ClearanceMilestone> {
|
async skipForContract(contractId: string, code: string): Promise<ClearanceMilestone> {
|
||||||
const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } });
|
const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } });
|
||||||
if (!milestone) {
|
if (!milestone) {
|
||||||
|
|||||||
@@ -204,6 +204,15 @@ export class ClearanceWorkflowService {
|
|||||||
await this.completeMilestoneForBooking(bookingId, 'DOCUMENTS_APPROVED');
|
await this.completeMilestoneForBooking(bookingId, 'DOCUMENTS_APPROVED');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Customer doc queried or re-uploaded — document approval milestone must reopen. */
|
||||||
|
async onDocumentReviewReopened(contractId: string): Promise<void> {
|
||||||
|
await this.milestoneService.reopenForContract(contractId, 'DOCUMENTS_APPROVED');
|
||||||
|
}
|
||||||
|
|
||||||
|
async onDocumentReviewReopenedForBooking(bookingId: string): Promise<void> {
|
||||||
|
await this.milestoneService.reopenForBooking(bookingId, 'DOCUMENTS_APPROVED');
|
||||||
|
}
|
||||||
|
|
||||||
async onDeclarationUploaded(contractId: string, userId?: string): Promise<void> {
|
async onDeclarationUploaded(contractId: string, userId?: string): Promise<void> {
|
||||||
await this.completeMilestone(contractId, 'UNDER_CUSTOMS_CLEARANCE');
|
await this.completeMilestone(contractId, 'UNDER_CUSTOMS_CLEARANCE');
|
||||||
await this.completeMilestone(contractId, 'DECLARED', userId);
|
await this.completeMilestone(contractId, 'DECLARED', userId);
|
||||||
@@ -441,7 +450,7 @@ export class ClearanceWorkflowService {
|
|||||||
if (!isDone('DECLARED')) {
|
if (!isDone('DECLARED')) {
|
||||||
return {
|
return {
|
||||||
actor: 'GL_ET',
|
actor: 'GL_ET',
|
||||||
action: 'Upload customs declaration (EX3/EX8)',
|
action: 'Upload customs declaration documents',
|
||||||
milestoneCode: 'DECLARED',
|
milestoneCode: 'DECLARED',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -452,6 +461,30 @@ export class ClearanceWorkflowService {
|
|||||||
milestoneCode: EXPORT_BOUNDARY,
|
milestoneCode: EXPORT_BOUNDARY,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (terminalScope === 'booking') {
|
||||||
|
if (!isDone('FREIGHT_PAYMENT_SETTLED')) {
|
||||||
|
return {
|
||||||
|
actor: 'CUSTOMER',
|
||||||
|
action: 'Pay freight charges',
|
||||||
|
milestoneCode: 'FREIGHT_PAYMENT_SETTLED',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!isDone('WAGON_ALLOCATED')) {
|
||||||
|
return {
|
||||||
|
actor: 'OPERATIONS',
|
||||||
|
action: 'Allocate wagon',
|
||||||
|
milestoneCode: 'WAGON_ALLOCATED',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!isDone('EXPORT_TRANSPORT_ISSUED')) {
|
||||||
|
return {
|
||||||
|
actor: 'GL_ET',
|
||||||
|
action: 'Upload transit permit',
|
||||||
|
milestoneCode: 'EXPORT_TRANSPORT_ISSUED',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER',
|
actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER',
|
||||||
action: terminalAction,
|
action: terminalAction,
|
||||||
@@ -462,7 +495,7 @@ export class ClearanceWorkflowService {
|
|||||||
if (!isDone('DECLARED')) {
|
if (!isDone('DECLARED')) {
|
||||||
return {
|
return {
|
||||||
actor: 'GL_ET',
|
actor: 'GL_ET',
|
||||||
action: 'Upload customs declaration (IM4/IM5)',
|
action: 'Upload customs declaration documents',
|
||||||
milestoneCode: 'DECLARED',
|
milestoneCode: 'DECLARED',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { FileUploadSettingsService } from '../file-upload-settings/file-upload-s
|
|||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
import { ContractsService, PaginatedContracts } from './contracts.service';
|
import { ContractsService, PaginatedContracts } from './contracts.service';
|
||||||
|
import { BookingsService } from '../bookings/bookings.service';
|
||||||
import { contractClearanceCodes } from './contract-clearance.util';
|
import { contractClearanceCodes } from './contract-clearance.util';
|
||||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
@@ -14,7 +15,7 @@ import { Contract } from './entities/contract.entity';
|
|||||||
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
||||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||||
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||||
import { assertDeclarationFiles, buildWorkflowFiles } from './phased-clearance.util';
|
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_CONTRACT_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util';
|
||||||
|
|
||||||
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
||||||
|
|
||||||
@@ -66,6 +67,9 @@ export interface ContractClearanceView {
|
|||||||
roAmendmentRequestedAt?: string | null;
|
roAmendmentRequestedAt?: string | null;
|
||||||
bookingReady?: boolean;
|
bookingReady?: boolean;
|
||||||
preClearanceFinalized?: boolean;
|
preClearanceFinalized?: boolean;
|
||||||
|
/** Export post-booking clearance finalized (transit permit uploaded + GL confirmed). */
|
||||||
|
exportClearanceFinalized?: boolean;
|
||||||
|
linkedBookingId?: string | null;
|
||||||
dutyAdvice?: {
|
dutyAdvice?: {
|
||||||
amount: number;
|
amount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
@@ -80,6 +84,7 @@ export class ContractClearanceService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly contractsRepository: ContractsRepository,
|
private readonly contractsRepository: ContractsRepository,
|
||||||
private readonly contractsService: ContractsService,
|
private readonly contractsService: ContractsService,
|
||||||
|
private readonly bookingsService: BookingsService,
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||||
private readonly workflowService: ClearanceWorkflowService,
|
private readonly workflowService: ClearanceWorkflowService,
|
||||||
@@ -198,14 +203,40 @@ export class ContractClearanceService {
|
|||||||
);
|
);
|
||||||
contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary);
|
contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary);
|
||||||
const phase = this.workflowService.resolvePhase(contract, cycle, milestones);
|
const phase = this.workflowService.resolvePhase(contract, cycle, milestones);
|
||||||
const nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
|
|
||||||
const dutyAdvice = this.buildDutyAdvice(files, milestones);
|
const dutyAdvice = this.buildDutyAdvice(files, milestones);
|
||||||
const documentFileKeys = new Set(documents.map((d) => d.fileKey));
|
let workflowFiles = buildWorkflowFiles(
|
||||||
const workflowFiles = buildWorkflowFiles(
|
|
||||||
files,
|
files,
|
||||||
contract.tradeDirection ?? 'IMPORT',
|
contract.tradeDirection ?? 'IMPORT',
|
||||||
documentFileKeys,
|
|
||||||
);
|
);
|
||||||
|
if (cycle?.bookingId) {
|
||||||
|
const bookingFiles = await this.filesService.findByResource(
|
||||||
|
cycle.bookingId,
|
||||||
|
'bookings',
|
||||||
|
);
|
||||||
|
const bookingWorkflow = buildWorkflowFiles(
|
||||||
|
bookingFiles,
|
||||||
|
contract.tradeDirection ?? 'IMPORT',
|
||||||
|
);
|
||||||
|
const byCode = new Map(workflowFiles.map((f) => [f.code, f]));
|
||||||
|
for (const row of bookingWorkflow) {
|
||||||
|
if (row.file) byCode.set(row.code, row);
|
||||||
|
}
|
||||||
|
workflowFiles = [...byCode.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
|
||||||
|
if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') {
|
||||||
|
const bookingMilestones = await this.workflowService.listMilestonesForBooking(
|
||||||
|
cycle.bookingId,
|
||||||
|
);
|
||||||
|
const booking = await this.bookingsService.findById(cycle.bookingId);
|
||||||
|
if (booking) {
|
||||||
|
nextAction = this.workflowService.computeNextActionForBooking(
|
||||||
|
booking,
|
||||||
|
bookingMilestones,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contractId,
|
contractId,
|
||||||
@@ -237,6 +268,8 @@ export class ContractClearanceService {
|
|||||||
: null,
|
: null,
|
||||||
bookingReady: boundary,
|
bookingReady: boundary,
|
||||||
preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt),
|
preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt),
|
||||||
|
exportClearanceFinalized: Boolean(cycle?.completedAt),
|
||||||
|
linkedBookingId: cycle?.bookingId ?? null,
|
||||||
dutyAdvice,
|
dutyAdvice,
|
||||||
workflowFiles,
|
workflowFiles,
|
||||||
};
|
};
|
||||||
@@ -413,6 +446,7 @@ export class ContractClearanceService {
|
|||||||
|
|
||||||
if (contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME') {
|
if (contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME') {
|
||||||
await this.workflowService.onCustomerDocsUploaded(contractId, contract.tradeDirection);
|
await this.workflowService.onCustomerDocsUploaded(contractId, contract.tradeDirection);
|
||||||
|
await this.workflowService.onDocumentReviewReopened(contractId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.contractsService.findById(contractId);
|
return this.contractsService.findById(contractId);
|
||||||
@@ -505,6 +539,15 @@ export class ContractClearanceService {
|
|||||||
|
|
||||||
const { inputCode, outputCode } = contractClearanceCodes(contract);
|
const { inputCode, outputCode } = contractClearanceCodes(contract);
|
||||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
if (
|
||||||
|
status === 'QUERIED' &&
|
||||||
|
this.isPhasedCustoms(contract) &&
|
||||||
|
cycle?.preClearanceFinalizedAt
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Customer documents cannot be queried after pre-clearance is finalized.',
|
||||||
|
);
|
||||||
|
}
|
||||||
const reviews = await this.contractsRepository.findDocumentReviews(
|
const reviews = await this.contractsRepository.findDocumentReviews(
|
||||||
contractId,
|
contractId,
|
||||||
cycle?.id ?? null,
|
cycle?.id ?? null,
|
||||||
@@ -539,10 +582,12 @@ export class ContractClearanceService {
|
|||||||
} as never);
|
} as never);
|
||||||
if (cycle) {
|
if (cycle) {
|
||||||
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
|
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
|
||||||
if (this.isPhasedCustoms(contract) && cycle.preClearanceFinalizedAt) {
|
}
|
||||||
|
if (this.isPhasedCustoms(contract)) {
|
||||||
|
await this.workflowService.onDocumentReviewReopened(contractId);
|
||||||
|
if (cycle) {
|
||||||
await this.contractsRepository.updateCycle(cycle.id, {
|
await this.contractsRepository.updateCycle(cycle.id, {
|
||||||
preClearanceFinalizedAt: null,
|
currentPhase: ContractDocPhase.GlEtReview,
|
||||||
currentPhase: ContractDocPhase.GlEtPostClearance,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -704,19 +749,14 @@ export class ContractClearanceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GL ET clearance hub: every customs (Path B) contract that still needs
|
* GL ET clearance hub: every customs (Path B) contract in phased clearance,
|
||||||
* customs clearance — awaiting the customer's documents, under GL review, or
|
* including after booking is created.
|
||||||
* finalized and waiting for the customer to create the booking in the portal.
|
|
||||||
*/
|
*/
|
||||||
async queue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
async queue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||||
return this.contractsRepository.findAllPaginated({
|
return this.contractsRepository.findAllPaginated({
|
||||||
page: filter.page ?? 1,
|
page: filter.page ?? 1,
|
||||||
pageSize: filter.pageSize ?? 100,
|
pageSize: filter.pageSize ?? 100,
|
||||||
statuses: [
|
statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES],
|
||||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
|
||||||
'CLEARANCE_UNDER_REVIEW',
|
|
||||||
'CLEARANCE_READY_FOR_BOOKING',
|
|
||||||
],
|
|
||||||
customsClearingEnabled: true,
|
customsClearingEnabled: true,
|
||||||
sortBy: filter.sortBy,
|
sortBy: filter.sortBy,
|
||||||
sortOrder: filter.sortOrder,
|
sortOrder: filter.sortOrder,
|
||||||
@@ -799,16 +839,13 @@ export class ContractClearanceService {
|
|||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
throw new BadRequestException('No declaration documents uploaded');
|
throw new BadRequestException('No declaration documents uploaded');
|
||||||
}
|
}
|
||||||
assertDeclarationFiles(files, contract.tradeDirection);
|
|
||||||
|
|
||||||
for (const file of files) {
|
await persistDeclarationUploads(
|
||||||
await this.filesService.upsertByCode({
|
this.filesService,
|
||||||
resourceId: contractId,
|
contractId,
|
||||||
resource: 'contracts',
|
'contracts',
|
||||||
code: file.fieldname,
|
files,
|
||||||
file,
|
);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.workflowService.onDeclarationUploaded(contractId, userId);
|
await this.workflowService.onDeclarationUploaded(contractId, userId);
|
||||||
|
|
||||||
@@ -913,7 +950,7 @@ export class ContractClearanceService {
|
|||||||
|
|
||||||
async uploadTransitPermit(
|
async uploadTransitPermit(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
file: Express.Multer.File,
|
files: Express.Multer.File[],
|
||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<Contract> {
|
): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
@@ -927,14 +964,16 @@ export class ContractClearanceService {
|
|||||||
'TRANSIT_PERMIT_UPLOADED',
|
'TRANSIT_PERMIT_UPLOADED',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!file) throw new BadRequestException('No transit permit uploaded');
|
if (files.length === 0) {
|
||||||
|
throw new BadRequestException('No transit permit documents uploaded');
|
||||||
|
}
|
||||||
|
|
||||||
await this.filesService.upsertByCode({
|
await persistTransitPermitUploads(
|
||||||
resourceId: contractId,
|
this.filesService,
|
||||||
resource: 'contracts',
|
contractId,
|
||||||
code: 'transit_permitted',
|
'contracts',
|
||||||
file,
|
files,
|
||||||
});
|
);
|
||||||
|
|
||||||
await this.workflowService.completeMilestone(contractId, 'TRANSIT_PERMIT_UPLOADED', userId);
|
await this.workflowService.completeMilestone(contractId, 'TRANSIT_PERMIT_UPLOADED', userId);
|
||||||
|
|
||||||
@@ -1135,12 +1174,51 @@ export class ContractClearanceService {
|
|||||||
return this.contractsService.findById(contractId);
|
return this.contractsService.findById(contractId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GL ET queue: customs ONE_TIME contracts with a pending ET-owned milestone. */
|
/** GL ET finalizes export clearance after post-booking transit permit is uploaded. */
|
||||||
|
async finalizeExportClearance(contractId: string, userId?: string): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
this.assertPhasedCustoms(contract);
|
||||||
|
if (contract.tradeDirection !== 'EXPORT') {
|
||||||
|
throw new BadRequestException('Export clearance finalize applies only to export contracts.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
if (!cycle?.bookingId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A shipment booking must exist before export clearance can be finalized.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cycle.completedAt) {
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingMilestones = await this.workflowService.listMilestonesForBooking(
|
||||||
|
cycle.bookingId,
|
||||||
|
);
|
||||||
|
const transportDone = bookingMilestones.some(
|
||||||
|
(m) => m.milestoneCode === 'EXPORT_TRANSPORT_ISSUED' && m.status === 'COMPLETED',
|
||||||
|
);
|
||||||
|
if (!transportDone) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Upload the transit permit before finalizing export clearance.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.contractsRepository.updateCycle(cycle.id, {
|
||||||
|
completedAt: new Date(),
|
||||||
|
currentPhase: ContractDocPhase.GlEtPostClearance,
|
||||||
|
});
|
||||||
|
|
||||||
|
void userId;
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GL ET queue: customs ONE_TIME contracts in phased clearance (persistent after booking). */
|
||||||
async etQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
async etQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||||
const base = await this.contractsRepository.findAllPaginated({
|
const base = await this.contractsRepository.findAllPaginated({
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 500,
|
pageSize: 500,
|
||||||
statuses: ['AWAITING_CLEARANCE_DOCUMENTS', 'CLEARANCE_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING'],
|
statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES],
|
||||||
customsClearingEnabled: true,
|
customsClearingEnabled: true,
|
||||||
contractKind: 'ONE_TIME',
|
contractKind: 'ONE_TIME',
|
||||||
sortBy: filter.sortBy,
|
sortBy: filter.sortBy,
|
||||||
@@ -1150,13 +1228,7 @@ export class ContractClearanceService {
|
|||||||
const filtered: typeof base.items = [];
|
const filtered: typeof base.items = [];
|
||||||
for (const c of base.items) {
|
for (const c of base.items) {
|
||||||
const milestones = await this.workflowService.listMilestones(c.id);
|
const milestones = await this.workflowService.listMilestones(c.id);
|
||||||
const pending = this.workflowService.etPendingMilestoneCodes(milestones);
|
if (belongsOnEtClearanceQueue(milestones)) filtered.push(c);
|
||||||
const next = this.workflowService.computeNextAction(
|
|
||||||
c,
|
|
||||||
await this.contractsRepository.currentCycle(c.id),
|
|
||||||
milestones,
|
|
||||||
);
|
|
||||||
if (pending || next?.actor === 'GL_ET') filtered.push(c);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = filter.page ?? 1;
|
const page = filter.page ?? 1;
|
||||||
@@ -1178,12 +1250,12 @@ export class ContractClearanceService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GL DJ queue: customs ONE_TIME contracts with a pending DJ-owned milestone or RO hold. */
|
/** GL DJ queue: customs ONE_TIME contracts handed off to or handled by Djibouti GL. */
|
||||||
async djQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
async djQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||||
const base = await this.contractsRepository.findAllPaginated({
|
const base = await this.contractsRepository.findAllPaginated({
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 500,
|
pageSize: 500,
|
||||||
statuses: ['AWAITING_CLEARANCE_DOCUMENTS', 'CLEARANCE_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING'],
|
statuses: [...DJ_CONTRACT_QUEUE_STATUSES],
|
||||||
customsClearingEnabled: true,
|
customsClearingEnabled: true,
|
||||||
contractKind: 'ONE_TIME',
|
contractKind: 'ONE_TIME',
|
||||||
sortBy: filter.sortBy,
|
sortBy: filter.sortBy,
|
||||||
@@ -1194,9 +1266,9 @@ export class ContractClearanceService {
|
|||||||
for (const c of base.items) {
|
for (const c of base.items) {
|
||||||
const cycle = await this.contractsRepository.currentCycle(c.id);
|
const cycle = await this.contractsRepository.currentCycle(c.id);
|
||||||
const milestones = await this.workflowService.listMilestones(c.id);
|
const milestones = await this.workflowService.listMilestones(c.id);
|
||||||
const pending = this.workflowService.djPendingMilestoneCodes(milestones);
|
if (belongsOnDjClearanceQueue(c.tradeDirection, cycle, milestones)) {
|
||||||
const next = this.workflowService.computeNextAction(c, cycle, milestones);
|
filtered.push(c);
|
||||||
if (cycle?.roHoldReason || pending || next?.actor === 'GL_DJ') filtered.push(c);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = filter.page ?? 1;
|
const page = filter.page ?? 1;
|
||||||
|
|||||||
@@ -532,7 +532,7 @@ export class ContractsController {
|
|||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
@UseInterceptors(AnyFilesInterceptor())
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@ApiOperation({ summary: 'GL ET uploads customs declaration (IM4/IM5 or EX3/EX8)' })
|
@ApiOperation({ summary: 'GL ET uploads customs declaration documents (multi-file)' })
|
||||||
uploadDeclaration(
|
uploadDeclaration(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
@@ -591,15 +591,15 @@ export class ContractsController {
|
|||||||
|
|
||||||
@Post(':id/clearance/transit-permit')
|
@Post(':id/clearance/transit-permit')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@ApiOperation({ summary: 'GL ET uploads transit permit screenshot (import)' })
|
@ApiOperation({ summary: 'GL ET uploads import transit permit documents (multi-file)' })
|
||||||
uploadTransitPermit(
|
uploadTransitPermit(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
return this.clearanceService.uploadTransitPermit(id, file, resolveAuthUserId(user));
|
return this.clearanceService.uploadTransitPermit(id, files ?? [], resolveAuthUserId(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/clearance/delivery-order')
|
@Post(':id/clearance/delivery-order')
|
||||||
@@ -655,16 +655,28 @@ export class ContractsController {
|
|||||||
return this.clearanceService.confirmExportRelease(id, resolveAuthUserId(user));
|
return this.clearanceService.confirmExportRelease(id, resolveAuthUserId(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/finalize-export-clearance')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'GL ET finalizes export clearance after post-booking transit permit upload',
|
||||||
|
})
|
||||||
|
finalizeExportClearance(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.clearanceService.finalizeExportClearance(id, resolveAuthUserId(user));
|
||||||
|
}
|
||||||
|
|
||||||
@Get('clearance/et-queue')
|
@Get('clearance/et-queue')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
@ApiOperation({ summary: 'GL Ethiopia phased clearance queue' })
|
@ApiOperation({ summary: 'GL Ethiopia phased clearance list (persistent after booking)' })
|
||||||
etClearanceQueue(@Query() filter: FilterContractDto) {
|
etClearanceQueue(@Query() filter: FilterContractDto) {
|
||||||
return this.clearanceService.etQueue(filter);
|
return this.clearanceService.etQueue(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('clearance/dj-queue')
|
@Get('clearance/dj-queue')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||||
@ApiOperation({ summary: 'GL Djibouti phased clearance queue' })
|
@ApiOperation({ summary: 'GL Djibouti phased clearance list (persistent after booking)' })
|
||||||
djClearanceQueue(@Query() filter: FilterContractDto) {
|
djClearanceQueue(@Query() filter: FilterContractDto) {
|
||||||
return this.clearanceService.djQueue(filter);
|
return this.clearanceService.djQueue(filter);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -548,6 +548,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
| 'currentPhase'
|
| 'currentPhase'
|
||||||
| 'status'
|
| 'status'
|
||||||
| 'preClearanceFinalizedAt'
|
| 'preClearanceFinalizedAt'
|
||||||
|
| 'completedAt'
|
||||||
>
|
>
|
||||||
>,
|
>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue } from './phased-clearance.util';
|
||||||
|
|
||||||
|
describe('buildWorkflowFiles', () => {
|
||||||
|
const resourceFiles = [
|
||||||
|
{ code: 'im4', id: 'f-im4', name: 'im4.pdf', url: '/files/im4' },
|
||||||
|
{ code: 'im5', id: 'f-im5', name: 'im5.pdf', url: '/files/im5' },
|
||||||
|
{
|
||||||
|
code: 'transit_permitted',
|
||||||
|
id: 'f-transit',
|
||||||
|
name: 'transit.png',
|
||||||
|
url: '/files/transit',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'duty_tax_notice',
|
||||||
|
id: 'f-duty',
|
||||||
|
name: 'notice.pdf',
|
||||||
|
url: '/files/duty',
|
||||||
|
},
|
||||||
|
{ code: 'commercial_invoice', id: 'f-inv', name: 'inv.pdf', url: '/files/inv' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('includes declaration and transit files even when they also appear in GL output document settings', () => {
|
||||||
|
const result = buildWorkflowFiles(resourceFiles, 'IMPORT');
|
||||||
|
|
||||||
|
expect(result.map((f) => f.code)).toEqual(
|
||||||
|
expect.arrayContaining(['im4', 'im5', 'transit_permitted', 'duty_tax_notice']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes multi-file declaration uploads alongside catalog codes', () => {
|
||||||
|
const result = buildWorkflowFiles(
|
||||||
|
[
|
||||||
|
...resourceFiles,
|
||||||
|
{
|
||||||
|
code: 'declaration_0',
|
||||||
|
id: 'f-dec-0',
|
||||||
|
name: 'decl-a.pdf',
|
||||||
|
url: '/files/decl-a',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'declaration_1',
|
||||||
|
id: 'f-dec-1',
|
||||||
|
name: 'decl-b.pdf',
|
||||||
|
url: '/files/decl-b',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'IMPORT',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.map((f) => f.code)).toEqual(
|
||||||
|
expect.arrayContaining(['im4', 'im5', 'declaration_0', 'declaration_1']),
|
||||||
|
);
|
||||||
|
expect(result.find((f) => f.code === 'declaration_0')?.label).toBe(
|
||||||
|
'Declaration document 1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes multi-file import transit permit uploads', () => {
|
||||||
|
const result = buildWorkflowFiles(
|
||||||
|
[
|
||||||
|
...resourceFiles,
|
||||||
|
{
|
||||||
|
code: 'transit_permit_0',
|
||||||
|
id: 'f-tp-0',
|
||||||
|
name: 'permit-a.pdf',
|
||||||
|
url: '/files/tp-a',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'transit_permit_1',
|
||||||
|
id: 'f-tp-1',
|
||||||
|
name: 'permit-b.pdf',
|
||||||
|
url: '/files/tp-b',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'IMPORT',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.map((f) => f.code)).toEqual(
|
||||||
|
expect.arrayContaining(['transit_permitted', 'transit_permit_0', 'transit_permit_1']),
|
||||||
|
);
|
||||||
|
expect(result.find((f) => f.code === 'transit_permit_0')?.label).toBe('Transit permit 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include non-catalog customer document codes', () => {
|
||||||
|
const result = buildWorkflowFiles(resourceFiles, 'IMPORT');
|
||||||
|
|
||||||
|
expect(result.some((f) => f.code === 'commercial_invoice')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('belongsOnDjClearanceQueue', () => {
|
||||||
|
it('keeps import contracts after pre-clearance is finalized (even post-booking)', () => {
|
||||||
|
expect(
|
||||||
|
belongsOnDjClearanceQueue(
|
||||||
|
'IMPORT',
|
||||||
|
{ preClearanceFinalizedAt: new Date('2026-01-01') },
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps contracts with completed Djibouti milestones', () => {
|
||||||
|
expect(
|
||||||
|
belongsOnDjClearanceQueue('IMPORT', null, [
|
||||||
|
{ ownerRegion: 'DJ', status: 'COMPLETED' },
|
||||||
|
]),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes import contracts still on Ethiopia-side clearance only', () => {
|
||||||
|
expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('belongsOnEtClearanceQueue', () => {
|
||||||
|
it('keeps contracts once phased clearance milestones exist', () => {
|
||||||
|
expect(
|
||||||
|
belongsOnEtClearanceQueue([{ ownerRegion: 'ET', status: 'COMPLETED' }]),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes contracts with no clearance milestones', () => {
|
||||||
|
expect(belongsOnEtClearanceQueue([])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,53 +1,213 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
catalogEntriesForTradeDirection,
|
catalogEntriesForTradeDirection,
|
||||||
|
declarationFileLabel,
|
||||||
|
isDeclarationFileCode,
|
||||||
|
isImportTransitPermitFileCode,
|
||||||
|
transitPermitFileLabel,
|
||||||
type ClearanceWorkflowFile,
|
type ClearanceWorkflowFile,
|
||||||
} from '@edr/types';
|
} from '@edr/types';
|
||||||
|
|
||||||
const IMPORT_DECLARATION_CODES = new Set(['im4', 'im5']);
|
/** Require at least one declaration file in the upload batch. */
|
||||||
const EXPORT_DECLARATION_CODES = new Set(['ex3', 'ex8']);
|
export function assertDeclarationFiles(files: Express.Multer.File[]): void {
|
||||||
|
|
||||||
/** Require at least one declaration file for the trade direction (IM4 or IM5, EX3 or EX8). */
|
|
||||||
export function assertDeclarationFiles(
|
|
||||||
files: Express.Multer.File[],
|
|
||||||
tradeDirection: string,
|
|
||||||
): void {
|
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
throw new BadRequestException('No declaration documents uploaded');
|
throw new BadRequestException('No declaration documents uploaded');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const allowed =
|
/** Assign stable `declaration_*` codes so multi-file uploads always pass validation. */
|
||||||
tradeDirection === 'EXPORT' ? EXPORT_DECLARATION_CODES : IMPORT_DECLARATION_CODES;
|
export function normalizeDeclarationFieldNames(
|
||||||
const labels = tradeDirection === 'EXPORT' ? 'EX3 or EX8' : 'IM4 or IM5';
|
files: Express.Multer.File[],
|
||||||
|
): Express.Multer.File[] {
|
||||||
|
return files.map((file, index) => ({
|
||||||
|
...file,
|
||||||
|
fieldname: `declaration_${index}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const uploaded = new Set(files.map((f) => f.fieldname?.toLowerCase()));
|
type DeclarationFileStore = {
|
||||||
const hasValid = [...allowed].some((code) => uploaded.has(code));
|
findByResource(
|
||||||
if (!hasValid) {
|
resourceId: string,
|
||||||
throw new BadRequestException(`Upload at least one declaration document (${labels}).`);
|
resource: string,
|
||||||
|
): Promise<Array<{ code?: string | null }>>;
|
||||||
|
deleteByCode(resourceId: string, resource: string, code: string): Promise<void>;
|
||||||
|
upload(input: {
|
||||||
|
resourceId: string;
|
||||||
|
resource: string;
|
||||||
|
code: string;
|
||||||
|
file: Express.Multer.File;
|
||||||
|
}): Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Replace all declaration files on a resource with a new multi-file upload batch. */
|
||||||
|
export async function persistDeclarationUploads(
|
||||||
|
store: DeclarationFileStore,
|
||||||
|
resourceId: string,
|
||||||
|
resource: string,
|
||||||
|
files: Express.Multer.File[],
|
||||||
|
): Promise<void> {
|
||||||
|
const normalized = normalizeDeclarationFieldNames(files);
|
||||||
|
assertDeclarationFiles(normalized);
|
||||||
|
|
||||||
|
const existing = await store.findByResource(resourceId, resource);
|
||||||
|
await Promise.all(
|
||||||
|
existing
|
||||||
|
.filter((f) => f.code && isDeclarationFileCode(f.code))
|
||||||
|
.map((f) => store.deleteByCode(resourceId, resource, f.code!)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
normalized.map((file, index) =>
|
||||||
|
store.upload({
|
||||||
|
resourceId,
|
||||||
|
resource,
|
||||||
|
code: `declaration_${index}`,
|
||||||
|
file,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Require at least one transit permit file in the upload batch. */
|
||||||
|
export function assertTransitPermitFiles(files: Express.Multer.File[]): void {
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new BadRequestException('No transit permit documents uploaded');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Assign stable `transit_permit_*` codes for multi-file import transit uploads. */
|
||||||
|
export function normalizeTransitPermitFieldNames(
|
||||||
|
files: Express.Multer.File[],
|
||||||
|
): Express.Multer.File[] {
|
||||||
|
return files.map((file, index) => ({
|
||||||
|
...file,
|
||||||
|
fieldname: `transit_permit_${index}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace all import transit permit files on a resource with a new multi-file batch. */
|
||||||
|
export async function persistTransitPermitUploads(
|
||||||
|
store: DeclarationFileStore,
|
||||||
|
resourceId: string,
|
||||||
|
resource: string,
|
||||||
|
files: Express.Multer.File[],
|
||||||
|
): Promise<void> {
|
||||||
|
const normalized = normalizeTransitPermitFieldNames(files);
|
||||||
|
assertTransitPermitFiles(normalized);
|
||||||
|
|
||||||
|
const existing = await store.findByResource(resourceId, resource);
|
||||||
|
await Promise.all(
|
||||||
|
existing
|
||||||
|
.filter((f) => f.code && isImportTransitPermitFileCode(f.code))
|
||||||
|
.map((f) => store.deleteByCode(resourceId, resource, f.code!)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
normalized.map((file, index) =>
|
||||||
|
store.upload({
|
||||||
|
resourceId,
|
||||||
|
resource,
|
||||||
|
code: `transit_permit_${index}`,
|
||||||
|
file,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function parseDutyRequiredForm(value: string | boolean | undefined): boolean {
|
export function parseDutyRequiredForm(value: string | boolean | undefined): boolean {
|
||||||
if (typeof value === 'boolean') return value;
|
if (typeof value === 'boolean') return value;
|
||||||
if (value === undefined || value === '') return false;
|
if (value === undefined || value === '') return false;
|
||||||
return value === 'true' || value === '1';
|
return value === 'true' || value === '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DjQueueMilestone = {
|
||||||
|
ownerRegion?: string | null;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DjQueueCycle = {
|
||||||
|
preClearanceFinalizedAt?: Date | null;
|
||||||
|
roHoldReason?: string | null;
|
||||||
|
} | null | undefined;
|
||||||
|
|
||||||
|
/** Whether a customs clearance item belongs on the persistent GL Djibouti list. */
|
||||||
|
export function belongsOnDjClearanceQueue(
|
||||||
|
tradeDirection: string | null | undefined,
|
||||||
|
cycle: DjQueueCycle,
|
||||||
|
milestones: DjQueueMilestone[],
|
||||||
|
extras?: {
|
||||||
|
roHoldReason?: string | null;
|
||||||
|
preClearanceFinalizedAt?: Date | null;
|
||||||
|
},
|
||||||
|
): boolean {
|
||||||
|
const roHold = cycle?.roHoldReason ?? extras?.roHoldReason;
|
||||||
|
if (roHold) return true;
|
||||||
|
|
||||||
|
const hasDjActivity = milestones.some(
|
||||||
|
(m) => m.ownerRegion === 'DJ' && (m.status === 'COMPLETED' || m.status === 'PENDING'),
|
||||||
|
);
|
||||||
|
if (hasDjActivity) return true;
|
||||||
|
|
||||||
|
const preFinalized =
|
||||||
|
cycle?.preClearanceFinalizedAt ?? extras?.preClearanceFinalizedAt ?? null;
|
||||||
|
if (tradeDirection === 'IMPORT' && preFinalized) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contract statuses for persistent phased customs clearance lists (ET + DJ). */
|
||||||
|
export const PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES = [
|
||||||
|
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||||
|
'CLEARANCE_UNDER_REVIEW',
|
||||||
|
'CLEARANCE_READY_FOR_BOOKING',
|
||||||
|
'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||||
|
'FULLY_EXECUTED',
|
||||||
|
'CONTRACT_ACTIVE',
|
||||||
|
'CONTRACT_CLOSED',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Whether a customs clearance item belongs on the persistent GL Ethiopia list. */
|
||||||
|
export function belongsOnEtClearanceQueue(milestones: DjQueueMilestone[]): boolean {
|
||||||
|
return milestones.some((m) => m.status === 'PENDING' || m.status === 'COMPLETED');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contract statuses that may appear on the GL Djibouti clearance list (includes post-booking). */
|
||||||
|
export const DJ_CONTRACT_QUEUE_STATUSES = PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES;
|
||||||
|
|
||||||
|
/** Booking statuses for persistent phased customs clearance lists (ET + DJ). */
|
||||||
|
export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [
|
||||||
|
'AWAITING_DOCUMENTS',
|
||||||
|
'DOCUMENTS_UNDER_REVIEW',
|
||||||
|
'CLEARANCE_READY',
|
||||||
|
'FULLY_EXECUTED',
|
||||||
|
'OPERATION_REQUEST_PENDING',
|
||||||
|
'OPERATION_CHANGES_REQUESTED',
|
||||||
|
'ROAD_DISPATCH_PENDING',
|
||||||
|
'IN_TRANSIT',
|
||||||
|
'PAID',
|
||||||
|
'COMPLETED',
|
||||||
|
'CONTRACT_ACTIVE',
|
||||||
|
'CONTRACT_CLOSED',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Booking statuses that may appear on the GL Djibouti clearance list (includes post-clearance). */
|
||||||
|
export const DJ_BOOKING_QUEUE_STATUSES = PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES;
|
||||||
|
|
||||||
/** Build labeled phased-customs file rows from resource files. */
|
/** Build labeled phased-customs file rows from resource files. */
|
||||||
export function buildWorkflowFiles(
|
export function buildWorkflowFiles(
|
||||||
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
|
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
|
||||||
tradeDirection: string,
|
tradeDirection: string,
|
||||||
documentFileKeys: Set<string> = new Set(),
|
|
||||||
): ClearanceWorkflowFile[] {
|
): ClearanceWorkflowFile[] {
|
||||||
const fileByCode = new Map(
|
const fileByCode = new Map(
|
||||||
files.filter((f) => f.code).map((f) => [f.code as string, f]),
|
files.filter((f) => f.code).map((f) => [f.code as string, f]),
|
||||||
);
|
);
|
||||||
const out: ClearanceWorkflowFile[] = [];
|
const out: ClearanceWorkflowFile[] = [];
|
||||||
|
const included = new Set<string>();
|
||||||
|
|
||||||
for (const entry of catalogEntriesForTradeDirection(tradeDirection)) {
|
for (const entry of catalogEntriesForTradeDirection(tradeDirection)) {
|
||||||
if (documentFileKeys.has(entry.code)) continue;
|
|
||||||
const file = fileByCode.get(entry.code) ?? null;
|
const file = fileByCode.get(entry.code) ?? null;
|
||||||
if (!file) continue;
|
if (!file) continue;
|
||||||
|
included.add(entry.code);
|
||||||
out.push({
|
out.push({
|
||||||
code: entry.code,
|
code: entry.code,
|
||||||
label: entry.label,
|
label: entry.label,
|
||||||
@@ -57,5 +217,39 @@ export function buildWorkflowFiles(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const extraDeclarations = files
|
||||||
|
.filter((f) => f.code && isDeclarationFileCode(f.code) && !included.has(f.code))
|
||||||
|
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
|
||||||
|
|
||||||
|
extraDeclarations.forEach((file, index) => {
|
||||||
|
if (!file.code) return;
|
||||||
|
included.add(file.code);
|
||||||
|
out.push({
|
||||||
|
code: file.code,
|
||||||
|
label: declarationFileLabel(file.code, index),
|
||||||
|
uploadedBy: 'gl_et',
|
||||||
|
category: 'declaration',
|
||||||
|
file: { id: file.id, name: file.name, url: file.url },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tradeDirection === 'IMPORT') {
|
||||||
|
const extraTransit = files
|
||||||
|
.filter((f) => f.code && isImportTransitPermitFileCode(f.code) && !included.has(f.code))
|
||||||
|
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
|
||||||
|
|
||||||
|
extraTransit.forEach((file, index) => {
|
||||||
|
if (!file.code) return;
|
||||||
|
included.add(file.code);
|
||||||
|
out.push({
|
||||||
|
code: file.code,
|
||||||
|
label: transitPermitFileLabel(file.code, index),
|
||||||
|
uploadedBy: 'gl_et',
|
||||||
|
category: 'transit',
|
||||||
|
file: { id: file.id, name: file.name, url: file.url },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,14 @@ export class FilesService {
|
|||||||
return this.upload(input);
|
return this.upload(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteByCode(
|
||||||
|
resourceId: string,
|
||||||
|
resource: string,
|
||||||
|
code: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.filesRepository.deleteByCode(resourceId, resource, code);
|
||||||
|
}
|
||||||
|
|
||||||
async uploadMany(
|
async uploadMany(
|
||||||
resourceId: string,
|
resourceId: string,
|
||||||
resource: string,
|
resource: string,
|
||||||
|
|||||||
@@ -219,13 +219,13 @@ export class PaymentService {
|
|||||||
|
|
||||||
//////////////// fake
|
//////////////// fake
|
||||||
|
|
||||||
await this.datasource.manager.update(
|
// await this.datasource.manager.update(
|
||||||
Booking,
|
// Booking,
|
||||||
{ id: input.referenceId },
|
// { id: input.referenceId },
|
||||||
{ status: "PAID", paymentStatus: "PAID" },
|
// { status: "PAID", paymentStatus: "PAID" },
|
||||||
);
|
// );
|
||||||
await this.firstMileService.acceptBooking(input.referenceId);
|
// await this.firstMileService.acceptBooking(input.referenceId);
|
||||||
await this.bookingBatchService.ensurePaidBookingAllocated(input.referenceId);
|
// await this.bookingBatchService.ensurePaidBookingAllocated(input.referenceId);
|
||||||
|
|
||||||
|
|
||||||
//////////////// fake
|
//////////////// fake
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Send,
|
Send,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
Ship,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Train,
|
Train,
|
||||||
Truck,
|
Truck,
|
||||||
@@ -43,10 +44,11 @@ import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPa
|
|||||||
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
||||||
import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage";
|
import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage";
|
||||||
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
|
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
|
||||||
|
import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage";
|
||||||
|
import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage";
|
||||||
import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
|
import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
|
||||||
import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage";
|
import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage";
|
||||||
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
|
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
|
||||||
import BookingMilestonesPage from "./pages/contracts/BookingMilestonesPage";
|
|
||||||
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
|
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
|
||||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||||
import CustomersPage from "./pages/customers/CustomersPage";
|
import CustomersPage from "./pages/customers/CustomersPage";
|
||||||
@@ -148,14 +150,19 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
permission: [
|
permission: [
|
||||||
FREIGHT_PERMS.contracts.clearanceReview,
|
FREIGHT_PERMS.contracts.clearanceReview,
|
||||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// {
|
||||||
|
// label: "Shipment Requests",
|
||||||
|
// href: "/dashboard/shipment-requests",
|
||||||
|
// icon: <Send />,
|
||||||
|
// permission: FREIGHT_PERMS.contracts.createBooking,
|
||||||
|
// },
|
||||||
{
|
{
|
||||||
label: "Shipment Requests",
|
label: "GL Djibouti Clearance",
|
||||||
href: "/dashboard/shipment-requests",
|
href: "/dashboard/gl-djibouti/clearance",
|
||||||
icon: <Send />,
|
icon: <Ship />,
|
||||||
permission: FREIGHT_PERMS.contracts.createBooking,
|
permission: FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Train Schedules",
|
label: "Train Schedules",
|
||||||
@@ -549,7 +556,6 @@ const App = () => {
|
|||||||
permission={[
|
permission={[
|
||||||
FREIGHT_PERMS.contracts.clearanceReview,
|
FREIGHT_PERMS.contracts.clearanceReview,
|
||||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<ContractClearanceListPage />
|
<ContractClearanceListPage />
|
||||||
@@ -563,17 +569,30 @@ const App = () => {
|
|||||||
permission={[
|
permission={[
|
||||||
FREIGHT_PERMS.contracts.clearanceReview,
|
FREIGHT_PERMS.contracts.clearanceReview,
|
||||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<ContractClearanceDetailPage />
|
<ContractClearanceDetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="gl-ethiopia/clearance" element={<LegacyGlClearanceRedirect />} />
|
<Route path="gl-ethiopia/clearance" element={<LegacyGlEthiopiaClearanceRedirect />} />
|
||||||
<Route path="gl-ethiopia/clearance/:id" element={<LegacyGlClearanceRedirect />} />
|
<Route path="gl-ethiopia/clearance/:id" element={<LegacyGlEthiopiaClearanceRedirect />} />
|
||||||
<Route path="gl-djibouti/clearance" element={<LegacyGlClearanceRedirect />} />
|
<Route
|
||||||
<Route path="gl-djibouti/clearance/:id" element={<LegacyGlClearanceRedirect />} />
|
path="gl-djibouti/clearance"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceDjActions}>
|
||||||
|
<GlDjiboutiClearanceListPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="gl-djibouti/clearance/:id"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceDjActions}>
|
||||||
|
<GlClearanceDetailPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{/* Path A ops queue out of scope for now → fold into the GL hub. */}
|
{/* Path A ops queue out of scope for now → fold into the GL hub. */}
|
||||||
<Route
|
<Route
|
||||||
path="contracts/ops-clearance"
|
path="contracts/ops-clearance"
|
||||||
@@ -589,11 +608,7 @@ const App = () => {
|
|||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="bookings/:id/milestones"
|
path="bookings/:id/milestones"
|
||||||
element={
|
element={<BookingMilestonesRedirect />}
|
||||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceReview}>
|
|
||||||
<BookingMilestonesPage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Route path="warehouses" element={<WarehouseListPage />} />
|
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||||
@@ -917,8 +932,16 @@ const App = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Redirect legacy GL Ethiopia/Djibouti clearance URLs to the unified hub. */
|
/** Redirect removed milestones page to document clearance. */
|
||||||
function LegacyGlClearanceRedirect() {
|
function BookingMilestonesRedirect() {
|
||||||
|
const { id } = useParams();
|
||||||
|
return (
|
||||||
|
<Navigate to={`/dashboard/bookings/${id}/clearance`} replace />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redirect legacy GL Ethiopia clearance URLs to the unified document clearance hub. */
|
||||||
|
function LegacyGlEthiopiaClearanceRedirect() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
if (id) {
|
if (id) {
|
||||||
return <Navigate to={`/dashboard/contracts/clearance/${id}`} replace />;
|
return <Navigate to={`/dashboard/contracts/clearance/${id}`} replace />;
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ export interface ClearanceReviewSectionProps {
|
|||||||
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;
|
||||||
|
/** Lock approve actions after document review phase completes. */
|
||||||
|
approvalsLocked?: boolean;
|
||||||
|
/** Block new queries after pre-clearance finalization. */
|
||||||
|
queriesLocked?: boolean;
|
||||||
|
/** Read-only audit view — no approve/query actions. */
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_META: Record<
|
const STATUS_META: Record<
|
||||||
@@ -64,6 +70,9 @@ export function ClearanceReviewSection({
|
|||||||
bookingId,
|
bookingId,
|
||||||
onChanged,
|
onChanged,
|
||||||
hideSummary,
|
hideSummary,
|
||||||
|
approvalsLocked = false,
|
||||||
|
queriesLocked = false,
|
||||||
|
readOnly = false,
|
||||||
}: ClearanceReviewSectionProps) {
|
}: ClearanceReviewSectionProps) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||||
@@ -147,6 +156,11 @@ export function ClearanceReviewSection({
|
|||||||
return { total, approved, queried, pending, pct };
|
return { total, approved, queried, pending, pct };
|
||||||
}, [customerDocs]);
|
}, [customerDocs]);
|
||||||
|
|
||||||
|
const hasDocsAwaitingApproval = customerDocs.some(
|
||||||
|
(d) => d.file && d.reviewStatus !== "APPROVED",
|
||||||
|
);
|
||||||
|
const effectiveApprovalsLocked = approvalsLocked && !hasDocsAwaitingApproval;
|
||||||
|
|
||||||
if (isLoading || !clearance) {
|
if (isLoading || !clearance) {
|
||||||
return (
|
return (
|
||||||
<Group justify="center" py="xl" gap={10}>
|
<Group justify="center" py="xl" gap={10}>
|
||||||
@@ -194,6 +208,9 @@ export function ClearanceReviewSection({
|
|||||||
<DocReviewCard
|
<DocReviewCard
|
||||||
key={`${doc.settingCode}:${doc.fileKey}`}
|
key={`${doc.settingCode}:${doc.fileKey}`}
|
||||||
doc={doc}
|
doc={doc}
|
||||||
|
approvalsLocked={effectiveApprovalsLocked}
|
||||||
|
queriesLocked={queriesLocked}
|
||||||
|
readOnly={readOnly}
|
||||||
note={queryNotes[doc.fileKey] ?? ""}
|
note={queryNotes[doc.fileKey] ?? ""}
|
||||||
queryOpen={openQuery[doc.fileKey] ?? false}
|
queryOpen={openQuery[doc.fileKey] ?? false}
|
||||||
onToggleQuery={(open) =>
|
onToggleQuery={(open) =>
|
||||||
@@ -397,6 +414,9 @@ function StatPill({
|
|||||||
|
|
||||||
function DocReviewCard({
|
function DocReviewCard({
|
||||||
doc,
|
doc,
|
||||||
|
approvalsLocked,
|
||||||
|
queriesLocked,
|
||||||
|
readOnly,
|
||||||
note,
|
note,
|
||||||
queryOpen,
|
queryOpen,
|
||||||
onToggleQuery,
|
onToggleQuery,
|
||||||
@@ -407,6 +427,9 @@ function DocReviewCard({
|
|||||||
busy,
|
busy,
|
||||||
}: {
|
}: {
|
||||||
doc: Freight.ClearanceDocument;
|
doc: Freight.ClearanceDocument;
|
||||||
|
approvalsLocked: boolean;
|
||||||
|
queriesLocked: boolean;
|
||||||
|
readOnly: boolean;
|
||||||
note: string;
|
note: string;
|
||||||
queryOpen: boolean;
|
queryOpen: boolean;
|
||||||
onToggleQuery: (open: boolean) => void;
|
onToggleQuery: (open: boolean) => void;
|
||||||
@@ -500,22 +523,24 @@ function DocReviewCard({
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasFile && (
|
{hasFile && !readOnly && (
|
||||||
<Box mt="sm">
|
<Box mt="sm">
|
||||||
{!queryOpen ? (
|
{!queryOpen ? (
|
||||||
<Group justify="flex-end" gap={8}>
|
<Group justify="flex-end" gap={8}>
|
||||||
<Button
|
{!queriesLocked && (
|
||||||
size="compact-sm"
|
<Button
|
||||||
variant="light"
|
size="compact-sm"
|
||||||
color="red"
|
variant="light"
|
||||||
radius="md"
|
color="red"
|
||||||
leftSection={<MessageSquareWarning size={14} />}
|
radius="md"
|
||||||
disabled={busy}
|
leftSection={<MessageSquareWarning size={14} />}
|
||||||
onClick={() => onToggleQuery(true)}
|
disabled={busy}
|
||||||
>
|
onClick={() => onToggleQuery(true)}
|
||||||
Open query
|
>
|
||||||
</Button>
|
Open query
|
||||||
{!isApproved && (
|
</Button>
|
||||||
|
)}
|
||||||
|
{!isApproved && !approvalsLocked && (
|
||||||
<Button
|
<Button
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Badge, Stack, Tabs, Text } from "@mantine/core";
|
||||||
|
import { AlertTriangle, FileText, ShieldAlert } from "lucide-react";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
|
import { AssignRiskCard } from "@/components/contracts/gl-actions/AssignRiskCard";
|
||||||
|
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
|
||||||
|
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||||
|
|
||||||
|
export interface ClearanceOpsTabsProps {
|
||||||
|
bookingId: string | undefined;
|
||||||
|
milestones?: Freight.IClearanceMilestone[];
|
||||||
|
/** When false, only the clearance tab content is rendered (no tab bar). */
|
||||||
|
showOpsTabs?: boolean;
|
||||||
|
clearanceTab: ReactNode;
|
||||||
|
/** Phased customs workflow files — enables the Uploaded documents tab. */
|
||||||
|
workflowFiles?: Freight.ClearanceWorkflowFile[];
|
||||||
|
showWorkflowFilesTab?: boolean;
|
||||||
|
tradeDirection?: string;
|
||||||
|
onViewFile?: (file: { name: string; url: string }) => void;
|
||||||
|
onDownloadFile?: (file: { id: string; name: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMilestone(
|
||||||
|
milestones: Freight.IClearanceMilestone[] | undefined,
|
||||||
|
code: string,
|
||||||
|
): Freight.IClearanceMilestone | undefined {
|
||||||
|
return milestones?.find((m) => m.milestoneCode === code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Document Clearance detail layout: primary clearance workflow plus optional
|
||||||
|
* uploaded documents, post-booking risk assignment, and incident reporting tabs.
|
||||||
|
*/
|
||||||
|
export function ClearanceOpsTabs({
|
||||||
|
bookingId,
|
||||||
|
milestones,
|
||||||
|
showOpsTabs = true,
|
||||||
|
clearanceTab,
|
||||||
|
workflowFiles = [],
|
||||||
|
showWorkflowFilesTab = false,
|
||||||
|
tradeDirection = "IMPORT",
|
||||||
|
onViewFile,
|
||||||
|
onDownloadFile,
|
||||||
|
}: ClearanceOpsTabsProps) {
|
||||||
|
const riskMs = findMilestone(milestones, "RISK_ASSIGNED");
|
||||||
|
const hasOps = Boolean(bookingId);
|
||||||
|
const isExport = tradeDirection === "EXPORT";
|
||||||
|
const uploadedDocCount = workflowFiles.filter((f) => {
|
||||||
|
if (!f.file) return false;
|
||||||
|
if (isExport) return f.category !== "duty";
|
||||||
|
return true;
|
||||||
|
}).length;
|
||||||
|
const showDocuments = showWorkflowFilesTab && Boolean(onViewFile);
|
||||||
|
const hasTabs = (showOpsTabs && hasOps) || showDocuments;
|
||||||
|
|
||||||
|
if (!hasTabs) {
|
||||||
|
return <>{clearanceTab}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs defaultValue="clearance" keepMounted={false}>
|
||||||
|
<Tabs.List mb="md">
|
||||||
|
<Tabs.Tab value="clearance">Clearance</Tabs.Tab>
|
||||||
|
{showDocuments ? (
|
||||||
|
<Tabs.Tab
|
||||||
|
value="documents"
|
||||||
|
leftSection={<FileText size={14} />}
|
||||||
|
rightSection={
|
||||||
|
uploadedDocCount > 0 ? (
|
||||||
|
<Badge size="xs" variant="light" color="edr-green" circle>
|
||||||
|
{uploadedDocCount}
|
||||||
|
</Badge>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Uploaded documents
|
||||||
|
</Tabs.Tab>
|
||||||
|
) : null}
|
||||||
|
{showOpsTabs && riskMs ? (
|
||||||
|
<Tabs.Tab value="risk" leftSection={<ShieldAlert size={14} />}>
|
||||||
|
Risk assignment
|
||||||
|
</Tabs.Tab>
|
||||||
|
) : null}
|
||||||
|
{showOpsTabs && bookingId ? (
|
||||||
|
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
|
||||||
|
Incidents
|
||||||
|
</Tabs.Tab>
|
||||||
|
) : null}
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Panel value="clearance">{clearanceTab}</Tabs.Panel>
|
||||||
|
|
||||||
|
{showDocuments ? (
|
||||||
|
<Tabs.Panel value="documents">
|
||||||
|
<ClearanceUploadedDocumentsPanel
|
||||||
|
files={workflowFiles}
|
||||||
|
tradeDirection={tradeDirection}
|
||||||
|
onView={onViewFile!}
|
||||||
|
onDownload={onDownloadFile}
|
||||||
|
/>
|
||||||
|
</Tabs.Panel>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showOpsTabs && riskMs && bookingId ? (
|
||||||
|
<Tabs.Panel value="risk">
|
||||||
|
<SectionCard icon={ShieldAlert} title="Customs risk" accent="edr-green">
|
||||||
|
<AssignRiskCard bookingId={bookingId} milestone={riskMs} />
|
||||||
|
</SectionCard>
|
||||||
|
</Tabs.Panel>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showOpsTabs && bookingId ? (
|
||||||
|
<Tabs.Panel value="incidents">
|
||||||
|
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Log container or seal issues discovered during clearance handling.
|
||||||
|
</Text>
|
||||||
|
<IncidentReportCard bookingId={bookingId} />
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
|
</Tabs.Panel>
|
||||||
|
) : null}
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { Badge, Box, Stack, Tabs, Text, ThemeIcon } from "@mantine/core";
|
||||||
|
import { FileText, Receipt, Ship, Truck } from "lucide-react";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
|
import { PhasedUploadedFileRow } from "@/components/contracts/PhasedUploadedFileRow";
|
||||||
|
|
||||||
|
type TabValue = Freight.ClearanceWorkflowFileCategory;
|
||||||
|
|
||||||
|
type TabConfig = {
|
||||||
|
value: TabValue;
|
||||||
|
label: string;
|
||||||
|
icon: typeof FileText;
|
||||||
|
emptyHint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function tabConfigForTradeDirection(tradeDirection: string): TabConfig[] {
|
||||||
|
if (tradeDirection === "EXPORT") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
value: "declaration",
|
||||||
|
label: "Declaration",
|
||||||
|
icon: FileText,
|
||||||
|
emptyHint: "No declaration uploaded yet.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "djibouti",
|
||||||
|
label: "Release order",
|
||||||
|
icon: Ship,
|
||||||
|
emptyHint: "No release order uploaded yet.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "transit",
|
||||||
|
label: "Transit Permit",
|
||||||
|
icon: Truck,
|
||||||
|
emptyHint: "No transit permit uploaded yet.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
value: "declaration",
|
||||||
|
label: "Declaration",
|
||||||
|
icon: FileText,
|
||||||
|
emptyHint: "No declaration uploaded yet.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "duty",
|
||||||
|
label: "Duty notice",
|
||||||
|
icon: Receipt,
|
||||||
|
emptyHint: "No duty notice or payment slip uploaded yet.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "transit",
|
||||||
|
label: "Transit permit",
|
||||||
|
icon: Truck,
|
||||||
|
emptyHint: "No transit permit uploaded yet.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function subtitleForTradeDirection(tradeDirection: string): string {
|
||||||
|
return tradeDirection === "EXPORT"
|
||||||
|
? "Declaration, release order, and transit permit files for this clearance."
|
||||||
|
: "Declaration, duty notice, and transit permit files for this clearance.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function footerHintForTradeDirection(tradeDirection: string): string {
|
||||||
|
return tradeDirection === "EXPORT"
|
||||||
|
? "Files appear here once GL uploads the declaration and release order, and after booking when the transit permit is uploaded."
|
||||||
|
: "Files appear here once GL Ethiopia uploads declaration, duty notice, or transit permit documents.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClearanceUploadedDocumentsPanelProps {
|
||||||
|
files: Freight.ClearanceWorkflowFile[];
|
||||||
|
tradeDirection?: string;
|
||||||
|
onView: (file: { name: string; url: string }) => void;
|
||||||
|
onDownload?: (file: { id: string; name: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClearanceUploadedDocumentsPanel({
|
||||||
|
files,
|
||||||
|
tradeDirection = "IMPORT",
|
||||||
|
onView,
|
||||||
|
onDownload,
|
||||||
|
}: ClearanceUploadedDocumentsPanelProps) {
|
||||||
|
const tabConfig = useMemo(
|
||||||
|
() => tabConfigForTradeDirection(tradeDirection),
|
||||||
|
[tradeDirection],
|
||||||
|
);
|
||||||
|
const isExport = tradeDirection === "EXPORT";
|
||||||
|
|
||||||
|
const visibleFiles = useMemo(
|
||||||
|
() =>
|
||||||
|
isExport ? files.filter((f) => f.category !== "duty") : files,
|
||||||
|
[files, isExport],
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadedCount = visibleFiles.filter((f) => f.file).length;
|
||||||
|
|
||||||
|
const defaultTab =
|
||||||
|
tabConfig.find((tab) =>
|
||||||
|
visibleFiles.some((f) => f.category === tab.value && f.file),
|
||||||
|
)?.value ?? tabConfig[0]?.value ?? "declaration";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard
|
||||||
|
icon={FileText}
|
||||||
|
title="Uploaded customs documents"
|
||||||
|
subtitle={subtitleForTradeDirection(tradeDirection)}
|
||||||
|
accent="edr-green"
|
||||||
|
>
|
||||||
|
<Tabs defaultValue={defaultTab} keepMounted={false}>
|
||||||
|
<Tabs.List mb="md">
|
||||||
|
{tabConfig.map((tab) => {
|
||||||
|
const count = visibleFiles.filter(
|
||||||
|
(f) => f.category === tab.value && f.file,
|
||||||
|
).length;
|
||||||
|
const Icon = tab.icon;
|
||||||
|
return (
|
||||||
|
<Tabs.Tab
|
||||||
|
key={tab.value}
|
||||||
|
value={tab.value}
|
||||||
|
leftSection={<Icon size={14} />}
|
||||||
|
rightSection={
|
||||||
|
count > 0 ? (
|
||||||
|
<Badge size="xs" variant="light" color="edr-green" circle>
|
||||||
|
{count}
|
||||||
|
</Badge>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Tabs.Tab>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
{tabConfig.map((tab) => {
|
||||||
|
const items = visibleFiles.filter(
|
||||||
|
(f) => f.category === tab.value && f.file,
|
||||||
|
);
|
||||||
|
const Icon = tab.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs.Panel key={tab.value} value={tab.value}>
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<Stack gap="sm">
|
||||||
|
{items.map((item) => (
|
||||||
|
<PhasedUploadedFileRow
|
||||||
|
key={item.code}
|
||||||
|
label={item.label}
|
||||||
|
file={item.file!}
|
||||||
|
onView={onView}
|
||||||
|
onDownload={onDownload}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<EmptyTabState icon={Icon} hint={tab.emptyHint} />
|
||||||
|
)}
|
||||||
|
</Tabs.Panel>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{uploadedCount === 0 ? (
|
||||||
|
<Text size="xs" c="dimmed" mt="md">
|
||||||
|
{footerHintForTradeDirection(tradeDirection)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyTabState({
|
||||||
|
icon: Icon,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
icon: typeof FileText;
|
||||||
|
hint: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
py={40}
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px dashed var(--mantine-color-gray-4)",
|
||||||
|
background: "var(--mantine-color-gray-0)",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap={8} align="center">
|
||||||
|
<ThemeIcon variant="light" color="gray" radius="xl" size={44}>
|
||||||
|
<Icon size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" c="dimmed" maw={320}>
|
||||||
|
{hint}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -56,9 +56,13 @@ export interface ContractClearanceReviewSectionProps {
|
|||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
/**
|
/**
|
||||||
* Document approvals are locked (e.g. after all docs approved in phased flow)
|
* Document approvals are locked (e.g. after all docs approved in phased flow)
|
||||||
* but queries remain available until {@link readOnly}.
|
* but queries remain available until {@link queriesLocked} or {@link readOnly}.
|
||||||
*/
|
*/
|
||||||
approvalsLocked?: boolean;
|
approvalsLocked?: boolean;
|
||||||
|
/**
|
||||||
|
* Pre-clearance finalized — block opening new queries on customer documents.
|
||||||
|
*/
|
||||||
|
queriesLocked?: boolean;
|
||||||
/**
|
/**
|
||||||
* ONE_TIME customs contracts use the phased milestone workflow. Hides the
|
* ONE_TIME customs contracts use the phased milestone workflow. Hides the
|
||||||
* legacy "Finalize clearance" shortcut; booking readiness follows delivery
|
* legacy "Finalize clearance" shortcut; booking readiness follows delivery
|
||||||
@@ -102,6 +106,7 @@ export function ContractClearanceReviewSection({
|
|||||||
readOnly = false,
|
readOnly = false,
|
||||||
phasedCustoms = false,
|
phasedCustoms = false,
|
||||||
approvalsLocked = false,
|
approvalsLocked = false,
|
||||||
|
queriesLocked = 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>>({});
|
||||||
@@ -150,6 +155,11 @@ export function ContractClearanceReviewSection({
|
|||||||
.filter((d) => d.file && d.reviewStatus !== "APPROVED")
|
.filter((d) => d.file && d.reviewStatus !== "APPROVED")
|
||||||
.map((d) => d.fileKey);
|
.map((d) => d.fileKey);
|
||||||
|
|
||||||
|
const hasDocsAwaitingApproval = customerDocs.some(
|
||||||
|
(d) => d.file && d.reviewStatus !== "APPROVED",
|
||||||
|
);
|
||||||
|
const effectiveApprovalsLocked = approvalsLocked && !hasDocsAwaitingApproval;
|
||||||
|
|
||||||
if (isLoading || !clearance) {
|
if (isLoading || !clearance) {
|
||||||
return (
|
return (
|
||||||
<Group justify="center" py="xl" gap={10}>
|
<Group justify="center" py="xl" gap={10}>
|
||||||
@@ -183,7 +193,7 @@ export function ContractClearanceReviewSection({
|
|||||||
subtitle={
|
subtitle={
|
||||||
readOnly
|
readOnly
|
||||||
? `Reviewed by the ${reviewerTeam} team.`
|
? `Reviewed by the ${reviewerTeam} team.`
|
||||||
: approvalsLocked
|
: effectiveApprovalsLocked
|
||||||
? "Documents are approved — you can still open a query if something needs fixing."
|
? "Documents are approved — you can still open a query if something needs fixing."
|
||||||
: "Approve each document, or open a query to tell the customer what to fix."
|
: "Approve each document, or open a query to tell the customer what to fix."
|
||||||
}
|
}
|
||||||
@@ -192,7 +202,7 @@ export function ContractClearanceReviewSection({
|
|||||||
<Text size="xs" c="dimmed" fw={600}>
|
<Text size="xs" c="dimmed" fw={600}>
|
||||||
{stats.approved}/{stats.total} approved
|
{stats.approved}/{stats.total} approved
|
||||||
</Text>
|
</Text>
|
||||||
{!readOnly && !approvalsLocked && approvableKeys.length > 0 && (
|
{!readOnly && !effectiveApprovalsLocked && approvableKeys.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
@@ -236,7 +246,8 @@ export function ContractClearanceReviewSection({
|
|||||||
doc={doc}
|
doc={doc}
|
||||||
reviewerTeam={reviewerTeam}
|
reviewerTeam={reviewerTeam}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
approvalsLocked={approvalsLocked}
|
approvalsLocked={effectiveApprovalsLocked}
|
||||||
|
queriesLocked={queriesLocked}
|
||||||
note={queryNotes[doc.fileKey] ?? ""}
|
note={queryNotes[doc.fileKey] ?? ""}
|
||||||
queryOpen={openQuery[doc.fileKey] ?? false}
|
queryOpen={openQuery[doc.fileKey] ?? false}
|
||||||
onToggleQuery={(open) =>
|
onToggleQuery={(open) =>
|
||||||
@@ -394,7 +405,7 @@ export function ContractClearanceReviewSection({
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
) : phasedCustoms && approvalsLocked ? (
|
) : phasedCustoms && effectiveApprovalsLocked ? (
|
||||||
<Paper withBorder radius="md" p="md">
|
<Paper withBorder radius="md" p="md">
|
||||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={28}>
|
<ThemeIcon variant="light" color="edr-green" radius="md" size={28}>
|
||||||
@@ -402,7 +413,10 @@ export function ContractClearanceReviewSection({
|
|||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text fz="12.5px" c="dimmed">
|
<Text fz="12.5px" c="dimmed">
|
||||||
Document review is complete. Use the action panel for declaration, duty, and
|
Document review is complete. Use the action panel for declaration, duty, and
|
||||||
transit steps — or open a query above if a customer document needs correction.
|
transit steps
|
||||||
|
{queriesLocked
|
||||||
|
? ". Pre-clearance is finalized — customer documents can no longer be queried."
|
||||||
|
: " — or open a query above if a customer document needs correction."}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -419,7 +433,7 @@ export function ContractClearanceReviewSection({
|
|||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text fz="12.5px" c="dimmed">
|
<Text fz="12.5px" c="dimmed">
|
||||||
{clearance.allApproved
|
{clearance.allApproved
|
||||||
? "All required documents are approved. Upload declaration, duty, transit permit, and delivery order in the action panel."
|
? "All required documents are approved. Continue declaration, duty, and transit in the action panel."
|
||||||
: "Approve every required document to unlock the customs milestone steps."}
|
: "Approve every required document to unlock the customs milestone steps."}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -498,6 +512,7 @@ function DocReviewCard({
|
|||||||
reviewerTeam,
|
reviewerTeam,
|
||||||
readOnly,
|
readOnly,
|
||||||
approvalsLocked,
|
approvalsLocked,
|
||||||
|
queriesLocked,
|
||||||
note,
|
note,
|
||||||
queryOpen,
|
queryOpen,
|
||||||
onToggleQuery,
|
onToggleQuery,
|
||||||
@@ -511,6 +526,7 @@ function DocReviewCard({
|
|||||||
reviewerTeam: string;
|
reviewerTeam: string;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
approvalsLocked: boolean;
|
approvalsLocked: boolean;
|
||||||
|
queriesLocked: boolean;
|
||||||
note: string;
|
note: string;
|
||||||
queryOpen: boolean;
|
queryOpen: boolean;
|
||||||
onToggleQuery: (open: boolean) => void;
|
onToggleQuery: (open: boolean) => void;
|
||||||
@@ -636,17 +652,19 @@ function DocReviewCard({
|
|||||||
<Box mt="sm">
|
<Box mt="sm">
|
||||||
{!queryOpen ? (
|
{!queryOpen ? (
|
||||||
<Group justify="flex-end" gap={8}>
|
<Group justify="flex-end" gap={8}>
|
||||||
<Button
|
{!queriesLocked && (
|
||||||
size="sm"
|
<Button
|
||||||
variant="light"
|
size="sm"
|
||||||
color="red"
|
variant="light"
|
||||||
radius="md"
|
color="red"
|
||||||
leftSection={<MessageSquareWarning size={15} />}
|
radius="md"
|
||||||
disabled={busy}
|
leftSection={<MessageSquareWarning size={15} />}
|
||||||
onClick={() => onToggleQuery(true)}
|
disabled={busy}
|
||||||
>
|
onClick={() => onToggleQuery(true)}
|
||||||
Open query
|
>
|
||||||
</Button>
|
Open query
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{!isApproved && !approvalsLocked && (
|
{!isApproved && !approvalsLocked && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||||
|
import { DateInput } from "@mantine/dates";
|
||||||
|
import { Ship, Upload } from "lucide-react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||||
|
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
|
||||||
|
import { contractsService } from "@/services/contracts.service";
|
||||||
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
export type GlClearanceUploadKind = "do" | "ro";
|
||||||
|
|
||||||
|
export interface GlClearanceUploadModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
kind: GlClearanceUploadKind | null;
|
||||||
|
onClose: () => void;
|
||||||
|
entityId: string;
|
||||||
|
isBooking: boolean;
|
||||||
|
workflowFiles?: Freight.ClearanceWorkflowFile[];
|
||||||
|
vesselDepartureDate?: string | null;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onPreview?: (file: { name: string; url: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlClearanceUploadModal({
|
||||||
|
opened,
|
||||||
|
kind,
|
||||||
|
onClose,
|
||||||
|
entityId,
|
||||||
|
isBooking,
|
||||||
|
workflowFiles = [],
|
||||||
|
vesselDepartureDate,
|
||||||
|
onSuccess,
|
||||||
|
onPreview,
|
||||||
|
}: GlClearanceUploadModalProps) {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [vesselDate, setVesselDate] = useState<Date | null>(
|
||||||
|
vesselDepartureDate ? new Date(vesselDepartureDate) : null,
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const isDo = kind === "do";
|
||||||
|
const isRo = kind === "ro";
|
||||||
|
const replaceMode = isDo
|
||||||
|
? Boolean(findWorkflowFile(workflowFiles, "delivery_order"))
|
||||||
|
: Boolean(findWorkflowFile(workflowFiles, "release_order"));
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setFile(null);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!file || !kind) return;
|
||||||
|
if (isRo && !vesselDate) {
|
||||||
|
toast.error("Vessel departure date is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (isDo) {
|
||||||
|
if (isBooking) {
|
||||||
|
await bookingsService.uploadDeliveryOrder(entityId, file);
|
||||||
|
} else {
|
||||||
|
await contractsService.uploadDeliveryOrder(entityId, file);
|
||||||
|
}
|
||||||
|
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
|
||||||
|
} else {
|
||||||
|
const iso = vesselDate!.toISOString().slice(0, 10);
|
||||||
|
const result = isBooking
|
||||||
|
? await bookingsService.uploadReleaseOrder(entityId, file, iso)
|
||||||
|
: await contractsService.uploadReleaseOrder(entityId, file, iso);
|
||||||
|
if (result.hold) {
|
||||||
|
toast.error(result.holdReason ?? "Vessel date too soon");
|
||||||
|
} else {
|
||||||
|
toast.success(replaceMode ? "Release Order updated" : "Release Order uploaded");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFile(null);
|
||||||
|
onSuccess?.();
|
||||||
|
close();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened && kind != null}
|
||||||
|
onClose={close}
|
||||||
|
title={
|
||||||
|
<Group gap={8}>
|
||||||
|
<Ship size={18} />
|
||||||
|
<Text fw={700}>{isDo ? "Upload Delivery Order" : "Upload Release Order"}</Text>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{isDo
|
||||||
|
? "Upload the Djibouti Delivery Order (DO) for this import shipment."
|
||||||
|
: "Upload the Release Order and confirm the vessel departure date."}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isRo ? (
|
||||||
|
<DateInput
|
||||||
|
label="Vessel departure date"
|
||||||
|
value={vesselDate}
|
||||||
|
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
|
||||||
|
size="sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<PhasedFileDropzone
|
||||||
|
label={isDo ? "Delivery Order file" : "Release Order file"}
|
||||||
|
description="PDF or image."
|
||||||
|
value={file}
|
||||||
|
onChange={setFile}
|
||||||
|
replaceMode={replaceMode}
|
||||||
|
onPreview={onPreview}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={close} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
loading={loading}
|
||||||
|
disabled={!file || (isRo && !vesselDate)}
|
||||||
|
leftSection={<Upload size={16} />}
|
||||||
|
onClick={() => void submit()}
|
||||||
|
>
|
||||||
|
{replaceMode
|
||||||
|
? isDo
|
||||||
|
? "Replace DO"
|
||||||
|
: "Replace RO"
|
||||||
|
: isDo
|
||||||
|
? "Upload DO"
|
||||||
|
: "Upload RO"}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
|||||||
|
import { Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||||
|
import { FileText, Upload } from "lucide-react";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||||
|
import { PhasedUploadedFileRow, findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
|
||||||
|
|
||||||
|
export interface PhasedDocumentUploadFieldProps {
|
||||||
|
fields: Array<{ key: string; label: string }>;
|
||||||
|
files: Record<string, File | null>;
|
||||||
|
onChange: (key: string, file: File | null) => void;
|
||||||
|
workflowFiles?: Freight.ClearanceWorkflowFile[];
|
||||||
|
helperText: string;
|
||||||
|
replaceMode?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
submitLabel?: string;
|
||||||
|
onSubmit: () => void;
|
||||||
|
onViewFile?: (file: { name: string; url: string }) => void;
|
||||||
|
onDownloadFile?: (file: { id: string; name: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consistent phased customs document upload with drag-and-drop, preview, and uploaded rows. */
|
||||||
|
export function PhasedDocumentUploadField({
|
||||||
|
fields,
|
||||||
|
files,
|
||||||
|
onChange,
|
||||||
|
workflowFiles = [],
|
||||||
|
helperText,
|
||||||
|
replaceMode = false,
|
||||||
|
loading = false,
|
||||||
|
disabled = false,
|
||||||
|
submitLabel,
|
||||||
|
onSubmit,
|
||||||
|
onViewFile,
|
||||||
|
onDownloadFile,
|
||||||
|
}: PhasedDocumentUploadFieldProps) {
|
||||||
|
const hasStaged = Object.values(files).some(Boolean);
|
||||||
|
const uploaded = fields
|
||||||
|
.map((f) => ({ ...f, file: findWorkflowFile(workflowFiles, f.key) }))
|
||||||
|
.filter((f) => f.file);
|
||||||
|
const multiField = fields.length > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
{uploaded.length > 0 ? (
|
||||||
|
<Stack gap={8}>
|
||||||
|
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
|
||||||
|
Current file{uploaded.length > 1 ? "s" : ""}
|
||||||
|
</Text>
|
||||||
|
{uploaded.map((row) => (
|
||||||
|
<PhasedUploadedFileRow
|
||||||
|
key={row.key}
|
||||||
|
label={row.label}
|
||||||
|
file={row.file!}
|
||||||
|
onView={onViewFile}
|
||||||
|
onDownload={onDownloadFile}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
|
||||||
|
<Group gap={8} mb="sm" wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
c="edr-green"
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 8,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "var(--mantine-color-edr-green-1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileText size={16} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text size="sm" fw={700}>
|
||||||
|
{replaceMode ? "Replace document" : "Upload document"}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{helperText}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Stack gap="md">
|
||||||
|
{fields.map((f) => (
|
||||||
|
<PhasedFileDropzone
|
||||||
|
key={f.key}
|
||||||
|
label={multiField ? f.label : "Choose file"}
|
||||||
|
description={
|
||||||
|
multiField
|
||||||
|
? uploaded.some((u) => u.key === f.key)
|
||||||
|
? "Drop a new file to replace the current one."
|
||||||
|
: `Upload ${f.label} (optional if another declaration type is provided).`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
value={files[f.key] ?? null}
|
||||||
|
onChange={(file) => onChange(f.key, file)}
|
||||||
|
replaceMode={replaceMode || uploaded.some((u) => u.key === f.key)}
|
||||||
|
onPreview={onViewFile}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
loading={loading}
|
||||||
|
disabled={disabled || !hasStaged}
|
||||||
|
leftSection={<Upload size={16} />}
|
||||||
|
onClick={onSubmit}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{submitLabel ?? (replaceMode ? "Replace document" : "Upload document")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { Eye, FileText, Trash2, UploadCloud } from "lucide-react";
|
||||||
|
import { isViewable } from "@edr/ui-common";
|
||||||
|
|
||||||
|
export interface PhasedFileDropzoneProps {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
value: File | null;
|
||||||
|
onChange: (file: File | null) => void;
|
||||||
|
accept?: string;
|
||||||
|
replaceMode?: boolean;
|
||||||
|
onPreview?: (file: { name: string; url: string; mimeType?: string | null }) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageFile(file: File): boolean {
|
||||||
|
if (file.type.startsWith("image/")) return true;
|
||||||
|
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
return ["png", "jpg", "jpeg", "webp", "gif", "bmp", "svg"].includes(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhasedFileDropzone({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
accept = "application/pdf,image/*",
|
||||||
|
replaceMode = false,
|
||||||
|
onPreview,
|
||||||
|
disabled = false,
|
||||||
|
}: PhasedFileDropzoneProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
|
||||||
|
const previewUrl = useMemo(
|
||||||
|
() => (value ? URL.createObjectURL(value) : null),
|
||||||
|
[value],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
|
};
|
||||||
|
}, [previewUrl]);
|
||||||
|
|
||||||
|
const pickFile = (file: File | null) => {
|
||||||
|
if (disabled) return;
|
||||||
|
onChange(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
if (disabled) return;
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) pickFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (value && previewUrl) {
|
||||||
|
const canPreview = onPreview && isViewable({ name: value.name, url: previewUrl, mimeType: value.type });
|
||||||
|
const image = isImageFile(value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Box
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid var(--mantine-color-edr-green-4)",
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, var(--mantine-color-edr-green-0) 0%, #fff 70%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap" align="center">
|
||||||
|
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
{image ? (
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() =>
|
||||||
|
canPreview && onPreview?.({ name: value.name, url: previewUrl, mimeType: value.type })
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: 10,
|
||||||
|
overflow: "hidden",
|
||||||
|
border: "1px solid var(--mantine-color-gray-3)",
|
||||||
|
cursor: canPreview ? "pointer" : "default",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt=""
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||||
|
/>
|
||||||
|
</UnstyledButton>
|
||||||
|
) : (
|
||||||
|
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
|
||||||
|
<FileText size={22} />
|
||||||
|
</ThemeIcon>
|
||||||
|
)}
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Text size="xs" fw={700} c="edr-green" tt="uppercase">
|
||||||
|
Ready to upload
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600} truncate>
|
||||||
|
{value.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{formatBytes(value.size)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
{canPreview ? (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="default"
|
||||||
|
leftSection={<Eye size={13} />}
|
||||||
|
onClick={() =>
|
||||||
|
onPreview({ name: value.name, url: previewUrl, mimeType: value.type })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
aria-label="Remove file"
|
||||||
|
onClick={() => pickFile(null)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{description ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Box
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!disabled) setDragOver(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => !disabled && inputRef.current?.click()}
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `2px dashed ${
|
||||||
|
dragOver
|
||||||
|
? "var(--mantine-color-edr-green-5)"
|
||||||
|
: "var(--mantine-color-gray-4)"
|
||||||
|
}`,
|
||||||
|
background: dragOver
|
||||||
|
? "var(--mantine-color-edr-green-0)"
|
||||||
|
: "var(--mantine-color-gray-0)",
|
||||||
|
padding: "28px 20px",
|
||||||
|
textAlign: "center",
|
||||||
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
|
opacity: disabled ? 0.6 : 1,
|
||||||
|
transition: "border-color 120ms ease, background 120ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
hidden
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => pickFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
<Stack gap={8} align="center">
|
||||||
|
<ThemeIcon
|
||||||
|
variant="light"
|
||||||
|
color={dragOver ? "edr-green" : "gray"}
|
||||||
|
radius="xl"
|
||||||
|
size={48}
|
||||||
|
>
|
||||||
|
<UploadCloud size={24} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{dragOver
|
||||||
|
? "Drop to upload"
|
||||||
|
: replaceMode
|
||||||
|
? "Drag & drop to replace"
|
||||||
|
: "Drag & drop your file here"}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
or <span style={{ color: "var(--mantine-color-edr-green-7)", fontWeight: 600 }}>browse</span>{" "}
|
||||||
|
— PDF or image
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhasedMultiFileDropzoneProps {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
value: File[];
|
||||||
|
onChange: (files: File[]) => void;
|
||||||
|
accept?: string;
|
||||||
|
replaceMode?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhasedMultiFileDropzone({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
accept = "application/pdf,image/*",
|
||||||
|
replaceMode = false,
|
||||||
|
disabled = false,
|
||||||
|
}: PhasedMultiFileDropzoneProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
|
||||||
|
const addFiles = (incoming: FileList | File[]) => {
|
||||||
|
if (disabled) return;
|
||||||
|
const next = [...value];
|
||||||
|
for (const file of Array.from(incoming)) {
|
||||||
|
if (!next.some((f) => f.name === file.name && f.size === file.size)) {
|
||||||
|
next.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAt = (index: number) => {
|
||||||
|
if (disabled) return;
|
||||||
|
onChange(value.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
if (e.dataTransfer.files.length > 0) addFiles(e.dataTransfer.files);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{description ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{value.length > 0 ? (
|
||||||
|
<Stack gap={8}>
|
||||||
|
{value.map((file, index) => (
|
||||||
|
<Box
|
||||||
|
key={`${file.name}-${file.size}-${index}`}
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid var(--mantine-color-edr-green-4)",
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, var(--mantine-color-edr-green-0) 0%, #fff 70%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap" align="center">
|
||||||
|
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<ThemeIcon variant="light" color="edr-green" radius="md" size={44}>
|
||||||
|
<FileText size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Text size="xs" fw={700} c="edr-green" tt="uppercase">
|
||||||
|
Ready to upload
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600} truncate>
|
||||||
|
{file.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{formatBytes(file.size)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
aria-label="Remove file"
|
||||||
|
onClick={() => removeAt(index)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!disabled) setDragOver(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => !disabled && inputRef.current?.click()}
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `2px dashed ${
|
||||||
|
dragOver
|
||||||
|
? "var(--mantine-color-edr-green-5)"
|
||||||
|
: "var(--mantine-color-gray-4)"
|
||||||
|
}`,
|
||||||
|
background: dragOver
|
||||||
|
? "var(--mantine-color-edr-green-0)"
|
||||||
|
: "var(--mantine-color-gray-0)",
|
||||||
|
padding: "28px 20px",
|
||||||
|
textAlign: "center",
|
||||||
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
|
opacity: disabled ? 0.6 : 1,
|
||||||
|
transition: "border-color 120ms ease, background 120ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.files?.length) addFiles(e.target.files);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack gap={8} align="center">
|
||||||
|
<ThemeIcon
|
||||||
|
variant="light"
|
||||||
|
color={dragOver ? "edr-green" : "gray"}
|
||||||
|
radius="xl"
|
||||||
|
size={48}
|
||||||
|
>
|
||||||
|
<UploadCloud size={24} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{dragOver
|
||||||
|
? "Drop to add files"
|
||||||
|
: replaceMode
|
||||||
|
? "Drag & drop to replace declaration files"
|
||||||
|
: "Drag & drop declaration files here"}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
or{" "}
|
||||||
|
<span style={{ color: "var(--mantine-color-edr-green-7)", fontWeight: 600 }}>
|
||||||
|
browse
|
||||||
|
</span>{" "}
|
||||||
|
— select one or more PDF or image files
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { Badge, Box, Button, Group, Paper, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
||||||
|
import { Download, Eye, FileText } from "lucide-react";
|
||||||
|
import { isViewable } from "@edr/ui-common";
|
||||||
|
|
||||||
|
import { fileViewUrl } from "@/constants/apiConfig";
|
||||||
|
|
||||||
|
export interface PhasedUploadedFileRowProps {
|
||||||
|
label: string;
|
||||||
|
file: { id: string; name: string };
|
||||||
|
onView?: (file: { name: string; url: string }) => void;
|
||||||
|
onDownload?: (file: { id: string; name: string }) => void;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inline preview row for a phased customs upload (declaration, transit permit, DO, etc.). */
|
||||||
|
export function PhasedUploadedFileRow({
|
||||||
|
label,
|
||||||
|
file,
|
||||||
|
onView,
|
||||||
|
onDownload,
|
||||||
|
compact = false,
|
||||||
|
}: PhasedUploadedFileRowProps) {
|
||||||
|
const viewUrl = fileViewUrl(file.id);
|
||||||
|
const canPreview = isViewable({ name: file.name, url: viewUrl });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
p={compact ? "xs" : "sm"}
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--mantine-color-edr-green-3)",
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, var(--mantine-color-edr-green-0) 0%, #FFFFFF 75%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap" align="center">
|
||||||
|
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<ThemeIcon variant="light" color="edr-green" radius="md" size={compact ? 32 : 36}>
|
||||||
|
<FileText size={compact ? 15 : 17} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Text size="sm" fw={600} truncate>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Group gap={6} wrap="nowrap" mt={2}>
|
||||||
|
<Badge size="xs" variant="light" color="edr-green" radius="sm">
|
||||||
|
Uploaded
|
||||||
|
</Badge>
|
||||||
|
<Text size="xs" c="dimmed" truncate>
|
||||||
|
{file.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
{canPreview && onView ? (
|
||||||
|
<Tooltip label="Preview">
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Eye size={13} />}
|
||||||
|
onClick={() => onView({ name: file.name, url: viewUrl })}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
{onDownload ? (
|
||||||
|
<Tooltip label="Download">
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Download size={13} />}
|
||||||
|
onClick={() => onDownload({ id: file.id, name: file.name })}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findWorkflowFile(
|
||||||
|
files: Array<{ code: string; file: { id: string; name: string } | null }> | undefined,
|
||||||
|
code: string,
|
||||||
|
): { id: string; name: string } | null {
|
||||||
|
return files?.find((f) => f.code === code)?.file ?? null;
|
||||||
|
}
|
||||||
@@ -13,13 +13,13 @@ export function TransportDocumentCard({ bookingId }: { bookingId: string }) {
|
|||||||
return (
|
return (
|
||||||
<ActionShell
|
<ActionShell
|
||||||
icon={FileText}
|
icon={FileText}
|
||||||
title="Export transport document"
|
title="Transit permit"
|
||||||
subtitle="Upload after wagon allocation (GL Ethiopia)"
|
subtitle="Upload after wagon allocation (GL Ethiopia)"
|
||||||
done={false}
|
done={false}
|
||||||
>
|
>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<FileInput
|
<FileInput
|
||||||
label="Transport document"
|
label="Transit permit"
|
||||||
value={file}
|
value={file}
|
||||||
onChange={setFile}
|
onChange={setFile}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Alert, Badge, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import { Boxes } from "lucide-react";
|
||||||
|
|
||||||
|
import { useContractCapacity } from "@/hooks/contracts/useContracts";
|
||||||
|
|
||||||
|
export function ContractCapacityNotice({
|
||||||
|
contractId,
|
||||||
|
isContainer,
|
||||||
|
}: {
|
||||||
|
contractId: string;
|
||||||
|
isContainer: boolean;
|
||||||
|
}) {
|
||||||
|
const { data: lines = [] } = useContractCapacity(contractId);
|
||||||
|
|
||||||
|
if (lines.length === 0) return null;
|
||||||
|
|
||||||
|
const allFull = lines.every((l) => l.remaining === 0);
|
||||||
|
const unit = isContainer ? "" : " tons";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
color={allFull ? "red" : "edr-green"}
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
icon={<Boxes size={16} />}
|
||||||
|
title={allFull ? "Contract capacity reached" : "Remaining contract capacity"}
|
||||||
|
>
|
||||||
|
{allFull ? (
|
||||||
|
<Text fz={13}>
|
||||||
|
This contract has been fully booked. No further shipments can be created
|
||||||
|
against it.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap={6} mt={4}>
|
||||||
|
{lines.map((l, i) => (
|
||||||
|
<Group key={i} justify="space-between" wrap="nowrap">
|
||||||
|
<Text fz={13}>{l.containerSize ?? "Bulk"}</Text>
|
||||||
|
<Badge
|
||||||
|
color={l.remaining === 0 ? "red" : "edr-green"}
|
||||||
|
variant="light"
|
||||||
|
radius="sm"
|
||||||
|
>
|
||||||
|
{l.remaining}
|
||||||
|
{unit} of {l.cap} left
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { Box, Group, Paper, Text, Title } from "@mantine/core";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
const INK = "#10202F";
|
||||||
|
const MUTED = "#6B7C8E";
|
||||||
|
const BORDER = "#E6ECF2";
|
||||||
|
const GREEN_DARK = "#0A6F4D";
|
||||||
|
|
||||||
|
export const fieldStyles = {
|
||||||
|
label: { fontWeight: 600, fontSize: 13, color: INK, marginBottom: 6 },
|
||||||
|
input: {
|
||||||
|
borderRadius: 12,
|
||||||
|
minHeight: 46,
|
||||||
|
height: 46,
|
||||||
|
fontSize: 14,
|
||||||
|
borderColor: BORDER,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StepLabel({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
fz={11}
|
||||||
|
fw={700}
|
||||||
|
tt="uppercase"
|
||||||
|
c={MUTED}
|
||||||
|
style={{ letterSpacing: "0.07em" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepCard({
|
||||||
|
children,
|
||||||
|
eyebrow,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
eyebrow?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
radius={20}
|
||||||
|
p={{ base: "lg", sm: 28 }}
|
||||||
|
withBorder
|
||||||
|
bg="white"
|
||||||
|
style={{ borderColor: BORDER, boxShadow: "0 2px 14px rgba(16,24,40,0.04)" }}
|
||||||
|
>
|
||||||
|
{eyebrow}
|
||||||
|
{children}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Group gap={14} align="flex-start" wrap="nowrap" mb={22}>
|
||||||
|
{icon ? (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 13,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "linear-gradient(135deg, #ECF6F1, #E4F3EC)",
|
||||||
|
color: GREEN_DARK,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
<Box>
|
||||||
|
<Title order={3} fz={20} fw={800} c={INK} style={{ letterSpacing: "-0.01em" }}>
|
||||||
|
{title}
|
||||||
|
</Title>
|
||||||
|
<Text size="sm" c={MUTED} mt={4} style={{ lineHeight: 1.5 }}>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -172,6 +172,8 @@ export const URL_CONSTANTS = {
|
|||||||
`/contracts/${id}/clearance/ro-amendment`,
|
`/contracts/${id}/clearance/ro-amendment`,
|
||||||
CLEARANCE_EXPORT_RELEASE: (id: string) =>
|
CLEARANCE_EXPORT_RELEASE: (id: string) =>
|
||||||
`/contracts/${id}/clearance/export-release`,
|
`/contracts/${id}/clearance/export-release`,
|
||||||
|
CLEARANCE_FINALIZE_EXPORT: (id: string) =>
|
||||||
|
`/contracts/${id}/clearance/finalize-export-clearance`,
|
||||||
CLEARANCE_ET_QUEUE: "/contracts/clearance/et-queue",
|
CLEARANCE_ET_QUEUE: "/contracts/clearance/et-queue",
|
||||||
CLEARANCE_DJ_QUEUE: "/contracts/clearance/dj-queue",
|
CLEARANCE_DJ_QUEUE: "/contracts/clearance/dj-queue",
|
||||||
// Path A self-clearance — Operations reviews the customer's own clearance docs.
|
// Path A self-clearance — Operations reviews the customer's own clearance docs.
|
||||||
|
|||||||
@@ -295,10 +295,10 @@ export default function BookingRequestDetailPage() {
|
|||||||
variant="default"
|
variant="default"
|
||||||
leftSection={<Milestone size={16} />}
|
leftSection={<Milestone size={16} />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(`/dashboard/bookings/${booking.id}/milestones`)
|
navigate(`/dashboard/bookings/${booking.id}/clearance`)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
View clearance milestones
|
View document clearance
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{showContractButton && (
|
{showContractButton && (
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
|
||||||
import { PageContainer } from "@/components/page/PageContainer";
|
import { PageContainer } from "@/components/page/PageContainer";
|
||||||
import { PageHeader } from "@/components/page/PageHeader";
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
import { SectionCard } from "@/components/bookings/detail";
|
import { SectionCard } from "@/components/bookings/detail";
|
||||||
@@ -32,12 +33,16 @@ import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceRe
|
|||||||
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
|
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
|
||||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||||
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
||||||
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
|
import { useBookingMilestones } from "@/hooks/contracts/useContracts";
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
|
import { downloadBookingFile } from "@/services/files.service";
|
||||||
import { useBookingDetail } from "@/hooks/bookings/useBookings";
|
import { useBookingDetail } from "@/hooks/bookings/useBookings";
|
||||||
|
|
||||||
export default function DocumentClearanceDetailPage() {
|
export default function DocumentClearanceDetailPage() {
|
||||||
const params = useParams<{ id?: string; bookingId?: string }>();
|
const params = useParams<{ id?: string; bookingId?: string }>();
|
||||||
const id = params.id ?? params.bookingId;
|
const id = params.id ?? params.bookingId;
|
||||||
|
const { view, viewer } = useFileViewer();
|
||||||
|
|
||||||
const { data: booking } = useBookingDetail(id);
|
const { data: booking } = useBookingDetail(id);
|
||||||
const {
|
const {
|
||||||
@@ -51,6 +56,8 @@ export default function DocumentClearanceDetailPage() {
|
|||||||
enabled: Boolean(id),
|
enabled: Boolean(id),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: bookingMilestones } = useBookingMilestones(id);
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const docs = (clearance?.documents ?? []).filter(
|
const docs = (clearance?.documents ?? []).filter(
|
||||||
(d) => d.uploadedBy === "customer",
|
(d) => d.uploadedBy === "customer",
|
||||||
@@ -69,6 +76,16 @@ export default function DocumentClearanceDetailPage() {
|
|||||||
booking?.contractKind === "GENERAL" &&
|
booking?.contractKind === "GENERAL" &&
|
||||||
Boolean(clearance?.phase);
|
Boolean(clearance?.phase);
|
||||||
|
|
||||||
|
const docsPhaseComplete =
|
||||||
|
clearance?.milestones?.some(
|
||||||
|
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
|
||||||
|
) ?? false;
|
||||||
|
const queriesLocked = Boolean(
|
||||||
|
(clearance as Freight.ContractClearanceView | undefined)?.preClearanceFinalized,
|
||||||
|
);
|
||||||
|
const workflowFiles =
|
||||||
|
(clearance as Freight.ContractClearanceView | undefined)?.workflowFiles ?? [];
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
@@ -85,9 +102,9 @@ export default function DocumentClearanceDetailPage() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Clearance not found"
|
title="Clearance not found"
|
||||||
backTo="/dashboard/clearance"
|
backTo="/dashboard/contracts/clearance"
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
{ label: "Document Clearance", href: "/dashboard/clearance" },
|
{ label: "Document Clearance", href: "/dashboard/contracts/clearance" },
|
||||||
{ label: "Not found" },
|
{ label: "Not found" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -103,9 +120,9 @@ export default function DocumentClearanceDetailPage() {
|
|||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={reference}
|
title={reference}
|
||||||
backTo="/dashboard/clearance"
|
backTo="/dashboard/contracts/clearance"
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
{ label: "Document Clearance", href: "/dashboard/clearance" },
|
{ label: "Document Clearance", href: "/dashboard/contracts/clearance" },
|
||||||
{ label: reference },
|
{ label: reference },
|
||||||
]}
|
]}
|
||||||
meta={
|
meta={
|
||||||
@@ -142,77 +159,94 @@ export default function DocumentClearanceDetailPage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Grid gap="lg">
|
<ClearanceOpsTabs
|
||||||
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 7 : 8 }}>
|
bookingId={id}
|
||||||
<ClearanceReviewSection bookingId={id!} hideSummary />
|
milestones={bookingMilestones}
|
||||||
</Grid.Col>
|
showOpsTabs={Boolean(id)}
|
||||||
|
showWorkflowFilesTab={isPhasedGeneral}
|
||||||
|
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
|
||||||
|
workflowFiles={workflowFiles}
|
||||||
|
onViewFile={view}
|
||||||
|
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||||
|
clearanceTab={
|
||||||
|
<Grid gap="lg">
|
||||||
|
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 7 : 8 }}>
|
||||||
|
<ClearanceReviewSection
|
||||||
|
bookingId={id!}
|
||||||
|
hideSummary
|
||||||
|
approvalsLocked={isPhasedGeneral && docsPhaseComplete}
|
||||||
|
queriesLocked={queriesLocked}
|
||||||
|
onChanged={() => void refetch()}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 5 : 4 }}>
|
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 5 : 4 }}>
|
||||||
{isPhasedGeneral ? (
|
{isPhasedGeneral ? (
|
||||||
<PhasedClearanceActionPanel
|
<PhasedClearanceActionPanel
|
||||||
bookingId={id!}
|
bookingId={id!}
|
||||||
clearance={clearance}
|
clearance={clearance}
|
||||||
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
|
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
|
||||||
onChanged={() => void refetch()}
|
workflowFiles={workflowFiles}
|
||||||
/>
|
roleMode="ET"
|
||||||
) : (
|
onChanged={() => void refetch()}
|
||||||
<Box style={{ position: "sticky", top: 24 }}>
|
onViewFile={view}
|
||||||
<SectionCard
|
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||||
icon={PackageCheck}
|
|
||||||
title="Review progress"
|
|
||||||
accent="edr-green"
|
|
||||||
>
|
|
||||||
<Stack align="center" gap="sm">
|
|
||||||
<RingProgress
|
|
||||||
size={140}
|
|
||||||
thickness={12}
|
|
||||||
roundCaps
|
|
||||||
sections={[{ value: stats.pct, color: "edr-green" }]}
|
|
||||||
label={
|
|
||||||
<Stack gap={0} align="center">
|
|
||||||
<Text fw={800} fz={26} lh={1}>
|
|
||||||
{stats.pct}%
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
approved
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Group gap="lg" justify="center">
|
) : (
|
||||||
<ProgressStat
|
<Box style={{ position: "sticky", top: 24 }}>
|
||||||
color="edr-green"
|
<SectionCard
|
||||||
label="Approved"
|
icon={PackageCheck}
|
||||||
value={stats.approved}
|
title="Review progress"
|
||||||
/>
|
accent="edr-green"
|
||||||
<ProgressStat
|
>
|
||||||
color="red"
|
<Stack align="center" gap="sm">
|
||||||
label="Queried"
|
<RingProgress
|
||||||
value={stats.queried}
|
size={140}
|
||||||
/>
|
thickness={12}
|
||||||
<ProgressStat
|
roundCaps
|
||||||
color="gray"
|
sections={[{ value: stats.pct, color: "edr-green" }]}
|
||||||
label="Pending"
|
label={
|
||||||
value={stats.pending}
|
<Stack gap={0} align="center">
|
||||||
/>
|
<Text fw={800} fz={26} lh={1}>
|
||||||
</Group>
|
{stats.pct}%
|
||||||
</Stack>
|
</Text>
|
||||||
</SectionCard>
|
<Text size="xs" c="dimmed">
|
||||||
</Box>
|
approved
|
||||||
)}
|
</Text>
|
||||||
</Grid.Col>
|
</Stack>
|
||||||
</Grid>
|
}
|
||||||
|
/>
|
||||||
|
<Group gap="lg" justify="center">
|
||||||
|
<ProgressStat
|
||||||
|
color="edr-green"
|
||||||
|
label="Approved"
|
||||||
|
value={stats.approved}
|
||||||
|
/>
|
||||||
|
<ProgressStat
|
||||||
|
color="red"
|
||||||
|
label="Queried"
|
||||||
|
value={stats.queried}
|
||||||
|
/>
|
||||||
|
<ProgressStat
|
||||||
|
color="gray"
|
||||||
|
label="Pending"
|
||||||
|
value={stats.pending}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{isPhasedGeneral && clearance.milestones && clearance.milestones.length > 0 ? (
|
{isPhasedGeneral && clearance.milestones && clearance.milestones.length > 0 ? (
|
||||||
<ClearanceMilestoneTimeline
|
<ClearanceMilestoneTimeline milestones={clearance.milestones} />
|
||||||
milestones={
|
|
||||||
clearance.milestones as Parameters<
|
|
||||||
typeof ClearanceMilestoneTimeline
|
|
||||||
>[0]["milestones"]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
{viewer}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,51 +19,31 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ClipboardList,
|
|
||||||
Clock,
|
Clock,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
|
||||||
import { PageContainer } from "@/components/page/PageContainer";
|
import { PageContainer } from "@/components/page/PageContainer";
|
||||||
import { PageHeader } from "@/components/page/PageHeader";
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||||
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
|
||||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { downloadBookingFile } from "@/services/files.service";
|
import { downloadBookingFile } from "@/services/files.service";
|
||||||
import { useContractDetail } from "@/hooks/contracts/useContracts";
|
import {
|
||||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
useBookingMilestones,
|
||||||
|
useContractDetail,
|
||||||
type RoleMode = "ET" | "DJ" | "ALL";
|
} from "@/hooks/contracts/useContracts";
|
||||||
|
|
||||||
function resolveRoleMode(
|
|
||||||
canReview: boolean,
|
|
||||||
canEt: boolean,
|
|
||||||
canDj: boolean,
|
|
||||||
): RoleMode {
|
|
||||||
if (canReview || (canEt && canDj)) return "ALL";
|
|
||||||
if (canEt) return "ET";
|
|
||||||
if (canDj) return "DJ";
|
|
||||||
return "ALL";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ContractClearanceDetailPage() {
|
export default function ContractClearanceDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { user } = useAuth();
|
|
||||||
const { view, viewer } = useFileViewer();
|
const { view, viewer } = useFileViewer();
|
||||||
|
|
||||||
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
|
|
||||||
const canEt =
|
|
||||||
hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) || canReview;
|
|
||||||
const canDj =
|
|
||||||
hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions) || canReview;
|
|
||||||
const roleMode = resolveRoleMode(canReview, canEt, canDj);
|
|
||||||
|
|
||||||
const { data: contract } = useContractDetail(id);
|
const { data: contract } = useContractDetail(id);
|
||||||
const {
|
const {
|
||||||
data: clearance,
|
data: clearance,
|
||||||
@@ -109,14 +89,20 @@ export default function ContractClearanceDetailPage() {
|
|||||||
"EXPIRED",
|
"EXPIRED",
|
||||||
].includes(contract.status),
|
].includes(contract.status),
|
||||||
);
|
);
|
||||||
const reviewReadOnly =
|
const linkedBookingId = useMemo(() => {
|
||||||
roleMode === "DJ" ? true : shipmentLocked;
|
const cycle = contract?.clearanceCycles?.find(
|
||||||
const reviewColSpan = roleMode === "DJ" ? 12 : 7;
|
(c) => c.cycleNumber === (contract?.clearanceCycleNumber ?? 1),
|
||||||
const actionColSpan = roleMode === "DJ" ? 12 : 5;
|
);
|
||||||
const bookingHref =
|
return cycle?.bookingId ?? undefined;
|
||||||
roleMode === "ET" || roleMode === "ALL"
|
}, [contract]);
|
||||||
? `/dashboard/contracts/${id}/create-booking`
|
const bookingAlreadyCreated = Boolean(linkedBookingId) || shipmentLocked;
|
||||||
: undefined;
|
const canCreateBooking = ready && !bookingAlreadyCreated;
|
||||||
|
const reviewReadOnly = shipmentLocked;
|
||||||
|
const queriesLocked = Boolean(clearance?.preClearanceFinalized);
|
||||||
|
const bookingHref = `/dashboard/contracts/${id}/create-booking`;
|
||||||
|
|
||||||
|
const { data: bookingMilestones, refetch: refetchBookingMilestones } =
|
||||||
|
useBookingMilestones(linkedBookingId);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -166,14 +152,23 @@ export default function ContractClearanceDetailPage() {
|
|||||||
{ label: reference },
|
{ label: reference },
|
||||||
]}
|
]}
|
||||||
meta={
|
meta={
|
||||||
ready ? (
|
bookingAlreadyCreated ? (
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<PackageCheck size={13} />}
|
||||||
|
>
|
||||||
|
Booking created
|
||||||
|
</Badge>
|
||||||
|
) : ready ? (
|
||||||
<Badge
|
<Badge
|
||||||
variant="light"
|
variant="light"
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
radius="sm"
|
radius="sm"
|
||||||
leftSection={<PackageCheck size={13} />}
|
leftSection={<PackageCheck size={13} />}
|
||||||
>
|
>
|
||||||
Ready — customer books
|
Ready — create booking
|
||||||
</Badge>
|
</Badge>
|
||||||
) : clearance.allApproved ? (
|
) : clearance.allApproved ? (
|
||||||
<Badge
|
<Badge
|
||||||
@@ -199,7 +194,30 @@ export default function ContractClearanceDetailPage() {
|
|||||||
|
|
||||||
<ClearanceHero contract={contract} stats={stats} />
|
<ClearanceHero contract={contract} stats={stats} />
|
||||||
|
|
||||||
{ready ? (
|
{bookingAlreadyCreated ? (
|
||||||
|
<Alert
|
||||||
|
color="blue"
|
||||||
|
radius="md"
|
||||||
|
icon={<PackageCheck size={16} />}
|
||||||
|
title="Shipment booking created"
|
||||||
|
>
|
||||||
|
GL Ethiopia has created the shipment booking for this contract.
|
||||||
|
{linkedBookingId ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<Text
|
||||||
|
component={Link}
|
||||||
|
to={`/dashboard/bookings/${linkedBookingId}/clearance`}
|
||||||
|
inherit
|
||||||
|
fw={600}
|
||||||
|
c="blue.7"
|
||||||
|
>
|
||||||
|
View booking →
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Alert>
|
||||||
|
) : canCreateBooking ? (
|
||||||
<Alert
|
<Alert
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -210,106 +228,103 @@ export default function ContractClearanceDetailPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Grid>
|
<ClearanceOpsTabs
|
||||||
<Grid.Col span={{ base: 12, lg: reviewColSpan }}>
|
bookingId={linkedBookingId}
|
||||||
{roleMode === "DJ" ? (
|
milestones={bookingMilestones}
|
||||||
<SectionCard
|
showOpsTabs={Boolean(linkedBookingId)}
|
||||||
icon={ClipboardList}
|
showWorkflowFilesTab={phasedCustoms}
|
||||||
title="Contract context"
|
tradeDirection={contract?.tradeDirection ?? "IMPORT"}
|
||||||
accent="edr-green"
|
workflowFiles={workflowFiles}
|
||||||
>
|
onViewFile={view}
|
||||||
<Text size="sm" c="dimmed" mb="sm">
|
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||||
Review upstream status before uploading Djibouti documents.
|
clearanceTab={
|
||||||
</Text>
|
<Grid>
|
||||||
|
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||||
<ContractClearanceReviewSection
|
<ContractClearanceReviewSection
|
||||||
contractId={id!}
|
contractId={id!}
|
||||||
hideSummary
|
hideSummary
|
||||||
selfClear={false}
|
selfClear={false}
|
||||||
readOnly
|
readOnly={reviewReadOnly}
|
||||||
|
approvalsLocked={phasedCustoms && docReviewLocked}
|
||||||
|
queriesLocked={queriesLocked}
|
||||||
phasedCustoms={phasedCustoms}
|
phasedCustoms={phasedCustoms}
|
||||||
|
onChanged={() => {
|
||||||
|
void refetch();
|
||||||
|
void refetchBookingMilestones();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</SectionCard>
|
</Grid.Col>
|
||||||
) : (
|
|
||||||
<ContractClearanceReviewSection
|
|
||||||
contractId={id!}
|
|
||||||
hideSummary
|
|
||||||
selfClear={false}
|
|
||||||
readOnly={reviewReadOnly}
|
|
||||||
approvalsLocked={phasedCustoms && docReviewLocked}
|
|
||||||
phasedCustoms={phasedCustoms}
|
|
||||||
onChanged={() => void refetch()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{workflowFiles.length > 0 ? (
|
|
||||||
<Box mt="lg">
|
|
||||||
<ClearanceWorkflowFilesPanel
|
|
||||||
files={workflowFiles}
|
|
||||||
onView={view}
|
|
||||||
onDownload={(f) => void downloadBookingFile(f.id, f.name)}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
</Grid.Col>
|
|
||||||
|
|
||||||
<Grid.Col span={{ base: 12, lg: actionColSpan }}>
|
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{phasedCustoms ? (
|
{phasedCustoms ? (
|
||||||
<PhasedClearanceActionPanel
|
<PhasedClearanceActionPanel
|
||||||
contractId={id!}
|
contractId={id!}
|
||||||
clearance={clearance}
|
bookingId={linkedBookingId}
|
||||||
tradeDirection={contract?.tradeDirection ?? "IMPORT"}
|
bookingMilestones={bookingMilestones ?? []}
|
||||||
roleMode={roleMode}
|
clearance={clearance}
|
||||||
onChanged={() => void refetch()}
|
tradeDirection={contract?.tradeDirection ?? "IMPORT"}
|
||||||
bookingCreateHref={ready ? bookingHref : undefined}
|
workflowFiles={workflowFiles}
|
||||||
/>
|
roleMode="ET"
|
||||||
) : (
|
onChanged={() => {
|
||||||
<Box style={{ position: "sticky", top: 24 }}>
|
void refetch();
|
||||||
<SectionCard
|
void refetchBookingMilestones();
|
||||||
icon={PackageCheck}
|
}}
|
||||||
title="Review progress"
|
onViewFile={view}
|
||||||
accent="edr-green"
|
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||||
>
|
bookingCreateHref={canCreateBooking ? bookingHref : undefined}
|
||||||
<Stack align="center" gap="sm">
|
bookingCreated={bookingAlreadyCreated}
|
||||||
<RingProgress
|
/>
|
||||||
size={140}
|
) : (
|
||||||
thickness={12}
|
<Box style={{ position: "sticky", top: 24 }}>
|
||||||
roundCaps
|
<SectionCard
|
||||||
sections={[{ value: stats.pct, color: "edr-green" }]}
|
icon={PackageCheck}
|
||||||
label={
|
title="Review progress"
|
||||||
<Stack gap={0} align="center">
|
accent="edr-green"
|
||||||
<Text fw={800} fz={26} lh={1}>
|
>
|
||||||
{stats.pct}%
|
<Stack align="center" gap="sm">
|
||||||
</Text>
|
<RingProgress
|
||||||
<Text size="xs" c="dimmed">
|
size={140}
|
||||||
approved
|
thickness={12}
|
||||||
</Text>
|
roundCaps
|
||||||
</Stack>
|
sections={[{ value: stats.pct, color: "edr-green" }]}
|
||||||
}
|
label={
|
||||||
/>
|
<Stack gap={0} align="center">
|
||||||
<Group gap="lg" justify="center">
|
<Text fw={800} fz={26} lh={1}>
|
||||||
<ProgressStat
|
{stats.pct}%
|
||||||
color="edr-green"
|
</Text>
|
||||||
label="Approved"
|
<Text size="xs" c="dimmed">
|
||||||
value={stats.approved}
|
approved
|
||||||
/>
|
</Text>
|
||||||
<ProgressStat
|
</Stack>
|
||||||
color="red"
|
}
|
||||||
label="Queried"
|
/>
|
||||||
value={stats.queried}
|
<Group gap="lg" justify="center">
|
||||||
/>
|
<ProgressStat
|
||||||
<ProgressStat
|
color="edr-green"
|
||||||
color="gray"
|
label="Approved"
|
||||||
label="Pending"
|
value={stats.approved}
|
||||||
value={stats.pending}
|
/>
|
||||||
/>
|
<ProgressStat
|
||||||
</Group>
|
color="red"
|
||||||
</Stack>
|
label="Queried"
|
||||||
</SectionCard>
|
value={stats.queried}
|
||||||
</Box>
|
/>
|
||||||
)}
|
<ProgressStat
|
||||||
</Stack>
|
color="gray"
|
||||||
</Grid.Col>
|
label="Pending"
|
||||||
</Grid>
|
value={stats.pending}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
{viewer}
|
{viewer}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Ship,
|
|
||||||
ShipWheel,
|
ShipWheel,
|
||||||
Table as TableIcon,
|
Table as TableIcon,
|
||||||
Truck,
|
Truck,
|
||||||
@@ -48,13 +47,12 @@ import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
|||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import {
|
import {
|
||||||
useContractClearanceQueue,
|
useContractClearanceQueue,
|
||||||
useDjClearanceQueue,
|
|
||||||
useEtClearanceQueue,
|
useEtClearanceQueue,
|
||||||
} from "@/hooks/contracts/useContracts";
|
} from "@/hooks/contracts/useContracts";
|
||||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
|
|
||||||
type ViewMode = "table" | "cards";
|
type ViewMode = "table" | "cards";
|
||||||
type QueueTab = "all" | "et" | "dj";
|
type QueueTab = "all" | "et";
|
||||||
|
|
||||||
interface ClearanceRow {
|
interface ClearanceRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -68,8 +66,10 @@ interface ClearanceRow {
|
|||||||
serviceTypeName: string;
|
serviceTypeName: string;
|
||||||
customs: boolean;
|
customs: boolean;
|
||||||
status: string;
|
status: string;
|
||||||
/** true once GL has finalized clearance — customer now books in the portal. */
|
/** true once GL has finalized clearance — customer may book in the portal. */
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
|
/** true once GL Ethiopia created the shipment booking. */
|
||||||
|
bookingCreated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function yardLabel(
|
function yardLabel(
|
||||||
@@ -102,6 +102,7 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
|
|||||||
contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled,
|
contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled,
|
||||||
status: contract.status,
|
status: contract.status,
|
||||||
ready: contract.status === "CLEARANCE_READY_FOR_BOOKING",
|
ready: contract.status === "CLEARANCE_READY_FOR_BOOKING",
|
||||||
|
bookingCreated: contract.status === "ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +144,21 @@ function DirectionIcon({ direction }: { direction: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ row }: { row: ClearanceRow }) {
|
function StatusBadge({ row }: { row: ClearanceRow }) {
|
||||||
|
if (row.bookingCreated) {
|
||||||
|
return (
|
||||||
|
<Tooltip label="GL Ethiopia created the shipment booking" withArrow>
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<PackagePlus size={12} />}
|
||||||
|
>
|
||||||
|
Booking created
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (row.ready) {
|
if (row.ready) {
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@@ -169,25 +185,16 @@ function StatusBadge({ row }: { row: ClearanceRow }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Document Clearance hub. Lists every customs (Path B) contract that still needs
|
* Document Clearance hub. Lists every customs (Path B) contract in phased clearance,
|
||||||
* customs clearance — awaiting documents, under GL review, or finalized and
|
* including after booking is created — stays visible for reference and follow-up.
|
||||||
* waiting for the customer to create the booking in the portal. A single list,
|
|
||||||
* no queue/history/direction tabs.
|
|
||||||
*/
|
*/
|
||||||
export default function ContractClearanceListPage() {
|
export default function ContractClearanceListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
|
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
|
||||||
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
|
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
|
||||||
const canDj = hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
|
|
||||||
|
|
||||||
const defaultQueue: QueueTab = canReview
|
const defaultQueue: QueueTab = canReview ? "all" : "et";
|
||||||
? "all"
|
|
||||||
: canEt
|
|
||||||
? "et"
|
|
||||||
: canDj
|
|
||||||
? "dj"
|
|
||||||
: "all";
|
|
||||||
const [queueTab, setQueueTab] = useState<QueueTab>(defaultQueue);
|
const [queueTab, setQueueTab] = useState<QueueTab>(defaultQueue);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [view, setView] = useState<ViewMode>("table");
|
const [view, setView] = useState<ViewMode>("table");
|
||||||
@@ -197,26 +204,19 @@ export default function ContractClearanceListPage() {
|
|||||||
useContractClearanceQueue(queueTab === "all");
|
useContractClearanceQueue(queueTab === "all");
|
||||||
const { data: etData, isLoading: etLoading, isError: etError, isFetching: etFetching, refetch: refetchEt } =
|
const { data: etData, isLoading: etLoading, isError: etError, isFetching: etFetching, refetch: refetchEt } =
|
||||||
useEtClearanceQueue(queueTab === "et");
|
useEtClearanceQueue(queueTab === "et");
|
||||||
const { data: djData, isLoading: djLoading, isError: djError, isFetching: djFetching, refetch: refetchDj } =
|
|
||||||
useDjClearanceQueue(queueTab === "dj");
|
|
||||||
|
|
||||||
const data =
|
const data = queueTab === "et" ? etData : allData;
|
||||||
queueTab === "et" ? etData : queueTab === "dj" ? djData : allData;
|
const isLoading = queueTab === "et" ? etLoading : allLoading;
|
||||||
const isLoading =
|
const isError = queueTab === "et" ? etError : allError;
|
||||||
queueTab === "et" ? etLoading : queueTab === "dj" ? djLoading : allLoading;
|
const isFetching = queueTab === "et" ? etFetching : allFetching;
|
||||||
const isError =
|
|
||||||
queueTab === "et" ? etError : queueTab === "dj" ? djError : allError;
|
|
||||||
const isFetching =
|
|
||||||
queueTab === "et" ? etFetching : queueTab === "dj" ? djFetching : allFetching;
|
|
||||||
const refetch = () => {
|
const refetch = () => {
|
||||||
if (queueTab === "et") void refetchEt();
|
if (queueTab === "et") void refetchEt();
|
||||||
else if (queueTab === "dj") void refetchDj();
|
|
||||||
else void refetchAll();
|
else void refetchAll();
|
||||||
};
|
};
|
||||||
|
|
||||||
const queueTabOptions = useMemo(() => {
|
const queueTabOptions = useMemo(() => {
|
||||||
const opts: { value: QueueTab; label: ReactNode }[] = [];
|
const opts: { value: QueueTab; label: ReactNode }[] = [];
|
||||||
if (canReview || (canEt && canDj)) {
|
if (canReview) {
|
||||||
opts.push({
|
opts.push({
|
||||||
value: "all",
|
value: "all",
|
||||||
label: (
|
label: (
|
||||||
@@ -238,19 +238,8 @@ export default function ContractClearanceListPage() {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (canDj) {
|
|
||||||
opts.push({
|
|
||||||
value: "dj",
|
|
||||||
label: (
|
|
||||||
<Group gap={6} wrap="nowrap">
|
|
||||||
<Ship size={15} />
|
|
||||||
<Box visibleFrom="sm">DJ queue</Box>
|
|
||||||
</Group>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return opts;
|
return opts;
|
||||||
}, [canReview, canEt, canDj]);
|
}, [canReview, canEt]);
|
||||||
|
|
||||||
const allRows = useMemo(
|
const allRows = useMemo(
|
||||||
() => (data?.items ?? []).map(toClearanceRow),
|
() => (data?.items ?? []).map(toClearanceRow),
|
||||||
@@ -261,7 +250,8 @@ export default function ContractClearanceListPage() {
|
|||||||
() => ({
|
() => ({
|
||||||
all: allRows.length,
|
all: allRows.length,
|
||||||
ready: allRows.filter((r) => r.ready).length,
|
ready: allRows.filter((r) => r.ready).length,
|
||||||
review: allRows.filter((r) => !r.ready).length,
|
booked: allRows.filter((r) => r.bookingCreated).length,
|
||||||
|
review: allRows.filter((r) => !r.ready && !r.bookingCreated).length,
|
||||||
}),
|
}),
|
||||||
[allRows],
|
[allRows],
|
||||||
);
|
);
|
||||||
@@ -407,7 +397,7 @@ export default function ContractClearanceListPage() {
|
|||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Document Clearance"
|
title="Document Clearance"
|
||||||
subtitle="Review pre-booking customs documents on contracts and finalize clearance. Once finalized, the customer creates the shipment booking in the portal."
|
subtitle="All customs contracts in phased clearance — stays visible after booking is created."
|
||||||
meta={
|
meta={
|
||||||
<Badge
|
<Badge
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -415,7 +405,7 @@ export default function ContractClearanceListPage() {
|
|||||||
radius="sm"
|
radius="sm"
|
||||||
leftSection={<ShieldCheck size={13} />}
|
leftSection={<ShieldCheck size={13} />}
|
||||||
>
|
>
|
||||||
{counts.all} need clearance
|
{counts.all} in clearance
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
@@ -436,7 +426,7 @@ export default function ContractClearanceListPage() {
|
|||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
label: "Need clearance",
|
label: "In clearance",
|
||||||
value: counts.all,
|
value: counts.all,
|
||||||
icon: Inbox,
|
icon: Inbox,
|
||||||
color: "edr-green",
|
color: "edr-green",
|
||||||
@@ -448,8 +438,8 @@ export default function ContractClearanceListPage() {
|
|||||||
color: "yellow",
|
color: "yellow",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Ready — customer books",
|
label: "Ready / booked",
|
||||||
value: counts.ready,
|
value: counts.ready + counts.booked,
|
||||||
icon: PackageCheck,
|
icon: PackageCheck,
|
||||||
color: "edr-green",
|
color: "edr-green",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Grid,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Stack,
|
||||||
|
Tabs,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { AlertCircle, ClipboardList, FileText, Upload } from "lucide-react";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { PageContainer } from "@/components/page/PageContainer";
|
||||||
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
|
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
|
||||||
|
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||||
|
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||||
|
import {
|
||||||
|
GlClearanceUploadModal,
|
||||||
|
type GlClearanceUploadKind,
|
||||||
|
} from "@/components/contracts/GlClearanceUploadModal";
|
||||||
|
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||||
|
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
|
||||||
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
|
import { contractsService } from "@/services/contracts.service";
|
||||||
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
|
import { downloadBookingFile } from "@/services/files.service";
|
||||||
|
|
||||||
|
type GlClearanceDetail =
|
||||||
|
| {
|
||||||
|
kind: "contract";
|
||||||
|
reference: string;
|
||||||
|
tradeDirection: string;
|
||||||
|
clearance: Freight.ContractClearanceView;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "booking";
|
||||||
|
reference: string;
|
||||||
|
tradeDirection: string;
|
||||||
|
clearance: Freight.ClearanceView;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
|
||||||
|
try {
|
||||||
|
const [clearance, contract] = await Promise.all([
|
||||||
|
contractsService.getClearance(id),
|
||||||
|
contractsService.getById(id),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
kind: "contract",
|
||||||
|
reference: contract.reference,
|
||||||
|
tradeDirection: contract.tradeDirection,
|
||||||
|
clearance,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
const [clearance, booking] = await Promise.all([
|
||||||
|
bookingsService.getClearance(id),
|
||||||
|
bookingsService.getById(id),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
kind: "booking",
|
||||||
|
reference: booking.reference,
|
||||||
|
tradeDirection: booking.tradeDirection,
|
||||||
|
clearance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Djibouti GL clearance detail — RO/DO upload and read-only upstream context. */
|
||||||
|
export default function GlClearanceDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { view, viewer } = useFileViewer();
|
||||||
|
const [uploadKind, setUploadKind] = useState<GlClearanceUploadKind | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ["gl-clearance-detail", id],
|
||||||
|
queryFn: () => loadGlClearanceDetail(id!),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<Stack align="center" py={80}>
|
||||||
|
<Loader color="edr-green" />
|
||||||
|
<Text c="dimmed">Loading clearance…</Text>
|
||||||
|
</Stack>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<Alert color="red" icon={<AlertCircle size={16} />}>
|
||||||
|
Could not load clearance for this item.
|
||||||
|
</Alert>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const backTo = "/dashboard/gl-djibouti/clearance";
|
||||||
|
const workflowFiles = data.clearance.workflowFiles ?? [];
|
||||||
|
const workflowFileCount = workflowFiles.filter((f) => f.file).length;
|
||||||
|
const isImport = data.tradeDirection === "IMPORT";
|
||||||
|
const hasDo = Boolean(findWorkflowFile(workflowFiles, "delivery_order"));
|
||||||
|
const hasRo = Boolean(findWorkflowFile(workflowFiles, "release_order"));
|
||||||
|
const canUploadDo = isImport && Boolean(data.clearance.preClearanceFinalized || hasDo);
|
||||||
|
const vesselDepartureDate =
|
||||||
|
"vesselDepartureDate" in data.clearance
|
||||||
|
? (data.clearance.vesselDepartureDate ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<PageHeader
|
||||||
|
title={data.reference}
|
||||||
|
backTo={backTo}
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "GL Djibouti Clearance", href: backTo },
|
||||||
|
{ label: data.reference },
|
||||||
|
]}
|
||||||
|
meta={
|
||||||
|
<Badge variant="light" color={isImport ? "edr-green" : "gray"} radius="sm">
|
||||||
|
{data.tradeDirection}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<Group gap="sm">
|
||||||
|
{isImport ? (
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Upload size={16} />}
|
||||||
|
disabled={!canUploadDo}
|
||||||
|
onClick={() => setUploadKind("do")}
|
||||||
|
>
|
||||||
|
{hasDo ? "Replace DO" : "Upload DO"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Upload size={16} />}
|
||||||
|
onClick={() => setUploadKind("ro")}
|
||||||
|
>
|
||||||
|
{hasRo ? "Replace RO" : "Upload RO"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs defaultValue="workflow" keepMounted={false}>
|
||||||
|
<Tabs.List mb="md">
|
||||||
|
<Tabs.Tab value="workflow" leftSection={<ClipboardList size={14} />}>
|
||||||
|
Clearance workflow
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab
|
||||||
|
value="documents"
|
||||||
|
leftSection={<FileText size={14} />}
|
||||||
|
rightSection={
|
||||||
|
workflowFileCount > 0 ? (
|
||||||
|
<Badge size="xs" variant="light" color="edr-green" circle>
|
||||||
|
{workflowFileCount}
|
||||||
|
</Badge>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Customs documents (all steps)
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Panel value="workflow">
|
||||||
|
<Grid gutter="lg">
|
||||||
|
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||||
|
{data.kind === "booking" ? (
|
||||||
|
<ClearanceReviewSection bookingId={id!} hideSummary readOnly />
|
||||||
|
) : (
|
||||||
|
<ContractClearanceReviewSection
|
||||||
|
contractId={id!}
|
||||||
|
hideSummary
|
||||||
|
selfClear={false}
|
||||||
|
readOnly
|
||||||
|
phasedCustoms
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||||
|
<PhasedClearanceActionPanel
|
||||||
|
contractId={data.kind === "contract" ? id : undefined}
|
||||||
|
bookingId={data.kind === "booking" ? id : undefined}
|
||||||
|
clearance={data.clearance}
|
||||||
|
tradeDirection={data.tradeDirection}
|
||||||
|
workflowFiles={workflowFiles}
|
||||||
|
roleMode="DJ"
|
||||||
|
useUploadModals
|
||||||
|
onUploadDoRequest={() => setUploadKind("do")}
|
||||||
|
onUploadRoRequest={() => setUploadKind("ro")}
|
||||||
|
onChanged={() => void refetch()}
|
||||||
|
onViewFile={view}
|
||||||
|
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="documents">
|
||||||
|
{workflowFiles.length > 0 ? (
|
||||||
|
<ClearanceWorkflowFilesPanel
|
||||||
|
files={workflowFiles}
|
||||||
|
title="Customs documents (all steps)"
|
||||||
|
onView={view}
|
||||||
|
onDownload={(f) => void downloadBookingFile(f.id, f.name)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
py={48}
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px dashed var(--mantine-color-gray-4)",
|
||||||
|
background: "var(--mantine-color-gray-0)",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap={8} align="center">
|
||||||
|
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||||
|
<FileText size={22} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" c="dimmed" maw={360}>
|
||||||
|
No customs workflow documents uploaded yet. Files from Ethiopia-side
|
||||||
|
clearance and your DO/RO uploads will appear here.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<GlClearanceUploadModal
|
||||||
|
opened={uploadKind != null}
|
||||||
|
kind={uploadKind}
|
||||||
|
onClose={() => setUploadKind(null)}
|
||||||
|
entityId={id!}
|
||||||
|
isBooking={data.kind === "booking"}
|
||||||
|
workflowFiles={workflowFiles}
|
||||||
|
vesselDepartureDate={vesselDepartureDate}
|
||||||
|
onSuccess={() => void refetch()}
|
||||||
|
onPreview={view}
|
||||||
|
/>
|
||||||
|
{viewer}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Badge, Card, Group, Loader, Stack, Tabs, Text } from "@mantine/core";
|
||||||
|
import { ChevronRight, Container, Ship } from "lucide-react";
|
||||||
|
|
||||||
|
import { PageContainer } from "@/components/page/PageContainer";
|
||||||
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
|
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||||
|
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||||
|
|
||||||
|
export default function GlDjiboutiClearanceListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue();
|
||||||
|
const { data: bookingQueue, isLoading: bookingsLoading } = useBookingDjClearanceQueue();
|
||||||
|
|
||||||
|
const contractItems = contractQueue?.items ?? [];
|
||||||
|
const bookingItems = bookingQueue ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title="GL Djibouti — Clearance"
|
||||||
|
subtitle="All customs contracts and bookings handed off to Djibouti GL — stays visible after DO/RO upload and booking creation."
|
||||||
|
/>
|
||||||
|
<Tabs defaultValue="contracts" keepMounted={false}>
|
||||||
|
<Tabs.List mb="md">
|
||||||
|
<Tabs.Tab value="contracts">Contracts ({contractItems.length})</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="bookings">Bookings ({bookingItems.length})</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Panel value="contracts">
|
||||||
|
{contractsLoading ? (
|
||||||
|
<Group justify="center" py={60}>
|
||||||
|
<Loader color="edr-green" />
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Stack gap="sm">
|
||||||
|
{contractItems.length === 0 ? (
|
||||||
|
<Text c="dimmed" ta="center" py="xl">
|
||||||
|
No Djibouti customs contracts yet. Items appear here once Ethiopia-side
|
||||||
|
pre-clearance is finalized.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
contractItems.map((c) => (
|
||||||
|
<Card
|
||||||
|
key={c.id}
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
padding="md"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap="sm">
|
||||||
|
<Ship size={18} className="text-[color:var(--freight-brand)]" />
|
||||||
|
<div>
|
||||||
|
<Text fw={700}>{c.reference}</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{c.tradeDirection} · {c.status}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Badge variant="light" color="edr-green">
|
||||||
|
Contract
|
||||||
|
</Badge>
|
||||||
|
<ChevronRight size={18} className="text-muted-foreground" />
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="bookings">
|
||||||
|
{bookingsLoading ? (
|
||||||
|
<Group justify="center" py={60}>
|
||||||
|
<Loader color="edr-green" />
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Stack gap="sm">
|
||||||
|
{bookingItems.length === 0 ? (
|
||||||
|
<Text c="dimmed" ta="center" py="xl">
|
||||||
|
No Djibouti customs bookings yet.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
bookingItems.map((b) => (
|
||||||
|
<Card
|
||||||
|
key={b.id}
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
padding="md"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${b.id}`)}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap="sm">
|
||||||
|
<Container size={18} className="text-[color:var(--freight-brand)]" />
|
||||||
|
<div>
|
||||||
|
<Text fw={700}>{b.reference}</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{b.tradeDirection} · {b.status}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Badge variant="light" color="blue">
|
||||||
|
Booking
|
||||||
|
</Badge>
|
||||||
|
<ChevronRight size={18} className="text-muted-foreground" />
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -348,9 +348,14 @@ export const bookingsService = {
|
|||||||
finalizePreClearance: (id: string) =>
|
finalizePreClearance: (id: string) =>
|
||||||
postBooking<BookingDetail>(B.CLEARANCE_FINALIZE_PRE(id)),
|
postBooking<BookingDetail>(B.CLEARANCE_FINALIZE_PRE(id)),
|
||||||
|
|
||||||
uploadTransitPermit: async (id: string, file: File): Promise<BookingDetail> => {
|
uploadTransitPermit: async (
|
||||||
|
id: string,
|
||||||
|
files: Record<string, File | null>,
|
||||||
|
): Promise<BookingDetail> => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file);
|
for (const [key, file] of Object.entries(files)) {
|
||||||
|
if (file) form.append(key, file);
|
||||||
|
}
|
||||||
const response = await client.post(B.CLEARANCE_TRANSIT_PERMIT(id), form, {
|
const response = await client.post(B.CLEARANCE_TRANSIT_PERMIT(id), form, {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -268,10 +268,12 @@ export const contractsService = {
|
|||||||
|
|
||||||
uploadContractTransitPermit: async (
|
uploadContractTransitPermit: async (
|
||||||
id: string,
|
id: string,
|
||||||
file: File,
|
files: Record<string, File | null>,
|
||||||
): Promise<Freight.IContract> => {
|
): Promise<Freight.IContract> => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file);
|
for (const [key, file] of Object.entries(files)) {
|
||||||
|
if (file) form.append(key, file);
|
||||||
|
}
|
||||||
const response = await client.post(C.CLEARANCE_TRANSIT_PERMIT(id), form, {
|
const response = await client.post(C.CLEARANCE_TRANSIT_PERMIT(id), form, {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
});
|
});
|
||||||
@@ -314,6 +316,9 @@ export const contractsService = {
|
|||||||
confirmExportRelease: (id: string) =>
|
confirmExportRelease: (id: string) =>
|
||||||
postContract<Freight.IContract>(C.CLEARANCE_EXPORT_RELEASE(id)),
|
postContract<Freight.IContract>(C.CLEARANCE_EXPORT_RELEASE(id)),
|
||||||
|
|
||||||
|
finalizeExportClearance: (id: string) =>
|
||||||
|
postContract<Freight.IContract>(C.CLEARANCE_FINALIZE_EXPORT(id)),
|
||||||
|
|
||||||
uploadTransportDocument: async (bookingId: string, file: File) => {
|
uploadTransportDocument: async (bookingId: string, file: File) => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file);
|
form.append("file", file);
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ export interface BookingDetail {
|
|||||||
equipmentReturn?: string;
|
equipmentReturn?: string;
|
||||||
customsClearingEnabled?: boolean;
|
customsClearingEnabled?: boolean;
|
||||||
customsClearingAgent?: string | null;
|
customsClearingAgent?: string | null;
|
||||||
|
contractKind?: "ONE_TIME" | "GENERAL" | null;
|
||||||
contractSummary?: string | null;
|
contractSummary?: string | null;
|
||||||
latestChangeRequestNote?: string | null;
|
latestChangeRequestNote?: string | null;
|
||||||
nextStep?: BookingNextStep | null;
|
nextStep?: BookingNextStep | null;
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { Box, Button, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { PortalFileDropzone } from "@/components/contracts/PortalFileDropzone";
|
||||||
|
import { INK } from "@/pages/contracts/contract-ui";
|
||||||
|
|
||||||
|
export type AdHocDoc = { name: string; file: File | null };
|
||||||
|
|
||||||
|
export function ClearanceAdHocUploadSection({
|
||||||
|
rows,
|
||||||
|
onAdd,
|
||||||
|
onRemove,
|
||||||
|
onNameChange,
|
||||||
|
onFileChange,
|
||||||
|
onPreview,
|
||||||
|
}: {
|
||||||
|
rows: AdHocDoc[];
|
||||||
|
onAdd: () => void;
|
||||||
|
onRemove: (index: number) => void;
|
||||||
|
onNameChange: (index: number, name: string) => void;
|
||||||
|
onFileChange: (index: number, file: File | null) => void;
|
||||||
|
onPreview?: (file: { name: string; url: string; mimeType?: string | null }) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box mt="lg">
|
||||||
|
<Group justify="space-between" align="center" mb="sm">
|
||||||
|
<Box>
|
||||||
|
<Text fz={13} fw={700} style={{ color: INK }}>
|
||||||
|
Additional documents
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} c="dimmed">
|
||||||
|
Optional supporting files not listed above.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Plus size={14} />}
|
||||||
|
onClick={onAdd}
|
||||||
|
>
|
||||||
|
Add document
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<Stack gap="md">
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<Box
|
||||||
|
key={i}
|
||||||
|
p="md"
|
||||||
|
style={{
|
||||||
|
borderRadius: 14,
|
||||||
|
border: "1px solid #E6ECF2",
|
||||||
|
background: "#FAFCFE",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<TextInput
|
||||||
|
label="Document name"
|
||||||
|
placeholder="e.g. Special permit"
|
||||||
|
value={row.name}
|
||||||
|
onChange={(e) => onNameChange(i, e.currentTarget.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
mt={24}
|
||||||
|
leftSection={<Trash2 size={14} />}
|
||||||
|
onClick={() => onRemove(i)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<PortalFileDropzone
|
||||||
|
label="File"
|
||||||
|
value={row.file}
|
||||||
|
onChange={(file) => onFileChange(i, file)}
|
||||||
|
onPreview={onPreview}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
FileText,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { isViewable } from "@edr/ui-common";
|
||||||
|
|
||||||
|
import { PortalFileDropzone } from "@/components/contracts/PortalFileDropzone";
|
||||||
|
import { fileViewUrl } from "@/constants/apiConfig";
|
||||||
|
import { BORDER, GREEN, INK } from "@/pages/contracts/contract-ui";
|
||||||
|
|
||||||
|
type ReviewStatus = "PENDING" | "APPROVED" | "QUERIED" | null;
|
||||||
|
|
||||||
|
export interface ClearanceDocumentUploadCardProps {
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
reviewStatus?: ReviewStatus;
|
||||||
|
note?: string | null;
|
||||||
|
uploadedFile?: { id: string; name: string } | null;
|
||||||
|
stagedFile?: File | null;
|
||||||
|
canUpload?: boolean;
|
||||||
|
onStageFile?: (file: File | null) => void;
|
||||||
|
onPreview?: (file: { name: string; url: string; mimeType?: string | null }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status, hasFile }: { status?: ReviewStatus; hasFile: boolean }) {
|
||||||
|
if (status === "APPROVED") {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<CheckCircle2 size={12} />}
|
||||||
|
radius="sm"
|
||||||
|
>
|
||||||
|
Approved
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === "QUERIED") {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
leftSection={<AlertCircle size={12} />}
|
||||||
|
radius="sm"
|
||||||
|
>
|
||||||
|
Needs correction
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (hasFile) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
leftSection={<Clock size={12} />}
|
||||||
|
radius="sm"
|
||||||
|
>
|
||||||
|
Under review
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Badge size="sm" variant="light" color="gray" radius="sm">
|
||||||
|
Not uploaded
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClearanceDocumentUploadCard({
|
||||||
|
label,
|
||||||
|
required = false,
|
||||||
|
reviewStatus = null,
|
||||||
|
note,
|
||||||
|
uploadedFile,
|
||||||
|
stagedFile = null,
|
||||||
|
canUpload = false,
|
||||||
|
onStageFile,
|
||||||
|
onPreview,
|
||||||
|
}: ClearanceDocumentUploadCardProps) {
|
||||||
|
const queried = reviewStatus === "QUERIED";
|
||||||
|
const approved = reviewStatus === "APPROVED";
|
||||||
|
const showUpload = canUpload && !approved && onStageFile;
|
||||||
|
const viewUrl = uploadedFile ? fileViewUrl(uploadedFile.id) : null;
|
||||||
|
const canPreviewUploaded =
|
||||||
|
uploadedFile &&
|
||||||
|
viewUrl &&
|
||||||
|
isViewable({ name: uploadedFile.name, url: viewUrl });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
radius="lg"
|
||||||
|
p="md"
|
||||||
|
style={{
|
||||||
|
borderColor: queried ? "#F0B4B4" : uploadedFile ? `${GREEN}55` : BORDER,
|
||||||
|
background: queried
|
||||||
|
? "linear-gradient(160deg, #FDF4F4 0%, #FFFFFF 70%)"
|
||||||
|
: uploadedFile
|
||||||
|
? "linear-gradient(160deg, #F6FBF8 0%, #FFFFFF 70%)"
|
||||||
|
: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||||
|
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<ThemeIcon
|
||||||
|
variant="light"
|
||||||
|
color={queried ? "red" : uploadedFile ? "edr-green" : "gray"}
|
||||||
|
radius="md"
|
||||||
|
size={42}
|
||||||
|
>
|
||||||
|
<FileText size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
||||||
|
{label}
|
||||||
|
{required ? (
|
||||||
|
<Text component="span" c="red" inherit>
|
||||||
|
{" "}
|
||||||
|
*
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Text>
|
||||||
|
{uploadedFile && !stagedFile ? (
|
||||||
|
<Text fz={12} c="dimmed" truncate mt={2}>
|
||||||
|
{uploadedFile.name}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<StatusBadge status={reviewStatus} hasFile={Boolean(uploadedFile)} />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{queried && note ? (
|
||||||
|
<Box
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
background: "#FEF2F2",
|
||||||
|
border: "1px solid #F0B4B4",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text fz={12} fw={600} c="red.8">
|
||||||
|
Reviewer note
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} c="red.7" mt={4}>
|
||||||
|
{note}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{uploadedFile && !stagedFile ? (
|
||||||
|
<Group gap={8}>
|
||||||
|
{canPreviewUploaded && onPreview ? (
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Eye size={14} />}
|
||||||
|
onClick={() =>
|
||||||
|
onPreview({ name: uploadedFile.name, url: viewUrl! })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
variant="default"
|
||||||
|
component="a"
|
||||||
|
href={fileViewUrl(uploadedFile.id, true)}
|
||||||
|
download={uploadedFile.name}
|
||||||
|
leftSection={<Download size={14} />}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showUpload ? (
|
||||||
|
<PortalFileDropzone
|
||||||
|
label={uploadedFile || stagedFile ? "Replace file" : "Upload file"}
|
||||||
|
description="PDF or image — drag and drop or browse."
|
||||||
|
value={stagedFile}
|
||||||
|
onChange={onStageFile}
|
||||||
|
replaceMode={Boolean(uploadedFile)}
|
||||||
|
onPreview={onPreview}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Tabs,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { Download, Eye, FileText, Receipt, Ship, Truck } from "lucide-react";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
import { isViewable } from "@edr/ui-common";
|
||||||
|
|
||||||
|
import { BORDER, GREEN, INK } from "@/pages/contracts/contract-ui";
|
||||||
|
|
||||||
|
type TabValue = Freight.ClearanceWorkflowFileCategory;
|
||||||
|
|
||||||
|
type TabConfig = {
|
||||||
|
value: TabValue;
|
||||||
|
label: string;
|
||||||
|
icon: typeof FileText;
|
||||||
|
emptyHint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function tabConfigForTradeDirection(tradeDirection: string): TabConfig[] {
|
||||||
|
if (tradeDirection === "EXPORT") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
value: "declaration",
|
||||||
|
label: "Declaration",
|
||||||
|
icon: FileText,
|
||||||
|
emptyHint:
|
||||||
|
"Global Logistics will upload your customs declaration documents here once they are ready.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "djibouti",
|
||||||
|
label: "Release order",
|
||||||
|
icon: Ship,
|
||||||
|
emptyHint: "The release order will appear here once Global Logistics Djibouti uploads it.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "transit",
|
||||||
|
label: "Transit Permit",
|
||||||
|
icon: Truck,
|
||||||
|
emptyHint:
|
||||||
|
"The transit permit will appear here after your booking is created and wagons are allocated.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
value: "declaration",
|
||||||
|
label: "Declaration",
|
||||||
|
icon: FileText,
|
||||||
|
emptyHint:
|
||||||
|
"Global Logistics will upload your customs declaration documents here once they are ready.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "duty",
|
||||||
|
label: "Duty notice",
|
||||||
|
icon: Receipt,
|
||||||
|
emptyHint: "Your duty/tax notice and payment slip will appear here when available.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "transit",
|
||||||
|
label: "Transit Permit",
|
||||||
|
icon: Truck,
|
||||||
|
emptyHint: "The transit permit will appear here once Global Logistics uploads it.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultSubtitle(tradeDirection: string): string {
|
||||||
|
return tradeDirection === "EXPORT"
|
||||||
|
? "Declaration, release order, and transit permit shared during your clearance."
|
||||||
|
: "Declaration, duty notice, and transit permit shared during your clearance.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const OWNER_LABELS: Record<Freight.ClearanceWorkflowFileOwner, string> = {
|
||||||
|
customer: "You",
|
||||||
|
gl_et: "Global Logistics Ethiopia",
|
||||||
|
gl_dj: "Global Logistics Djibouti",
|
||||||
|
};
|
||||||
|
|
||||||
|
function fileTypeChip(name: string): { ext: string; color: string } {
|
||||||
|
const dot = name.lastIndexOf(".");
|
||||||
|
const ext = dot >= 0 ? name.slice(dot + 1).toUpperCase() : "FILE";
|
||||||
|
const color =
|
||||||
|
ext === "PDF"
|
||||||
|
? "#D64545"
|
||||||
|
: ["PNG", "JPG", "JPEG", "GIF", "WEBP", "SVG"].includes(ext)
|
||||||
|
? "#2F9E6E"
|
||||||
|
: "#6B7C8E";
|
||||||
|
return { ext: ext.slice(0, 4), color };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClearanceUploadedDocumentsPanelProps {
|
||||||
|
files: Freight.ClearanceWorkflowFile[];
|
||||||
|
tradeDirection?: string;
|
||||||
|
onView: (file: { name: string; url: string; mimeType?: string }) => void;
|
||||||
|
onDownload?: (file: { id: string; name: string }) => void;
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
embedded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClearanceUploadedDocumentsPanel({
|
||||||
|
files,
|
||||||
|
tradeDirection = "IMPORT",
|
||||||
|
onView,
|
||||||
|
onDownload,
|
||||||
|
title = "Customs documents",
|
||||||
|
subtitle,
|
||||||
|
embedded = false,
|
||||||
|
}: ClearanceUploadedDocumentsPanelProps) {
|
||||||
|
const tabConfig = useMemo(
|
||||||
|
() => tabConfigForTradeDirection(tradeDirection),
|
||||||
|
[tradeDirection],
|
||||||
|
);
|
||||||
|
const isExport = tradeDirection === "EXPORT";
|
||||||
|
const visibleFiles = useMemo(
|
||||||
|
() => (isExport ? files.filter((f) => f.category !== "duty") : files),
|
||||||
|
[files, isExport],
|
||||||
|
);
|
||||||
|
const uploadedCount = visibleFiles.filter((f) => f.file).length;
|
||||||
|
|
||||||
|
const defaultTab =
|
||||||
|
tabConfig.find((tab) =>
|
||||||
|
visibleFiles.some((f) => f.category === tab.value && f.file),
|
||||||
|
)?.value ?? tabConfig[0]?.value ?? "declaration";
|
||||||
|
|
||||||
|
const resolvedSubtitle = subtitle ?? defaultSubtitle(tradeDirection);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<Tabs defaultValue={defaultTab} keepMounted={false}>
|
||||||
|
<Tabs.List mb="md" style={{ flexWrap: "wrap", gap: 6 }}>
|
||||||
|
{tabConfig.map((tab) => {
|
||||||
|
const count = visibleFiles.filter(
|
||||||
|
(f) => f.category === tab.value && f.file,
|
||||||
|
).length;
|
||||||
|
const Icon = tab.icon;
|
||||||
|
return (
|
||||||
|
<Tabs.Tab
|
||||||
|
key={tab.value}
|
||||||
|
value={tab.value}
|
||||||
|
leftSection={<Icon size={14} />}
|
||||||
|
rightSection={
|
||||||
|
count > 0 ? (
|
||||||
|
<Badge size="xs" variant="light" color="edr-green" circle>
|
||||||
|
{count}
|
||||||
|
</Badge>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
styles={{
|
||||||
|
tab: { borderRadius: 10, fontWeight: 600, padding: "8px 14px" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Tabs.Tab>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
{tabConfig.map((tab) => {
|
||||||
|
const items = visibleFiles.filter(
|
||||||
|
(f) => f.category === tab.value && f.file,
|
||||||
|
);
|
||||||
|
const Icon = tab.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs.Panel key={tab.value} value={tab.value}>
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<Stack gap={10}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<WorkflowFileRow
|
||||||
|
key={item.code}
|
||||||
|
item={item}
|
||||||
|
onView={onView}
|
||||||
|
onDownload={onDownload}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<EmptyTabState icon={Icon} hint={tab.emptyHint} />
|
||||||
|
)}
|
||||||
|
</Tabs.Panel>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (embedded) return content;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
radius="lg"
|
||||||
|
p="lg"
|
||||||
|
style={{
|
||||||
|
borderColor: "#CDEBDD",
|
||||||
|
background: "linear-gradient(160deg, #F6FBF8 0%, #FFFFFF 55%)",
|
||||||
|
boxShadow: "0 4px 18px rgba(14,163,113,0.06)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={10} mb={4} wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 10,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: `${GREEN}18`,
|
||||||
|
color: GREEN,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileText size={18} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<Text fw={700} fz={15} style={{ color: INK }}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{uploadedCount > 0 ? (
|
||||||
|
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||||
|
{uploadedCount} file{uploadedCount === 1 ? "" : "s"}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
<Text fz={12} c="dimmed" mt={2}>
|
||||||
|
{resolvedSubtitle}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<Box mt="md">{content}</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkflowFileRow({
|
||||||
|
item,
|
||||||
|
onView,
|
||||||
|
onDownload,
|
||||||
|
}: {
|
||||||
|
item: Freight.ClearanceWorkflowFile;
|
||||||
|
onView: (file: { name: string; url: string; mimeType?: string }) => void;
|
||||||
|
onDownload?: (file: { id: string; name: string }) => void;
|
||||||
|
}) {
|
||||||
|
const file = item.file;
|
||||||
|
if (!file) return null;
|
||||||
|
|
||||||
|
const { ext, color } = fileTypeChip(file.name);
|
||||||
|
const canPreview = isViewable({ name: file.name, url: file.url });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
borderRadius: 14,
|
||||||
|
border: `1px solid ${GREEN}44`,
|
||||||
|
background: "linear-gradient(135deg, #F2FBF6 0%, #FFFFFF 72%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: 10,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: `${color}14`,
|
||||||
|
color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileText size={16} />
|
||||||
|
<Text fz={8} fw={800} mt={1} style={{ letterSpacing: "0.04em" }}>
|
||||||
|
{ext}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Text fz={14} fw={600} style={{ color: INK }} truncate>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
<Group gap={6} wrap="nowrap" mt={3}>
|
||||||
|
<Badge size="xs" variant="light" color="edr-green" radius="sm">
|
||||||
|
Uploaded
|
||||||
|
</Badge>
|
||||||
|
<Badge size="xs" variant="light" color="gray" radius="sm" tt="none">
|
||||||
|
{OWNER_LABELS[item.uploadedBy]}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Text fz={12} c="dimmed" truncate mt={2}>
|
||||||
|
{file.name}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
{canPreview ? (
|
||||||
|
<Tooltip label="Preview">
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Eye size={13} />}
|
||||||
|
onClick={() => onView({ name: file.name, url: file.url })}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
{onDownload ? (
|
||||||
|
<Tooltip label="Download">
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Download size={13} />}
|
||||||
|
onClick={() => onDownload({ id: file.id, name: file.name })}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyTabState({
|
||||||
|
icon: Icon,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
icon: typeof FileText;
|
||||||
|
hint: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
py={36}
|
||||||
|
px="md"
|
||||||
|
style={{
|
||||||
|
borderRadius: 14,
|
||||||
|
border: `1px dashed ${BORDER}`,
|
||||||
|
background: "#FAFCFE",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap={8} align="center">
|
||||||
|
<ThemeIcon variant="light" color="gray" radius="xl" size={44}>
|
||||||
|
<Icon size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fz={13} c="dimmed" maw={360}>
|
||||||
|
{hint}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use ClearanceUploadedDocumentsPanel for phased customs UI. */
|
||||||
|
export function ClearanceWorkflowFilesSection({
|
||||||
|
files,
|
||||||
|
onView,
|
||||||
|
onDownload,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
files: Freight.ClearanceWorkflowFile[];
|
||||||
|
onView: (file: { name: string; url: string; mimeType?: string }) => void;
|
||||||
|
onDownload?: (file: { id: string; name: string }) => void;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
|
if (files.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<ClearanceUploadedDocumentsPanel
|
||||||
|
files={files}
|
||||||
|
onView={onView}
|
||||||
|
onDownload={onDownload}
|
||||||
|
title={title}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import {
|
|
||||||
Badge,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
ThemeIcon,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { Download, Eye, FileText } from "lucide-react";
|
|
||||||
import type { Freight } from "@edr/types";
|
|
||||||
import { isViewable } from "@edr/ui-common";
|
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<
|
|
||||||
Freight.ClearanceWorkflowFileCategory,
|
|
||||||
string
|
|
||||||
> = {
|
|
||||||
declaration: "Declaration",
|
|
||||||
duty: "Duty & taxes",
|
|
||||||
transit: "Transit",
|
|
||||||
djibouti: "Djibouti",
|
|
||||||
};
|
|
||||||
|
|
||||||
const CATEGORY_ORDER: Freight.ClearanceWorkflowFileCategory[] = [
|
|
||||||
"declaration",
|
|
||||||
"duty",
|
|
||||||
"transit",
|
|
||||||
"djibouti",
|
|
||||||
];
|
|
||||||
|
|
||||||
const OWNER_LABELS: Record<Freight.ClearanceWorkflowFileOwner, string> = {
|
|
||||||
customer: "You",
|
|
||||||
gl_et: "GL Ethiopia",
|
|
||||||
gl_dj: "GL Djibouti",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ClearanceWorkflowFilesSection({
|
|
||||||
files,
|
|
||||||
onView,
|
|
||||||
onDownload,
|
|
||||||
title = "Customs documents",
|
|
||||||
}: {
|
|
||||||
files: Freight.ClearanceWorkflowFile[];
|
|
||||||
onView: (file: { name: string; url: string; mimeType?: string }) => void;
|
|
||||||
onDownload?: (file: { id: string; name: string }) => void;
|
|
||||||
title?: string;
|
|
||||||
}) {
|
|
||||||
if (files.length === 0) return null;
|
|
||||||
|
|
||||||
const grouped = CATEGORY_ORDER.map((category) => ({
|
|
||||||
category,
|
|
||||||
label: CATEGORY_LABELS[category],
|
|
||||||
items: files.filter((f) => f.category === category),
|
|
||||||
})).filter((g) => g.items.length > 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper withBorder radius="lg" p="lg">
|
|
||||||
<Text fw={700} size="sm" mb="md">
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
<Stack gap="md">
|
|
||||||
{grouped.map((group) => (
|
|
||||||
<Box key={group.category}>
|
|
||||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase" mb={8}>
|
|
||||||
{group.label}
|
|
||||||
</Text>
|
|
||||||
<Stack gap={8}>
|
|
||||||
{group.items.map((item) => {
|
|
||||||
const file = item.file;
|
|
||||||
if (!file) return null;
|
|
||||||
const canPreview = isViewable({ name: file.name, url: file.url });
|
|
||||||
return (
|
|
||||||
<Paper key={item.code} withBorder radius="md" p="sm">
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
|
||||||
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
|
||||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={36}>
|
|
||||||
<FileText size={17} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Box style={{ minWidth: 0 }}>
|
|
||||||
<Text size="sm" fw={600} truncate>
|
|
||||||
{item.label}
|
|
||||||
</Text>
|
|
||||||
<Group gap={6} wrap="nowrap" mt={2}>
|
|
||||||
<Badge size="xs" variant="light" color="gray" radius="sm" tt="none">
|
|
||||||
{OWNER_LABELS[item.uploadedBy]}
|
|
||||||
</Badge>
|
|
||||||
<Text size="xs" c="dimmed" truncate>
|
|
||||||
{file.name}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Box>
|
|
||||||
</Group>
|
|
||||||
<Group gap={6} wrap="nowrap">
|
|
||||||
{canPreview ? (
|
|
||||||
<Tooltip label="Preview">
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="default"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<Eye size={13} />}
|
|
||||||
onClick={() =>
|
|
||||||
onView({ name: file.name, url: file.url })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
View
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
) : null}
|
|
||||||
{onDownload ? (
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="light"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<Download size={13} />}
|
|
||||||
onClick={() => onDownload({ id: file.id, name: file.name })}
|
|
||||||
>
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { Eye, FileText, Trash2, UploadCloud } from "lucide-react";
|
||||||
|
import { isViewable } from "@edr/ui-common";
|
||||||
|
|
||||||
|
import { BORDER, GREEN, INK } from "@/pages/contracts/contract-ui";
|
||||||
|
|
||||||
|
export interface PortalFileDropzoneProps {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
value: File | null;
|
||||||
|
onChange: (file: File | null) => void;
|
||||||
|
accept?: string;
|
||||||
|
onPreview?: (file: { name: string; url: string; mimeType?: string | null }) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
replaceMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageFile(file: File): boolean {
|
||||||
|
if (file.type.startsWith("image/")) return true;
|
||||||
|
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
return ["png", "jpg", "jpeg", "webp", "gif", "bmp", "svg"].includes(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PortalFileDropzone({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
accept = "application/pdf,image/*",
|
||||||
|
onPreview,
|
||||||
|
disabled = false,
|
||||||
|
replaceMode = false,
|
||||||
|
}: PortalFileDropzoneProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
|
||||||
|
const previewUrl = useMemo(
|
||||||
|
() => (value ? URL.createObjectURL(value) : null),
|
||||||
|
[value],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
|
};
|
||||||
|
}, [previewUrl]);
|
||||||
|
|
||||||
|
const pickFile = (file: File | null) => {
|
||||||
|
if (disabled) return;
|
||||||
|
onChange(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
if (disabled) return;
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) pickFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (value && previewUrl) {
|
||||||
|
const canPreview =
|
||||||
|
onPreview && isViewable({ name: value.name, url: previewUrl, mimeType: value.type });
|
||||||
|
const image = isImageFile(value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Text fz={13} fw={700} style={{ color: INK }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Group
|
||||||
|
gap={12}
|
||||||
|
wrap="nowrap"
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
borderRadius: 14,
|
||||||
|
border: `1px solid ${GREEN}`,
|
||||||
|
background: "linear-gradient(135deg, #F2FBF6 0%, #fff 75%)",
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{image ? (
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() =>
|
||||||
|
canPreview &&
|
||||||
|
onPreview?.({ name: value.name, url: previewUrl, mimeType: value.type })
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: 10,
|
||||||
|
overflow: "hidden",
|
||||||
|
border: `1px solid ${BORDER}`,
|
||||||
|
cursor: canPreview ? "pointer" : "default",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt=""
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||||
|
/>
|
||||||
|
</UnstyledButton>
|
||||||
|
) : (
|
||||||
|
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
|
||||||
|
<FileText size={22} />
|
||||||
|
</ThemeIcon>
|
||||||
|
)}
|
||||||
|
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Text fz={11} fw={700} c="edr-green" tt="uppercase">
|
||||||
|
Ready to upload
|
||||||
|
</Text>
|
||||||
|
<Text fz={13} fw={600} style={{ color: INK }} truncate>
|
||||||
|
{value.name}
|
||||||
|
</Text>
|
||||||
|
<Text fz={11} c="dimmed">
|
||||||
|
{formatBytes(value.size)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
{canPreview ? (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Eye size={13} />}
|
||||||
|
onClick={() =>
|
||||||
|
onPreview({ name: value.name, url: previewUrl, mimeType: value.type })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
aria-label="Remove file"
|
||||||
|
onClick={() => pickFile(null)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Text fz={13} fw={700} style={{ color: INK }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{description ? (
|
||||||
|
<Text fz={12} c="dimmed">
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Box
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!disabled) setDragOver(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => !disabled && inputRef.current?.click()}
|
||||||
|
style={{
|
||||||
|
borderRadius: 14,
|
||||||
|
border: `2px dashed ${dragOver ? GREEN : BORDER}`,
|
||||||
|
background: dragOver ? "#F2FBF6" : "#FAFCFE",
|
||||||
|
padding: "28px 20px",
|
||||||
|
textAlign: "center",
|
||||||
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
|
opacity: disabled ? 0.6 : 1,
|
||||||
|
transition: "border-color 120ms ease, background 120ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
hidden
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => pickFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
<Stack gap={8} align="center">
|
||||||
|
<ThemeIcon
|
||||||
|
variant="light"
|
||||||
|
color={dragOver ? "edr-green" : "gray"}
|
||||||
|
radius="xl"
|
||||||
|
size={48}
|
||||||
|
>
|
||||||
|
<UploadCloud size={24} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box>
|
||||||
|
<Text fz={13} fw={600} style={{ color: INK }}>
|
||||||
|
{dragOver
|
||||||
|
? "Drop to upload"
|
||||||
|
: replaceMode
|
||||||
|
? "Drag & drop to replace"
|
||||||
|
: "Drag & drop your file here"}
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} c="dimmed" mt={4}>
|
||||||
|
or{" "}
|
||||||
|
<span style={{ color: GREEN, fontWeight: 700 }}>browse</span> — PDF or
|
||||||
|
image
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import type { Freight } from "@edr/types";
|
|||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fileViewUrl } from "@/constants/apiConfig";
|
||||||
import { ClearancePhaseStepper } from "../contracts/ClearancePhaseStepper";
|
import { ClearancePhaseStepper } from "../contracts/ClearancePhaseStepper";
|
||||||
|
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||||
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
|
|
||||||
const BORDER = "#E6ECF2";
|
const BORDER = "#E6ECF2";
|
||||||
|
|
||||||
@@ -20,6 +22,8 @@ export function BookingClearanceWorkflowBanner({
|
|||||||
booking.customsClearingEnabled &&
|
booking.customsClearingEnabled &&
|
||||||
booking.contractKind === "GENERAL";
|
booking.contractKind === "GENERAL";
|
||||||
|
|
||||||
|
const { view, viewer } = useFileViewer();
|
||||||
|
|
||||||
const { data: clearance, refetch } = useQuery({
|
const { data: clearance, refetch } = useQuery({
|
||||||
queryKey: ["booking-clearance", booking.id],
|
queryKey: ["booking-clearance", booking.id],
|
||||||
queryFn: () => bookingsService.getClearance(booking.id),
|
queryFn: () => bookingsService.getClearance(booking.id),
|
||||||
@@ -73,7 +77,21 @@ export function BookingClearanceWorkflowBanner({
|
|||||||
Clearance is complete. You may proceed to request your operation date.
|
Clearance is complete. You may proceed to request your operation date.
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<ClearanceUploadedDocumentsPanel
|
||||||
|
embedded
|
||||||
|
files={clearance.workflowFiles ?? []}
|
||||||
|
title="Customs documents"
|
||||||
|
onView={(f) => view(f)}
|
||||||
|
onDownload={({ id, name }) => {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = fileViewUrl(id, true);
|
||||||
|
a.download = name;
|
||||||
|
a.click();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
{viewer}
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
FileButton,
|
|
||||||
Group,
|
Group,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -15,58 +12,22 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
Plus,
|
|
||||||
Upload,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import { isViewable } from "@edr/ui-common";
|
import { isViewable } from "@edr/ui-common";
|
||||||
|
|
||||||
import { IconSquare } from "../BookingDetailPage/components/Documents";
|
import { IconSquare } from "../BookingDetailPage/components/Documents";
|
||||||
|
import {
|
||||||
|
ClearanceAdHocUploadSection,
|
||||||
|
} from "@/components/contracts/ClearanceAdHocUploadSection";
|
||||||
|
import { ClearanceDocumentUploadCard } from "@/components/contracts/ClearanceDocumentUploadCard";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fileViewUrl } from "@/constants/apiConfig";
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
import { OperationDatePicker } from "./OperationDatePicker";
|
import { OperationDatePicker } from "./OperationDatePicker";
|
||||||
import type { ClearanceFlowController } from "./useClearanceFlow";
|
import type { ClearanceFlowController } from "./useClearanceFlow";
|
||||||
|
|
||||||
const GREEN = "#0A6F4D";
|
const BORDER = "#E6ECF2";
|
||||||
|
|
||||||
function StatusPill({ doc }: { doc: Freight.ClearanceDocument }) {
|
|
||||||
if (doc.reviewStatus === "APPROVED") {
|
|
||||||
return (
|
|
||||||
<Group gap={6} c={GREEN}>
|
|
||||||
<CheckCircle2 size={15} />
|
|
||||||
<Text fz="12px" fw={600} c={GREEN}>
|
|
||||||
Approved
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (doc.reviewStatus === "QUERIED") {
|
|
||||||
return (
|
|
||||||
<Group gap={6} c="#C0392B">
|
|
||||||
<AlertCircle size={15} />
|
|
||||||
<Text fz="12px" fw={600} c="#C0392B">
|
|
||||||
Queried
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (doc.file) {
|
|
||||||
return (
|
|
||||||
<Group gap={6} c="#2E5B96">
|
|
||||||
<Clock size={15} />
|
|
||||||
<Text fz="12px" fw={600} c="#2E5B96">
|
|
||||||
Pending review
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Text fz="12px" fw={600} c="#9AA8B5">
|
|
||||||
Not uploaded
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ClearanceFlowProps {
|
interface ClearanceFlowProps {
|
||||||
booking: Freight.IBooking;
|
booking: Freight.IBooking;
|
||||||
@@ -101,6 +62,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
|||||||
missingRequired,
|
missingRequired,
|
||||||
stagePending,
|
stagePending,
|
||||||
addAdHocRow,
|
addAdHocRow,
|
||||||
|
removeAdHocRow,
|
||||||
setAdHocName,
|
setAdHocName,
|
||||||
setAdHocFile,
|
setAdHocFile,
|
||||||
scheduledDate,
|
scheduledDate,
|
||||||
@@ -143,84 +105,32 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Stack gap={10}>
|
<Stack gap="md">
|
||||||
|
<Box>
|
||||||
|
<Text fz={13} fw={700} c="#10202F">
|
||||||
|
Your clearance documents
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} c="dimmed" mt={4}>
|
||||||
|
Upload each required document below. Items marked * are mandatory.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
{customerDocs.map((doc) => (
|
{customerDocs.map((doc) => (
|
||||||
<Box
|
<ClearanceDocumentUploadCard
|
||||||
key={doc.fileKey}
|
key={doc.fileKey}
|
||||||
className="rounded-xl"
|
label={doc.label}
|
||||||
style={{ border: "1px solid #E6ECF2", padding: 12 }}
|
required={doc.required}
|
||||||
>
|
reviewStatus={doc.reviewStatus}
|
||||||
<Group justify="space-between" align="center" wrap="nowrap">
|
note={doc.note}
|
||||||
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
uploadedFile={doc.file}
|
||||||
<Box c="#2E5B96">
|
stagedFile={pending[doc.fileKey] ?? null}
|
||||||
<FileText size={18} />
|
canUpload={canUpload}
|
||||||
</Box>
|
onStageFile={
|
||||||
<Box style={{ minWidth: 0 }}>
|
canUpload && doc.reviewStatus !== "APPROVED"
|
||||||
<Text fz="13.5px" fw={600} c="#10202F" truncate>
|
? (file) => stagePending(doc.fileKey, file)
|
||||||
{doc.label}
|
: undefined
|
||||||
{doc.required ? " *" : ""}
|
}
|
||||||
</Text>
|
onPreview={view}
|
||||||
{doc.file && (
|
/>
|
||||||
<Text fz="12px" c="dimmed" truncate>
|
|
||||||
{doc.file.name}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Group>
|
|
||||||
<Group gap={10} wrap="nowrap">
|
|
||||||
<StatusPill doc={doc} />
|
|
||||||
{doc.file &&
|
|
||||||
isViewable({
|
|
||||||
name: doc.file.name,
|
|
||||||
url: fileViewUrl(doc.file.id),
|
|
||||||
}) && (
|
|
||||||
<IconSquare
|
|
||||||
icon={<Eye size={15} />}
|
|
||||||
onClick={() =>
|
|
||||||
view({
|
|
||||||
name: doc.file!.name,
|
|
||||||
url: fileViewUrl(doc.file!.id),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{doc.file && (
|
|
||||||
<IconSquare
|
|
||||||
href={fileViewUrl(doc.file.id, true)}
|
|
||||||
icon={<Download size={15} />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{canUpload && doc.reviewStatus !== "APPROVED" && (
|
|
||||||
<FileButton
|
|
||||||
onChange={(f) => f && stagePending(doc.fileKey, f)}
|
|
||||||
accept="application/pdf,image/*"
|
|
||||||
>
|
|
||||||
{(props) => (
|
|
||||||
<Button
|
|
||||||
{...props}
|
|
||||||
size="compact-xs"
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
leftSection={<Upload size={13} />}
|
|
||||||
>
|
|
||||||
{pending[doc.fileKey] ? "Selected" : "Upload"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</FileButton>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
{doc.reviewStatus === "QUERIED" && doc.note && (
|
|
||||||
<Text fz="12px" c="#C0392B" mt={6}>
|
|
||||||
Query: {doc.note}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{pending[doc.fileKey] && (
|
|
||||||
<Text fz="12px" c={GREEN} mt={6}>
|
|
||||||
Ready to upload: {pending[doc.fileKey].name}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
@@ -237,16 +147,37 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
|||||||
justify="space-between"
|
justify="space-between"
|
||||||
wrap="nowrap"
|
wrap="nowrap"
|
||||||
className="rounded-xl"
|
className="rounded-xl"
|
||||||
style={{ border: "1px solid #E6ECF2", padding: 10 }}
|
style={{ border: `1px solid ${BORDER}`, padding: 10 }}
|
||||||
>
|
>
|
||||||
<Text fz="13px" c="#10202F" truncate>
|
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
{doc.label}
|
<Box c="#2E5B96">
|
||||||
</Text>
|
<FileText size={18} />
|
||||||
|
</Box>
|
||||||
|
<Text fz="13px" c="#10202F" truncate>
|
||||||
|
{doc.label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
{doc.file ? (
|
{doc.file ? (
|
||||||
<IconSquare
|
<Group gap={8} wrap="nowrap">
|
||||||
href={fileViewUrl(doc.file.id, true)}
|
{isViewable({
|
||||||
icon={<Download size={15} />}
|
name: doc.file.name,
|
||||||
/>
|
url: fileViewUrl(doc.file.id),
|
||||||
|
}) && (
|
||||||
|
<IconSquare
|
||||||
|
icon={<Eye size={15} />}
|
||||||
|
onClick={() =>
|
||||||
|
view({
|
||||||
|
name: doc.file!.name,
|
||||||
|
url: fileViewUrl(doc.file!.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<IconSquare
|
||||||
|
href={fileViewUrl(doc.file.id, true)}
|
||||||
|
icon={<Download size={15} />}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
) : (
|
) : (
|
||||||
<Text fz="12px" c="#9AA8B5">
|
<Text fz="12px" c="#9AA8B5">
|
||||||
Pending
|
Pending
|
||||||
@@ -258,48 +189,16 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Ad-hoc / additional documents. */}
|
{canUpload ? (
|
||||||
{canUpload && (
|
<ClearanceAdHocUploadSection
|
||||||
<Box mt="lg">
|
rows={adHoc}
|
||||||
<Group justify="space-between" align="center" mb={8}>
|
onAdd={addAdHocRow}
|
||||||
<Text fz="12.5px" fw={700} c="#10202F">
|
onRemove={removeAdHocRow}
|
||||||
Additional documents
|
onNameChange={setAdHocName}
|
||||||
</Text>
|
onFileChange={setAdHocFile}
|
||||||
<Button
|
onPreview={view}
|
||||||
size="compact-xs"
|
/>
|
||||||
variant="light"
|
) : null}
|
||||||
color="edr-green"
|
|
||||||
leftSection={<Plus size={13} />}
|
|
||||||
onClick={addAdHocRow}
|
|
||||||
>
|
|
||||||
Add document
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
<Stack gap={8}>
|
|
||||||
{adHoc.map((row, i) => (
|
|
||||||
<Group key={i} gap={8} wrap="nowrap">
|
|
||||||
<TextInput
|
|
||||||
placeholder="Document name"
|
|
||||||
value={row.name}
|
|
||||||
onChange={(e) => setAdHocName(i, e.currentTarget.value)}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
radius="md"
|
|
||||||
/>
|
|
||||||
<FileButton
|
|
||||||
onChange={(f) => setAdHocFile(i, f)}
|
|
||||||
accept="application/pdf,image/*"
|
|
||||||
>
|
|
||||||
{(props) => (
|
|
||||||
<Button {...props} variant="default" radius="md">
|
|
||||||
{row.file ? row.file.name.slice(0, 14) : "Choose file"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</FileButton>
|
|
||||||
</Group>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{uploadMutation.isError && (
|
{uploadMutation.isError && (
|
||||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />} mt="md">
|
<Alert color="red" radius="md" icon={<AlertCircle size={16} />} mt="md">
|
||||||
|
|||||||
@@ -97,11 +97,19 @@ export function useClearanceFlow(booking: Freight.IBooking) {
|
|||||||
|
|
||||||
// --- staged-upload mutators ----------------------------------------------
|
// --- staged-upload mutators ----------------------------------------------
|
||||||
|
|
||||||
const stagePending = (fileKey: string, file: File) =>
|
const stagePending = (fileKey: string, file: File | null) =>
|
||||||
setPending((p) => ({ ...p, [fileKey]: file }));
|
setPending((p) => {
|
||||||
|
if (file) return { ...p, [fileKey]: file };
|
||||||
|
const next = { ...p };
|
||||||
|
delete next[fileKey];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
const addAdHocRow = () => setAdHoc((r) => [...r, { name: "", file: null }]);
|
const addAdHocRow = () => setAdHoc((r) => [...r, { name: "", file: null }]);
|
||||||
|
|
||||||
|
const removeAdHocRow = (index: number) =>
|
||||||
|
setAdHoc((rows) => rows.filter((_, j) => j !== index));
|
||||||
|
|
||||||
const setAdHocName = (index: number, name: string) =>
|
const setAdHocName = (index: number, name: string) =>
|
||||||
setAdHoc((rows) =>
|
setAdHoc((rows) =>
|
||||||
rows.map((r, j) => (j === index ? { ...r, name } : r)),
|
rows.map((r, j) => (j === index ? { ...r, name } : r)),
|
||||||
@@ -148,6 +156,7 @@ export function useClearanceFlow(booking: Freight.IBooking) {
|
|||||||
canSubmit,
|
canSubmit,
|
||||||
stagePending,
|
stagePending,
|
||||||
addAdHocRow,
|
addAdHocRow,
|
||||||
|
removeAdHocRow,
|
||||||
setAdHocName,
|
setAdHocName,
|
||||||
setAdHocFile,
|
setAdHocFile,
|
||||||
// schedule
|
// schedule
|
||||||
|
|||||||
@@ -69,7 +69,12 @@ export default function ContractClearanceFlow() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{id && <ContractClearancePanel contractId={id} />}
|
{id && (
|
||||||
|
<ContractClearancePanel
|
||||||
|
contractId={id}
|
||||||
|
tradeDirection={contract?.tradeDirection ?? "IMPORT"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Center,
|
Center,
|
||||||
FileButton,
|
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -20,7 +18,6 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
Plus,
|
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -28,48 +25,20 @@ import type { Freight } from "@edr/types";
|
|||||||
import { isViewable } from "@edr/ui-common";
|
import { isViewable } from "@edr/ui-common";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fileViewUrl } from "@/constants/apiConfig";
|
||||||
import { ClearanceWorkflowFilesSection } from "@/components/contracts/ClearanceWorkflowFilesSection";
|
import {
|
||||||
|
ClearanceAdHocUploadSection,
|
||||||
|
type AdHocDoc,
|
||||||
|
} from "@/components/contracts/ClearanceAdHocUploadSection";
|
||||||
|
import { ClearanceDocumentUploadCard } from "@/components/contracts/ClearanceDocumentUploadCard";
|
||||||
|
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
import { IconSquare } from "@/pages/bookings/BookingDetailPage/components/Documents";
|
import { IconSquare } from "@/pages/bookings/BookingDetailPage/components/Documents";
|
||||||
|
|
||||||
const GREEN = "#0A6F4D";
|
|
||||||
const BORDER = "#E6ECF2";
|
const BORDER = "#E6ECF2";
|
||||||
|
|
||||||
type AdHocDoc = { name: string; file: File | null };
|
|
||||||
|
|
||||||
function StatusPill({ doc }: { doc: Freight.ContractClearanceDocument }) {
|
|
||||||
if (doc.reviewStatus === "APPROVED") {
|
|
||||||
return (
|
|
||||||
<Group gap={6} c={GREEN}>
|
|
||||||
<CheckCircle2 size={15} />
|
|
||||||
<Text fz="12px" fw={600} c={GREEN}>
|
|
||||||
Approved
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (doc.reviewStatus === "QUERIED") {
|
|
||||||
return (
|
|
||||||
<Group gap={6} c="#C0392B">
|
|
||||||
<AlertCircle size={15} />
|
|
||||||
<Text fz="12px" fw={600} c="#C0392B">
|
|
||||||
Query
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (doc.file) {
|
|
||||||
return (
|
|
||||||
<Text fz="12px" fw={600} c="#6B7C8E">
|
|
||||||
Uploaded
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContractClearancePanelProps {
|
export interface ContractClearancePanelProps {
|
||||||
contractId: string;
|
contractId: string;
|
||||||
|
tradeDirection?: string;
|
||||||
/** Show the loading state without the surrounding Paper (e.g. inside a modal). */
|
/** Show the loading state without the surrounding Paper (e.g. inside a modal). */
|
||||||
bare?: boolean;
|
bare?: boolean;
|
||||||
}
|
}
|
||||||
@@ -83,6 +52,7 @@ export interface ContractClearancePanelProps {
|
|||||||
*/
|
*/
|
||||||
export function ContractClearancePanel({
|
export function ContractClearancePanel({
|
||||||
contractId,
|
contractId,
|
||||||
|
tradeDirection = "IMPORT",
|
||||||
bare,
|
bare,
|
||||||
}: ContractClearancePanelProps) {
|
}: ContractClearancePanelProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -157,8 +127,13 @@ export function ContractClearancePanel({
|
|||||||
? hasStagedFiles && missingRequired.length === 0
|
? hasStagedFiles && missingRequired.length === 0
|
||||||
: hasStagedFiles;
|
: hasStagedFiles;
|
||||||
|
|
||||||
const stagePending = (fileKey: string, file: File) =>
|
const stagePending = (fileKey: string, file: File | null) =>
|
||||||
setPending((p) => ({ ...p, [fileKey]: file }));
|
setPending((p) => {
|
||||||
|
if (file) return { ...p, [fileKey]: file };
|
||||||
|
const next = { ...p };
|
||||||
|
delete next[fileKey];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
const submitDocuments = () => {
|
const submitDocuments = () => {
|
||||||
const files: Record<string, File | null> = { ...pending };
|
const files: Record<string, File | null> = { ...pending };
|
||||||
@@ -220,89 +195,32 @@ export function ContractClearancePanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Required customer documents */}
|
{/* Required customer documents */}
|
||||||
<Stack gap={10}>
|
<Stack gap="md">
|
||||||
|
<Box>
|
||||||
|
<Text fz={13} fw={700} c="#10202F">
|
||||||
|
Your clearance documents
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} c="dimmed" mt={4}>
|
||||||
|
Upload each required document below. Items marked * are mandatory.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
{customerDocs.map((doc) => (
|
{customerDocs.map((doc) => (
|
||||||
<Box
|
<ClearanceDocumentUploadCard
|
||||||
key={doc.fileKey}
|
key={doc.fileKey}
|
||||||
className="rounded-xl"
|
label={doc.label}
|
||||||
style={{
|
required={doc.required}
|
||||||
border:
|
reviewStatus={doc.reviewStatus}
|
||||||
doc.reviewStatus === "QUERIED"
|
note={doc.note}
|
||||||
? "1px solid #F0B4B4"
|
uploadedFile={doc.file}
|
||||||
: `1px solid ${BORDER}`,
|
stagedFile={pending[doc.fileKey] ?? null}
|
||||||
background: doc.reviewStatus === "QUERIED" ? "#FDF4F4" : "#fff",
|
canUpload={canUpload}
|
||||||
padding: 12,
|
onStageFile={
|
||||||
}}
|
canUpload && doc.reviewStatus !== "APPROVED"
|
||||||
>
|
? (file) => stagePending(doc.fileKey, file)
|
||||||
<Group justify="space-between" align="center" wrap="nowrap">
|
: undefined
|
||||||
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
}
|
||||||
<Box c="#2E5B96">
|
onPreview={view}
|
||||||
<FileText size={18} />
|
/>
|
||||||
</Box>
|
|
||||||
<Box style={{ minWidth: 0 }}>
|
|
||||||
<Text fz="13.5px" fw={600} c="#10202F" truncate>
|
|
||||||
{doc.label}
|
|
||||||
{doc.required ? " *" : ""}
|
|
||||||
</Text>
|
|
||||||
{doc.file && (
|
|
||||||
<Text fz="12px" c="dimmed" truncate>
|
|
||||||
{doc.file.name}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Group>
|
|
||||||
<Group gap={10} wrap="nowrap">
|
|
||||||
<StatusPill doc={doc} />
|
|
||||||
{doc.file &&
|
|
||||||
isViewable({
|
|
||||||
name: doc.file.name,
|
|
||||||
url: fileViewUrl(doc.file.id),
|
|
||||||
}) && (
|
|
||||||
<IconSquare
|
|
||||||
icon={<Eye size={15} />}
|
|
||||||
onClick={() =>
|
|
||||||
view({
|
|
||||||
name: doc.file!.name,
|
|
||||||
url: fileViewUrl(doc.file!.id),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{doc.file && (
|
|
||||||
<IconSquare
|
|
||||||
href={fileViewUrl(doc.file.id, true)}
|
|
||||||
icon={<Download size={15} />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{canUpload && doc.reviewStatus !== "APPROVED" && (
|
|
||||||
<FileButton
|
|
||||||
onChange={(f) => f && stagePending(doc.fileKey, f)}
|
|
||||||
accept="application/pdf,image/*"
|
|
||||||
>
|
|
||||||
{(props) => (
|
|
||||||
<Button
|
|
||||||
{...props}
|
|
||||||
size="compact-xs"
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
leftSection={<Upload size={13} />}
|
|
||||||
>
|
|
||||||
{pending[doc.fileKey] ? "Selected" : "Upload"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</FileButton>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
{doc.reviewStatus === "QUERIED" && doc.note && (
|
|
||||||
<Text fz="12px" c="#C0392B" mt={6}>
|
|
||||||
Query: {doc.note}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{pending[doc.fileKey] && (
|
|
||||||
<StagedFilePreview file={pending[doc.fileKey]} onPreview={view} />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
))}
|
))}
|
||||||
{customerDocs.length === 0 && (
|
{customerDocs.length === 0 && (
|
||||||
<Text fz="sm" c="dimmed">
|
<Text fz="sm" c="dimmed">
|
||||||
@@ -361,73 +279,36 @@ export function ContractClearancePanel({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(clearance?.workflowFiles?.length ?? 0) > 0 ? (
|
<Box mt="lg">
|
||||||
<Box mt="lg">
|
<ClearanceUploadedDocumentsPanel
|
||||||
<ClearanceWorkflowFilesSection
|
embedded
|
||||||
files={clearance!.workflowFiles!}
|
tradeDirection={tradeDirection}
|
||||||
title="Customs workflow documents"
|
files={clearance?.workflowFiles ?? []}
|
||||||
onView={(f) => view(f)}
|
title="Customs workflow documents"
|
||||||
/>
|
onView={(f) => view(f)}
|
||||||
</Box>
|
onDownload={({ id, name }) => {
|
||||||
) : null}
|
const a = document.createElement("a");
|
||||||
|
a.href = fileViewUrl(id, true);
|
||||||
|
a.download = name;
|
||||||
|
a.click();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Ad-hoc / additional documents. */}
|
{canUpload ? (
|
||||||
{canUpload && (
|
<ClearanceAdHocUploadSection
|
||||||
<Box mt="lg">
|
rows={adHoc}
|
||||||
<Group justify="space-between" align="center" mb={8}>
|
onAdd={() => setAdHoc((r) => [...r, { name: "", file: null }])}
|
||||||
<Text fz="12.5px" fw={700} c="#10202F">
|
onRemove={(i) => setAdHoc((rows) => rows.filter((_, j) => j !== i))}
|
||||||
Additional documents
|
onNameChange={(i, name) =>
|
||||||
</Text>
|
setAdHoc((rows) => rows.map((r, j) => (j === i ? { ...r, name } : r)))
|
||||||
<Button
|
}
|
||||||
size="compact-xs"
|
onFileChange={(i, file) =>
|
||||||
variant="light"
|
setAdHoc((rows) => rows.map((r, j) => (j === i ? { ...r, file } : r)))
|
||||||
color="edr-green"
|
}
|
||||||
leftSection={<Plus size={13} />}
|
onPreview={view}
|
||||||
onClick={() => setAdHoc((r) => [...r, { name: "", file: null }])}
|
/>
|
||||||
>
|
) : null}
|
||||||
Add document
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
<Stack gap={8}>
|
|
||||||
{adHoc.map((row, i) => (
|
|
||||||
<Box key={i}>
|
|
||||||
<Group gap={8} wrap="nowrap">
|
|
||||||
<TextInput
|
|
||||||
placeholder="Document name"
|
|
||||||
value={row.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
setAdHoc((rows) =>
|
|
||||||
rows.map((r, j) =>
|
|
||||||
j === i ? { ...r, name: e.currentTarget.value } : r,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
radius="md"
|
|
||||||
/>
|
|
||||||
<FileButton
|
|
||||||
onChange={(f) =>
|
|
||||||
setAdHoc((rows) =>
|
|
||||||
rows.map((r, j) => (j === i ? { ...r, file: f } : r)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
accept="application/pdf,image/*"
|
|
||||||
>
|
|
||||||
{(props) => (
|
|
||||||
<Button {...props} variant="default" radius="md">
|
|
||||||
{row.file ? row.file.name.slice(0, 14) : "Choose file"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</FileButton>
|
|
||||||
</Group>
|
|
||||||
{row.file && (
|
|
||||||
<StagedFilePreview file={row.file} onPreview={view} />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{uploadMutation.isError && (
|
{uploadMutation.isError && (
|
||||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />} mt="md">
|
<Alert color="red" radius="md" icon={<AlertCircle size={16} />} mt="md">
|
||||||
@@ -438,10 +319,11 @@ export function ContractClearancePanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{canUpload && (
|
{canUpload && (
|
||||||
<Group justify="flex-end" mt="lg">
|
<Group justify="flex-end" mt="xl">
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
radius="md"
|
radius="md"
|
||||||
|
size="md"
|
||||||
leftSection={<Upload size={16} />}
|
leftSection={<Upload size={16} />}
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
loading={uploadMutation.isPending}
|
loading={uploadMutation.isPending}
|
||||||
@@ -465,99 +347,4 @@ export function ContractClearancePanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A compact preview chip for a locally-staged (not-yet-uploaded) clearance file.
|
|
||||||
* Shows an image thumbnail (or a file glyph) plus a Preview button that opens the
|
|
||||||
* file in the shared viewer via a local object URL. The URL is minted once per
|
|
||||||
* File and revoked on unmount.
|
|
||||||
*/
|
|
||||||
function StagedFilePreview({
|
|
||||||
file,
|
|
||||||
onPreview,
|
|
||||||
}: {
|
|
||||||
file: File;
|
|
||||||
onPreview: (f: { name: string; url: string; mimeType?: string | null }) => void;
|
|
||||||
}) {
|
|
||||||
const url = useMemo(() => URL.createObjectURL(file), [file]);
|
|
||||||
useEffect(() => () => URL.revokeObjectURL(url), [url]);
|
|
||||||
|
|
||||||
const isImage =
|
|
||||||
file.type.startsWith("image/") ||
|
|
||||||
["png", "jpg", "jpeg", "webp", "gif", "bmp", "svg"].includes(
|
|
||||||
file.name.split(".").pop()?.toLowerCase() ?? "",
|
|
||||||
);
|
|
||||||
const canPreview = isViewable({ name: file.name, url, mimeType: file.type });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group
|
|
||||||
gap={10}
|
|
||||||
wrap="nowrap"
|
|
||||||
mt={8}
|
|
||||||
p={8}
|
|
||||||
style={{
|
|
||||||
borderRadius: 12,
|
|
||||||
border: `1px dashed ${GREEN}`,
|
|
||||||
background: "#F2FBF6",
|
|
||||||
minWidth: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isImage ? (
|
|
||||||
<Box
|
|
||||||
onClick={() => onPreview({ name: file.name, url, mimeType: file.type })}
|
|
||||||
style={{
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
flexShrink: 0,
|
|
||||||
borderRadius: 8,
|
|
||||||
overflow: "hidden",
|
|
||||||
border: `1px solid ${BORDER}`,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={url}
|
|
||||||
alt=""
|
|
||||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
flexShrink: 0,
|
|
||||||
borderRadius: 8,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
background: "#E7F6EE",
|
|
||||||
color: GREEN,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FileText size={18} />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
|
||||||
<Text fz="12px" fw={700} c={GREEN}>
|
|
||||||
Ready to upload
|
|
||||||
</Text>
|
|
||||||
<Text fz="12px" c="#10202F" truncate>
|
|
||||||
{file.name}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
{canPreview && (
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="edr-green"
|
|
||||||
leftSection={<Eye size={13} />}
|
|
||||||
onClick={() => onPreview({ name: file.name, url, mimeType: file.type })}
|
|
||||||
>
|
|
||||||
Preview
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ContractClearancePanel;
|
export default ContractClearancePanel;
|
||||||
|
|||||||
@@ -1,22 +1,31 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert, Anchor, Button, FileInput, Group, Paper, Stack, Text } from "@mantine/core";
|
import { Alert, Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||||
import { AlertTriangle, Download, Receipt, Upload } from "lucide-react";
|
import { AlertTriangle, Download, Receipt, Upload } from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||||
|
import { PortalFileDropzone } from "@/components/contracts/PortalFileDropzone";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fileViewUrl } from "@/constants/apiConfig";
|
||||||
import { ClearanceWorkflowFilesSection } from "@/components/contracts/ClearanceWorkflowFilesSection";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
|
import { BORDER, INK } from "./contract-ui";
|
||||||
import { ClearancePhaseStepper } from "./ClearancePhaseStepper";
|
import { ClearancePhaseStepper } from "./ClearancePhaseStepper";
|
||||||
|
|
||||||
const BORDER = "#E6ECF2";
|
function downloadWorkflowFile({ id, name }: { id: string; name: string }) {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = fileViewUrl(id, true);
|
||||||
|
a.download = name;
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
|
||||||
export function ContractClearanceWorkflowBanner({
|
export function ContractClearanceWorkflowBanner({
|
||||||
contract,
|
contract,
|
||||||
}: {
|
}: {
|
||||||
contract: Freight.IContract;
|
contract: Freight.IContract;
|
||||||
}) {
|
}) {
|
||||||
|
const { view, viewer } = useFileViewer();
|
||||||
const isPhased =
|
const isPhased =
|
||||||
contract.customsClearingEnabled && contract.contractKind === "ONE_TIME";
|
contract.customsClearingEnabled && contract.contractKind === "ONE_TIME";
|
||||||
|
|
||||||
@@ -37,52 +46,56 @@ export function ContractClearanceWorkflowBanner({
|
|||||||
!dutyPaid;
|
!dutyPaid;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
<>
|
||||||
<Stack gap="md">
|
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
||||||
<Text fw={700} size="sm">
|
<Stack gap="md">
|
||||||
Clearance progress
|
<Text fw={700} size="sm" style={{ color: INK }}>
|
||||||
</Text>
|
Clearance progress
|
||||||
<ClearancePhaseStepper
|
</Text>
|
||||||
clearance={clearance}
|
<ClearancePhaseStepper
|
||||||
tradeDirection={contract.tradeDirection}
|
clearance={clearance}
|
||||||
compact
|
tradeDirection={contract.tradeDirection}
|
||||||
/>
|
compact
|
||||||
|
|
||||||
{clearance.roHold && clearance.roHoldReason ? (
|
|
||||||
<Alert color="orange" icon={<AlertTriangle size={16} />} title="Release Order on hold">
|
|
||||||
{clearance.roHoldReason}
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{clearance.nextAction?.actor === "CUSTOMER" ? (
|
|
||||||
<Alert color="blue" variant="light">
|
|
||||||
{clearance.nextAction.action}
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{dutyPending && clearance.dutyAdvice ? (
|
|
||||||
<DutyAdvicePanel
|
|
||||||
dutyAdvice={clearance.dutyAdvice}
|
|
||||||
contractId={contract.id}
|
|
||||||
onUploaded={() => void refetch()}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{(clearance.workflowFiles?.length ?? 0) > 0 ? (
|
{clearance.roHold && clearance.roHoldReason ? (
|
||||||
<ClearanceWorkflowFilesSection
|
<Alert color="orange" icon={<AlertTriangle size={16} />} title="Release Order on hold">
|
||||||
files={clearance.workflowFiles!}
|
{clearance.roHoldReason}
|
||||||
title="Uploaded customs documents"
|
</Alert>
|
||||||
onView={({ name, url }) => window.open(url, "_blank", "noopener")}
|
) : null}
|
||||||
|
|
||||||
|
{clearance.nextAction?.actor === "CUSTOMER" ? (
|
||||||
|
<Alert color="blue" variant="light">
|
||||||
|
{clearance.nextAction.action}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{dutyPending && clearance.dutyAdvice ? (
|
||||||
|
<DutyAdvicePanel
|
||||||
|
dutyAdvice={clearance.dutyAdvice}
|
||||||
|
contractId={contract.id}
|
||||||
|
onUploaded={() => void refetch()}
|
||||||
|
onPreview={view}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ClearanceUploadedDocumentsPanel
|
||||||
|
embedded
|
||||||
|
tradeDirection={contract.tradeDirection ?? "IMPORT"}
|
||||||
|
files={clearance.workflowFiles ?? []}
|
||||||
|
onView={view}
|
||||||
|
onDownload={downloadWorkflowFile}
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{clearance.bookingReady ? (
|
{clearance.bookingReady ? (
|
||||||
<Alert color="green" variant="light">
|
<Alert color="green" variant="light">
|
||||||
Clearance is complete. Global Logistics will create your shipment booking shortly.
|
Clearance is complete. Global Logistics will create your shipment booking shortly.
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
{viewer}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,60 +103,112 @@ function DutyAdvicePanel({
|
|||||||
dutyAdvice,
|
dutyAdvice,
|
||||||
contractId,
|
contractId,
|
||||||
onUploaded,
|
onUploaded,
|
||||||
|
onPreview,
|
||||||
}: {
|
}: {
|
||||||
dutyAdvice: NonNullable<Freight.ContractClearanceView["dutyAdvice"]>;
|
dutyAdvice: NonNullable<Freight.ContractClearanceView["dutyAdvice"]>;
|
||||||
contractId: string;
|
contractId: string;
|
||||||
onUploaded: () => void;
|
onUploaded: () => void;
|
||||||
|
onPreview: (file: { name: string; url: string; mimeType?: string | null }) => void;
|
||||||
}) {
|
}) {
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper withBorder radius="md" p="md" bg="#FFFBF0">
|
<Paper
|
||||||
<Stack gap="sm">
|
withBorder
|
||||||
<GroupLabel icon={Receipt} text="Duty / tax payment" />
|
radius="lg"
|
||||||
<Text size="sm">
|
p="md"
|
||||||
Amount due:{" "}
|
style={{
|
||||||
<strong>
|
borderColor: "#F5D9A8",
|
||||||
{dutyAdvice.amount.toLocaleString()} {dutyAdvice.currency}
|
background: "linear-gradient(160deg, #FFFBF0 0%, #FFFFFF 70%)",
|
||||||
</strong>
|
}}
|
||||||
{dutyAdvice.declarationSerial
|
>
|
||||||
? ` · Payment code: ${dutyAdvice.declarationSerial}`
|
<Stack gap="md">
|
||||||
: null}
|
<Group gap={8} wrap="nowrap">
|
||||||
</Text>
|
<Box
|
||||||
{dutyAdvice.noticeFile ? (
|
style={{
|
||||||
<Anchor
|
width: 32,
|
||||||
href={fileViewUrl(dutyAdvice.noticeFile.id, true)}
|
height: 32,
|
||||||
target="_blank"
|
borderRadius: 8,
|
||||||
rel="noopener noreferrer"
|
display: "flex",
|
||||||
size="sm"
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "#FFF3D6",
|
||||||
|
color: "#C77F09",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Receipt size={16} />
|
||||||
<Download size={14} />
|
</Box>
|
||||||
Download duty notice ({dutyAdvice.noticeFile.name})
|
<Text fw={700} fz={14} style={{ color: INK }}>
|
||||||
|
Duty / tax payment
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="sm" bg="#fff">
|
||||||
|
<Text fz={13} style={{ color: INK }}>
|
||||||
|
Amount due:{" "}
|
||||||
|
<strong>
|
||||||
|
{dutyAdvice.amount.toLocaleString()} {dutyAdvice.currency}
|
||||||
|
</strong>
|
||||||
|
{dutyAdvice.declarationSerial
|
||||||
|
? ` · Payment code: ${dutyAdvice.declarationSerial}`
|
||||||
|
: null}
|
||||||
|
</Text>
|
||||||
|
{dutyAdvice.noticeFile ? (
|
||||||
|
<Group gap={8} mt={8}>
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
leftSection={<Download size={14} />}
|
||||||
|
component="a"
|
||||||
|
href={fileViewUrl(dutyAdvice.noticeFile.id, true)}
|
||||||
|
download={dutyAdvice.noticeFile.name}
|
||||||
|
>
|
||||||
|
Download duty notice
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={() =>
|
||||||
|
onPreview({
|
||||||
|
name: dutyAdvice.noticeFile!.name,
|
||||||
|
url: fileViewUrl(dutyAdvice.noticeFile!.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Preview notice
|
||||||
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Anchor>
|
) : null}
|
||||||
) : null}
|
</Paper>
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
|
<Text fz={13} c="dimmed">
|
||||||
Pay the amount above, then upload your payment slip so clearance can continue.
|
Pay the amount above, then upload your payment slip so clearance can continue.
|
||||||
</Text>
|
</Text>
|
||||||
<FileInput
|
|
||||||
|
<PortalFileDropzone
|
||||||
label="Payment slip"
|
label="Payment slip"
|
||||||
|
description="Upload proof of duty/tax payment (PDF or image)."
|
||||||
value={file}
|
value={file}
|
||||||
onChange={setFile}
|
onChange={setFile}
|
||||||
size="sm"
|
onPreview={onPreview}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="orange"
|
color="orange"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={!file}
|
disabled={!file}
|
||||||
leftSection={<Upload size={16} />}
|
leftSection={<Upload size={16} />}
|
||||||
|
fullWidth
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await contractsService.uploadContractDutySlip(contractId, file);
|
await contractsService.uploadContractDutySlip(contractId, file);
|
||||||
toast.success("Payment slip uploaded");
|
toast.success("Payment slip uploaded");
|
||||||
|
setFile(null);
|
||||||
onUploaded();
|
onUploaded();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Upload failed");
|
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||||
@@ -158,14 +223,3 @@ function DutyAdvicePanel({
|
|||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupLabel({ icon: Icon, text }: { icon: typeof Receipt; text: string }) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<Icon size={16} />
|
|
||||||
<Text fw={600} size="sm">
|
|
||||||
{text}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ import toast from "react-hot-toast";
|
|||||||
import { labelForDocCode } from "@/pages/bookings/resubmit";
|
import { labelForDocCode } from "@/pages/bookings/resubmit";
|
||||||
import { ContractClearancePanel } from "./ContractClearancePanel";
|
import { ContractClearancePanel } from "./ContractClearancePanel";
|
||||||
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
|
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
|
||||||
import { ClearanceWorkflowFilesSection } from "@/components/contracts/ClearanceWorkflowFilesSection";
|
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||||
import { getContractBookingAction } from "./contract-booking-action";
|
import { getContractBookingAction } from "./contract-booking-action";
|
||||||
import {
|
import {
|
||||||
@@ -167,13 +167,19 @@ export default function ContractDetailPage() {
|
|||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clearance view — drives the "documents need correction" alert / query count.
|
// Clearance view — drives queries alert, workflow documents, and duty panels.
|
||||||
const inClearance =
|
const inClearance =
|
||||||
!!contract && CLEARANCE_UPLOAD_STATUSES.includes(contract.status);
|
!!contract && CLEARANCE_UPLOAD_STATUSES.includes(contract.status);
|
||||||
|
const isPhasedCustomsClearance =
|
||||||
|
!!contract &&
|
||||||
|
contract.customsClearingEnabled &&
|
||||||
|
contract.contractKind === "ONE_TIME";
|
||||||
const { data: clearanceView } = useQuery({
|
const { data: clearanceView } = useQuery({
|
||||||
...api.contracts.getClearance.queryOptions({ input: { id: id! } }),
|
...api.contracts.getClearance.queryOptions({ input: { id: id! } }),
|
||||||
enabled: !!id && inClearance,
|
enabled: !!id && (inClearance || isPhasedCustomsClearance),
|
||||||
});
|
});
|
||||||
|
const workflowFiles = clearanceView?.workflowFiles ?? [];
|
||||||
|
const workflowFileCount = workflowFiles.filter((f) => f.file).length;
|
||||||
const queriedCount = (clearanceView?.documents ?? []).filter(
|
const queriedCount = (clearanceView?.documents ?? []).filter(
|
||||||
(d) => d.uploadedBy === "customer" && d.reviewStatus === "QUERIED",
|
(d) => d.uploadedBy === "customer" && d.reviewStatus === "QUERIED",
|
||||||
).length;
|
).length;
|
||||||
@@ -485,7 +491,7 @@ export default function ContractDetailPage() {
|
|||||||
active={tab === "documents"}
|
active={tab === "documents"}
|
||||||
icon={<Download size={16} />}
|
icon={<Download size={16} />}
|
||||||
label="Documents"
|
label="Documents"
|
||||||
count={files.length}
|
count={files.length + (isPhasedCustomsClearance ? workflowFileCount : 0)}
|
||||||
/>
|
/>
|
||||||
<DetailTab
|
<DetailTab
|
||||||
value="bookings"
|
value="bookings"
|
||||||
@@ -840,6 +846,25 @@ export default function ContractDetailPage() {
|
|||||||
|
|
||||||
{/* ── Documents tab ─────────────────────────────────────────── */}
|
{/* ── Documents tab ─────────────────────────────────────────── */}
|
||||||
<Tabs.Panel value="documents">
|
<Tabs.Panel value="documents">
|
||||||
|
<Stack gap="lg">
|
||||||
|
{isPhasedCustomsClearance ? (
|
||||||
|
<ClearanceUploadedDocumentsPanel
|
||||||
|
files={workflowFiles}
|
||||||
|
tradeDirection={contract.tradeDirection ?? "IMPORT"}
|
||||||
|
onView={view}
|
||||||
|
onDownload={async (f) => {
|
||||||
|
try {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = fileViewUrl(f.id, true);
|
||||||
|
a.download = f.name;
|
||||||
|
a.click();
|
||||||
|
} catch {
|
||||||
|
toast.error("Could not download file.");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
withBorder
|
withBorder
|
||||||
radius="lg"
|
radius="lg"
|
||||||
@@ -923,25 +948,10 @@ export default function ContractDetailPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{(clearanceView?.workflowFiles?.length ?? 0) > 0 ? (
|
|
||||||
<ClearanceWorkflowFilesSection
|
|
||||||
files={clearanceView!.workflowFiles!}
|
|
||||||
onView={view}
|
|
||||||
onDownload={async (f) => {
|
|
||||||
try {
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = fileViewUrl(f.id, true);
|
|
||||||
a.download = f.name;
|
|
||||||
a.click();
|
|
||||||
} catch {
|
|
||||||
toast.error("Could not download file.");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
</Stack>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
{/* ── Bookings tab ──────────────────────────────────────────── */}
|
{/* ── Bookings tab ──────────────────────────────────────────── */}
|
||||||
@@ -1137,7 +1147,11 @@ export default function ContractDetailPage() {
|
|||||||
centered
|
centered
|
||||||
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
||||||
>
|
>
|
||||||
<ContractClearancePanel contractId={contract.id} bare />
|
<ContractClearancePanel
|
||||||
|
contractId={contract.id}
|
||||||
|
tradeDirection={contract.tradeDirection ?? "IMPORT"}
|
||||||
|
bare
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,11 +23,34 @@ export const CLEARANCE_WORKFLOW_FILE_CATALOG: ClearanceWorkflowFileCatalogEntry[
|
|||||||
{ code: "ex8", label: "EX8 Declaration", uploadedBy: "gl_et", category: "declaration", tradeDirection: "EXPORT" },
|
{ code: "ex8", label: "EX8 Declaration", uploadedBy: "gl_et", category: "declaration", tradeDirection: "EXPORT" },
|
||||||
{ code: "duty_tax_notice", label: "Duty / Tax Notice", uploadedBy: "gl_et", category: "duty" },
|
{ code: "duty_tax_notice", label: "Duty / Tax Notice", uploadedBy: "gl_et", category: "duty" },
|
||||||
{ code: "duty_tax_receipt", label: "Duty / Tax Payment Slip", uploadedBy: "customer", category: "duty" },
|
{ code: "duty_tax_receipt", label: "Duty / Tax Payment Slip", uploadedBy: "customer", category: "duty" },
|
||||||
{ code: "transit_permitted", label: "Transit Permit", uploadedBy: "gl_et", category: "transit" },
|
{ code: "transit_permitted", label: "Transit Permit", uploadedBy: "gl_et", category: "transit", tradeDirection: "IMPORT" },
|
||||||
|
{
|
||||||
|
code: "export_transport_document",
|
||||||
|
label: "Transit Permit",
|
||||||
|
uploadedBy: "gl_et",
|
||||||
|
category: "transit",
|
||||||
|
tradeDirection: "EXPORT",
|
||||||
|
},
|
||||||
{ code: "delivery_order", label: "Delivery Order", uploadedBy: "gl_dj", category: "djibouti", tradeDirection: "IMPORT" },
|
{ code: "delivery_order", label: "Delivery Order", uploadedBy: "gl_dj", category: "djibouti", tradeDirection: "IMPORT" },
|
||||||
{ code: "release_order", label: "Release Order", uploadedBy: "gl_dj", category: "djibouti", tradeDirection: "EXPORT" },
|
{ code: "release_order", label: "Release Order", uploadedBy: "gl_dj", category: "djibouti", tradeDirection: "EXPORT" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Legacy single-type declaration codes (still shown when already uploaded). */
|
||||||
|
export const LEGACY_DECLARATION_FILE_CODES = ["im4", "im5", "ex3", "ex8"] as const;
|
||||||
|
|
||||||
|
/** Multi-file declaration uploads use `declaration_0`, `declaration_1`, … */
|
||||||
|
export const DECLARATION_FILE_PREFIX = "declaration_";
|
||||||
|
|
||||||
|
export function isDeclarationFileCode(code: string | null | undefined): boolean {
|
||||||
|
if (!code) return false;
|
||||||
|
const lower = code.toLowerCase();
|
||||||
|
return (
|
||||||
|
(LEGACY_DECLARATION_FILE_CODES as readonly string[]).includes(lower) ||
|
||||||
|
lower === "declaration" ||
|
||||||
|
lower.startsWith(DECLARATION_FILE_PREFIX)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClearanceWorkflowFile {
|
export interface ClearanceWorkflowFile {
|
||||||
code: string;
|
code: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -45,6 +68,39 @@ export function clearanceWorkflowFileLabel(code: string | null | undefined): str
|
|||||||
return CATALOG_BY_CODE.get(code)?.label ?? null;
|
return CATALOG_BY_CODE.get(code)?.label ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function declarationFileLabel(code: string, index?: number): string {
|
||||||
|
const lower = code.toLowerCase();
|
||||||
|
const legacy = CATALOG_BY_CODE.get(lower);
|
||||||
|
if (legacy?.category === "declaration") return legacy.label;
|
||||||
|
if (lower.startsWith(DECLARATION_FILE_PREFIX) || lower === "declaration") {
|
||||||
|
return index != null ? `Declaration document ${index + 1}` : "Customs declaration";
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy single import transit permit code. */
|
||||||
|
export const LEGACY_IMPORT_TRANSIT_PERMIT_CODE = "transit_permitted";
|
||||||
|
|
||||||
|
/** Multi-file import transit permit uploads use `transit_permit_0`, `transit_permit_1`, … */
|
||||||
|
export const TRANSIT_PERMIT_FILE_PREFIX = "transit_permit_";
|
||||||
|
|
||||||
|
export function isImportTransitPermitFileCode(code: string | null | undefined): boolean {
|
||||||
|
if (!code) return false;
|
||||||
|
const lower = code.toLowerCase();
|
||||||
|
return (
|
||||||
|
lower === LEGACY_IMPORT_TRANSIT_PERMIT_CODE || lower.startsWith(TRANSIT_PERMIT_FILE_PREFIX)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transitPermitFileLabel(code: string, index?: number): string {
|
||||||
|
const lower = code.toLowerCase();
|
||||||
|
if (lower === LEGACY_IMPORT_TRANSIT_PERMIT_CODE) return "Transit Permit";
|
||||||
|
if (lower.startsWith(TRANSIT_PERMIT_FILE_PREFIX)) {
|
||||||
|
return index != null ? `Transit permit ${index + 1}` : "Transit Permit";
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
export function catalogEntriesForTradeDirection(
|
export function catalogEntriesForTradeDirection(
|
||||||
tradeDirection: string,
|
tradeDirection: string,
|
||||||
): ClearanceWorkflowFileCatalogEntry[] {
|
): ClearanceWorkflowFileCatalogEntry[] {
|
||||||
|
|||||||
@@ -272,6 +272,9 @@ export interface ContractClearanceView {
|
|||||||
roAmendmentRequestedAt?: string | null;
|
roAmendmentRequestedAt?: string | null;
|
||||||
bookingReady?: boolean;
|
bookingReady?: boolean;
|
||||||
preClearanceFinalized?: boolean;
|
preClearanceFinalized?: boolean;
|
||||||
|
/** Export post-booking clearance finalized after transit permit upload. */
|
||||||
|
exportClearanceFinalized?: boolean;
|
||||||
|
linkedBookingId?: string | null;
|
||||||
dutyAdvice?: {
|
dutyAdvice?: {
|
||||||
amount: number;
|
amount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user