mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
finalize gl flow for import
This commit is contained in:
@@ -617,12 +617,14 @@ export class BookingsController {
|
||||
async uploadBookingDeliveryOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('vesselDepartureDate') vesselDepartureDate: string | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
|
||||
id,
|
||||
file,
|
||||
resolveAuthUserId(user),
|
||||
vesselDepartureDate,
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
ContractDocPhase,
|
||||
type ClearanceFinalInvoiceSummary,
|
||||
type ClearanceSecondDuty,
|
||||
type ClearanceT1State,
|
||||
type ClearanceTrainState,
|
||||
} from '@edr/types';
|
||||
@@ -80,6 +81,12 @@ export interface BookingClearanceView {
|
||||
offloaded?: boolean;
|
||||
/** GL Djibouti post-offload final invoice (export). */
|
||||
finalInvoice?: ClearanceFinalInvoiceSummary | null;
|
||||
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||
riskLevel?: string | null;
|
||||
riskAssignedAt?: string | null;
|
||||
/** Post-arrival additional duty/tax round (import). */
|
||||
secondDuty?: ClearanceSecondDuty | null;
|
||||
importReleaseGranted?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -200,6 +207,8 @@ export class BookingClearanceService {
|
||||
milestones.find((m) => m.milestoneCode === code);
|
||||
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
|
||||
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
|
||||
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
|
||||
const secondDuty = this.glOperationsService.secondDutyState(milestones, files);
|
||||
|
||||
return {
|
||||
bookingId,
|
||||
@@ -248,6 +257,17 @@ export class BookingClearanceService {
|
||||
: null,
|
||||
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
|
||||
finalInvoice,
|
||||
riskLevel:
|
||||
riskMilestone?.status === 'COMPLETED'
|
||||
? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null)
|
||||
: null,
|
||||
riskAssignedAt:
|
||||
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
|
||||
? riskMilestone.triggeredAt.toISOString()
|
||||
: null,
|
||||
secondDuty,
|
||||
importReleaseGranted:
|
||||
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -497,6 +517,7 @@ export class BookingClearanceService {
|
||||
bookingId: string,
|
||||
file: Express.Multer.File,
|
||||
userId?: string,
|
||||
vesselDepartureDate?: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
@@ -515,6 +536,12 @@ export class BookingClearanceService {
|
||||
file,
|
||||
});
|
||||
|
||||
if (vesselDepartureDate?.trim()) {
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
vesselDepartureDate: vesselDepartureDate.trim(),
|
||||
} as never);
|
||||
}
|
||||
|
||||
if (booking.preClearanceFinalizedAt) {
|
||||
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
|
||||
await this.workflowService.markReadyForOperation(bookingId);
|
||||
|
||||
@@ -39,6 +39,16 @@ const IMPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
|
||||
OFFLOADED: { label: 'Offloaded', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
RISK_ASSIGNED: { label: 'Risk Assigned', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
SECOND_DUTY_ADVISED: {
|
||||
label: 'Additional Duty and Taxes Advised',
|
||||
ownerRegion: 'ET',
|
||||
triggeredByDoc: false,
|
||||
},
|
||||
SECOND_DUTY_PAID: {
|
||||
label: 'Additional Duty and Tax Paid',
|
||||
ownerRegion: 'CUST',
|
||||
triggeredByDoc: true,
|
||||
},
|
||||
IMPORT_RELEASE_GRANTED: { label: 'Import Release Granted', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
IMPORT_PROCESS_COMPLETED: { label: 'Import Process Completed', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
STORAGE_INVOICE_RAISED: { label: 'Storage Invoice Raised', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, ConflictException, Injectable } from '@nestjs/comm
|
||||
import {
|
||||
ContractDocPhase,
|
||||
type ClearanceFinalInvoiceSummary,
|
||||
type ClearanceSecondDuty,
|
||||
type ClearanceT1State,
|
||||
type ClearanceTrainState,
|
||||
} from '@edr/types';
|
||||
@@ -94,6 +95,12 @@ export interface ContractClearanceView {
|
||||
offloaded?: boolean;
|
||||
/** GL Djibouti post-offload final invoice (export). */
|
||||
finalInvoice?: ClearanceFinalInvoiceSummary | null;
|
||||
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||
riskLevel?: string | null;
|
||||
riskAssignedAt?: string | null;
|
||||
/** Post-arrival additional duty/tax round (import). */
|
||||
secondDuty?: ClearanceSecondDuty | null;
|
||||
importReleaseGranted?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -226,8 +233,9 @@ export class ContractClearanceService {
|
||||
files,
|
||||
contract.tradeDirection ?? 'IMPORT',
|
||||
);
|
||||
let bookingFiles: Awaited<ReturnType<FilesService['findByResource']>> = [];
|
||||
if (cycle?.bookingId) {
|
||||
const bookingFiles = await this.filesService.findByResource(
|
||||
bookingFiles = await this.filesService.findByResource(
|
||||
cycle.bookingId,
|
||||
'bookings',
|
||||
);
|
||||
@@ -269,6 +277,11 @@ export class ContractClearanceService {
|
||||
bookingMilestones.find((m) => m.milestoneCode === code);
|
||||
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
|
||||
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
|
||||
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
|
||||
const secondDuty = this.glOperationsService.secondDutyState(
|
||||
bookingMilestones,
|
||||
bookingFiles,
|
||||
);
|
||||
|
||||
let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
|
||||
if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') {
|
||||
@@ -332,6 +345,17 @@ export class ContractClearanceService {
|
||||
: null,
|
||||
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
|
||||
finalInvoice,
|
||||
riskLevel:
|
||||
riskMilestone?.status === 'COMPLETED'
|
||||
? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null)
|
||||
: null,
|
||||
riskAssignedAt:
|
||||
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
|
||||
? riskMilestone.triggeredAt.toISOString()
|
||||
: null,
|
||||
secondDuty,
|
||||
importReleaseGranted:
|
||||
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1091,6 +1115,7 @@ export class ContractClearanceService {
|
||||
contractId: string,
|
||||
file: Express.Multer.File,
|
||||
userId?: string,
|
||||
vesselDepartureDate?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
@@ -1111,6 +1136,11 @@ export class ContractClearanceService {
|
||||
});
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (cycle && vesselDepartureDate?.trim()) {
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
vesselDepartureDate: vesselDepartureDate.trim(),
|
||||
});
|
||||
}
|
||||
if (cycle?.preClearanceFinalizedAt) {
|
||||
await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId);
|
||||
await this.workflowService.markReadyForBooking(contractId);
|
||||
|
||||
@@ -611,9 +611,15 @@ export class ContractsController {
|
||||
uploadDeliveryOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('vesselDepartureDate') vesselDepartureDate: string | undefined,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user));
|
||||
return this.clearanceService.uploadDeliveryOrder(
|
||||
id,
|
||||
file,
|
||||
resolveAuthUserId(user),
|
||||
vesselDepartureDate,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/release-order')
|
||||
@@ -998,6 +1004,47 @@ export class ContractsController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/second-duty')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(FileInterceptor('attachment'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary: 'GL ET advises (or skips) the post-arrival additional duty/tax round (import)',
|
||||
})
|
||||
adviseSecondDuty(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body('dutyRequired') dutyRequiredRaw: string,
|
||||
@Body('amount') amountRaw: string | undefined,
|
||||
@Body('currency') currency: string | undefined,
|
||||
@Body('declarationSerial') declarationSerial: string | undefined,
|
||||
@UploadedFile() attachment: Express.Multer.File | undefined,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.glOperationsService.adviseSecondDuty(
|
||||
bookingId,
|
||||
{
|
||||
dutyRequired: dutyRequiredRaw === 'true' || dutyRequiredRaw === '1',
|
||||
amount:
|
||||
amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined,
|
||||
currency: currency ?? 'ETB',
|
||||
declarationSerial,
|
||||
},
|
||||
attachment,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/second-duty-slip')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer attaches the additional duty/tax payment slip' })
|
||||
uploadSecondDutySlip(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.glOperationsService.uploadSecondDutySlip(bookingId, file);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/documents')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
|
||||
@@ -645,6 +645,129 @@ export class GlOperationsService {
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* GL ET advises (or skips) the post-arrival additional duty/tax round (import).
|
||||
* Customer then attaches a slip; SECOND_DUTY_PAID completes on that upload.
|
||||
*/
|
||||
async adviseSecondDuty(
|
||||
bookingId: string,
|
||||
input: {
|
||||
dutyRequired: boolean;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
declarationSerial?: string;
|
||||
},
|
||||
attachment?: Express.Multer.File,
|
||||
userId?: string,
|
||||
): Promise<{ advised: boolean; skipped: boolean }> {
|
||||
const booking = await this.getBooking(bookingId);
|
||||
if (!booking.customsClearingEnabled) {
|
||||
throw new BadRequestException('Additional duty applies to customs bookings only.');
|
||||
}
|
||||
const tradeDirection = booking.tradeDirection ?? 'IMPORT';
|
||||
if (tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Additional duty applies to import shipments only.');
|
||||
}
|
||||
|
||||
await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_ADVISED', tradeDirection);
|
||||
await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_PAID', tradeDirection);
|
||||
|
||||
if (!input.dutyRequired) {
|
||||
await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_ADVISED');
|
||||
await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_PAID');
|
||||
return { advised: false, skipped: true };
|
||||
}
|
||||
|
||||
if (!input.amount || input.amount <= 0) {
|
||||
throw new BadRequestException('Duty amount must be greater than zero.');
|
||||
}
|
||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
const hasNotice = files.some((f) => f.code === 'duty_tax_notice_2');
|
||||
if (!attachment && !hasNotice) {
|
||||
throw new BadRequestException('Attach the additional duty/tax notice.');
|
||||
}
|
||||
if (attachment) {
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'duty_tax_notice_2',
|
||||
file: attachment,
|
||||
});
|
||||
}
|
||||
|
||||
await this.milestoneService.completeWithMetadataForBooking(
|
||||
bookingId,
|
||||
'SECOND_DUTY_ADVISED',
|
||||
{
|
||||
dutyAmount: input.amount,
|
||||
dutyCurrency: input.currency ?? 'ETB',
|
||||
declarationSerial: input.declarationSerial,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
return { advised: true, skipped: false };
|
||||
}
|
||||
|
||||
/** Customer attaches the payment slip for the additional duty round. */
|
||||
async uploadSecondDutySlip(
|
||||
bookingId: string,
|
||||
file: Express.Multer.File,
|
||||
): Promise<{ milestoneCompleted: boolean }> {
|
||||
const booking = await this.getBooking(bookingId);
|
||||
if (!file) throw new BadRequestException('No payment slip uploaded');
|
||||
|
||||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||
const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED');
|
||||
if (advised?.status !== 'COMPLETED') {
|
||||
throw new BadRequestException('No additional duty has been advised for this shipment.');
|
||||
}
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'duty_tax_receipt_2',
|
||||
file,
|
||||
});
|
||||
|
||||
await this.milestoneService.ensureForBooking(
|
||||
bookingId,
|
||||
'SECOND_DUTY_PAID',
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
);
|
||||
await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID');
|
||||
return { milestoneCompleted: true };
|
||||
}
|
||||
|
||||
/** Second duty round state for clearance views. */
|
||||
secondDutyState(
|
||||
milestones: Array<{
|
||||
milestoneCode: string;
|
||||
status: string;
|
||||
metadata?: { dutyAmount?: number; dutyCurrency?: string; declarationSerial?: string } | null;
|
||||
}>,
|
||||
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
|
||||
): Freight.ClearanceSecondDuty | null {
|
||||
const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED');
|
||||
const paid = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_PAID');
|
||||
if (!advised && !paid) return null;
|
||||
|
||||
const toRef = (code: string) => {
|
||||
const f = files.find((x) => x.code === code);
|
||||
return f ? { id: f.id, name: f.name, url: f.url } : null;
|
||||
};
|
||||
|
||||
return {
|
||||
advised: advised?.status === 'COMPLETED',
|
||||
skipped: advised?.status === 'SKIPPED',
|
||||
amount: advised?.metadata?.dutyAmount ?? null,
|
||||
currency: advised?.metadata?.dutyCurrency ?? null,
|
||||
declarationSerial: advised?.metadata?.declarationSerial ?? null,
|
||||
noticeFile: toRef('duty_tax_notice_2'),
|
||||
slipFile: toRef('duty_tax_receipt_2'),
|
||||
paid: paid?.status === 'COMPLETED',
|
||||
};
|
||||
}
|
||||
|
||||
/** Final-invoice state joined with its document + slip files, for clearance views. */
|
||||
async finalInvoiceSummary(
|
||||
bookingId: string,
|
||||
|
||||
@@ -62,10 +62,11 @@ export function GlClearanceUploadModal({
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isDo) {
|
||||
const iso = vesselDate ? vesselDate.toISOString().slice(0, 10) : undefined;
|
||||
if (isBooking) {
|
||||
await bookingsService.uploadDeliveryOrder(entityId, file);
|
||||
await bookingsService.uploadDeliveryOrder(entityId, file, iso);
|
||||
} else {
|
||||
await contractsService.uploadDeliveryOrder(entityId, file);
|
||||
await contractsService.uploadDeliveryOrder(entityId, file, iso);
|
||||
}
|
||||
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
|
||||
} else {
|
||||
@@ -117,7 +118,15 @@ export function GlClearanceUploadModal({
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
) : null}
|
||||
) : (
|
||||
<DateInput
|
||||
label="Vessel departure date (optional)"
|
||||
value={vesselDate}
|
||||
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
|
||||
size="sm"
|
||||
clearable
|
||||
/>
|
||||
)}
|
||||
|
||||
<PhasedFileDropzone
|
||||
label={isDo ? "Delivery Order file" : "Release Order file"}
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Stepper,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
import {
|
||||
TransitPermitMultiUpload,
|
||||
@@ -25,6 +28,7 @@ import {
|
||||
FileText,
|
||||
PackageCheck,
|
||||
Receipt,
|
||||
ShieldAlert,
|
||||
Ship,
|
||||
Truck,
|
||||
Upload,
|
||||
@@ -68,6 +72,10 @@ export type ClearanceViewLike = Pick<
|
||||
| "finalInvoice"
|
||||
| "vesselDepartureDate"
|
||||
| "linkedBookingId"
|
||||
| "riskLevel"
|
||||
| "riskAssignedAt"
|
||||
| "secondDuty"
|
||||
| "importReleaseGranted"
|
||||
> & { operationReady?: boolean };
|
||||
|
||||
export type MilestoneRow = NonNullable<ClearanceViewLike["milestones"]>[number];
|
||||
@@ -91,6 +99,7 @@ export function isBookingMilestoneDone(
|
||||
function computeImportActiveStep(
|
||||
clearance: ClearanceViewLike,
|
||||
bookingCreated: boolean,
|
||||
bookingMilestones: MilestoneRow[],
|
||||
): number {
|
||||
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
|
||||
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1;
|
||||
@@ -111,8 +120,17 @@ function computeImportActiveStep(
|
||||
if (!clearance.preClearanceFinalized) return 5;
|
||||
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6;
|
||||
if (!bookingCreated) return 7;
|
||||
if (!clearance.t1?.closed) return 8;
|
||||
return 9;
|
||||
if (!clearance.gatepassGranted) return 8;
|
||||
if (!clearance.t1?.closed) return 9;
|
||||
if (!isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")) return 10;
|
||||
// Additional duty round is optional — resolved once skipped or paid.
|
||||
const secondDutyResolved =
|
||||
clearance.secondDuty?.skipped ||
|
||||
clearance.secondDuty?.paid ||
|
||||
isBookingMilestoneDone(bookingMilestones, "SECOND_DUTY_PAID");
|
||||
if (!secondDutyResolved) return 11;
|
||||
if (!clearance.importReleaseGranted) return 12;
|
||||
return 13;
|
||||
}
|
||||
|
||||
function t1FilesFromWorkflow(
|
||||
@@ -133,7 +151,9 @@ function declarationFilesFromWorkflow(
|
||||
workflowFiles: Freight.ClearanceWorkflowFile[],
|
||||
): Array<{ code: string; label: string; file: { id: string; name: string } }> {
|
||||
return workflowFiles
|
||||
.filter((f) => f.category === "declaration" && f.file)
|
||||
.filter(
|
||||
(f) => f.category === "declaration" && f.code !== "import_release" && f.file,
|
||||
)
|
||||
.map((f) => ({
|
||||
code: f.code,
|
||||
label: f.label,
|
||||
@@ -205,9 +225,15 @@ export function PhasedClearanceActionPanel({
|
||||
// The server only builds the t1 block once a booking is linked — use it as the
|
||||
// booking-created signal on pages that don't pass bookingCreated (GL DJ detail).
|
||||
const effectiveBookingCreated = bookingCreated || Boolean(clearance.t1);
|
||||
// The booking that carries the post-booking steps (gate pass, risk, duty, release).
|
||||
const actionBookingId =
|
||||
clearance.t1?.bookingId ?? clearance.linkedBookingId ?? bookingId ?? null;
|
||||
const activeStep = useMemo(
|
||||
() => (isImport ? computeImportActiveStep(clearance, effectiveBookingCreated) : 0),
|
||||
[clearance, isImport, effectiveBookingCreated],
|
||||
() =>
|
||||
isImport
|
||||
? computeImportActiveStep(clearance, effectiveBookingCreated, bookingMilestones)
|
||||
: 0,
|
||||
[clearance, isImport, effectiveBookingCreated, bookingMilestones],
|
||||
);
|
||||
|
||||
if (isImport) {
|
||||
@@ -508,6 +534,21 @@ export function PhasedClearanceActionPanel({
|
||||
)}
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Gate pass"
|
||||
description="GL Djibouti grants after wagon allocation"
|
||||
icon={
|
||||
clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />
|
||||
}
|
||||
>
|
||||
<ImportGatepassStep
|
||||
bookingId={actionBookingId}
|
||||
clearance={clearance}
|
||||
canAct={showDj && canDj}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="T1 transport documents"
|
||||
description="GL Djibouti uploads after wagon allocation; GL Ethiopia closes on arrival"
|
||||
@@ -525,6 +566,64 @@ export function PhasedClearanceActionPanel({
|
||||
onDownloadFile={onDownloadFile}
|
||||
/>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Customs risk"
|
||||
description="GL Ethiopia assigns Green / Yellow / Red"
|
||||
icon={
|
||||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED") ? (
|
||||
<CheckCircle2 size={14} />
|
||||
) : (
|
||||
<ShieldAlert size={14} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<RiskStep
|
||||
bookingId={actionBookingId}
|
||||
clearance={clearance}
|
||||
canAct={showEt && canEt}
|
||||
done={isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Additional duty & tax"
|
||||
description="GL Ethiopia advises if more duty applies"
|
||||
icon={<Receipt size={14} />}
|
||||
>
|
||||
<SecondDutyStep
|
||||
bookingId={actionBookingId}
|
||||
clearance={clearance}
|
||||
canAct={showEt && canEt}
|
||||
riskAssigned={isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")}
|
||||
onChanged={onChanged}
|
||||
onViewFile={onViewFile}
|
||||
onDownloadFile={onDownloadFile}
|
||||
/>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Import release"
|
||||
description="GL Ethiopia uploads the release document"
|
||||
icon={
|
||||
clearance.importReleaseGranted ? (
|
||||
<CheckCircle2 size={14} />
|
||||
) : (
|
||||
<FileText size={14} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<ImportReleaseStep
|
||||
bookingId={actionBookingId}
|
||||
clearance={clearance}
|
||||
canAct={showEt && canEt}
|
||||
workflowFiles={workflowFiles}
|
||||
onChanged={onChanged}
|
||||
onViewFile={onViewFile}
|
||||
onDownloadFile={onDownloadFile}
|
||||
/>
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
</Paper>
|
||||
</Stack>
|
||||
@@ -717,6 +816,453 @@ function ImportT1Section({
|
||||
);
|
||||
}
|
||||
|
||||
/** GL DJ grants the import gate pass once wagons are allocated (captures time). */
|
||||
function ImportGatepassStep({
|
||||
bookingId,
|
||||
clearance,
|
||||
canAct,
|
||||
onChanged,
|
||||
}: {
|
||||
bookingId: string | null;
|
||||
clearance: ClearanceViewLike;
|
||||
canAct: boolean;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [at, setAt] = useState<Date | null>(new Date());
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (clearance.gatepassGranted) {
|
||||
return (
|
||||
<StepStatus
|
||||
done
|
||||
pendingLabel=""
|
||||
doneLabel={`Gate pass granted${
|
||||
clearance.gatepassAt ? ` · ${new Date(clearance.gatepassAt).toLocaleString()}` : ""
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const wagonAllocated = Boolean(clearance.train?.wagonAllocated);
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<StepStatus
|
||||
done={false}
|
||||
pendingLabel={
|
||||
wagonAllocated
|
||||
? "Wagons allocated — GL Djibouti can grant the gate pass."
|
||||
: "Available once wagons are allocated."
|
||||
}
|
||||
doneLabel=""
|
||||
/>
|
||||
{canAct && bookingId ? (
|
||||
<>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Truck size={16} />}
|
||||
disabled={!wagonAllocated}
|
||||
onClick={() => {
|
||||
setAt(new Date());
|
||||
setOpened(true);
|
||||
}}
|
||||
>
|
||||
Grant gate pass
|
||||
</Button>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title={<Text fw={700}>Grant gate pass</Text>}
|
||||
radius="md"
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<DateTimePicker
|
||||
label="Gate pass time"
|
||||
value={at}
|
||||
onChange={(v) => setAt(v ? new Date(v) : null)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setOpened(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await contractsService.grantGatepass(
|
||||
bookingId,
|
||||
(at ?? new Date()).toISOString(),
|
||||
);
|
||||
toast.success("Gate pass granted");
|
||||
setOpened(false);
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Grant
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const RISK_LEVEL_COLOR: Record<string, string> = {
|
||||
GREEN: "green",
|
||||
YELLOW: "yellow",
|
||||
RED: "red",
|
||||
};
|
||||
|
||||
/** GL ET assigns the customs examination risk (visible to the customer). */
|
||||
function RiskStep({
|
||||
bookingId,
|
||||
clearance,
|
||||
canAct,
|
||||
done,
|
||||
onChanged,
|
||||
}: {
|
||||
bookingId: string | null;
|
||||
clearance: ClearanceViewLike;
|
||||
canAct: boolean;
|
||||
done: boolean;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [level, setLevel] = useState<string>("GREEN");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (done || clearance.riskLevel) {
|
||||
return (
|
||||
<Group gap="sm">
|
||||
<Badge
|
||||
color={RISK_LEVEL_COLOR[clearance.riskLevel ?? ""] ?? "gray"}
|
||||
variant="filled"
|
||||
radius="sm"
|
||||
>
|
||||
{clearance.riskLevel ?? "Assigned"}
|
||||
</Badge>
|
||||
<Text size="sm" c="dimmed">
|
||||
Customs risk assigned
|
||||
{clearance.riskAssignedAt
|
||||
? ` · ${new Date(clearance.riskAssignedAt).toLocaleString()}`
|
||||
: ""}
|
||||
. The customer can see this level.
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canAct || !bookingId) {
|
||||
return (
|
||||
<StepStatus
|
||||
done={false}
|
||||
pendingLabel="Waiting for GL Ethiopia to assign the customs risk level."
|
||||
doneLabel=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
value={level}
|
||||
onChange={setLevel}
|
||||
data={[
|
||||
{ label: "Green", value: "GREEN" },
|
||||
{ label: "Yellow", value: "YELLOW" },
|
||||
{ label: "Red", value: "RED" },
|
||||
]}
|
||||
/>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
The customer sees the assigned risk level.
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await contractsService.assignRisk(bookingId, {
|
||||
riskLevel: level as Freight.CustomsRiskLevel,
|
||||
});
|
||||
toast.success("Customs risk assigned");
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Assign risk
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Optional post-arrival additional duty/tax round (GL ET advises; customer pays slip). */
|
||||
function SecondDutyStep({
|
||||
bookingId,
|
||||
clearance,
|
||||
canAct,
|
||||
riskAssigned,
|
||||
onChanged,
|
||||
onViewFile,
|
||||
onDownloadFile,
|
||||
}: {
|
||||
bookingId: string | null;
|
||||
clearance: ClearanceViewLike;
|
||||
canAct: boolean;
|
||||
riskAssigned: boolean;
|
||||
onChanged?: () => void;
|
||||
onViewFile?: (file: { name: string; url: string }) => void;
|
||||
onDownloadFile?: (file: { id: string; name: string }) => void;
|
||||
}) {
|
||||
const [dutyRequired, setDutyRequired] = useState(true);
|
||||
const [amount, setAmount] = useState<number | string>("");
|
||||
const [currency, setCurrency] = useState("ETB");
|
||||
const [serial, setSerial] = useState("");
|
||||
const [attachment, setAttachment] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const duty = clearance.secondDuty ?? null;
|
||||
|
||||
if (duty?.skipped) {
|
||||
return (
|
||||
<StepStatus done pendingLabel="" doneLabel="No additional duty or tax applies." />
|
||||
);
|
||||
}
|
||||
|
||||
if (duty?.advised) {
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">
|
||||
Additional duty advised:{" "}
|
||||
<strong>
|
||||
{duty.amount?.toLocaleString()} {duty.currency}
|
||||
</strong>
|
||||
{duty.declarationSerial ? ` · ${duty.declarationSerial}` : ""}
|
||||
</Text>
|
||||
{duty.noticeFile ? (
|
||||
<PhasedUploadedFileRow
|
||||
label="Additional Duty / Tax Notice"
|
||||
file={duty.noticeFile}
|
||||
onView={onViewFile}
|
||||
onDownload={onDownloadFile}
|
||||
compact
|
||||
/>
|
||||
) : null}
|
||||
{duty.slipFile ? (
|
||||
<PhasedUploadedFileRow
|
||||
label="Customer payment slip"
|
||||
file={duty.slipFile}
|
||||
onView={onViewFile}
|
||||
onDownload={onDownloadFile}
|
||||
compact
|
||||
/>
|
||||
) : null}
|
||||
<StepStatus
|
||||
done={duty.paid}
|
||||
pendingLabel="Waiting for the customer to pay and attach the slip in the portal."
|
||||
doneLabel="Additional duty paid — slip received."
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canAct || !bookingId) {
|
||||
return (
|
||||
<StepStatus
|
||||
done={false}
|
||||
pendingLabel="GL Ethiopia decides whether additional duty/tax applies."
|
||||
doneLabel=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{!riskAssigned ? (
|
||||
<Alert color="blue" variant="light" icon={<Clock size={16} />}>
|
||||
Usually decided after the customs risk is assigned.
|
||||
</Alert>
|
||||
) : null}
|
||||
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
|
||||
<Stack gap="md">
|
||||
<Switch
|
||||
label="Additional duty/tax applies"
|
||||
description="Turn off if no further duty or tax is due after arrival."
|
||||
checked={dutyRequired}
|
||||
onChange={(e) => setDutyRequired(e.currentTarget.checked)}
|
||||
/>
|
||||
{dutyRequired ? (
|
||||
<>
|
||||
<Group grow align="flex-start">
|
||||
<NumberInput
|
||||
label="Amount"
|
||||
placeholder="0.00"
|
||||
value={amount}
|
||||
onChange={setAmount}
|
||||
min={0}
|
||||
size="sm"
|
||||
thousandSeparator=","
|
||||
/>
|
||||
<Select
|
||||
label="Currency"
|
||||
data={["ETB", "USD"]}
|
||||
value={currency}
|
||||
onChange={(v) => setCurrency(v ?? "ETB")}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label="Declaration / payment code"
|
||||
placeholder="Customs payment reference"
|
||||
value={serial}
|
||||
onChange={(e) => setSerial(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
<PhasedFileDropzone
|
||||
label="Duty notice attachment"
|
||||
description="Any file type — shown to the customer in the portal."
|
||||
accept="*/*"
|
||||
value={attachment}
|
||||
onChange={setAttachment}
|
||||
onPreview={onViewFile}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
disabled={dutyRequired && (amount === "" || Number(amount) <= 0 || !attachment)}
|
||||
fullWidth
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await contractsService.adviseSecondDuty(bookingId, {
|
||||
dutyRequired,
|
||||
amount: dutyRequired ? Number(amount) : undefined,
|
||||
currency,
|
||||
declarationSerial: serial || undefined,
|
||||
attachment: dutyRequired ? attachment : null,
|
||||
});
|
||||
toast.success(
|
||||
dutyRequired ? "Additional duty advised to customer" : "No additional duty recorded",
|
||||
);
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{dutyRequired ? "Send to customer" : "No additional duty"}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** GL ET uploads the import release document (auto-completes IMPORT_RELEASE_GRANTED). */
|
||||
function ImportReleaseStep({
|
||||
bookingId,
|
||||
clearance,
|
||||
canAct,
|
||||
workflowFiles = [],
|
||||
onChanged,
|
||||
onViewFile,
|
||||
onDownloadFile,
|
||||
}: {
|
||||
bookingId: string | null;
|
||||
clearance: ClearanceViewLike;
|
||||
canAct: boolean;
|
||||
workflowFiles?: Freight.ClearanceWorkflowFile[];
|
||||
onChanged?: () => void;
|
||||
onViewFile?: (file: { name: string; url: string }) => void;
|
||||
onDownloadFile?: (file: { id: string; name: string }) => void;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const releaseFile = findWorkflowFile(workflowFiles, "import_release");
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{releaseFile ? (
|
||||
<PhasedUploadedFileRow
|
||||
label="Import Release"
|
||||
file={releaseFile}
|
||||
onView={onViewFile}
|
||||
onDownload={onDownloadFile}
|
||||
compact
|
||||
/>
|
||||
) : null}
|
||||
{clearance.importReleaseGranted ? (
|
||||
<StepStatus done pendingLabel="" doneLabel="Import release granted." />
|
||||
) : canAct && bookingId ? (
|
||||
<>
|
||||
<PhasedFileDropzone
|
||||
label="Import release document"
|
||||
description="Any file type."
|
||||
accept="*/*"
|
||||
value={file}
|
||||
onChange={setFile}
|
||||
replaceMode={Boolean(releaseFile)}
|
||||
onPreview={onViewFile}
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
disabled={!file}
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={async () => {
|
||||
if (!file) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await contractsService.uploadGlDocuments(bookingId, {
|
||||
import_release: file,
|
||||
});
|
||||
setFile(null);
|
||||
toast.success("Import release uploaded");
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{releaseFile ? "Replace import release" : "Upload import release"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<StepStatus
|
||||
done={false}
|
||||
pendingLabel="Waiting for GL Ethiopia to upload the import release document."
|
||||
doneLabel=""
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function StepStatus({
|
||||
done,
|
||||
pendingLabel,
|
||||
|
||||
@@ -224,6 +224,8 @@ export const URL_CONSTANTS = {
|
||||
`/contracts/bookings/${bookingId}/final-invoice`,
|
||||
BOOKING_FINAL_INVOICE_CONFIRM: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/final-invoice/confirm`,
|
||||
BOOKING_SECOND_DUTY: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/second-duty`,
|
||||
BOOKING_INCIDENTS: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/incidents`,
|
||||
},
|
||||
|
||||
@@ -362,9 +362,14 @@ export const bookingsService = {
|
||||
return unwrap(response.data) as BookingDetail;
|
||||
},
|
||||
|
||||
uploadDeliveryOrder: async (id: string, file: File): Promise<BookingDetail> => {
|
||||
uploadDeliveryOrder: async (
|
||||
id: string,
|
||||
file: File,
|
||||
vesselDepartureDate?: string,
|
||||
): Promise<BookingDetail> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
if (vesselDepartureDate) form.append("vesselDepartureDate", vesselDepartureDate);
|
||||
const response = await client.post(B.CLEARANCE_DELIVERY_ORDER(id), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
|
||||
@@ -283,9 +283,11 @@ export const contractsService = {
|
||||
uploadDeliveryOrder: async (
|
||||
id: string,
|
||||
file: File,
|
||||
vesselDepartureDate?: string,
|
||||
): Promise<Freight.IContract> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
if (vesselDepartureDate) form.append("vesselDepartureDate", vesselDepartureDate);
|
||||
const response = await client.post(C.CLEARANCE_DELIVERY_ORDER(id), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
@@ -407,6 +409,30 @@ export const contractsService = {
|
||||
return unwrap(response.data) as Freight.ClearanceFinalInvoiceSummary;
|
||||
},
|
||||
|
||||
/** GL ET advises (or skips) the post-arrival additional duty/tax round (import). */
|
||||
adviseSecondDuty: async (
|
||||
bookingId: string,
|
||||
payload: {
|
||||
dutyRequired: boolean;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
declarationSerial?: string;
|
||||
attachment?: File | null;
|
||||
},
|
||||
): Promise<{ advised: boolean; skipped: boolean }> => {
|
||||
const form = new FormData();
|
||||
form.append("dutyRequired", String(payload.dutyRequired));
|
||||
if (payload.amount != null) form.append("amount", String(payload.amount));
|
||||
if (payload.currency) form.append("currency", payload.currency);
|
||||
if (payload.declarationSerial)
|
||||
form.append("declarationSerial", payload.declarationSerial);
|
||||
if (payload.attachment) form.append("attachment", payload.attachment);
|
||||
const response = await client.post(C.BOOKING_SECOND_DUTY(bookingId), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return unwrap(response.data) as { advised: boolean; skipped: boolean };
|
||||
},
|
||||
|
||||
// ── Path A self-clearance (Operations review) ──
|
||||
getOpsClearanceQueue: async (): Promise<PaginatedContracts> => {
|
||||
const response = await client.get<PaginatedContracts>(
|
||||
|
||||
@@ -133,6 +133,8 @@ export const URL_CONSTANTS = {
|
||||
`/api/contracts/bookings/${bookingId}/duty-slip`,
|
||||
BOOKING_FINAL_INVOICE_SLIP: (bookingId: string) =>
|
||||
`/api/contracts/bookings/${bookingId}/final-invoice-slip`,
|
||||
BOOKING_SECOND_DUTY_SLIP: (bookingId: string) =>
|
||||
`/api/contracts/bookings/${bookingId}/second-duty-slip`,
|
||||
CAPACITY: (id: string) => `/api/contracts/${id}/capacity`,
|
||||
BOOKING_REQUESTS: (id: string) => `/api/contracts/${id}/booking-requests`,
|
||||
BOOKING_REQUEST_CANCEL: (reqId: string) =>
|
||||
|
||||
@@ -584,6 +584,42 @@ export default function ContractDetailPage() {
|
||||
<ContractClearanceWorkflowBanner contract={contract} />
|
||||
) : null}
|
||||
|
||||
{clearanceView?.riskLevel ? (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="lg"
|
||||
p="md"
|
||||
style={{ borderColor: BORDER, background: "#FBFDFC" }}
|
||||
>
|
||||
<Group gap={10} align="center">
|
||||
<Text fw={700} fz={14} c={INK}>
|
||||
Customs risk level
|
||||
</Text>
|
||||
<Badge
|
||||
color={CUSTOMS_RISK_COLOR[clearanceView.riskLevel] ?? "gray"}
|
||||
variant="filled"
|
||||
radius="sm"
|
||||
>
|
||||
{clearanceView.riskLevel}
|
||||
</Badge>
|
||||
{clearanceView.riskAssignedAt ? (
|
||||
<Text fz={12} c="dimmed">
|
||||
assigned {new Date(clearanceView.riskAssignedAt).toLocaleString()}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{clearanceView?.secondDuty?.advised && clearanceView?.linkedBookingId ? (
|
||||
<SecondDutyDueCard
|
||||
duty={clearanceView.secondDuty}
|
||||
bookingId={clearanceView.linkedBookingId}
|
||||
onView={view}
|
||||
onChanged={() => void refetchClearance()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{clearanceView?.finalInvoice && clearanceView?.linkedBookingId ? (
|
||||
<FinalInvoiceDueCard
|
||||
invoice={clearanceView.finalInvoice}
|
||||
@@ -1605,3 +1641,145 @@ function FinalInvoiceDueCard({
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
const CUSTOMS_RISK_COLOR: Record<string, string> = {
|
||||
GREEN: "green",
|
||||
YELLOW: "yellow",
|
||||
RED: "red",
|
||||
};
|
||||
|
||||
/**
|
||||
* Post-arrival additional duty/tax round (import): GL advises an extra amount
|
||||
* with a notice; the customer pays offline and attaches another slip here.
|
||||
*/
|
||||
function SecondDutyDueCard({
|
||||
duty,
|
||||
bookingId,
|
||||
onView,
|
||||
onChanged,
|
||||
}: {
|
||||
duty: NonNullable<Freight.ContractClearanceView["secondDuty"]>;
|
||||
bookingId: string;
|
||||
onView: (file: { name: string; url: string }) => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [slip, setSlip] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const paid = duty.paid;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="lg"
|
||||
p="lg"
|
||||
style={{
|
||||
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
|
||||
background: paid ? "#F6FBF8" : "#FFFBF2",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 3,
|
||||
background: paid ? GREEN : "#E3A93C",
|
||||
}}
|
||||
/>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<div>
|
||||
<Group gap={8} align="center">
|
||||
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
|
||||
<Text fw={700} fz={15} c={INK}>
|
||||
{paid ? "Additional duty & tax paid" : "Additional duty & tax due"}
|
||||
</Text>
|
||||
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
|
||||
{paid ? "PAID" : "DUE"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text fz={20} fw={800} mt={6} c={INK}>
|
||||
{(duty.amount ?? 0).toLocaleString()} {duty.currency ?? ""}
|
||||
</Text>
|
||||
{duty.declarationSerial ? (
|
||||
<Text fz={13} c="dimmed" mt={2}>
|
||||
Payment code: {duty.declarationSerial}
|
||||
</Text>
|
||||
) : null}
|
||||
{!paid ? (
|
||||
<Text fz={13} c="#9A6B1F" mt={6}>
|
||||
Customs advised additional duty/tax after arrival. Pay the amount
|
||||
above and attach your payment slip.
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Stack gap="xs" miw={260}>
|
||||
{duty.noticeFile ? (
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="sm"
|
||||
leftSection={<Eye size={15} />}
|
||||
onClick={() =>
|
||||
onView({ name: duty.noticeFile!.name, url: duty.noticeFile!.url })
|
||||
}
|
||||
>
|
||||
View duty notice
|
||||
</Button>
|
||||
) : null}
|
||||
{duty.slipFile ? (
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="sm"
|
||||
leftSection={<Eye size={15} />}
|
||||
onClick={() =>
|
||||
onView({ name: duty.slipFile!.name, url: duty.slipFile!.url })
|
||||
}
|
||||
>
|
||||
View payment slip
|
||||
</Button>
|
||||
) : null}
|
||||
{!paid ? (
|
||||
<>
|
||||
<FileInput
|
||||
placeholder={duty.slipFile ? "Replace payment slip" : "Attach payment slip"}
|
||||
value={slip}
|
||||
onChange={setSlip}
|
||||
size="sm"
|
||||
radius="md"
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="sm"
|
||||
loading={uploading}
|
||||
disabled={!slip}
|
||||
leftSection={<Upload size={15} />}
|
||||
onClick={async () => {
|
||||
if (!slip) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
await contractsService.uploadSecondDutySlip(bookingId, slip);
|
||||
setSlip(null);
|
||||
toast.success("Payment slip attached");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{duty.slipFile ? "Replace slip" : "Submit payment slip"}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -333,4 +333,19 @@ export const contractsService = {
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** Customer attaches the slip for the post-arrival additional duty round (import). */
|
||||
uploadSecondDutySlip: async (
|
||||
bookingId: string,
|
||||
file: File,
|
||||
): Promise<{ milestoneCompleted: boolean }> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const { data } = await client.post(
|
||||
C.BOOKING_SECOND_DUTY_SLIP(bookingId),
|
||||
form,
|
||||
{ headers: { "Content-Type": "multipart/form-data" } },
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -23,6 +23,27 @@ export const CLEARANCE_WORKFLOW_FILE_CATALOG: ClearanceWorkflowFileCatalogEntry[
|
||||
{ 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_receipt", label: "Duty / Tax Payment Slip", uploadedBy: "customer", category: "duty" },
|
||||
{
|
||||
code: "duty_tax_notice_2",
|
||||
label: "Additional Duty / Tax Notice",
|
||||
uploadedBy: "gl_et",
|
||||
category: "duty",
|
||||
tradeDirection: "IMPORT",
|
||||
},
|
||||
{
|
||||
code: "duty_tax_receipt_2",
|
||||
label: "Additional Duty / Tax Payment Slip",
|
||||
uploadedBy: "customer",
|
||||
category: "duty",
|
||||
tradeDirection: "IMPORT",
|
||||
},
|
||||
{
|
||||
code: "import_release",
|
||||
label: "Import Release",
|
||||
uploadedBy: "gl_et",
|
||||
category: "declaration",
|
||||
tradeDirection: "IMPORT",
|
||||
},
|
||||
{ code: "transit_permitted", label: "Transit Permit", uploadedBy: "gl_et", category: "transit", tradeDirection: "IMPORT" },
|
||||
{
|
||||
code: "export_transport_document",
|
||||
|
||||
@@ -282,6 +282,22 @@ export interface ClearanceFinalInvoiceSummary {
|
||||
confirmedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-arrival second duty/tax round (import): GL ET advises an additional
|
||||
* amount with a notice; the customer attaches another payment slip. Optional —
|
||||
* GL ET may mark it skipped when no further duty applies.
|
||||
*/
|
||||
export interface ClearanceSecondDuty {
|
||||
advised: boolean;
|
||||
skipped: boolean;
|
||||
amount: number | null;
|
||||
currency: string | null;
|
||||
declarationSerial?: string | null;
|
||||
noticeFile: { id: string; name: string; url: string } | null;
|
||||
slipFile: { id: string; name: string; url: string } | null;
|
||||
paid: boolean;
|
||||
}
|
||||
|
||||
/** A customs booking riding a train schedule, as shown on the GL DJ schedules tab. */
|
||||
export interface DjClearanceScheduleBooking {
|
||||
bookingId: string;
|
||||
@@ -359,6 +375,12 @@ export interface ContractClearanceView {
|
||||
offloaded?: boolean;
|
||||
/** GL Djibouti post-offload final invoice (export). */
|
||||
finalInvoice?: ClearanceFinalInvoiceSummary | null;
|
||||
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||
riskLevel?: string | null;
|
||||
riskAssignedAt?: string | null;
|
||||
/** Post-arrival additional duty/tax round (import). */
|
||||
secondDuty?: ClearanceSecondDuty | null;
|
||||
importReleaseGranted?: boolean;
|
||||
}
|
||||
|
||||
export type ClearanceActorRole = "CUSTOMER" | "GL_ET" | "GL_DJ" | "OPERATIONS";
|
||||
@@ -456,6 +478,8 @@ export const IMPORT_MILESTONES = [
|
||||
"OFFLOADED",
|
||||
"T1_CLOSED",
|
||||
"RISK_ASSIGNED",
|
||||
"SECOND_DUTY_ADVISED",
|
||||
"SECOND_DUTY_PAID",
|
||||
"IMPORT_RELEASE_GRANTED",
|
||||
"IMPORT_PROCESS_COMPLETED",
|
||||
"STORAGE_INVOICE_RAISED",
|
||||
|
||||
@@ -566,6 +566,12 @@ export interface ClearanceView {
|
||||
offloaded?: boolean;
|
||||
/** GL Djibouti post-offload final invoice (export). */
|
||||
finalInvoice?: import("./contracts").ClearanceFinalInvoiceSummary | null;
|
||||
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||
riskLevel?: string | null;
|
||||
riskAssignedAt?: string | null;
|
||||
/** Post-arrival additional duty/tax round (import). */
|
||||
secondDuty?: import("./contracts").ClearanceSecondDuty | null;
|
||||
importReleaseGranted?: boolean;
|
||||
}
|
||||
|
||||
/** Company an invoice is billed to (minimal projection). */
|
||||
|
||||
Reference in New Issue
Block a user