mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 04:15:43 +00:00
Merge pull request #997 from Tria-plc/freight_feature/usermanagement
let customers dispute booking duty/tax advice so GL Ethiopia can re-a…
This commit is contained in:
@@ -392,6 +392,21 @@ export class BookingLifecycleNotifierService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer disputed the advised duty & tax. This goes to STAFF, not the
|
||||||
|
* customer: GL Ethiopia is the one who has to re-advise, and the clearance
|
||||||
|
* page is where they do it.
|
||||||
|
*/
|
||||||
|
dutyDisputed(b: Booking, note: string): void {
|
||||||
|
const msg =
|
||||||
|
`The customer disputed the duty & tax advised on booking ${this.ref(b)}: ` +
|
||||||
|
`"${note}". Review and re-advise the amount on the clearance page.`;
|
||||||
|
this.inAppStaff(b, `Duty disputed on ${this.ref(b)}`, msg, {
|
||||||
|
type: NotificationType.CLEARANCE_REVIEW,
|
||||||
|
link: `/dashboard/bookings/${b.id}/clearance`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Customer uploaded a duty/tax payment slip — GL verifies it. */
|
/** Customer uploaded a duty/tax payment slip — GL verifies it. */
|
||||||
dutySlipUploadedToStaff(b: Booking, round: 'first' | 'second' | 'final'): void {
|
dutySlipUploadedToStaff(b: Booking, round: 'first' | 'second' | 'final'): void {
|
||||||
const label =
|
const label =
|
||||||
|
|||||||
@@ -900,6 +900,24 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/duty/dispute')
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)',
|
||||||
|
})
|
||||||
|
async disputeBookingDuty(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body('note') note: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingClearanceService.disputeDuty(
|
||||||
|
id,
|
||||||
|
note,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/clearance/finalize-pre-clearance')
|
@Post(':id/clearance/finalize-pre-clearance')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' })
|
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' })
|
||||||
|
|||||||
@@ -611,6 +611,17 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Review notes of one type, newest first — the duty advice/dispute rounds. */
|
||||||
|
async findReviewNotes(
|
||||||
|
bookingId: string,
|
||||||
|
type: ReviewNoteType,
|
||||||
|
): Promise<BookingReviewNote[]> {
|
||||||
|
return this.dataSource.getRepository(BookingReviewNote).find({
|
||||||
|
where: { bookingId, type },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async findLatestReviewNote(
|
async findLatestReviewNote(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
type?: ReviewNoteType,
|
type?: ReviewNoteType,
|
||||||
|
|||||||
@@ -2,7 +2,16 @@ import { BaseEntity } from '@edr/api-common';
|
|||||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
import { Booking } from './booking.entity';
|
import { Booking } from './booking.entity';
|
||||||
|
|
||||||
export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION', 'STAFF_NOTE'] as const;
|
export const REVIEW_NOTE_TYPES = [
|
||||||
|
'CHANGES_REQUESTED',
|
||||||
|
'REJECTION',
|
||||||
|
'STAFF_NOTE',
|
||||||
|
/**
|
||||||
|
* The customer disputed the advised duty & tax and asked GL Ethiopia to
|
||||||
|
* correct it. One row per round — the advice/dispute loop can repeat.
|
||||||
|
*/
|
||||||
|
'DUTY_DISPUTE',
|
||||||
|
] as const;
|
||||||
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
|
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'booking_review_note' })
|
@Entity({ schema: 'freight', name: 'booking_review_note' })
|
||||||
|
|||||||
@@ -88,6 +88,15 @@ export interface BookingClearanceView {
|
|||||||
declarationSerial?: string | null;
|
declarationSerial?: string | null;
|
||||||
noticeFile?: { id: string; name: string; url: string } | null;
|
noticeFile?: { id: string; name: string; url: string } | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
/**
|
||||||
|
* The customer's open objection to the advised duty. Present only until GL
|
||||||
|
* re-advises; `rounds` counts how many times it has been sent back.
|
||||||
|
*/
|
||||||
|
dutyDispute?: {
|
||||||
|
note: string;
|
||||||
|
raisedAt: string;
|
||||||
|
rounds: number;
|
||||||
|
} | null;
|
||||||
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
|
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
|
||||||
/** Import post-allocation T1 transit document state (null until wagon allocation). */
|
/** Import post-allocation T1 transit document state (null until wagon allocation). */
|
||||||
t1?: ClearanceT1State | null;
|
t1?: ClearanceT1State | null;
|
||||||
@@ -222,6 +231,7 @@ export class BookingClearanceService {
|
|||||||
booking.tradeDirection ?? 'IMPORT',
|
booking.tradeDirection ?? 'IMPORT',
|
||||||
);
|
);
|
||||||
const dutyAdvice = this.buildDutyAdvice(files, milestones);
|
const dutyAdvice = this.buildDutyAdvice(files, milestones);
|
||||||
|
const dutyDispute = await this.buildDutyDispute(bookingId, milestones);
|
||||||
const workflowFiles = buildWorkflowFiles(
|
const workflowFiles = buildWorkflowFiles(
|
||||||
files,
|
files,
|
||||||
booking.tradeDirection ?? 'IMPORT',
|
booking.tradeDirection ?? 'IMPORT',
|
||||||
@@ -291,6 +301,7 @@ export class BookingClearanceService {
|
|||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
dutyAdvice,
|
dutyAdvice,
|
||||||
|
dutyDispute,
|
||||||
workflowFiles,
|
workflowFiles,
|
||||||
t1,
|
t1,
|
||||||
train,
|
train,
|
||||||
@@ -348,6 +359,22 @@ export class BookingClearanceService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async buildDutyDispute(
|
||||||
|
bookingId: string,
|
||||||
|
milestones: ClearanceMilestone[],
|
||||||
|
): Promise<BookingClearanceView['dutyDispute']> {
|
||||||
|
const advised = milestones.find((m) => m.milestoneCode === 'DUTY_TAXES_ADVISED');
|
||||||
|
if (!advised || advised.status === 'COMPLETED') return null;
|
||||||
|
const notes = await this.bookingsRepository.findReviewNotes(bookingId, 'DUTY_DISPUTE');
|
||||||
|
const latest = notes[0];
|
||||||
|
if (!latest) return null;
|
||||||
|
return {
|
||||||
|
note: latest.note,
|
||||||
|
raisedAt: latest.createdAt.toISOString(),
|
||||||
|
rounds: notes.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async isClearanceFullyApproved(booking: Booking): Promise<boolean> {
|
private async isClearanceFullyApproved(booking: Booking): Promise<boolean> {
|
||||||
const { inputCode } = clearanceCodesForBooking(booking);
|
const { inputCode } = clearanceCodesForBooking(booking);
|
||||||
if (!inputCode) return true;
|
if (!inputCode) return true;
|
||||||
@@ -534,6 +561,65 @@ export class BookingClearanceService {
|
|||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer disagrees with the advised duty & tax on this booking and asks
|
||||||
|
* GL Ethiopia to correct it. Nothing is paid; the advice milestone reopens so
|
||||||
|
* the Duty & tax step becomes actionable again on the GL clearance page, with
|
||||||
|
* the customer's message shown beside it. GL re-advises (same endpoint as the
|
||||||
|
* first time), which closes the dispute — the loop may run as many rounds as
|
||||||
|
* it takes.
|
||||||
|
*/
|
||||||
|
async disputeDuty(
|
||||||
|
bookingId: string,
|
||||||
|
note: string,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<Booking> {
|
||||||
|
const booking = await this.loadBooking(bookingId);
|
||||||
|
if (booking.tradeDirection !== 'IMPORT') {
|
||||||
|
throw new BadRequestException('Duty applies only to import bookings.');
|
||||||
|
}
|
||||||
|
if (!note?.trim()) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Say what is wrong with the advised amount so GL can correct it.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!booking.dutyRequired) {
|
||||||
|
throw new BadRequestException('Duty/tax is not required for this clearance.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
|
||||||
|
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
||||||
|
if (byCode.get('DUTY_TAXES_ADVISED')?.status !== 'COMPLETED') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'There is no advised duty amount to dispute yet.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Once the slip is in, the money is paid — a dispute then is a refund
|
||||||
|
// conversation, not a re-advice.
|
||||||
|
if (byCode.get('DUTY_TAX_PAID')?.status === 'COMPLETED') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'The duty payment slip has already been submitted — contact GL Ethiopia directly.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.bookingsRepository.createReviewNote(
|
||||||
|
bookingId,
|
||||||
|
note.trim(),
|
||||||
|
'DUTY_DISPUTE',
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
// Back to GL: reopening the milestone is what re-arms the Duty & tax step
|
||||||
|
// (the stepper picks its active step from milestone completion).
|
||||||
|
await this.milestoneService.reopenForBooking(bookingId, 'DUTY_TAXES_ADVISED');
|
||||||
|
await this.bookingsRepository.update(bookingId, {
|
||||||
|
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const updated = await this.bookingsService.findById(bookingId);
|
||||||
|
this.notifier.dutyDisputed(updated, note.trim());
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise<Booking> {
|
async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise<Booking> {
|
||||||
const booking = await this.loadBooking(bookingId);
|
const booking = await this.loadBooking(bookingId);
|
||||||
if (booking.tradeDirection !== 'IMPORT') {
|
if (booking.tradeDirection !== 'IMPORT') {
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { BookingClearanceService } from './booking-clearance.service';
|
||||||
|
import type { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The duty advice → dispute → re-advice loop, at the booking level. GL
|
||||||
|
* Ethiopia advises an amount; the customer either pays it or sends it back
|
||||||
|
* with a reason. Sending it back reopens the advice milestone — that is what
|
||||||
|
* puts the Duty & tax step back in GL's hands — and the round can repeat
|
||||||
|
* until the amount is agreed.
|
||||||
|
*/
|
||||||
|
describe('BookingClearanceService — duty dispute', () => {
|
||||||
|
const booking = (over: Partial<Booking> = {}): Booking =>
|
||||||
|
({
|
||||||
|
id: 'bk-1',
|
||||||
|
reference: 'BKG-2026-00042',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
customsClearingEnabled: true,
|
||||||
|
contractId: 'ctr-1',
|
||||||
|
dutyRequired: true,
|
||||||
|
...over,
|
||||||
|
}) as Booking;
|
||||||
|
|
||||||
|
const milestone = (code: string, status: string) =>
|
||||||
|
({ milestoneCode: code, status }) as never;
|
||||||
|
|
||||||
|
let repo: {
|
||||||
|
createReviewNote: jest.Mock;
|
||||||
|
findReviewNotes: jest.Mock;
|
||||||
|
update: jest.Mock;
|
||||||
|
};
|
||||||
|
let bookingsService: { findById: jest.Mock };
|
||||||
|
let workflowService: { listMilestonesForBooking: jest.Mock };
|
||||||
|
let milestoneService: { reopenForBooking: jest.Mock };
|
||||||
|
let notifier: { dutyDisputed: jest.Mock };
|
||||||
|
let service: BookingClearanceService;
|
||||||
|
|
||||||
|
const build = (milestones: unknown[]) => {
|
||||||
|
workflowService.listMilestonesForBooking.mockResolvedValue(milestones);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repo = {
|
||||||
|
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||||
|
findReviewNotes: jest.fn().mockResolvedValue([]),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
bookingsService = { findById: jest.fn().mockResolvedValue(booking()) };
|
||||||
|
workflowService = { listMilestonesForBooking: jest.fn().mockResolvedValue([]) };
|
||||||
|
milestoneService = { reopenForBooking: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
notifier = { dutyDisputed: jest.fn() };
|
||||||
|
|
||||||
|
service = new BookingClearanceService(
|
||||||
|
repo as never,
|
||||||
|
bookingsService as never,
|
||||||
|
{} as never, // filesService
|
||||||
|
{} as never, // fileUploadSettingsService
|
||||||
|
workflowService as never,
|
||||||
|
milestoneService as never,
|
||||||
|
{} as never, // dropdownSettingsService
|
||||||
|
{} as never, // glOperationsService
|
||||||
|
notifier as never,
|
||||||
|
);
|
||||||
|
build([
|
||||||
|
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
||||||
|
milestone('DUTY_TAX_PAID', 'PENDING'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records the objection and hands the step back to GL', async () => {
|
||||||
|
await service.disputeDuty('bk-1', ' Declared value is wrong ', 'user-1');
|
||||||
|
|
||||||
|
expect(repo.createReviewNote).toHaveBeenCalledWith(
|
||||||
|
'bk-1',
|
||||||
|
'Declared value is wrong',
|
||||||
|
'DUTY_DISPUTE',
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
// Reopening the advice milestone is what re-arms the Duty & tax step.
|
||||||
|
expect(milestoneService.reopenForBooking).toHaveBeenCalledWith(
|
||||||
|
'bk-1',
|
||||||
|
'DUTY_TAXES_ADVISED',
|
||||||
|
);
|
||||||
|
expect(repo.update).toHaveBeenCalledWith('bk-1', {
|
||||||
|
clearanceCurrentPhase: 'GL_ET_OUTPUT',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells GL Ethiopia, not the customer', async () => {
|
||||||
|
await service.disputeDuty('bk-1', 'Too high', 'user-1');
|
||||||
|
expect(notifier.dutyDisputed).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: 'bk-1' }),
|
||||||
|
'Too high',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a reason — GL cannot correct an unexplained objection', async () => {
|
||||||
|
await expect(service.disputeDuty('bk-1', ' ')).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(milestoneService.reopenForBooking).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses when nothing has been advised yet', async () => {
|
||||||
|
build([milestone('DUTY_TAXES_ADVISED', 'PENDING')]);
|
||||||
|
await expect(service.disputeDuty('bk-1', 'Too high')).rejects.toThrow(
|
||||||
|
/no advised duty amount/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses once the payment slip is in — that is a refund, not a re-advice', async () => {
|
||||||
|
build([
|
||||||
|
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
||||||
|
milestone('DUTY_TAX_PAID', 'COMPLETED'),
|
||||||
|
]);
|
||||||
|
await expect(service.disputeDuty('bk-1', 'Too high')).rejects.toThrow(
|
||||||
|
/already been submitted/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses when duty was never required for this clearance', async () => {
|
||||||
|
bookingsService.findById.mockResolvedValue(booking({ dutyRequired: false }));
|
||||||
|
await expect(service.disputeDuty('bk-1', 'Too high')).rejects.toThrow(
|
||||||
|
/not required/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the view', () => {
|
||||||
|
const buildDispute = (milestones: unknown[]) =>
|
||||||
|
(
|
||||||
|
service as unknown as {
|
||||||
|
buildDutyDispute: (id: string, m: unknown[]) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
).buildDutyDispute('bk-1', milestones);
|
||||||
|
|
||||||
|
it('shows the objection while GL still owes a corrected advice', async () => {
|
||||||
|
repo.findReviewNotes.mockResolvedValue([
|
||||||
|
{ note: 'Second look please', createdAt: new Date('2026-07-20T09:00:00Z') },
|
||||||
|
{ note: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const dispute = await buildDispute([
|
||||||
|
milestone('DUTY_TAXES_ADVISED', 'PENDING'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(dispute).toMatchObject({ note: 'Second look please', rounds: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears itself once GL re-advises', async () => {
|
||||||
|
repo.findReviewNotes.mockResolvedValue([
|
||||||
|
{ note: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const dispute = await buildDispute([
|
||||||
|
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(dispute).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,12 +10,14 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
|
Textarea,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
FileBadge,
|
FileBadge,
|
||||||
|
MessageSquareWarning,
|
||||||
Receipt,
|
Receipt,
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -84,6 +86,9 @@ export function BookingClearanceWorkflowBanner({
|
|||||||
clearance.dutyRequired &&
|
clearance.dutyRequired &&
|
||||||
clearance.dutyAdvice &&
|
clearance.dutyAdvice &&
|
||||||
!dutyPaid;
|
!dutyPaid;
|
||||||
|
// A dispute clears the advice while it's open — show the "waiting on GL"
|
||||||
|
// state instead of the pay panel until GL re-advises.
|
||||||
|
const dutyDisputePending = Boolean(clearance.dutyDispute);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
||||||
@@ -109,11 +114,13 @@ export function BookingClearanceWorkflowBanner({
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{dutyPending && clearance.dutyAdvice ? (
|
{dutyDisputePending && clearance.dutyDispute ? (
|
||||||
|
<DutyDisputePendingCard dispute={clearance.dutyDispute} />
|
||||||
|
) : dutyPending && clearance.dutyAdvice ? (
|
||||||
<DutyAdvicePanel
|
<DutyAdvicePanel
|
||||||
dutyAdvice={clearance.dutyAdvice}
|
dutyAdvice={clearance.dutyAdvice}
|
||||||
bookingId={booking.id}
|
bookingId={booking.id}
|
||||||
onUploaded={() => void refetch()}
|
onChanged={() => void refetch()}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -178,14 +185,17 @@ export function BookingClearanceWorkflowBanner({
|
|||||||
function DutyAdvicePanel({
|
function DutyAdvicePanel({
|
||||||
dutyAdvice,
|
dutyAdvice,
|
||||||
bookingId,
|
bookingId,
|
||||||
onUploaded,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
dutyAdvice: NonNullable<Freight.ClearanceView["dutyAdvice"]>;
|
dutyAdvice: NonNullable<Freight.ClearanceView["dutyAdvice"]>;
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
onUploaded: () => void;
|
onChanged: () => 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);
|
||||||
|
const [disputing, setDisputing] = useState(false);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [submittingDispute, setSubmittingDispute] = useState(false);
|
||||||
const noticeFile = dutyAdvice.noticeFile;
|
const noticeFile = dutyAdvice.noticeFile;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -229,7 +239,7 @@ function DutyAdvicePanel({
|
|||||||
try {
|
try {
|
||||||
await bookingsService.uploadBookingClearanceDutySlip(bookingId, file);
|
await bookingsService.uploadBookingClearanceDutySlip(bookingId, file);
|
||||||
toast.success("Payment slip uploaded");
|
toast.success("Payment slip uploaded");
|
||||||
onUploaded();
|
onChanged();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Upload failed");
|
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -239,11 +249,101 @@ function DutyAdvicePanel({
|
|||||||
>
|
>
|
||||||
Submit payment slip
|
Submit payment slip
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{disputing ? (
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Textarea
|
||||||
|
label="What's wrong with this amount?"
|
||||||
|
placeholder="Explain why you're disputing the advised duty/tax…"
|
||||||
|
minRows={2}
|
||||||
|
autosize
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => {
|
||||||
|
setDisputing(false);
|
||||||
|
setNote("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
size="xs"
|
||||||
|
loading={submittingDispute}
|
||||||
|
disabled={!note.trim()}
|
||||||
|
onClick={async () => {
|
||||||
|
setSubmittingDispute(true);
|
||||||
|
try {
|
||||||
|
await bookingsService.disputeBookingClearanceDuty(
|
||||||
|
bookingId,
|
||||||
|
note.trim(),
|
||||||
|
);
|
||||||
|
toast.success("Sent to GL Ethiopia for review");
|
||||||
|
setDisputing(false);
|
||||||
|
setNote("");
|
||||||
|
onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Failed to send");
|
||||||
|
} finally {
|
||||||
|
setSubmittingDispute(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Submit request
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Anchor
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
c="dimmed"
|
||||||
|
onClick={() => setDisputing(true)}
|
||||||
|
>
|
||||||
|
Not right? Request a change
|
||||||
|
</Anchor>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The customer's dispute is open — GL Ethiopia owes a corrected advice. */
|
||||||
|
function DutyDisputePendingCard({
|
||||||
|
dispute,
|
||||||
|
}: {
|
||||||
|
dispute: NonNullable<Freight.ClearanceView["dutyDispute"]>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
color="orange"
|
||||||
|
radius="md"
|
||||||
|
icon={<MessageSquareWarning size={16} />}
|
||||||
|
title={
|
||||||
|
dispute.rounds > 1
|
||||||
|
? `Waiting on GL Ethiopia (round ${dispute.rounds})`
|
||||||
|
: "Waiting on GL Ethiopia"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{dispute.note}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Sent {new Date(dispute.raisedAt).toLocaleString()} — you'll see the
|
||||||
|
corrected amount here once GL Ethiopia re-advises.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function GroupLabel({ icon: Icon, text }: { icon: typeof Receipt; text: string }) {
|
function GroupLabel({ icon: Icon, text }: { icon: typeof Receipt; text: string }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
|||||||
@@ -398,6 +398,16 @@ export const bookingsService = {
|
|||||||
return data.data ?? data;
|
return data.data ?? data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
disputeBookingClearanceDuty: async (
|
||||||
|
id: string,
|
||||||
|
note: string,
|
||||||
|
): Promise<Freight.IBooking> => {
|
||||||
|
const { data } = await client.post(`/api/bookings/${id}/clearance/duty/dispute`, {
|
||||||
|
note,
|
||||||
|
});
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
getContractView: async (id: string): Promise<ContractView> => {
|
getContractView: async (id: string): Promise<ContractView> => {
|
||||||
const { data } = await client.get(B.CONTRACT_VIEW(id));
|
const { data } = await client.get(B.CONTRACT_VIEW(id));
|
||||||
return data.data ?? data;
|
return data.data ?? data;
|
||||||
|
|||||||
@@ -800,6 +800,15 @@ export interface ClearanceView {
|
|||||||
declarationSerial?: string | null;
|
declarationSerial?: string | null;
|
||||||
noticeFile?: { id: string; name: string; url: string } | null;
|
noticeFile?: { id: string; name: string; url: string } | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
/**
|
||||||
|
* The customer's open objection to the advised duty. Present only until GL
|
||||||
|
* re-advises; `rounds` counts how many times it has been sent back.
|
||||||
|
*/
|
||||||
|
dutyDispute?: {
|
||||||
|
note: string;
|
||||||
|
raisedAt: string;
|
||||||
|
rounds: number;
|
||||||
|
} | null;
|
||||||
/** Phased customs uploads (IM4, DO, transit permit, etc.) with friendly labels. */
|
/** Phased customs uploads (IM4, DO, transit permit, etc.) with friendly labels. */
|
||||||
workflowFiles?: import("./clearance-files.catalog").ClearanceWorkflowFile[];
|
workflowFiles?: import("./clearance-files.catalog").ClearanceWorkflowFile[];
|
||||||
/** Import post-allocation T1 transit document state (null until wagon allocation). */
|
/** Import post-allocation T1 transit document state (null until wagon allocation). */
|
||||||
|
|||||||
Reference in New Issue
Block a user