mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
refactor: remove gate pass granting logic from clearance services and UI
- Removed the gate pass granting functionality from the BookingClearanceService and ContractClearanceService, replacing it with a new method to retrieve gate pass status from train schedules. - Updated the ContractsController to eliminate endpoints related to gate pass granting. - Refactored the UI components (ExportClearanceStepper and PhasedClearanceActionPanel) to reflect the new gate pass securing process, linking to the train scheduling interface instead. - Cleaned up related constants and query hooks, removing unused code and references to the gate pass functionality. - Adjusted types in the contracts to accommodate changes in the gate pass handling logic.
This commit is contained in:
@@ -205,7 +205,7 @@ export class BookingClearanceService {
|
|||||||
const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId);
|
const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId);
|
||||||
const bookingMilestone = (code: string) =>
|
const bookingMilestone = (code: string) =>
|
||||||
milestones.find((m) => m.milestoneCode === code);
|
milestones.find((m) => m.milestoneCode === code);
|
||||||
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
|
const gatepass = await this.glOperationsService.gatepassForBooking(bookingId);
|
||||||
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
|
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
|
||||||
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
|
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
|
||||||
const secondDuty = this.glOperationsService.secondDutyState(milestones, files);
|
const secondDuty = this.glOperationsService.secondDutyState(milestones, files);
|
||||||
@@ -242,14 +242,8 @@ export class BookingClearanceService {
|
|||||||
workflowFiles,
|
workflowFiles,
|
||||||
t1,
|
t1,
|
||||||
train,
|
train,
|
||||||
gatepassGranted: gatepassMilestone?.status === 'COMPLETED',
|
gatepassGranted: gatepass.granted,
|
||||||
gatepassAt:
|
gatepassAt: gatepass.grantedAt,
|
||||||
gatepassMilestone?.status === 'COMPLETED'
|
|
||||||
? (gatepassMilestone.metadata?.gatepassAt ??
|
|
||||||
(gatepassMilestone.triggeredAt
|
|
||||||
? gatepassMilestone.triggeredAt.toISOString()
|
|
||||||
: null))
|
|
||||||
: null,
|
|
||||||
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
|
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
|
||||||
t1ClosedAt:
|
t1ClosedAt:
|
||||||
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
|
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
|
||||||
|
|||||||
@@ -278,7 +278,9 @@ export class ContractClearanceService {
|
|||||||
}
|
}
|
||||||
const bookingMilestone = (code: string) =>
|
const bookingMilestone = (code: string) =>
|
||||||
bookingMilestones.find((m) => m.milestoneCode === code);
|
bookingMilestones.find((m) => m.milestoneCode === code);
|
||||||
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
|
const gatepass = cycle?.bookingId
|
||||||
|
? await this.glOperationsService.gatepassForBooking(cycle.bookingId)
|
||||||
|
: { granted: false, grantedAt: null };
|
||||||
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
|
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
|
||||||
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
|
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
|
||||||
const secondDuty = this.glOperationsService.secondDutyState(
|
const secondDuty = this.glOperationsService.secondDutyState(
|
||||||
@@ -344,14 +346,8 @@ export class ContractClearanceService {
|
|||||||
workflowFiles,
|
workflowFiles,
|
||||||
t1,
|
t1,
|
||||||
train,
|
train,
|
||||||
gatepassGranted: gatepassMilestone?.status === 'COMPLETED',
|
gatepassGranted: gatepass.granted,
|
||||||
gatepassAt:
|
gatepassAt: gatepass.grantedAt,
|
||||||
gatepassMilestone?.status === 'COMPLETED'
|
|
||||||
? (gatepassMilestone.metadata?.gatepassAt ??
|
|
||||||
(gatepassMilestone.triggeredAt
|
|
||||||
? gatepassMilestone.triggeredAt.toISOString()
|
|
||||||
: null))
|
|
||||||
: null,
|
|
||||||
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
|
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
|
||||||
t1ClosedAt:
|
t1ClosedAt:
|
||||||
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
|
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ import {
|
|||||||
} from './dto/gl-operations.dto';
|
} from './dto/gl-operations.dto';
|
||||||
import {
|
import {
|
||||||
AdviseContractDutyDto,
|
AdviseContractDutyDto,
|
||||||
GatepassDto,
|
|
||||||
RoAmendmentDto,
|
RoAmendmentDto,
|
||||||
} from './dto/phased-clearance.dto';
|
} from './dto/phased-clearance.dto';
|
||||||
|
|
||||||
@@ -688,30 +687,6 @@ export class ContractsController {
|
|||||||
return this.clearanceService.djQueue(filter);
|
return this.clearanceService.djQueue(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('clearance/dj-schedules')
|
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
|
||||||
@ApiOperation({ summary: 'Train schedules carrying customs bookings — GL DJ gate-pass table' })
|
|
||||||
djClearanceSchedules() {
|
|
||||||
return this.glOperationsService.djSchedules();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('clearance/schedules/:scheduleId/gatepass')
|
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'GL DJ grants the gate pass for every customs booking on a train schedule',
|
|
||||||
})
|
|
||||||
grantScheduleGatepass(
|
|
||||||
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
|
|
||||||
@Body() dto: GatepassDto,
|
|
||||||
@CurrentUser() user: AuthUserPayload,
|
|
||||||
) {
|
|
||||||
return this.glOperationsService.grantScheduleGatepass(
|
|
||||||
scheduleId,
|
|
||||||
dto?.gatepassAt,
|
|
||||||
resolveAuthUserId(user),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
|
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
|
||||||
|
|
||||||
@Get('clearance/ops-queue')
|
@Get('clearance/ops-queue')
|
||||||
@@ -947,21 +922,6 @@ export class ContractsController {
|
|||||||
return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user));
|
return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('bookings/:bookingId/gatepass')
|
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
|
||||||
@ApiOperation({ summary: 'GL DJ grants the gate pass for a customs booking (captures time)' })
|
|
||||||
grantGatepass(
|
|
||||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
|
||||||
@Body() dto: GatepassDto,
|
|
||||||
@CurrentUser() user: AuthUserPayload,
|
|
||||||
) {
|
|
||||||
return this.glOperationsService.grantGatepass(
|
|
||||||
bookingId,
|
|
||||||
dto?.gatepassAt,
|
|
||||||
resolveAuthUserId(user),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('bookings/:bookingId/final-invoice')
|
@Post('bookings/:bookingId/final-invoice')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
|||||||
@@ -36,11 +36,3 @@ export class RoAmendmentDto {
|
|||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GatepassDto {
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'When the gate pass was granted (ISO datetime; defaults to now)',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
gatepassAt?: string;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DataSource, In, IsNull } from 'typeorm';
|
import { DataSource, IsNull } from 'typeorm';
|
||||||
import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types';
|
import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types';
|
||||||
|
|
||||||
import { BillingService } from '../billing/billing.service';
|
import { BillingService } from '../billing/billing.service';
|
||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
ClearanceIncident,
|
ClearanceIncident,
|
||||||
IncidentType,
|
IncidentType,
|
||||||
} from './entities/clearance-incident.entity';
|
} from './entities/clearance-incident.entity';
|
||||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
|
||||||
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import {
|
import {
|
||||||
@@ -198,6 +197,7 @@ export class GlOperationsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
scheduleId: schedule?.id ?? null,
|
||||||
wagonAllocated,
|
wagonAllocated,
|
||||||
departedAt: schedule?.actualDepartureAt
|
departedAt: schedule?.actualDepartureAt
|
||||||
? new Date(schedule.actualDepartureAt).toISOString()
|
? new Date(schedule.actualDepartureAt).toISOString()
|
||||||
@@ -208,6 +208,41 @@ export class GlOperationsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate pass status for a booking, sourced from the train schedule's Djibouti
|
||||||
|
* gate-pass operation (secured via the train-scheduling "Save as Secured"
|
||||||
|
* action) rather than a clearance milestone. For EXPORT bookings this also
|
||||||
|
* backfills the arrival-chain milestones once secured, same as the retired
|
||||||
|
* clearance-side grant action used to.
|
||||||
|
*/
|
||||||
|
async gatepassForBooking(
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<{ granted: boolean; grantedAt: string | null }> {
|
||||||
|
const train = await this.trainState(bookingId);
|
||||||
|
if (!train.scheduleId) return { granted: false, grantedAt: null };
|
||||||
|
const operation = await this.dataSource
|
||||||
|
.getRepository(ImportDjiboutiOperation)
|
||||||
|
.findOne({ where: { trainScheduleId: train.scheduleId } });
|
||||||
|
const grantedAt = operation?.gatepassGrantedAt
|
||||||
|
? new Date(operation.gatepassGrantedAt).toISOString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (grantedAt) {
|
||||||
|
const booking = await this.getBooking(bookingId);
|
||||||
|
if ((booking.tradeDirection ?? 'IMPORT') === 'EXPORT') {
|
||||||
|
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||||
|
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
||||||
|
for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) {
|
||||||
|
if (byCode.get(code)?.status === 'PENDING') {
|
||||||
|
await this.milestoneService.completeForBooking(bookingId, code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { granted: Boolean(grantedAt), grantedAt };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* T1 transit-document lifecycle state for an import shipment booking. Wagon
|
* T1 transit-document lifecycle state for an import shipment booking. Wagon
|
||||||
* allocation opens the upload window; train departure locks it; train arrival
|
* allocation opens the upload window; train departure locks it; train arrival
|
||||||
@@ -302,8 +337,11 @@ export class GlOperationsService {
|
|||||||
'The transport document must be uploaded before T1 can be closed.',
|
'The transport document must be uploaded before T1 can be closed.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!done('GATEPASS_GRANTED')) {
|
const gatepass = await this.gatepassForBooking(bookingId);
|
||||||
throw new BadRequestException('Grant the gate pass before closing T1.');
|
if (!gatepass.granted) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Secure the Djibouti gate pass on the train schedule before closing T1.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Export bookings seeded before T1_CLOSED joined the catalog lack the row.
|
// Export bookings seeded before T1_CLOSED joined the catalog lack the row.
|
||||||
await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection);
|
await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection);
|
||||||
@@ -322,182 +360,6 @@ export class GlOperationsService {
|
|||||||
'ARRIVED_AT_DJIBOUTI',
|
'ARRIVED_AT_DJIBOUTI',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* GL Djibouti grants the gate pass for a customs booking, capturing the time.
|
|
||||||
* Export: requires the train to have arrived at Djibouti; back-fills the
|
|
||||||
* arrival-chain milestones. Import: requires wagon allocation (pre-loading).
|
|
||||||
*/
|
|
||||||
async grantGatepass(
|
|
||||||
bookingId: string,
|
|
||||||
gatepassAt?: string,
|
|
||||||
userId?: string,
|
|
||||||
): Promise<{ bookingId: string; gatepassAt: string }> {
|
|
||||||
const booking = await this.getBooking(bookingId);
|
|
||||||
if (!booking.customsClearingEnabled) {
|
|
||||||
throw new BadRequestException('Gate pass applies to customs bookings only.');
|
|
||||||
}
|
|
||||||
const tradeDirection = booking.tradeDirection ?? 'IMPORT';
|
|
||||||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
|
||||||
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
|
||||||
|
|
||||||
const existing = byCode.get('GATEPASS_GRANTED');
|
|
||||||
if (existing?.status === 'COMPLETED') {
|
|
||||||
return {
|
|
||||||
bookingId,
|
|
||||||
gatepassAt:
|
|
||||||
existing.metadata?.gatepassAt ??
|
|
||||||
(existing.triggeredAt ? new Date(existing.triggeredAt).toISOString() : ''),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const train = await this.trainState(bookingId);
|
|
||||||
if (tradeDirection === 'EXPORT') {
|
|
||||||
if (!train.arrivedAt) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'The train has not arrived at Djibouti yet — gate pass can be granted after arrival.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) {
|
|
||||||
if (byCode.get(code)?.status === 'PENDING') {
|
|
||||||
await this.milestoneService.completeForBooking(bookingId, code, userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (!train.wagonAllocated) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'Wagons must be allocated before the gate pass can be granted.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const at = gatepassAt?.trim() || new Date().toISOString();
|
|
||||||
await this.milestoneService.completeWithMetadataForBooking(
|
|
||||||
bookingId,
|
|
||||||
'GATEPASS_GRANTED',
|
|
||||||
{ gatepassAt: at },
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
return { bookingId, gatepassAt: at };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Train schedules carrying ≥1 customs booking — the GL Djibouti gate-pass table. */
|
|
||||||
async djSchedules(): Promise<Freight.DjClearanceSchedule[]> {
|
|
||||||
const schedules = await this.dataSource.getRepository(TrainSchedule).find({
|
|
||||||
relations: {
|
|
||||||
scheduleBookings: { booking: true },
|
|
||||||
originStation: true,
|
|
||||||
destinationStation: true,
|
|
||||||
},
|
|
||||||
order: { scheduledDepartureDate: 'DESC' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const withCustoms = schedules
|
|
||||||
.filter((s) => s.status !== 'CANCELLED')
|
|
||||||
.map((s) => ({
|
|
||||||
schedule: s,
|
|
||||||
customs: (s.scheduleBookings ?? [])
|
|
||||||
.map((sb) => sb.booking)
|
|
||||||
.filter((b): b is Booking => Boolean(b?.customsClearingEnabled)),
|
|
||||||
}))
|
|
||||||
.filter((s) => s.customs.length > 0);
|
|
||||||
|
|
||||||
const bookingIds = withCustoms.flatMap((s) => s.customs.map((b) => b.id));
|
|
||||||
const gatepassRows = bookingIds.length
|
|
||||||
? await this.dataSource.getRepository(ClearanceMilestone).find({
|
|
||||||
where: { bookingId: In(bookingIds), milestoneCode: 'GATEPASS_GRANTED' },
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
const gatepassByBooking = new Map(gatepassRows.map((m) => [m.bookingId, m]));
|
|
||||||
|
|
||||||
return withCustoms.map(({ schedule, customs }) => {
|
|
||||||
const freightTypes = [...new Set(customs.map((b) => b.freightType).filter(Boolean))];
|
|
||||||
return {
|
|
||||||
id: schedule.id,
|
|
||||||
trainNumber: schedule.trainNumber ?? null,
|
|
||||||
routeName: null,
|
|
||||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
|
||||||
destination:
|
|
||||||
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
|
||||||
status: schedule.status,
|
|
||||||
scheduledDepartureDate: schedule.scheduledDepartureDate
|
|
||||||
? new Date(schedule.scheduledDepartureDate).toISOString()
|
|
||||||
: null,
|
|
||||||
actualDepartureAt: schedule.actualDepartureAt
|
|
||||||
? new Date(schedule.actualDepartureAt).toISOString()
|
|
||||||
: null,
|
|
||||||
actualArrivalAt: schedule.actualArrivalAt
|
|
||||||
? new Date(schedule.actualArrivalAt).toISOString()
|
|
||||||
: null,
|
|
||||||
freightType:
|
|
||||||
freightTypes.length === 1 ? (freightTypes[0] as string) : freightTypes.length ? 'MIXED' : null,
|
|
||||||
customsBookings: customs.map((b) => {
|
|
||||||
const m = gatepassByBooking.get(b.id);
|
|
||||||
const granted = m?.status === 'COMPLETED';
|
|
||||||
return {
|
|
||||||
bookingId: b.id,
|
|
||||||
reference: b.reference ?? b.id,
|
|
||||||
tradeDirection: b.tradeDirection ?? 'IMPORT',
|
|
||||||
contractId: b.contractId ?? null,
|
|
||||||
gatepassGranted: granted,
|
|
||||||
gatepassAt: granted
|
|
||||||
? (m?.metadata?.gatepassAt ??
|
|
||||||
(m?.triggeredAt ? new Date(m.triggeredAt).toISOString() : null))
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One-click gate pass for every customs booking on a train schedule. Per-booking
|
|
||||||
* guard failures are collected, not fatal. Import schedules also get the
|
|
||||||
* schedule-level ImportDjiboutiOperation gate pass so loading unblocks.
|
|
||||||
*/
|
|
||||||
async grantScheduleGatepass(
|
|
||||||
scheduleId: string,
|
|
||||||
gatepassAt?: string,
|
|
||||||
userId?: string,
|
|
||||||
): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> {
|
|
||||||
const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({
|
|
||||||
where: { id: scheduleId },
|
|
||||||
relations: { scheduleBookings: { booking: true } },
|
|
||||||
});
|
|
||||||
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
|
||||||
|
|
||||||
const customs = (schedule.scheduleBookings ?? [])
|
|
||||||
.map((sb) => sb.booking)
|
|
||||||
.filter((b): b is Booking => Boolean(b?.customsClearingEnabled));
|
|
||||||
if (customs.length === 0) {
|
|
||||||
throw new BadRequestException('No customs bookings ride this schedule.');
|
|
||||||
}
|
|
||||||
|
|
||||||
let granted = 0;
|
|
||||||
const skipped: Array<{ bookingId: string; error: string }> = [];
|
|
||||||
for (const booking of customs) {
|
|
||||||
try {
|
|
||||||
await this.grantGatepass(booking.id, gatepassAt, userId);
|
|
||||||
granted += 1;
|
|
||||||
} catch (e) {
|
|
||||||
skipped.push({
|
|
||||||
bookingId: booking.id,
|
|
||||||
error: e instanceof Error ? e.message : 'Failed',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (granted > 0 && customs.some((b) => (b.tradeDirection ?? 'IMPORT') === 'IMPORT')) {
|
|
||||||
const opRepo = this.dataSource.getRepository(ImportDjiboutiOperation);
|
|
||||||
let operation = await opRepo.findOne({ where: { trainScheduleId: scheduleId } });
|
|
||||||
if (!operation) {
|
|
||||||
operation = opRepo.create({ trainScheduleId: scheduleId });
|
|
||||||
}
|
|
||||||
if (!operation.gatepassGrantedAt) {
|
|
||||||
operation.gatepassGrantedAt = gatepassAt ? new Date(gatepassAt) : new Date();
|
|
||||||
await opRepo.save(operation);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { granted, skipped };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GL Djibouti raises the post-offload final invoice (export): manual amount +
|
* GL Djibouti raises the post-offload final invoice (export): manual amount +
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { DateInput, DateTimePicker } from "@mantine/dates";
|
import { DateInput } from "@mantine/dates";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -397,15 +397,10 @@ export function ExportClearanceStepper({
|
|||||||
|
|
||||||
<Stepper.Step
|
<Stepper.Step
|
||||||
label="Gate pass"
|
label="Gate pass"
|
||||||
description="GL Djibouti grants after arrival"
|
description="Secured on the train schedule after arrival"
|
||||||
icon={clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />}
|
icon={clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />}
|
||||||
>
|
>
|
||||||
<GatepassStep
|
<GatepassStep clearance={clearance} />
|
||||||
bookingId={actionBookingId}
|
|
||||||
clearance={clearance}
|
|
||||||
canAct={showDj && canDj}
|
|
||||||
onChanged={onChanged}
|
|
||||||
/>
|
|
||||||
</Stepper.Step>
|
</Stepper.Step>
|
||||||
|
|
||||||
<Stepper.Step
|
<Stepper.Step
|
||||||
@@ -491,27 +486,19 @@ function ConfirmExportReleaseFallback({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GatepassStep({
|
/**
|
||||||
bookingId,
|
* Gate pass status, read-only. Secured on the train schedule's "Save as
|
||||||
clearance,
|
* Secured" action (train-scheduling-v2) — clearance no longer grants it directly.
|
||||||
canAct,
|
*/
|
||||||
onChanged,
|
function GatepassStep({ clearance }: { clearance: ClearanceViewLike }) {
|
||||||
}: {
|
const scheduleId = clearance.train?.scheduleId ?? null;
|
||||||
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) {
|
if (clearance.gatepassGranted) {
|
||||||
return (
|
return (
|
||||||
<StepStatus
|
<StepStatus
|
||||||
done
|
done
|
||||||
pendingLabel=""
|
pendingLabel=""
|
||||||
doneLabel={`Gate pass granted${
|
doneLabel={`Gate pass secured${
|
||||||
clearance.gatepassAt ? ` · ${new Date(clearance.gatepassAt).toLocaleString()}` : ""
|
clearance.gatepassAt ? ` · ${new Date(clearance.gatepassAt).toLocaleString()}` : ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
@@ -526,68 +513,21 @@ function GatepassStep({
|
|||||||
done={false}
|
done={false}
|
||||||
pendingLabel={
|
pendingLabel={
|
||||||
arrived
|
arrived
|
||||||
? "Train arrived — GL Djibouti can grant the gate pass."
|
? "Train arrived — secure the gate pass on the train schedule."
|
||||||
: "Available once the train arrives at Djibouti."
|
: "Available once the train arrives at Djibouti."
|
||||||
}
|
}
|
||||||
doneLabel=""
|
doneLabel=""
|
||||||
/>
|
/>
|
||||||
{canAct && bookingId ? (
|
{scheduleId ? (
|
||||||
<>
|
<Button
|
||||||
<Button
|
component="a"
|
||||||
color="edr-green"
|
href={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
|
||||||
leftSection={<Truck size={16} />}
|
variant="light"
|
||||||
disabled={!arrived}
|
color="edr-green"
|
||||||
onClick={() => {
|
leftSection={<Truck size={16} />}
|
||||||
setAt(new Date());
|
>
|
||||||
setOpened(true);
|
Secure gate pass on train schedule
|
||||||
}}
|
</Button>
|
||||||
>
|
|
||||||
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}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
Modal,
|
|
||||||
NumberInput,
|
NumberInput,
|
||||||
Paper,
|
Paper,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
@@ -15,7 +14,6 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { DateTimePicker } from "@mantine/dates";
|
|
||||||
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||||
import {
|
import {
|
||||||
TransitPermitMultiUpload,
|
TransitPermitMultiUpload,
|
||||||
@@ -536,17 +534,12 @@ export function PhasedClearanceActionPanel({
|
|||||||
|
|
||||||
<Stepper.Step
|
<Stepper.Step
|
||||||
label="Gate pass"
|
label="Gate pass"
|
||||||
description="GL Djibouti grants after wagon allocation"
|
description="Secured on the train schedule after wagon allocation"
|
||||||
icon={
|
icon={
|
||||||
clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />
|
clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ImportGatepassStep
|
<ImportGatepassStep clearance={clearance} />
|
||||||
bookingId={actionBookingId}
|
|
||||||
clearance={clearance}
|
|
||||||
canAct={showDj && canDj}
|
|
||||||
onChanged={onChanged}
|
|
||||||
/>
|
|
||||||
</Stepper.Step>
|
</Stepper.Step>
|
||||||
|
|
||||||
<Stepper.Step
|
<Stepper.Step
|
||||||
@@ -816,28 +809,19 @@ function ImportT1Section({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GL DJ grants the import gate pass once wagons are allocated (captures time). */
|
/**
|
||||||
function ImportGatepassStep({
|
* Gate pass status, read-only. Secured on the train schedule's "Save as
|
||||||
bookingId,
|
* Secured" action (train-scheduling-v2) — clearance no longer grants it directly.
|
||||||
clearance,
|
*/
|
||||||
canAct,
|
function ImportGatepassStep({ clearance }: { clearance: ClearanceViewLike }) {
|
||||||
onChanged,
|
const scheduleId = clearance.train?.scheduleId ?? null;
|
||||||
}: {
|
|
||||||
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) {
|
if (clearance.gatepassGranted) {
|
||||||
return (
|
return (
|
||||||
<StepStatus
|
<StepStatus
|
||||||
done
|
done
|
||||||
pendingLabel=""
|
pendingLabel=""
|
||||||
doneLabel={`Gate pass granted${
|
doneLabel={`Gate pass secured${
|
||||||
clearance.gatepassAt ? ` · ${new Date(clearance.gatepassAt).toLocaleString()}` : ""
|
clearance.gatepassAt ? ` · ${new Date(clearance.gatepassAt).toLocaleString()}` : ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
@@ -852,68 +836,21 @@ function ImportGatepassStep({
|
|||||||
done={false}
|
done={false}
|
||||||
pendingLabel={
|
pendingLabel={
|
||||||
wagonAllocated
|
wagonAllocated
|
||||||
? "Wagons allocated — GL Djibouti can grant the gate pass."
|
? "Wagons allocated — secure the gate pass on the train schedule."
|
||||||
: "Available once wagons are allocated."
|
: "Available once wagons are allocated."
|
||||||
}
|
}
|
||||||
doneLabel=""
|
doneLabel=""
|
||||||
/>
|
/>
|
||||||
{canAct && bookingId ? (
|
{scheduleId ? (
|
||||||
<>
|
<Button
|
||||||
<Button
|
component="a"
|
||||||
color="edr-green"
|
href={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
|
||||||
leftSection={<Truck size={16} />}
|
variant="light"
|
||||||
disabled={!wagonAllocated}
|
color="edr-green"
|
||||||
onClick={() => {
|
leftSection={<Truck size={16} />}
|
||||||
setAt(new Date());
|
>
|
||||||
setOpened(true);
|
Secure gate pass on train schedule
|
||||||
}}
|
</Button>
|
||||||
>
|
|
||||||
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}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ export const QUERY_KEYS = {
|
|||||||
["contracts", "clearance-queue", region ?? "ET"] as const,
|
["contracts", "clearance-queue", region ?? "ET"] as const,
|
||||||
clearanceHistory: (region?: string) =>
|
clearanceHistory: (region?: string) =>
|
||||||
["contracts", "clearance-history", region ?? "ET"] as const,
|
["contracts", "clearance-history", region ?? "ET"] as const,
|
||||||
djSchedules: ["contracts", "clearance-dj-schedules"] as const,
|
|
||||||
milestones: (id: string) => ["contracts", "milestones", id] as const,
|
milestones: (id: string) => ["contracts", "milestones", id] as const,
|
||||||
capacity: (id: string) => ["contracts", "capacity", id] as const,
|
capacity: (id: string) => ["contracts", "capacity", id] as const,
|
||||||
bookingMilestones: (bookingId: string) =>
|
bookingMilestones: (bookingId: string) =>
|
||||||
|
|||||||
@@ -232,11 +232,6 @@ export const URL_CONSTANTS = {
|
|||||||
`/contracts/bookings/${bookingId}/t1-documents`,
|
`/contracts/bookings/${bookingId}/t1-documents`,
|
||||||
BOOKING_T1_CLOSE: (bookingId: string) =>
|
BOOKING_T1_CLOSE: (bookingId: string) =>
|
||||||
`/contracts/bookings/${bookingId}/t1-close`,
|
`/contracts/bookings/${bookingId}/t1-close`,
|
||||||
CLEARANCE_DJ_SCHEDULES: "/contracts/clearance/dj-schedules",
|
|
||||||
CLEARANCE_SCHEDULE_GATEPASS: (scheduleId: string) =>
|
|
||||||
`/contracts/clearance/schedules/${scheduleId}/gatepass`,
|
|
||||||
BOOKING_GATEPASS: (bookingId: string) =>
|
|
||||||
`/contracts/bookings/${bookingId}/gatepass`,
|
|
||||||
BOOKING_FINAL_INVOICE: (bookingId: string) =>
|
BOOKING_FINAL_INVOICE: (bookingId: string) =>
|
||||||
`/contracts/bookings/${bookingId}/final-invoice`,
|
`/contracts/bookings/${bookingId}/final-invoice`,
|
||||||
BOOKING_FINAL_INVOICE_CONFIRM: (bookingId: string) =>
|
BOOKING_FINAL_INVOICE_CONFIRM: (bookingId: string) =>
|
||||||
|
|||||||
@@ -68,15 +68,6 @@ export function useDjClearanceQueue(enabled = true) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Train schedules carrying customs bookings — GL DJ gate-pass table. */
|
|
||||||
export function useDjClearanceSchedules(enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: QUERY_KEYS.CONTRACTS.djSchedules,
|
|
||||||
queryFn: () => contractsService.getDjClearanceSchedules(),
|
|
||||||
enabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Path A self-clearance queue (Operations reviews non-customs contracts). */
|
/** Path A self-clearance queue (Operations reviews non-customs contracts). */
|
||||||
export function useOpsClearanceQueue(enabled = true) {
|
export function useOpsClearanceQueue(enabled = true) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -1,326 +1,65 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core";
|
||||||
Badge,
|
import { ChevronRight, Ship } from "lucide-react";
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Modal,
|
|
||||||
Stack,
|
|
||||||
Tabs,
|
|
||||||
Text,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { DateTimePicker } from "@mantine/dates";
|
|
||||||
import { ChevronRight, Ship, Train, Truck } from "lucide-react";
|
|
||||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
|
||||||
import type { Freight } from "@edr/types";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
|
|
||||||
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 {
|
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||||
useDjClearanceQueue,
|
|
||||||
useDjClearanceSchedules,
|
|
||||||
} from "@/hooks/contracts/useContracts";
|
|
||||||
import { contractsService } from "@/services/contracts.service";
|
|
||||||
|
|
||||||
export default function GlDjiboutiClearanceListPage() {
|
export default function GlDjiboutiClearanceListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue();
|
const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue();
|
||||||
const schedulesQuery = useDjClearanceSchedules();
|
|
||||||
|
|
||||||
const contractItems = contractQueue?.items ?? [];
|
const contractItems = contractQueue?.items ?? [];
|
||||||
const scheduleItems = schedulesQuery.data ?? [];
|
|
||||||
|
|
||||||
const [gatepassTarget, setGatepassTarget] =
|
|
||||||
useState<Freight.DjClearanceSchedule | null>(null);
|
|
||||||
const [gatepassAt, setGatepassAt] = useState<Date | null>(new Date());
|
|
||||||
const [granting, setGranting] = useState(false);
|
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<Freight.DjClearanceSchedule>[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
header: "Train",
|
|
||||||
accessorKey: "trainNumber",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Text size="sm" fw={700}>
|
|
||||||
{row.original.trainNumber ?? "—"}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "Route",
|
|
||||||
id: "route",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Text size="sm">
|
|
||||||
{row.original.origin ?? "—"} → {row.original.destination ?? "—"}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "Scheduled departure",
|
|
||||||
id: "scheduled",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Text size="sm">
|
|
||||||
{row.original.scheduledDepartureDate
|
|
||||||
? new Date(row.original.scheduledDepartureDate).toLocaleDateString()
|
|
||||||
: "—"}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "Departed",
|
|
||||||
id: "departed",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Text size="sm">
|
|
||||||
{row.original.actualDepartureAt
|
|
||||||
? new Date(row.original.actualDepartureAt).toLocaleString()
|
|
||||||
: "—"}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "Arrived",
|
|
||||||
id: "arrived",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Text size="sm">
|
|
||||||
{row.original.actualArrivalAt
|
|
||||||
? new Date(row.original.actualArrivalAt).toLocaleString()
|
|
||||||
: "—"}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "Status",
|
|
||||||
accessorKey: "status",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Badge variant="light" color={statusColor(row.original.status)} radius="sm">
|
|
||||||
{row.original.status}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "Customs bookings",
|
|
||||||
id: "customs",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const bookings = row.original.customsBookings;
|
|
||||||
const directions = [...new Set(bookings.map((b) => b.tradeDirection))];
|
|
||||||
return (
|
|
||||||
<Group gap={6} wrap="nowrap">
|
|
||||||
<Badge variant="light" color="edr-green" radius="sm">
|
|
||||||
{bookings.length}
|
|
||||||
</Badge>
|
|
||||||
{directions.map((d) => (
|
|
||||||
<Badge key={d} variant="outline" color={d === "IMPORT" ? "edr-green" : "blue"} radius="sm">
|
|
||||||
{d}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "Gate pass",
|
|
||||||
id: "gatepass",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const bookings = row.original.customsBookings;
|
|
||||||
const allGranted =
|
|
||||||
bookings.length > 0 && bookings.every((b) => b.gatepassGranted);
|
|
||||||
const grantedAt = bookings.find((b) => b.gatepassAt)?.gatepassAt ?? null;
|
|
||||||
if (allGranted) {
|
|
||||||
return (
|
|
||||||
<Badge variant="light" color="edr-green" radius="sm">
|
|
||||||
Granted{grantedAt ? ` · ${new Date(grantedAt).toLocaleString()}` : ""}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
color="edr-green"
|
|
||||||
leftSection={<Truck size={14} />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setGatepassAt(new Date());
|
|
||||||
setGatepassTarget(row.original);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Gate pass
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="GL Djibouti — Clearance"
|
title="GL Djibouti — Clearance"
|
||||||
subtitle="Customs contracts handed off to Djibouti GL, plus train schedules for gate-pass control."
|
subtitle="Customs contracts handed off to Djibouti GL."
|
||||||
/>
|
/>
|
||||||
<Tabs defaultValue="contracts" keepMounted={false}>
|
{contractsLoading ? (
|
||||||
<Tabs.List mb="md">
|
<Group justify="center" py={60}>
|
||||||
<Tabs.Tab value="contracts">Contracts ({contractItems.length})</Tabs.Tab>
|
<Loader color="edr-green" />
|
||||||
<Tabs.Tab value="schedules" leftSection={<Train size={14} />}>
|
</Group>
|
||||||
Schedules ({scheduleItems.length})
|
) : (
|
||||||
</Tabs.Tab>
|
<Stack gap="sm">
|
||||||
</Tabs.List>
|
{contractItems.length === 0 ? (
|
||||||
|
<Text c="dimmed" ta="center" py="xl">
|
||||||
<Tabs.Panel value="contracts">
|
No Djibouti customs contracts yet.
|
||||||
{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.
|
|
||||||
</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="schedules">
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={scheduleItems}
|
|
||||||
status={
|
|
||||||
schedulesQuery.isLoading
|
|
||||||
? "loading"
|
|
||||||
: schedulesQuery.isError
|
|
||||||
? "error"
|
|
||||||
: "success"
|
|
||||||
}
|
|
||||||
error={
|
|
||||||
schedulesQuery.isError
|
|
||||||
? {
|
|
||||||
message: "Failed to load train schedules.",
|
|
||||||
onRetry: () => void schedulesQuery.refetch(),
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
emptyMessage="No train schedules carry customs bookings yet."
|
|
||||||
/>
|
|
||||||
</Tabs.Panel>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
opened={gatepassTarget != null}
|
|
||||||
onClose={() => setGatepassTarget(null)}
|
|
||||||
title={
|
|
||||||
<Group gap={8}>
|
|
||||||
<Truck size={18} />
|
|
||||||
<Text fw={700}>
|
|
||||||
Gate pass — train {gatepassTarget?.trainNumber ?? ""}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
) : (
|
||||||
}
|
contractItems.map((c) => (
|
||||||
radius="md"
|
<Card
|
||||||
size="sm"
|
key={c.id}
|
||||||
>
|
withBorder
|
||||||
<Stack gap="md">
|
radius="md"
|
||||||
<Text size="sm" c="dimmed">
|
padding="md"
|
||||||
Grants the gate pass for all{" "}
|
style={{ cursor: "pointer" }}
|
||||||
{gatepassTarget?.customsBookings.length ?? 0} customs booking
|
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
|
||||||
{(gatepassTarget?.customsBookings.length ?? 0) === 1 ? "" : "s"} on this
|
>
|
||||||
train.
|
<Group justify="space-between" wrap="nowrap">
|
||||||
</Text>
|
<Group gap="sm">
|
||||||
<DateTimePicker
|
<Ship size={18} className="text-[color:var(--freight-brand)]" />
|
||||||
label="Gate pass time"
|
<div>
|
||||||
value={gatepassAt}
|
<Text fw={700}>{c.reference}</Text>
|
||||||
onChange={(v) => setGatepassAt(v ? new Date(v) : null)}
|
<Text size="sm" c="dimmed">
|
||||||
required
|
{c.tradeDirection} · {c.status}
|
||||||
/>
|
</Text>
|
||||||
<Group justify="flex-end">
|
</div>
|
||||||
<Button
|
</Group>
|
||||||
variant="default"
|
<Group gap="xs">
|
||||||
onClick={() => setGatepassTarget(null)}
|
<Badge variant="light" color="edr-green">
|
||||||
disabled={granting}
|
Contract
|
||||||
>
|
</Badge>
|
||||||
Cancel
|
<ChevronRight size={18} className="text-muted-foreground" />
|
||||||
</Button>
|
</Group>
|
||||||
<Button
|
</Group>
|
||||||
color="edr-green"
|
</Card>
|
||||||
loading={granting}
|
))
|
||||||
leftSection={<Truck size={16} />}
|
)}
|
||||||
onClick={async () => {
|
|
||||||
if (!gatepassTarget) return;
|
|
||||||
setGranting(true);
|
|
||||||
try {
|
|
||||||
const result = await contractsService.grantScheduleGatepass(
|
|
||||||
gatepassTarget.id,
|
|
||||||
(gatepassAt ?? new Date()).toISOString(),
|
|
||||||
);
|
|
||||||
if (result.skipped.length > 0) {
|
|
||||||
toast.error(
|
|
||||||
`${result.granted} granted, ${result.skipped.length} skipped: ${result.skipped[0]?.error ?? ""}`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
toast.success(
|
|
||||||
`Gate pass granted for ${result.granted} booking${result.granted === 1 ? "" : "s"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setGatepassTarget(null);
|
|
||||||
void schedulesQuery.refetch();
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : "Failed");
|
|
||||||
} finally {
|
|
||||||
setGranting(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Grant gate pass
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
)}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusColor(status: string): string {
|
|
||||||
switch (status) {
|
|
||||||
case "SCHEDULED":
|
|
||||||
return "blue";
|
|
||||||
case "DISPATCHED":
|
|
||||||
return "yellow";
|
|
||||||
case "ARRIVED":
|
|
||||||
return "edr-green";
|
|
||||||
default:
|
|
||||||
return "gray";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -391,35 +391,6 @@ export const contractsService = {
|
|||||||
return unwrap(response.data) as Freight.ClearanceT1State;
|
return unwrap(response.data) as Freight.ClearanceT1State;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Train schedules carrying customs bookings — GL DJ gate-pass table. */
|
|
||||||
getDjClearanceSchedules: async (): Promise<Freight.DjClearanceSchedule[]> => {
|
|
||||||
const response = await client.get(C.CLEARANCE_DJ_SCHEDULES);
|
|
||||||
return unwrap(response.data) as Freight.DjClearanceSchedule[];
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Gate pass for every customs booking on a train schedule (captures time). */
|
|
||||||
grantScheduleGatepass: async (
|
|
||||||
scheduleId: string,
|
|
||||||
gatepassAt?: string,
|
|
||||||
): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> => {
|
|
||||||
const response = await client.post(C.CLEARANCE_SCHEDULE_GATEPASS(scheduleId), {
|
|
||||||
gatepassAt,
|
|
||||||
});
|
|
||||||
return unwrap(response.data) as {
|
|
||||||
granted: number;
|
|
||||||
skipped: Array<{ bookingId: string; error: string }>;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Gate pass for a single customs booking (captures time). */
|
|
||||||
grantGatepass: async (
|
|
||||||
bookingId: string,
|
|
||||||
gatepassAt?: string,
|
|
||||||
): Promise<{ bookingId: string; gatepassAt: string }> => {
|
|
||||||
const response = await client.post(C.BOOKING_GATEPASS(bookingId), { gatepassAt });
|
|
||||||
return unwrap(response.data) as { bookingId: string; gatepassAt: string };
|
|
||||||
},
|
|
||||||
|
|
||||||
/** GL DJ raises the post-offload final invoice (amount + invoice document). */
|
/** GL DJ raises the post-offload final invoice (amount + invoice document). */
|
||||||
sendFinalInvoice: async (
|
sendFinalInvoice: async (
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ export interface ClearanceT1State {
|
|||||||
|
|
||||||
/** Train link state for the booking tied to a customs clearance flow. */
|
/** Train link state for the booking tied to a customs clearance flow. */
|
||||||
export interface ClearanceTrainState {
|
export interface ClearanceTrainState {
|
||||||
|
scheduleId: string | null;
|
||||||
wagonAllocated: boolean;
|
wagonAllocated: boolean;
|
||||||
departedAt: string | null;
|
departedAt: string | null;
|
||||||
arrivedAt: string | null;
|
arrivedAt: string | null;
|
||||||
@@ -305,31 +306,6 @@ export interface ClearanceSecondDuty {
|
|||||||
paid: boolean;
|
paid: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A customs booking riding a train schedule, as shown on the GL DJ schedules tab. */
|
|
||||||
export interface DjClearanceScheduleBooking {
|
|
||||||
bookingId: string;
|
|
||||||
reference: string;
|
|
||||||
tradeDirection: string;
|
|
||||||
contractId: string | null;
|
|
||||||
gatepassGranted: boolean;
|
|
||||||
gatepassAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Train schedule row for the GL Djibouti gate-pass table. */
|
|
||||||
export interface DjClearanceSchedule {
|
|
||||||
id: string;
|
|
||||||
trainNumber: string | null;
|
|
||||||
routeName: string | null;
|
|
||||||
origin: string | null;
|
|
||||||
destination: string | null;
|
|
||||||
status: string;
|
|
||||||
scheduledDepartureDate: string | null;
|
|
||||||
actualDepartureAt: string | null;
|
|
||||||
actualArrivalAt: string | null;
|
|
||||||
freightType: string | null;
|
|
||||||
customsBookings: DjClearanceScheduleBooking[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContractClearanceView {
|
export interface ContractClearanceView {
|
||||||
contractId: string;
|
contractId: string;
|
||||||
/** Overall contract status (e.g. CLEARANCE_UNDER_REVIEW). */
|
/** Overall contract status (e.g. CLEARANCE_UNDER_REVIEW). */
|
||||||
|
|||||||
Reference in New Issue
Block a user