mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
resolve conflict
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
"type-check": "tsc --noEmit",
|
||||
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
|
||||
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
|
||||
"seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts",
|
||||
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
|
||||
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
|
||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
|
||||
|
||||
@@ -80,8 +80,15 @@ export class ContractPdfService {
|
||||
this.logger.error(
|
||||
`Puppeteer PDF failed (executable=${executablePath ?? 'default'}): ${err}`,
|
||||
);
|
||||
const fallback = this.htmlToBasicPdfBuffer(preparedHtml);
|
||||
if (this.isValidPdf(fallback)) {
|
||||
this.logger.warn(
|
||||
`Using basic PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
throw new InternalServerErrorException(
|
||||
'Contract PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
|
||||
'PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -113,4 +120,85 @@ export class ContractPdfService {
|
||||
buffer.subarray(0, 5).toString('ascii') === '%PDF-'
|
||||
);
|
||||
}
|
||||
|
||||
private htmlToBasicPdfBuffer(html: string): Buffer {
|
||||
const text = this.htmlToPlainText(html);
|
||||
const lines = this.wrapLines(text, 92).slice(0, 72);
|
||||
const body = lines
|
||||
.map((line, index) => {
|
||||
const prefix = index === 0 ? '50 790 Td' : '0 -12 Td';
|
||||
return `${prefix} (${this.escapePdfText(line)}) Tj`;
|
||||
})
|
||||
.join('\n');
|
||||
const stream = `BT\n/F1 10 Tf\n12 TL\n${body}\nET`;
|
||||
|
||||
const objects = [
|
||||
'<< /Type /Catalog /Pages 2 0 R >>',
|
||||
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
||||
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
|
||||
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
|
||||
`<< /Length ${Buffer.byteLength(stream, 'latin1')} >>\nstream\n${stream}\nendstream`,
|
||||
];
|
||||
|
||||
let pdf = '%PDF-1.4\n';
|
||||
const offsets: number[] = [0];
|
||||
objects.forEach((object, index) => {
|
||||
offsets.push(Buffer.byteLength(pdf, 'latin1'));
|
||||
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
||||
});
|
||||
while (Buffer.byteLength(pdf, 'latin1') < MIN_VALID_PDF_BYTES) {
|
||||
pdf += '% fallback padding\n';
|
||||
}
|
||||
const xrefOffset = Buffer.byteLength(pdf, 'latin1');
|
||||
pdf += `xref\n0 ${objects.length + 1}\n`;
|
||||
pdf += '0000000000 65535 f \n';
|
||||
for (const offset of offsets.slice(1)) {
|
||||
pdf += `${String(offset).padStart(10, '0')} 00000 n \n`;
|
||||
}
|
||||
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
|
||||
return Buffer.from(pdf, 'latin1');
|
||||
}
|
||||
|
||||
private htmlToPlainText(html: string): string {
|
||||
return html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<\/(h1|h2|h3|p|div|tr|table|section|header|footer)>/gi, '\n')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/[^\x09\x0a\x0d\x20-\x7e]/g, '-')
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/\s+/g, ' ').trim())
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
private wrapLines(text: string, width: number): string[] {
|
||||
const wrapped: string[] = [];
|
||||
for (const rawLine of text.split('\n')) {
|
||||
const words = rawLine.split(' ');
|
||||
let line = '';
|
||||
for (const word of words) {
|
||||
const next = line ? `${line} ${word}` : word;
|
||||
if (next.length > width && line) {
|
||||
wrapped.push(line);
|
||||
line = word;
|
||||
} else {
|
||||
line = next;
|
||||
}
|
||||
}
|
||||
if (line) wrapped.push(line);
|
||||
}
|
||||
return wrapped.length ? wrapped : ['Document'];
|
||||
}
|
||||
|
||||
private escapePdfText(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ export class CreateWarehouseModule1790000000000 implements MigrationInterface {
|
||||
weight NUMERIC(14,3) NOT NULL DEFAULT 0,
|
||||
volume NUMERIC(12,3) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'ARRIVED_AT_WAREHOUSE',
|
||||
inspection_status VARCHAR(20) NULL,
|
||||
arrived_at TIMESTAMPTZ NULL,
|
||||
inspected_at TIMESTAMPTZ NULL,
|
||||
ready_for_loading_at TIMESTAMPTZ NULL,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Catch-up for environments where AddWarehouseInspection ran before the
|
||||
* warehouse module table existed. Production needs this column for unload and
|
||||
* inspection flows because the WarehouseInventory entity maps inspectionStatus.
|
||||
*/
|
||||
export class EnsureWarehouseInventoryInspectionStatus1821000000001 implements MigrationInterface {
|
||||
private readonly table = 'freight.warehouse_inventory';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (!(await queryRunner.hasColumn(this.table, 'inspection_status'))) {
|
||||
await queryRunner.addColumn(
|
||||
this.table,
|
||||
new TableColumn({
|
||||
name: 'inspection_status',
|
||||
type: 'varchar',
|
||||
length: '20',
|
||||
isNullable: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_inspection_status
|
||||
ON freight.warehouse_inventory(inspection_status)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_warehouse_inventory_inspection_status
|
||||
`);
|
||||
|
||||
if (await queryRunner.hasColumn(this.table, 'inspection_status')) {
|
||||
await queryRunner.dropColumn(this.table, 'inspection_status');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,9 @@ export class FirstMileService {
|
||||
* unknown or the booking has not reached PAID status.
|
||||
*/
|
||||
async acceptBooking(bookingId: string): Promise<FirstMile | null> {
|
||||
const booking = await this.bookingsRepository.findById(bookingId);
|
||||
const booking = await this.bookingsRepository.findById(bookingId, {
|
||||
relations: { serviceType: true },
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
return null;
|
||||
@@ -55,6 +57,10 @@ export class FirstMileService {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.bookingRequestsFirstMile(booking)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.create({
|
||||
bookingId: booking.id,
|
||||
advancedPayment: 0,
|
||||
@@ -62,7 +68,11 @@ export class FirstMileService {
|
||||
}
|
||||
|
||||
async acceptBookingByReference(bookingReference: string): Promise<FirstMile | null> {
|
||||
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
||||
const [booking] = await this.bookingsRepository.findAll({
|
||||
where: { reference: bookingReference },
|
||||
relations: { serviceType: true },
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
return null;
|
||||
@@ -72,6 +82,10 @@ export class FirstMileService {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.bookingRequestsFirstMile(booking)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.create({
|
||||
bookingId: booking.id,
|
||||
advancedPayment: 0,
|
||||
@@ -131,6 +145,11 @@ export class FirstMileService {
|
||||
}
|
||||
|
||||
async create(dto: CreateFirstMileDto): Promise<FirstMile> {
|
||||
const existing = await this.findByBookingId(dto.bookingId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
return this.firstMileRepository.create({
|
||||
bookingId: dto.bookingId,
|
||||
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||
@@ -142,6 +161,25 @@ export class FirstMileService {
|
||||
});
|
||||
}
|
||||
|
||||
private async findByBookingId(bookingId: string): Promise<FirstMile | null> {
|
||||
const [records] = await this.firstMileRepository.findAndCount({
|
||||
where: { bookingId },
|
||||
relations: {
|
||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
|
||||
vehicle: true,
|
||||
},
|
||||
take: 1,
|
||||
});
|
||||
return records[0] ?? null;
|
||||
}
|
||||
|
||||
private bookingRequestsFirstMile(booking: {
|
||||
firstMilePickupAddress?: string | null;
|
||||
serviceType?: { includesFirstMile?: boolean | null } | null;
|
||||
}): boolean {
|
||||
return Boolean(booking.firstMilePickupAddress?.trim() || booking.serviceType?.includesFirstMile);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
|
||||
const existing = await this.findById(id);
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ export class LastMileService {
|
||||
private readonly logger = new Logger(LastMileService.name);
|
||||
|
||||
constructor(
|
||||
|
||||
private readonly lastMileRepository: LastMileRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly vehiclesService: VehiclesService,
|
||||
|
||||
@@ -344,10 +344,10 @@ export class PaymentService {
|
||||
? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt }
|
||||
: { paymentStatus: "PAID", status: "PAID" },
|
||||
);
|
||||
await this.firstMileService.acceptBooking(input.bookingId);
|
||||
|
||||
});
|
||||
|
||||
await this.firstMileService.acceptBooking(input.bookingId);
|
||||
|
||||
if (isGeneralContract) {
|
||||
this.logger.log(
|
||||
`General contract ${booking?.reference ?? input.bookingId} ACTIVE — ordering open until ${contractExpiresAt?.toISOString()}`,
|
||||
|
||||
@@ -724,9 +724,7 @@ export class TrainSchedulingService {
|
||||
}
|
||||
});
|
||||
|
||||
const detail = await this.getTrainScheduleById(scheduleId);
|
||||
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
|
||||
return Object.assign(detail, { warehouseAutomation });
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
async finalizeSchedule(scheduleId: string) {
|
||||
@@ -1136,7 +1134,9 @@ export class TrainSchedulingService {
|
||||
}
|
||||
});
|
||||
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
const detail = await this.getTrainScheduleById(scheduleId);
|
||||
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
|
||||
return Object.assign(detail, { warehouseAutomation });
|
||||
}
|
||||
|
||||
async getContainerTrainSchedules() {
|
||||
|
||||
@@ -1,5 +1,154 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ArrayNotEmpty, IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { ArrayNotEmpty, IsArray, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
||||
|
||||
export class TruckEntranceDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ownerName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
consigneeDetails?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
edrDigitalBookingId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tin?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
truckPlateNumber!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
trailerPlateNumber?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assignedEquipmentNumber?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
customsSealNumber?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
declarationNumber?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
incoterms?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
hsCodes?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
itemCode?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
itemDescription?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
packagingType?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
unitCount?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
grossWeightKg?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
netWeightKg?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
volumeDimensions?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
conditionAtReceipt?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
damagedRejectedQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
warehouseCodeLocation?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
driverName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
driverPhone!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
driverLicenseNumber?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
truckType?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
entranceTareWeightKg!: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
exitTareWeightKg?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
driverSignatoryName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
warehouseManagerName?: string;
|
||||
}
|
||||
|
||||
/** Bulk-receive eligible PAID bookings into a warehouse location, by trade direction. */
|
||||
export class BulkReceiveDto {
|
||||
@@ -25,6 +174,9 @@ export class BulkReceiveDto {
|
||||
@IsUUID('all', { each: true })
|
||||
bookingIds!: string[];
|
||||
|
||||
@ApiProperty({ type: TruckEntranceDto })
|
||||
truckEntrance!: TruckEntranceDto;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
||||
import { TruckEntranceDto } from './bulk-receive.dto';
|
||||
|
||||
export class ReceiveWarehouseInventoryDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@@ -55,6 +56,9 @@ export class ReceiveWarehouseInventoryDto {
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@ApiProperty({ type: TruckEntranceDto })
|
||||
truckEntrance!: TruckEntranceDto;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -21,6 +21,9 @@ export interface ImportTrainRow {
|
||||
totalBookings: number;
|
||||
totalContainers: number;
|
||||
totalCargoes: number;
|
||||
unloadedBookings: number;
|
||||
pendingUnloadBookings: number;
|
||||
fullyUnloaded: boolean;
|
||||
status: string;
|
||||
}
|
||||
|
||||
@@ -34,6 +37,7 @@ export interface ImportTrainItemRow {
|
||||
weight: number | null;
|
||||
arrivalTime: string | null;
|
||||
currentStatus: string | null;
|
||||
inspectionStatus: string | null;
|
||||
lastMileRequested: boolean;
|
||||
pickupOption: string;
|
||||
}
|
||||
@@ -133,7 +137,7 @@ export class SchedulingReadFacade {
|
||||
`SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId"
|
||||
FROM freight.wagons
|
||||
WHERE deleted_at IS NULL
|
||||
AND status NOT IN ('RETIRED', 'MAINTENANCE')
|
||||
AND UPPER(status) NOT IN ('RETIRED', 'MAINTENANCE')
|
||||
ORDER BY wagon_number ASC`,
|
||||
);
|
||||
}
|
||||
@@ -205,7 +209,24 @@ export class SchedulingReadFacade {
|
||||
WHERE tsbc.train_schedule_id = ts.id AND c.deleted_at IS NULL) AS "totalContainers",
|
||||
(SELECT count(*) FROM freight.cargoes cg
|
||||
JOIN freight.train_schedule_bookings tsbg ON tsbg.booking_id = cg.booking_id AND tsbg.deleted_at IS NULL
|
||||
WHERE tsbg.train_schedule_id = ts.id AND cg.deleted_at IS NULL) AS "totalCargoes"
|
||||
WHERE tsbg.train_schedule_id = ts.id AND cg.deleted_at IS NULL) AS "totalCargoes",
|
||||
(SELECT count(*) FROM freight.train_schedule_bookings tsbp
|
||||
JOIN freight.bookings bp ON bp.id = tsbp.booking_id AND bp.deleted_at IS NULL
|
||||
WHERE tsbp.train_schedule_id = ts.id
|
||||
AND tsbp.deleted_at IS NULL
|
||||
AND bp.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED')
|
||||
AND (
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM freight.warehouse_inventory invp
|
||||
WHERE invp.booking_id = bp.id AND invp.deleted_at IS NULL
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM freight.warehouse_inventory invr
|
||||
WHERE invr.booking_id = bp.id
|
||||
AND invr.deleted_at IS NULL
|
||||
AND invr.status = 'RECEIVED'
|
||||
)
|
||||
)) AS "pendingUnloadBookings"
|
||||
FROM freight.train_schedules ts
|
||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||
@@ -219,13 +240,22 @@ export class SchedulingReadFacade {
|
||||
(r) =>
|
||||
deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'IMPORT',
|
||||
)
|
||||
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => ({
|
||||
...rest,
|
||||
totalBookings: Number(rest.totalBookings) || 0,
|
||||
totalContainers: Number(rest.totalContainers) || 0,
|
||||
totalCargoes: Number(rest.totalCargoes) || 0,
|
||||
route: rest.origin || rest.destination ? `${rest.origin ?? '?'} → ${rest.destination ?? '?'}` : null,
|
||||
}));
|
||||
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => {
|
||||
const totalBookings = Number(rest.totalBookings) || 0;
|
||||
const pendingUnloadBookings = Number(rest.pendingUnloadBookings) || 0;
|
||||
const unloadedBookings = Math.max(totalBookings - pendingUnloadBookings, 0);
|
||||
|
||||
return {
|
||||
...rest,
|
||||
totalBookings,
|
||||
totalContainers: Number(rest.totalContainers) || 0,
|
||||
totalCargoes: Number(rest.totalCargoes) || 0,
|
||||
unloadedBookings,
|
||||
pendingUnloadBookings,
|
||||
fullyUnloaded: totalBookings > 0 && pendingUnloadBookings === 0,
|
||||
route: rest.origin || rest.destination ? `${rest.origin ?? '?'} → ${rest.destination ?? '?'}` : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Assigned bookings/items for an arrived import train (one row per booking). Read-only. */
|
||||
@@ -242,6 +272,7 @@ export class SchedulingReadFacade {
|
||||
b.cargo_total_weight_vgm AS "weight",
|
||||
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime",
|
||||
COALESCE(inv.status, b.status) AS "currentStatus",
|
||||
inv.inspection_status AS "inspectionStatus",
|
||||
(b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested",
|
||||
CASE WHEN b.last_mile_delivery_address IS NOT NULL
|
||||
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption"
|
||||
@@ -281,7 +312,15 @@ export class SchedulingReadFacade {
|
||||
];
|
||||
params.push(
|
||||
filter.status ? [filter.status] : ['ARRIVED', 'ARRIVED_AT_DJIBOUTI'],
|
||||
['DISPATCHED', 'IN_TRANSIT', 'ARRIVED_AT_DJIBOUTI', 'ARRIVED_AT_PORT', 'ARRIVED_AT_DESTINATION'],
|
||||
[
|
||||
'LOADED',
|
||||
'DISPATCHED',
|
||||
'IN_TRANSIT',
|
||||
'ARRIVED_AT_DJIBOUTI',
|
||||
'ARRIVED_AT_PORT',
|
||||
'ARRIVED_AT_DESTINATION',
|
||||
'UNLOADED_AT_DJIBOUTI_PORT',
|
||||
],
|
||||
);
|
||||
|
||||
if (filter.scheduleId) {
|
||||
|
||||
@@ -6,9 +6,8 @@ import { Cargo } from '../cargoes/entities/cargoes.entity';
|
||||
import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service';
|
||||
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
||||
import { LastMileService } from '../last-mile/last-mile.service';
|
||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||
import { BulkReceiveDto } from './dto/bulk-receive.dto';
|
||||
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
||||
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
|
||||
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
|
||||
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
||||
@@ -36,10 +35,20 @@ import { SchedulingReadFacade } from './scheduling-read.facade';
|
||||
import { WarehouseActivityLogService } from './warehouse-activity-log.service';
|
||||
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||||
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
||||
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
|
||||
|
||||
/** Wagon states that may receive a load (besides being part of an existing schedule). */
|
||||
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
|
||||
|
||||
const normalizeWagonStatus = (status: string | null | undefined) =>
|
||||
(status ?? '')
|
||||
.trim()
|
||||
.replace(/[\s-]+/g, '_')
|
||||
.toUpperCase();
|
||||
|
||||
const isLoadableWagonStatus = (status: string | null | undefined) =>
|
||||
LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status));
|
||||
|
||||
export interface InventoryInquiryResult {
|
||||
id: string;
|
||||
inventoryId: string | null;
|
||||
@@ -190,12 +199,18 @@ export interface EligibleBookingRow {
|
||||
weight: string | null;
|
||||
paymentStatus: string;
|
||||
status: string;
|
||||
hasFirstMile: boolean;
|
||||
firstMileRequestId: string | null;
|
||||
firstMileStatus: string | null;
|
||||
firstMileVehicleId: string | null;
|
||||
firstMileTruckPlateNumber: string | null;
|
||||
firstMileTrailerPlateNumber: string | null;
|
||||
}
|
||||
|
||||
export interface BulkReceiveResult {
|
||||
receivedCount: number;
|
||||
skippedCount: number;
|
||||
results: { bookingId: string; status: string; inventoryId?: string; reason?: string }[];
|
||||
results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[];
|
||||
}
|
||||
|
||||
export interface LoadPassedExportResult {
|
||||
@@ -283,7 +298,7 @@ export class WarehouseInventoryService {
|
||||
private readonly allocation: WarehouseAllocationService,
|
||||
private readonly invoices: WarehouseInvoiceService,
|
||||
private readonly inspectionService: WarehouseInspectionService,
|
||||
private readonly pdfService: ContractPdfService,
|
||||
private readonly releaseDocuments: WarehouseReleaseDocumentService,
|
||||
private readonly interchangeDocuments: InterchangeDocumentsService,
|
||||
private readonly lastMileService: LastMileService,
|
||||
) {}
|
||||
@@ -294,18 +309,53 @@ export class WarehouseInventoryService {
|
||||
* inspection / storage / loading steps — only the final release.
|
||||
*/
|
||||
async gateClearance(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(id);
|
||||
const [item]: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query(
|
||||
`SELECT id, warehouse_id AS "warehouseId"
|
||||
FROM freight.warehouse_inventory
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[id],
|
||||
);
|
||||
if (!item) {
|
||||
throw new NotFoundException(`Inventory item ${id} not found`);
|
||||
}
|
||||
|
||||
const blocking = await this.invoices.findBlockingInvoice(id);
|
||||
if (blocking) {
|
||||
throw new BadRequestException(
|
||||
'Warehouse demurrage/storage fee must be paid before terminal release.',
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await this.inventoryRepository.update(id, {
|
||||
gateClearedAt: now,
|
||||
releaseDate: item.releaseDate ?? now,
|
||||
});
|
||||
const [gateColumn]: Array<{ exists: boolean }> = await this.dataSource.query(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'freight'
|
||||
AND table_name = 'warehouse_inventory'
|
||||
AND column_name = 'gate_cleared_at'
|
||||
) AS "exists"`,
|
||||
);
|
||||
if (gateColumn?.exists) {
|
||||
await this.dataSource.query(
|
||||
`UPDATE freight.warehouse_inventory
|
||||
SET gate_cleared_at = $2,
|
||||
release_date = COALESCE(release_date, $2),
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[id, now],
|
||||
);
|
||||
} else {
|
||||
await this.dataSource.query(
|
||||
`UPDATE freight.warehouse_inventory
|
||||
SET release_date = COALESCE(release_date, $2),
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[id, now],
|
||||
);
|
||||
}
|
||||
|
||||
await this.activityLog.record({
|
||||
activityType: 'INVENTORY_DISPATCHED',
|
||||
inventoryId: id,
|
||||
@@ -602,13 +652,29 @@ export class WarehouseInventoryService {
|
||||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargo",
|
||||
b.cargo_total_weight_vgm AS "weight",
|
||||
b.payment_status AS "paymentStatus",
|
||||
b.status AS "status"
|
||||
b.status AS "status",
|
||||
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
|
||||
fm.id AS "firstMileRequestId",
|
||||
fm.status AS "firstMileStatus",
|
||||
fm.vehicle_id AS "firstMileVehicleId",
|
||||
v.plate_number AS "firstMileTruckPlateNumber",
|
||||
v.trailer_plate_no AS "firstMileTrailerPlateNumber"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT first_mile.id, first_mile.status, first_mile.vehicle_id
|
||||
FROM freight.first_mile first_mile
|
||||
WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
|
||||
ORDER BY first_mile.created_at DESC
|
||||
LIMIT 1
|
||||
) fm ON true
|
||||
LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
|
||||
WHERE b.deleted_at IS NULL
|
||||
AND b.payment_status = 'PAID'
|
||||
AND inv.id IS NULL
|
||||
@@ -629,6 +695,7 @@ export class WarehouseInventoryService {
|
||||
/** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */
|
||||
async bulkReceive(dto: BulkReceiveDto): Promise<BulkReceiveResult> {
|
||||
const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] };
|
||||
this.assertTruckEntrance(dto.truckEntrance);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.validateLocation(manager, {
|
||||
@@ -645,10 +712,22 @@ export class WarehouseInventoryService {
|
||||
|
||||
const [booking] = await manager.query(
|
||||
`SELECT b.payment_status AS "paymentStatus", b.cargo_total_weight_vgm AS "weight",
|
||||
oy.country AS "originCountry", dy.country AS "destinationCountry"
|
||||
oy.country AS "originCountry", dy.country AS "destinationCountry",
|
||||
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
|
||||
fm.id AS "firstMileRequestId",
|
||||
fm.status AS "firstMileStatus"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT first_mile.id, first_mile.status
|
||||
FROM freight.first_mile first_mile
|
||||
WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
|
||||
ORDER BY first_mile.created_at DESC
|
||||
LIMIT 1
|
||||
) fm ON true
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
@@ -663,10 +742,28 @@ export class WarehouseInventoryService {
|
||||
skip(`Booking route is ${bookingDirection}, not ${dto.direction}`);
|
||||
continue;
|
||||
}
|
||||
if (dto.direction === 'EXPORT' && booking.hasFirstMile) {
|
||||
if (!booking.firstMileRequestId) {
|
||||
skip('First-mile request not created');
|
||||
continue;
|
||||
}
|
||||
if (booking.firstMileStatus !== 'RECEIVED_TO_PORT') {
|
||||
skip('First-mile truck has not arrived');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } });
|
||||
if (existing) { skip('Already received'); continue; }
|
||||
|
||||
const now = new Date();
|
||||
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now);
|
||||
const receiveNote = this.buildReceiveNote({
|
||||
grnNumber,
|
||||
direction: dto.direction,
|
||||
notes: `Bulk received (${dto.direction})`,
|
||||
truckEntrance: dto.truckEntrance,
|
||||
});
|
||||
const saved = await manager.getRepository(WarehouseInventory).save(
|
||||
manager.getRepository(WarehouseInventory).create({
|
||||
warehouseId: dto.warehouseId,
|
||||
@@ -676,8 +773,8 @@ export class WarehouseInventoryService {
|
||||
quantity: 1,
|
||||
weight: Number(booking.weight) || 0,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt: new Date(),
|
||||
notes: `Bulk received (${dto.direction})`,
|
||||
arrivedAt: now,
|
||||
notes: receiveNote,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -686,14 +783,14 @@ export class WarehouseInventoryService {
|
||||
activityType: 'INVENTORY_RECEIVED',
|
||||
inventoryId: saved.id,
|
||||
warehouseId: dto.warehouseId,
|
||||
description: `Bulk received ${dto.direction} booking`,
|
||||
description: `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${dto.truckEntrance.truckPlateNumber}`,
|
||||
performedBy: dto.performedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
result.receivedCount += 1;
|
||||
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id });
|
||||
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -892,6 +989,7 @@ export class WarehouseInventoryService {
|
||||
/** Booking statuses that must never be unloaded into warehouse inventory. */
|
||||
private readonly IMPORT_UNLOAD_BLOCKED_STATUSES = ['DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED'];
|
||||
private readonly EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES = [
|
||||
'LOADED',
|
||||
'DISPATCHED',
|
||||
'IN_TRANSIT',
|
||||
'ARRIVED_AT_DJIBOUTI',
|
||||
@@ -1263,16 +1361,22 @@ export class WarehouseInventoryService {
|
||||
});
|
||||
|
||||
if (result.unloadedCount > 0) {
|
||||
const document = await this.interchangeDocuments.generateFromSchedule({
|
||||
let document = await this.interchangeDocuments.generateFromSchedule({
|
||||
scheduleId,
|
||||
direction: 'EXPORT',
|
||||
handoverLocation: schedule.destinationName ?? 'Djibouti Port',
|
||||
handoverFrom: 'EDR',
|
||||
handoverTo: 'Djibouti Port Operator',
|
||||
portOperatorName: 'Doraleh Multipurpose Port',
|
||||
generatedBy: performedBy,
|
||||
remarks: 'Generated after export unloading at Djibouti Port',
|
||||
generatedBy: performedBy ?? 'EDR Operations',
|
||||
remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.',
|
||||
});
|
||||
if (document.status !== 'ACKNOWLEDGED') {
|
||||
document = await this.interchangeDocuments.acknowledge(document.id, {
|
||||
acknowledgedBy: 'Djibouti Port Operator',
|
||||
remarks: 'Auto acknowledged after Djibouti export unloading.',
|
||||
});
|
||||
}
|
||||
result.interchangeDocument = {
|
||||
id: document.id,
|
||||
documentNo: document.documentNo,
|
||||
@@ -1379,9 +1483,11 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
|
||||
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
|
||||
this.assertTruckEntrance(dto.truckEntrance);
|
||||
const weight = Number(dto.weight) || 0;
|
||||
const volume = Number(dto.volume) || 0;
|
||||
const containerCount = dto.containerId ? Math.round(Number(dto.quantity) || 0) : 0;
|
||||
const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
|
||||
|
||||
const id = await this.dataSource.transaction(async (manager) => {
|
||||
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
|
||||
@@ -1395,6 +1501,12 @@ export class WarehouseInventoryService {
|
||||
this.assertCapacity('Zone', zone, weight, volume, containerCount);
|
||||
|
||||
const now = new Date();
|
||||
const grnNumber = this.generateGrnNumber(bookingDirection ?? 'WH', dto.bookingId ?? 'MANUAL', now);
|
||||
const receiveNote = this.buildReceiveNote({
|
||||
grnNumber,
|
||||
notes: dto.notes?.trim() || 'Single booking received',
|
||||
truckEntrance: dto.truckEntrance,
|
||||
});
|
||||
const saved = await manager.getRepository(WarehouseInventory).save(
|
||||
manager.getRepository(WarehouseInventory).create({
|
||||
warehouseId: dto.warehouseId,
|
||||
@@ -1409,7 +1521,7 @@ export class WarehouseInventoryService {
|
||||
volume: dto.volume ?? null,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt: now,
|
||||
notes: dto.notes?.trim() ?? null,
|
||||
notes: receiveNote,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1420,7 +1532,7 @@ export class WarehouseInventoryService {
|
||||
activityType: 'INVENTORY_RECEIVED',
|
||||
inventoryId: saved.id,
|
||||
warehouseId: dto.warehouseId,
|
||||
description: `Received ${weight}kg at warehouse location`,
|
||||
description: `GRN ${grnNumber}: received ${weight}kg via truck ${dto.truckEntrance.truckPlateNumber}`,
|
||||
performedBy: dto.performedBy,
|
||||
},
|
||||
manager,
|
||||
@@ -1688,15 +1800,10 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
|
||||
async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const item = await this.findById(id);
|
||||
if (!item.releaseDate) {
|
||||
throw new BadRequestException('A release order must be issued before downloading the exit paper');
|
||||
}
|
||||
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.id,
|
||||
inv.release_order_reference AS "releaseOrderReference",
|
||||
inv.release_date AS "releaseDate",
|
||||
inv.release_order_reference AS "releaseOrderReference",
|
||||
inv.quantity,
|
||||
inv.weight,
|
||||
inv.status,
|
||||
@@ -1706,7 +1813,7 @@ export class WarehouseInventoryService {
|
||||
b.freight_type AS "freightType",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
company.name AS "customerName",
|
||||
container.container_number AS "containerNumber",
|
||||
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
|
||||
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
|
||||
wh.name AS "warehouseName",
|
||||
wh.code AS "warehouseCode",
|
||||
@@ -1720,23 +1827,29 @@ export class WarehouseInventoryService {
|
||||
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
|
||||
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
|
||||
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
|
||||
LEFT JOIN freight.containers container ON (
|
||||
(inv.container_id IS NOT NULL AND container.id = inv.container_id)
|
||||
OR (inv.container_id IS NULL AND container.booking_id = b.id)
|
||||
) AND container.deleted_at IS NULL
|
||||
LEFT JOIN freight.cargoes cargo ON (
|
||||
(inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id)
|
||||
OR (inv.cargo_id IS NULL AND cargo.booking_id = b.id)
|
||||
) AND cargo.deleted_at IS NULL
|
||||
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
|
||||
LEFT JOIN freight.booking_container booking_container ON (
|
||||
booking_container.booking_id = b.id
|
||||
AND booking_container.deleted_at IS NULL
|
||||
)
|
||||
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
|
||||
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
|
||||
WHERE inv.id = $1 AND inv.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[id],
|
||||
);
|
||||
if (!row) {
|
||||
throw new NotFoundException(`Inventory item ${id} not found`);
|
||||
}
|
||||
if (!row.releaseDate) {
|
||||
throw new BadRequestException('A release order must be issued before downloading the exit paper');
|
||||
}
|
||||
|
||||
const reference = row?.releaseOrderReference || `REL-${id.slice(0, 8).toUpperCase()}`;
|
||||
const bookingReference = row?.bookingReference || item.bookingId || 'N/A';
|
||||
const issuedAt = row?.releaseDate ? new Date(row.releaseDate) : new Date();
|
||||
const bookingReference = row?.bookingReference || 'N/A';
|
||||
const reference =
|
||||
row?.releaseOrderReference ||
|
||||
(row?.bookingReference ? `REL-${String(row.bookingReference).replace(/^BK-?/i, '')}` : 'REL-N/A');
|
||||
const issuedAt = new Date(row.releaseDate);
|
||||
const html = this.buildReleaseDocumentHtml({
|
||||
reference,
|
||||
issuedAt,
|
||||
@@ -1747,17 +1860,18 @@ export class WarehouseInventoryService {
|
||||
tradeDirection: row?.tradeDirection ?? null,
|
||||
containerNumber: row?.containerNumber ?? null,
|
||||
cargoDescription: row?.cargoDescription ?? null,
|
||||
quantity: Number(row?.quantity ?? item.quantity ?? 0),
|
||||
weight: Number(row?.weight ?? item.weight ?? 0),
|
||||
quantity: Number(row?.quantity ?? 0),
|
||||
weight: Number(row?.weight ?? 0),
|
||||
warehouse: [row?.warehouseName, row?.warehouseCode].filter(Boolean).join(' / ') || null,
|
||||
yard: [row?.yardName, row?.yardCode].filter(Boolean).join(' / ') || null,
|
||||
zone: [row?.zoneName, row?.zoneCode].filter(Boolean).join(' / ') || null,
|
||||
inventoryStatus: row?.status ?? item.status,
|
||||
inventoryStatus: row?.status ?? null,
|
||||
clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT',
|
||||
});
|
||||
|
||||
return {
|
||||
filename: `release-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||
buffer: await this.pdfService.htmlToPdfBuffer(html),
|
||||
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1842,7 +1956,7 @@ export class WarehouseInventoryService {
|
||||
|
||||
// 4. wagon must be available, or already selected by an existing train schedule.
|
||||
const scheduled = await this.scheduling.isWagonScheduled(dto.wagonId);
|
||||
if (!LOADABLE_WAGON_STATUSES.includes(wagon.status) && !scheduled) {
|
||||
if (!isLoadableWagonStatus(wagon.status) && !scheduled) {
|
||||
throw new BadRequestException(
|
||||
`Wagon ${wagon.wagonNumber} is not available for loading (status: ${wagon.status})`,
|
||||
);
|
||||
@@ -2257,6 +2371,7 @@ export class WarehouseInventoryService {
|
||||
yard: string | null;
|
||||
zone: string | null;
|
||||
inventoryStatus: string | null;
|
||||
clearanceStatus: string;
|
||||
}): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
@@ -2273,71 +2388,85 @@ export class WarehouseInventoryService {
|
||||
minute: '2-digit',
|
||||
});
|
||||
const rows = [
|
||||
['Booking reference', data.bookingReference],
|
||||
['Customer', data.customerName],
|
||||
['Booking status', data.bookingStatus],
|
||||
['Freight type', data.freightType],
|
||||
['Trade direction', data.tradeDirection],
|
||||
['Container number', data.containerNumber],
|
||||
['Cargo / goods', data.cargoDescription],
|
||||
['Booking Reference', data.bookingReference],
|
||||
['Customer / Consignee', data.customerName],
|
||||
['Booking Status', data.bookingStatus],
|
||||
['Freight Type', data.freightType],
|
||||
['Trade Direction', data.tradeDirection],
|
||||
['Container Number', data.containerNumber],
|
||||
['Cargo / Goods Description', data.cargoDescription],
|
||||
['Quantity', data.quantity],
|
||||
['Weight', `${data.weight.toLocaleString()} kg`],
|
||||
['Declared Weight', `${data.weight.toLocaleString()} kg`],
|
||||
['Warehouse', data.warehouse],
|
||||
['Yard', data.yard],
|
||||
['Zone', data.zone],
|
||||
['Inventory status', data.inventoryStatus],
|
||||
['Inventory Status', data.inventoryStatus],
|
||||
['Clearance Status', data.clearanceStatus],
|
||||
];
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Warehouse Release Exit Paper</title>
|
||||
<title>Warehouse Gate Clearance / Release Order</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; color: #0f172a; margin: 0; }
|
||||
.doc { padding: 18px 8px; }
|
||||
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 18px; }
|
||||
.brand { font-size: 13px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
|
||||
h1 { margin: 8px 0 0; font-size: 30px; }
|
||||
.ref { text-align: right; font-size: 13px; color: #475569; }
|
||||
.ref strong { display: block; color: #0f172a; font-size: 18px; margin-top: 6px; }
|
||||
.notice { margin: 22px 0; padding: 14px 16px; background: #ecfdf5; border: 1px solid #99f6e4; border-radius: 8px; font-weight: 700; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 16px; }
|
||||
th { width: 32%; text-align: left; color: #475569; background: #f8fafc; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 10px 12px; font-size: 13px; }
|
||||
.signatures { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; margin-top: 42px; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 12px; color: #475569; }
|
||||
.footer { margin-top: 28px; font-size: 11px; color: #64748b; line-height: 1.5; }
|
||||
* { box-sizing: border-box; }
|
||||
@page { size: A4; margin: 12mm 15mm 14mm; }
|
||||
body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 0; background: #fff; }
|
||||
.doc { position: relative; padding: 0; }
|
||||
.top { display: grid; grid-template-columns: 1fr 190px; gap: 26px; border-top: 5px solid #2a2a2a; padding-top: 18px; }
|
||||
.brand { font-size: 12px; color: #064c27; text-transform: uppercase; letter-spacing: .13em; font-weight: 800; }
|
||||
h1 { margin: 8px 0 0; max-width: 360px; font-size: 29px; line-height: .98; text-transform: uppercase; letter-spacing: .02em; }
|
||||
.subtitle { margin-top: 12px; font-size: 11px; color: #3d516a; text-transform: uppercase; letter-spacing: .14em; }
|
||||
.ref { text-align: right; font-size: 11px; color: #334155; padding-top: 8px; }
|
||||
.ref strong { display: block; color: #061323; font-size: 18px; margin: 5px 0 8px; letter-spacing: .02em; }
|
||||
.rule { height: 3px; background: #064c27; margin: 16px 0 22px; }
|
||||
.notice { width: 74%; margin: 0 0 18px; padding: 13px 18px; background: #f3fff6; border: 1px solid #61d98b; border-left: 5px solid #16743d; font-size: 13px; line-height: 1.45; }
|
||||
.section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #064c27; text-transform: uppercase; letter-spacing: .12em; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { width: 31%; text-align: left; color: #0f2744; background: #f8fafc; font-weight: 800; }
|
||||
th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12.2px; vertical-align: top; }
|
||||
.clause { margin-top: 14px; border: 1px solid #b9c7d8; padding: 12px 15px; font-size: 12.2px; line-height: 1.45; }
|
||||
.signatures { display: grid; grid-template-columns: 1fr 96px 1.45fr; gap: 22px; align-items: start; margin-top: 42px; }
|
||||
.line { border-top: 1.4px solid #061323; padding-top: 7px; font-size: 10.8px; color: #24384f; min-height: 42px; }
|
||||
.seal { width: 96px; height: 96px; margin: -28px auto 0; border: 3px double #17633a; border-radius: 999px; color: #17633a; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 14px; line-height: 1.05; transform: rotate(-17deg); text-transform: uppercase; }
|
||||
.seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; }
|
||||
.seal span { position: relative; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="brand">EDR Warehouse Operations</div>
|
||||
<h1>Warehouse Release / Exit Paper</h1>
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Warehouse Gate Clearance / Release Order</h1>
|
||||
<div class="subtitle">Official warehouse release and exit authorization</div>
|
||||
</div>
|
||||
<div class="ref">
|
||||
Release reference
|
||||
Document / Release No.
|
||||
<strong>${esc(data.reference)}</strong>
|
||||
Issued: ${esc(issuedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rule"></div>
|
||||
<div class="notice">
|
||||
This document authorizes the listed booking/goods to leave the warehouse after release checks.
|
||||
This clearance document confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.
|
||||
</div>
|
||||
<div class="section-title">Release Particulars</div>
|
||||
<table>
|
||||
<tbody>
|
||||
${rows.map(([label, value]) => `<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="signatures">
|
||||
<div class="line">Warehouse officer name / signature / date</div>
|
||||
<div class="line">Customer or driver name / signature / date</div>
|
||||
<div class="section-title">Authorization Clause</div>
|
||||
<div class="clause">
|
||||
The warehouse officer and gate staff shall verify this document against the booking reference, customer or driver identity,
|
||||
cargo details, clearance status, and payment records before permitting exit from the warehouse premises.
|
||||
</div>
|
||||
<div class="footer">
|
||||
Present this release paper at the warehouse gate. Gate staff should verify booking reference,
|
||||
customer/driver identity, cargo details, and any unpaid blocking fees before exit.
|
||||
<div class="signatures">
|
||||
<div class="line">Officer in charge name / signature / date</div>
|
||||
<div class="seal"><span>EDR<br />Warehouse<br />Cleared</span></div>
|
||||
<div class="line">Customer or driver name / signature / date</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
@@ -2378,6 +2507,71 @@ export class WarehouseInventoryService {
|
||||
return trimmed ? `${trimmed}\n${note}` : note;
|
||||
}
|
||||
|
||||
private assertTruckEntrance(truckEntrance?: TruckEntranceDto): void {
|
||||
if (!truckEntrance?.truckPlateNumber?.trim()) {
|
||||
throw new BadRequestException('Truck plate number is required for entrance registration');
|
||||
}
|
||||
if (!truckEntrance.driverName?.trim()) {
|
||||
throw new BadRequestException('Driver name is required for entrance registration');
|
||||
}
|
||||
if (!truckEntrance.driverPhone?.trim()) {
|
||||
throw new BadRequestException('Driver phone is required for entrance registration');
|
||||
}
|
||||
if (truckEntrance.entranceTareWeightKg === undefined || Number(truckEntrance.entranceTareWeightKg) < 0) {
|
||||
throw new BadRequestException('Entrance tare weight is required for entrance registration');
|
||||
}
|
||||
}
|
||||
|
||||
private generateGrnNumber(direction: string, referenceId: string, date: Date): string {
|
||||
const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
|
||||
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
|
||||
}
|
||||
|
||||
private buildReceiveNote(input: {
|
||||
grnNumber: string;
|
||||
direction?: string | null;
|
||||
notes?: string | null;
|
||||
truckEntrance: TruckEntranceDto;
|
||||
}): string {
|
||||
const truck = input.truckEntrance;
|
||||
const rows = [
|
||||
`GRN Number: ${input.grnNumber}`,
|
||||
input.direction ? `Direction: ${input.direction}` : null,
|
||||
truck.ownerName ? `Owner / Shipper: ${truck.ownerName}` : null,
|
||||
truck.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null,
|
||||
truck.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null,
|
||||
truck.tin ? `TIN: ${truck.tin}` : null,
|
||||
`Truck Plate: ${truck.truckPlateNumber}`,
|
||||
truck.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null,
|
||||
truck.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null,
|
||||
truck.customsSealNumber ? `Customs Seal Number: ${truck.customsSealNumber}` : null,
|
||||
truck.truckType ? `Truck Type: ${truck.truckType}` : null,
|
||||
`Driver: ${truck.driverName}`,
|
||||
`Driver Phone: ${truck.driverPhone}`,
|
||||
truck.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null,
|
||||
`Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg`,
|
||||
truck.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null,
|
||||
truck.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null,
|
||||
truck.incoterms ? `Incoterms: ${truck.incoterms}` : null,
|
||||
truck.hsCodes ? `HS Codes: ${truck.hsCodes}` : null,
|
||||
truck.itemCode ? `Item Code: ${truck.itemCode}` : null,
|
||||
truck.itemDescription ? `Item Description: ${truck.itemDescription}` : null,
|
||||
truck.packagingType ? `Packaging Type: ${truck.packagingType}` : null,
|
||||
truck.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null,
|
||||
truck.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null,
|
||||
truck.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null,
|
||||
truck.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null,
|
||||
truck.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null,
|
||||
truck.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null,
|
||||
truck.warehouseCodeLocation ? `Warehouse Code / Location: ${truck.warehouseCodeLocation}` : null,
|
||||
truck.driverSignatoryName ? `Driver Signatory: ${truck.driverSignatoryName}` : null,
|
||||
truck.warehouseManagerName ? `EDR Warehouse Manager: ${truck.warehouseManagerName}` : null,
|
||||
input.notes?.trim() ? `Remarks: ${input.notes.trim()}` : null,
|
||||
];
|
||||
return rows.filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
private async getInventoryAllocationCriteria(item: WarehouseInventory): Promise<InventoryAllocationCriteria> {
|
||||
const fallbackFreightType = item.containerId ? 'CONTAINER' : item.cargoId ? 'BULK' : null;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
|
||||
import { WarehouseInvoiceService } from './warehouse-invoice.service';
|
||||
@@ -54,6 +55,26 @@ export class WarehouseInvoiceController {
|
||||
return this.invoiceService.findById(id);
|
||||
}
|
||||
|
||||
@Get('warehouse-fee-invoices/:id/document')
|
||||
@ApiOperation({ summary: 'Download sealed warehouse fee invoice PDF' })
|
||||
async document(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.invoiceService.document(id);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Get('warehouse-fee-invoices/:id/receipt')
|
||||
@ApiOperation({ summary: 'Download sealed warehouse fee payment receipt PDF' })
|
||||
async receipt(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.invoiceService.receipt(id);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Patch('warehouse-fee-invoices/:id/cancel')
|
||||
@ApiOperation({ summary: 'Cancel a warehouse fee invoice' })
|
||||
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { WarehouseFeeType } from './entities/warehouse-fee-invoice-item.entity';
|
||||
import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository';
|
||||
import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository';
|
||||
import { WarehouseFeeService } from './warehouse-fee.service';
|
||||
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
|
||||
|
||||
interface GenerateOptions {
|
||||
confirmZero?: boolean;
|
||||
@@ -27,6 +28,22 @@ export interface PayInvoiceDto {
|
||||
const BLOCKING_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID'];
|
||||
const ACTIVE_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID', 'PAID'];
|
||||
|
||||
export interface InvoiceDocumentDetails {
|
||||
bookingReference: string | null;
|
||||
customerName: string | null;
|
||||
inventoryReference: string | null;
|
||||
inventoryInfo: string | null;
|
||||
inventoryStatus: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoDescription: string | null;
|
||||
clearanceStatus: string;
|
||||
warehouseName: string | null;
|
||||
yardName: string | null;
|
||||
zoneName: string | null;
|
||||
}
|
||||
|
||||
export type WarehouseFeeInvoiceWithDisplay = WarehouseFeeInvoice & Partial<InvoiceDocumentDetails>;
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseInvoiceService {
|
||||
constructor(
|
||||
@@ -34,6 +51,7 @@ export class WarehouseInvoiceService {
|
||||
private readonly invoiceRepository: WarehouseFeeInvoiceRepository,
|
||||
private readonly itemRepository: WarehouseFeeInvoiceItemRepository,
|
||||
private readonly feeService: WarehouseFeeService,
|
||||
private readonly documents: WarehouseReleaseDocumentService,
|
||||
) {}
|
||||
|
||||
// ── Generation ───────────────────────────────────────────────────────────
|
||||
@@ -150,11 +168,35 @@ export class WarehouseInvoiceService {
|
||||
}
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────
|
||||
async findById(id: string): Promise<WarehouseFeeInvoice & { items: unknown[] }> {
|
||||
async findById(id: string): Promise<WarehouseFeeInvoiceWithDisplay & { items: unknown[] }> {
|
||||
const invoice = await this.invoiceRepository.findById(id);
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||
const items = await this.itemRepository.findAll({ where: { invoiceId: id } });
|
||||
return { ...invoice, items } as WarehouseFeeInvoice & { items: unknown[] };
|
||||
const details = await this.getInvoiceDocumentDetails(invoice);
|
||||
return { ...invoice, ...details, items } as WarehouseFeeInvoiceWithDisplay & { items: unknown[] };
|
||||
}
|
||||
|
||||
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const invoice = await this.findById(id);
|
||||
const details = await this.getInvoiceDocumentDetails(invoice);
|
||||
const html = this.buildInvoiceDocumentHtml(invoice, 'INVOICE', details);
|
||||
return {
|
||||
filename: `warehouse-invoice-${this.safeFilename(invoice.invoiceNumber)}.pdf`,
|
||||
buffer: await this.documents.htmlToPdfBuffer(html),
|
||||
};
|
||||
}
|
||||
|
||||
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const invoice = await this.findById(id);
|
||||
if (Number(invoice.paidAmount) <= 0) {
|
||||
throw new BadRequestException('A receipt is available only after payment is recorded.');
|
||||
}
|
||||
const details = await this.getInvoiceDocumentDetails(invoice);
|
||||
const html = this.buildInvoiceDocumentHtml(invoice, 'RECEIPT', details);
|
||||
return {
|
||||
filename: `warehouse-receipt-${this.safeFilename(invoice.invoiceNumber)}.pdf`,
|
||||
buffer: await this.documents.htmlToPdfBuffer(html),
|
||||
};
|
||||
}
|
||||
|
||||
listForInventory(inventoryId: string): Promise<WarehouseFeeInvoice[]> {
|
||||
@@ -213,4 +255,207 @@ export class WarehouseInvoiceService {
|
||||
const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } });
|
||||
return invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)) ?? null;
|
||||
}
|
||||
|
||||
async assertClearanceAllowed(inventoryId: string): Promise<void> {
|
||||
const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } });
|
||||
const blocking = invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status));
|
||||
if (blocking) {
|
||||
throw new BadRequestException(
|
||||
`Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (invoices.some((inv) => inv.status === 'PAID')) return;
|
||||
|
||||
const previews = await this.feeService.previewForInventory(inventoryId, 'USD');
|
||||
const payableAmount = previews.reduce((sum, fee) => sum + Number(fee.amount || 0), 0);
|
||||
if (payableAmount > 0) {
|
||||
throw new BadRequestException(
|
||||
'Generate and fully pay the warehouse demurrage/storage invoice before terminal release.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async getInvoiceDocumentDetails(invoice: WarehouseFeeInvoice): Promise<InvoiceDocumentDetails> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT b.reference AS "bookingReference",
|
||||
company.name AS "customerName",
|
||||
COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference",
|
||||
inv.status AS "inventoryStatus",
|
||||
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
|
||||
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
|
||||
CONCAT_WS(
|
||||
' / ',
|
||||
NULLIF(inv.status, ''),
|
||||
NULLIF(COALESCE(container.container_number, booking_container.container_number), ''),
|
||||
NULLIF(COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description), '')
|
||||
) AS "inventoryInfo",
|
||||
wh.name AS "warehouseName",
|
||||
yard.name AS "yardName",
|
||||
zone.name AS "zoneName",
|
||||
CASE
|
||||
WHEN inv.release_date IS NOT NULL THEN 'RELEASE ISSUED'
|
||||
WHEN $2 = 'PAID' THEN 'FEE PAID - READY FOR RELEASE'
|
||||
ELSE 'PENDING PAYMENT'
|
||||
END AS "clearanceStatus"
|
||||
FROM freight.warehouse_fee_invoices fee
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL
|
||||
LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id)
|
||||
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
|
||||
LEFT JOIN freight.booking_container booking_container ON (
|
||||
booking_container.booking_id = b.id
|
||||
AND booking_container.deleted_at IS NULL
|
||||
)
|
||||
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
|
||||
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
|
||||
LEFT JOIN freight.warehouses wh ON wh.id = fee.warehouse_id
|
||||
LEFT JOIN freight.warehouse_yards yard ON yard.id = fee.yard_id
|
||||
LEFT JOIN freight.warehouse_zones zone ON zone.id = fee.zone_id
|
||||
WHERE fee.id = $1
|
||||
LIMIT 1`,
|
||||
[invoice.id, invoice.status],
|
||||
);
|
||||
|
||||
return {
|
||||
bookingReference: row?.bookingReference ?? null,
|
||||
customerName: row?.customerName ?? null,
|
||||
inventoryReference: row?.inventoryReference ?? null,
|
||||
inventoryInfo: row?.inventoryInfo ?? null,
|
||||
inventoryStatus: row?.inventoryStatus ?? null,
|
||||
containerNumber: row?.containerNumber ?? null,
|
||||
cargoDescription: row?.cargoDescription ?? null,
|
||||
warehouseName: row?.warehouseName ?? null,
|
||||
yardName: row?.yardName ?? null,
|
||||
zoneName: row?.zoneName ?? null,
|
||||
clearanceStatus: row?.clearanceStatus ?? (invoice.status === 'PAID' ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT'),
|
||||
};
|
||||
}
|
||||
|
||||
private buildInvoiceDocumentHtml(
|
||||
invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] },
|
||||
kind: 'INVOICE' | 'RECEIPT',
|
||||
details: InvoiceDocumentDetails,
|
||||
): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
const money = (amount: unknown, currency = invoice.currency) =>
|
||||
`${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
|
||||
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
|
||||
const items = invoice.items as Array<{
|
||||
id?: string;
|
||||
description?: string;
|
||||
feeType?: string;
|
||||
quantity?: number;
|
||||
unitRate?: number;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
chargeableDays?: number | null;
|
||||
}>;
|
||||
const lastPayment = [...(invoice.payments ?? [])].pop();
|
||||
const sealText = kind === 'RECEIPT' || invoice.status === 'PAID' ? 'EDR PAID' : 'EDR';
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; color: #0f172a; margin: 0; }
|
||||
.doc { padding: 16px 8px; position: relative; }
|
||||
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 16px; }
|
||||
.brand { font-size: 13px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
|
||||
h1 { margin: 8px 0 0; font-size: 30px; }
|
||||
.meta { text-align: right; font-size: 12px; color: #475569; }
|
||||
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
|
||||
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
|
||||
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
|
||||
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
|
||||
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
|
||||
th { text-align: left; background: #f8fafc; color: #475569; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; font-size: 12px; }
|
||||
td.num, th.num { text-align: right; }
|
||||
.totals { margin-left: auto; width: 330px; margin-top: 18px; }
|
||||
.total-row { display: flex; justify-content: space-between; border-bottom: 1px solid #e2e8f0; padding: 8px 0; font-size: 13px; }
|
||||
.grand { font-size: 16px; font-weight: 800; }
|
||||
.footer { margin-top: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 12px; color: #475569; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}</h1>
|
||||
</div>
|
||||
<div class="meta">
|
||||
Document no.
|
||||
<strong>${esc(invoice.invoiceNumber)}</strong>
|
||||
Issued: ${esc(date(invoice.issuedAt ?? invoice.createdAt))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="seal">${esc(sealText)}</div>
|
||||
<div class="summary">
|
||||
<div><span>Status</span>${esc(invoice.status.replace(/_/g, ' '))}</div>
|
||||
<div><span>Invoice type</span>${esc(invoice.invoiceType.replace(/_/g, ' '))}</div>
|
||||
<div><span>Booking reference</span>${esc(details.bookingReference)}</div>
|
||||
<div><span>Customer</span>${esc(details.customerName)}</div>
|
||||
<div><span>Inventory reference</span>${esc(details.inventoryReference)}</div>
|
||||
<div><span>Inventory info</span>${esc(details.inventoryInfo)}</div>
|
||||
<div><span>Clearance</span>${esc(details.clearanceStatus)}</div>
|
||||
<div><span>Warehouse</span>${esc(details.warehouseName)}</div>
|
||||
<div><span>Yard / Zone</span>${esc([details.yardName, details.zoneName].filter(Boolean).join(' / ') || null)}</div>
|
||||
<div><span>Period</span>${esc(date(invoice.periodStart))} - ${esc(date(invoice.periodEnd))}</div>
|
||||
<div><span>Payment</span>${esc(lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt)}` : '-')}</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>Fee type</th>
|
||||
<th class="num">Qty</th>
|
||||
<th class="num">Rate</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${items
|
||||
.map(
|
||||
(item) => `<tr>
|
||||
<td>${esc(item.description)}</td>
|
||||
<td>${esc((item.feeType ?? '').replace(/_/g, ' '))}</td>
|
||||
<td class="num">${esc(item.quantity ?? item.chargeableDays ?? 0)}</td>
|
||||
<td class="num">${esc(money(item.unitRate, item.currency ?? invoice.currency))}</td>
|
||||
<td class="num">${esc(money(item.amount, item.currency ?? invoice.currency))}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="totals">
|
||||
<div class="total-row"><span>Subtotal</span><strong>${esc(money(invoice.subtotalAmount))}</strong></div>
|
||||
<div class="total-row"><span>Tax</span><strong>${esc(money(invoice.taxAmount))}</strong></div>
|
||||
<div class="total-row grand"><span>Total</span><strong>${esc(money(invoice.totalAmount))}</strong></div>
|
||||
<div class="total-row"><span>Paid</span><strong>${esc(money(invoice.paidAmount))}</strong></div>
|
||||
<div class="total-row"><span>Balance</span><strong>${esc(money(invoice.balanceAmount))}</strong></div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="line">Prepared by EDR warehouse finance</div>
|
||||
<div class="line">Authorized seal / signature</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private safeFilename(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9_-]+/g, '-');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
|
||||
const MIN_VALID_PDF_BYTES = 2_000;
|
||||
|
||||
const RELEASE_DOCUMENT_PRINT_STYLES = `
|
||||
<style id="warehouse-release-document-print-fix">
|
||||
@media print {
|
||||
html, body {
|
||||
background: #fff !important;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
</style>`;
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseReleaseDocumentService {
|
||||
private readonly logger = new Logger(WarehouseReleaseDocumentService.name);
|
||||
|
||||
async htmlToPdfBuffer(html: string): Promise<Buffer> {
|
||||
const preparedHtml = this.injectPdfPrintStyles(html);
|
||||
const executablePath = this.resolveExecutablePath();
|
||||
|
||||
try {
|
||||
const puppeteer = await import('puppeteer');
|
||||
const launchOptions: import('puppeteer').LaunchOptions = {
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
};
|
||||
|
||||
const browser = await puppeteer.default.launch(launchOptions);
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
|
||||
await page.setContent(preparedHtml, { waitUntil: 'load', timeout: 60_000 });
|
||||
await page.emulateMediaType('print');
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
const pdf = await page.pdf({
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: { top: '16mm', bottom: '18mm', left: '14mm', right: '14mm' },
|
||||
});
|
||||
|
||||
const buffer = Buffer.from(pdf);
|
||||
if (!this.isValidPdf(buffer)) {
|
||||
throw new Error(`Puppeteer produced invalid release PDF (${buffer.length} bytes)`);
|
||||
}
|
||||
this.logger.log(
|
||||
`Warehouse release PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`,
|
||||
);
|
||||
return buffer;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Warehouse release PDF failed (executable=${executablePath ?? 'default'}): ${error}`,
|
||||
);
|
||||
const fallback = this.htmlToBasicPdfBuffer(preparedHtml);
|
||||
if (this.isValidPdf(fallback)) {
|
||||
this.logger.warn(
|
||||
`Using basic warehouse release PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
throw new InternalServerErrorException(
|
||||
'Warehouse release PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private injectPdfPrintStyles(html: string): string {
|
||||
if (html.includes('warehouse-release-document-print-fix')) return html;
|
||||
if (html.includes('</head>')) {
|
||||
return html.replace('</head>', `${RELEASE_DOCUMENT_PRINT_STYLES}</head>`);
|
||||
}
|
||||
return `${RELEASE_DOCUMENT_PRINT_STYLES}${html}`;
|
||||
}
|
||||
|
||||
private resolveExecutablePath(): string | undefined {
|
||||
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
|
||||
if (fromEnv && existsSync(fromEnv)) return fromEnv;
|
||||
|
||||
const candidates = [
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/google-chrome',
|
||||
];
|
||||
return candidates.find((path) => existsSync(path));
|
||||
}
|
||||
|
||||
private isValidPdf(buffer: Buffer): boolean {
|
||||
return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString('ascii') === '%PDF-';
|
||||
}
|
||||
|
||||
private htmlToBasicPdfBuffer(html: string): Buffer {
|
||||
const text = this.htmlToPlainText(html);
|
||||
const lines = this.wrapLines(text, 86).slice(0, 52);
|
||||
const body = lines
|
||||
.map((line, index) => {
|
||||
const y = 770 - index * 12;
|
||||
const isTitle = index < 2 || /clearance|release order/i.test(line);
|
||||
const size = index === 0 ? 13 : isTitle ? 11 : 9.6;
|
||||
const font = isTitle ? 'F2' : 'F1';
|
||||
return this.textOp(line, 48, y, size, font);
|
||||
})
|
||||
.join('\n');
|
||||
const stream = [
|
||||
this.lineOp(48, 752, 548, 752),
|
||||
body,
|
||||
this.circularSealOps(184, 154),
|
||||
this.lineOp(48, 92, 278, 92, '0 0 0'),
|
||||
this.textOp('Officer in charge name / signature / date', 48, 76, 9, 'F1'),
|
||||
this.lineOp(326, 92, 548, 92, '0 0 0'),
|
||||
this.textOp('Customer or driver name / signature / date', 326, 76, 9, 'F1'),
|
||||
this.textOp('OFFICIAL WAREHOUSE GATE CLEARANCE DOCUMENT', 145, 52, 9, 'F2', '0.08 0.32 0.18'),
|
||||
].join('\n');
|
||||
|
||||
const objects = [
|
||||
'<< /Type /Catalog /Pages 2 0 R >>',
|
||||
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
||||
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>',
|
||||
'<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >>',
|
||||
'<< /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >>',
|
||||
`<< /Length ${Buffer.byteLength(stream, 'latin1')} >>\nstream\n${stream}\nendstream`,
|
||||
];
|
||||
|
||||
let pdf = '%PDF-1.4\n';
|
||||
const offsets: number[] = [0];
|
||||
objects.forEach((object, index) => {
|
||||
offsets.push(Buffer.byteLength(pdf, 'latin1'));
|
||||
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
||||
});
|
||||
while (Buffer.byteLength(pdf, 'latin1') < MIN_VALID_PDF_BYTES) {
|
||||
pdf += '% fallback padding\n';
|
||||
}
|
||||
const xrefOffset = Buffer.byteLength(pdf, 'latin1');
|
||||
pdf += `xref\n0 ${objects.length + 1}\n`;
|
||||
pdf += '0000000000 65535 f \n';
|
||||
for (const offset of offsets.slice(1)) {
|
||||
pdf += `${String(offset).padStart(10, '0')} 00000 n \n`;
|
||||
}
|
||||
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
|
||||
return Buffer.from(pdf, 'latin1');
|
||||
}
|
||||
|
||||
private htmlToPlainText(html: string): string {
|
||||
return html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<\/(h1|h2|h3|p|div|tr|table|section|header|footer)>/gi, '\n')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/[^\x09\x0a\x0d\x20-\x7e]/g, '-')
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/\s+/g, ' ').trim())
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
private wrapLines(text: string, width: number): string[] {
|
||||
const wrapped: string[] = [];
|
||||
for (const rawLine of text.split('\n')) {
|
||||
const words = rawLine.split(' ');
|
||||
let line = '';
|
||||
for (const word of words) {
|
||||
const next = line ? `${line} ${word}` : word;
|
||||
if (next.length > width && line) {
|
||||
wrapped.push(line);
|
||||
line = word;
|
||||
} else {
|
||||
line = next;
|
||||
}
|
||||
}
|
||||
if (line) wrapped.push(line);
|
||||
}
|
||||
return wrapped.length ? wrapped : ['Warehouse release document'];
|
||||
}
|
||||
|
||||
private escapePdfText(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
}
|
||||
|
||||
private textOp(
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
font: 'F1' | 'F2' = 'F1',
|
||||
color = '0 0 0',
|
||||
): string {
|
||||
return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${this.escapePdfText(text)}) Tj\nET`;
|
||||
}
|
||||
|
||||
private lineOp(x1: number, y1: number, x2: number, y2: number, color = '0.08 0.32 0.18'): string {
|
||||
return `q\n${color} RG\n0.8 w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
|
||||
}
|
||||
|
||||
private circularSealOps(cx: number, cy: number): string {
|
||||
return [
|
||||
'q',
|
||||
'0.08 0.32 0.18 RG',
|
||||
'0.08 0.32 0.18 rg',
|
||||
'2.2 w',
|
||||
this.circlePath(cx, cy, 51),
|
||||
'S',
|
||||
'0.8 w',
|
||||
this.circlePath(cx, cy, 41),
|
||||
'S',
|
||||
this.textOp('EDR', cx - 14, cy + 18, 14, 'F2', '0.08 0.32 0.18'),
|
||||
this.textOp('WAREHOUSE', cx - 31, cy + 2, 9, 'F2', '0.08 0.32 0.18'),
|
||||
this.textOp('CLEARED', cx - 27, cy - 16, 11, 'F2', '0.08 0.32 0.18'),
|
||||
'Q',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private circlePath(cx: number, cy: number, r: number): string {
|
||||
const k = 0.5522847498;
|
||||
const c = r * k;
|
||||
return [
|
||||
`${cx + r} ${cy} m`,
|
||||
`${cx + r} ${cy + c} ${cx + c} ${cy + r} ${cx} ${cy + r} c`,
|
||||
`${cx - c} ${cy + r} ${cx - r} ${cy + c} ${cx - r} ${cy} c`,
|
||||
`${cx - r} ${cy - c} ${cx - c} ${cy - r} ${cx} ${cy - r} c`,
|
||||
`${cx + c} ${cy - r} ${cx + r} ${cy - c} ${cx + r} ${cy} c`,
|
||||
'h',
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||
@@ -32,6 +31,7 @@ import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
||||
import { WarehouseLoadingsController } from './warehouse-loadings.controller';
|
||||
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
|
||||
import { WarehouseAllocationRuleRepository } from './warehouse-allocation-rule.repository';
|
||||
import { WarehouseAllocationService } from './warehouse-allocation.service';
|
||||
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
|
||||
@@ -111,8 +111,8 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseFeeService,
|
||||
WarehouseInvoiceService,
|
||||
WarehouseSchedulingAdapterService,
|
||||
WarehouseReleaseDocumentService,
|
||||
SchedulingReadFacade,
|
||||
ContractPdfService,
|
||||
],
|
||||
exports: [
|
||||
WarehousesService,
|
||||
|
||||
40
apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts
Normal file
40
apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'reflect-metadata';
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
config({ path: resolve(__dirname, '../../.env') });
|
||||
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from '../app.module';
|
||||
import { Batch14TestDataSeeder } from '../seed/batch1-4-test-data.seeder';
|
||||
import { Batch5TestDataSeeder } from '../seed/batch5-test-data.seeder';
|
||||
import { Batch7TestDataSeeder } from '../seed/batch7-test-data.seeder';
|
||||
import { Batch8TestDataSeeder } from '../seed/batch8-test-data.seeder';
|
||||
import { IndodeFacilitySeeder } from '../seed/indode-facility.seeder';
|
||||
import { PricingDataSeeder } from '../seed/pricing-data.seeder';
|
||||
import { WarehouseDemoSeeder } from '../seed/warehouse-demo.seeder';
|
||||
|
||||
async function main() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
logger: ['error', 'warn', 'log'],
|
||||
});
|
||||
|
||||
try {
|
||||
await app.get(PricingDataSeeder).run();
|
||||
await app.get(IndodeFacilitySeeder).run();
|
||||
await app.get(Batch14TestDataSeeder).run();
|
||||
await app.get(Batch5TestDataSeeder).run();
|
||||
await app.get(Batch7TestDataSeeder).run();
|
||||
await app.get(Batch8TestDataSeeder).run();
|
||||
await app.get(WarehouseDemoSeeder).run();
|
||||
|
||||
console.log('Warehouse demo data seeded.');
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Warehouse demo seed failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5183",
|
||||
"prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5183",
|
||||
"lint": "eslint src",
|
||||
|
||||
@@ -77,6 +77,7 @@ import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
|
||||
import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage";
|
||||
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
|
||||
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
|
||||
import { HealthCheck } from "./features/health/HealthCheck";
|
||||
|
||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
{
|
||||
@@ -374,6 +375,7 @@ const App = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/um/*" element={<UserManagementHostPage />} />
|
||||
<Route path="/health" element={<HealthCheck />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardShell />}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from "axios";
|
||||
|
||||
import { API_BASE_URL } from "@/pages/fleet/config/vehicles";
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
import {
|
||||
AUTH_TOKEN_COOKIE,
|
||||
REFRESH_TOKEN_COOKIE,
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { API_BASE_URL } from "@/pages/fleet/config/vehicles";
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
|
||||
interface Cargo {
|
||||
id: string;
|
||||
|
||||
@@ -134,15 +134,23 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
const releasedItem = await gateClear.mutateAsync(inventoryId) as WarehouseInventoryItem;
|
||||
const documentResponse = await warehouseService.downloadReleaseDocument(inventoryId);
|
||||
const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? inventoryId}.pdf`;
|
||||
const opened = openPdfBlob(documentResponse.data, filename, pdfWindow);
|
||||
toast({
|
||||
title: 'Gate clearance recorded',
|
||||
description: opened
|
||||
? 'The release PDF opened in a browser tab.'
|
||||
: 'The browser blocked the preview tab, so the PDF was downloaded.',
|
||||
});
|
||||
try {
|
||||
const documentResponse = await warehouseService.downloadReleaseDocument(inventoryId);
|
||||
const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? inventoryId}.pdf`;
|
||||
const opened = openPdfBlob(documentResponse.data, filename, pdfWindow);
|
||||
toast({
|
||||
title: 'Gate clearance recorded',
|
||||
description: opened
|
||||
? 'The release PDF opened in a browser tab.'
|
||||
: 'The browser blocked the preview tab, so the PDF was downloaded.',
|
||||
});
|
||||
} catch (documentError) {
|
||||
pdfWindow?.close();
|
||||
toast({
|
||||
title: 'Gate clearance recorded',
|
||||
description: `Release paper could not be opened: ${extractErrorMessage(documentError)}`,
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
|
||||
@@ -69,6 +69,8 @@ export function InterchangeDocumentDetailPanel({ id }: { id: string }) {
|
||||
/>
|
||||
<DetailField label="Generated At" value={formatDate(document.generatedAt)} />
|
||||
<DetailField label="Acknowledged At" value={formatDate(document.acknowledgedAt)} />
|
||||
<DetailField label="Signed by EDR" value={document.generatedBy} />
|
||||
<DetailField label="Signed by Djibouti Port" value={document.acknowledgedBy} />
|
||||
<DetailField label="Customs Ref" value={document.customsReference} />
|
||||
<DetailField label="Manifest Ref" value={document.manifestReference} />
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -24,6 +24,15 @@ function DetailRow({ label, value }: { label: string; value: React.ReactNode })
|
||||
}
|
||||
|
||||
export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) {
|
||||
const bookingReference = item?.booking?.reference ?? '-';
|
||||
const inventorySummary = [
|
||||
item?.status?.replace(/_/g, ' '),
|
||||
item?.releaseOrderReference ? `Release ${item.releaseOrderReference}` : null,
|
||||
item?.warehouse ? `${item.warehouse.name} (${item.warehouse.code})` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Inventory detail" centered size="xl">
|
||||
{!item ? (
|
||||
@@ -33,10 +42,10 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Stack gap={2}>
|
||||
<Text size="lg" fw={800}>
|
||||
{item.booking?.reference ?? item.bookingId ?? item.id}
|
||||
{bookingReference}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Inventory ID: {item.id}
|
||||
{inventorySummary || 'Inventory information'}
|
||||
</Text>
|
||||
</Stack>
|
||||
<InventoryStatusBadge status={item.status} />
|
||||
@@ -51,12 +60,12 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
|
||||
|
||||
<Divider label="Booking & item" labelPosition="left" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<DetailRow label="Booking reference" value={bookingReference} />
|
||||
<DetailRow label="Booking status" value={item.booking?.status ?? '-'} />
|
||||
<DetailRow label="Payment status" value={item.booking?.paymentStatus ?? '-'} />
|
||||
<DetailRow label="Trade direction" value={item.booking?.tradeDirection ?? '-'} />
|
||||
<DetailRow label="Container ID" value={item.containerId ?? '-'} />
|
||||
<DetailRow label="Cargo ID" value={item.cargoId ?? '-'} />
|
||||
<DetailRow label="Goods ID" value={item.goodsId ?? '-'} />
|
||||
<DetailRow label="Inventory status" value={item.status.replace(/_/g, ' ')} />
|
||||
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
|
||||
<DetailRow label="Quantity" value={formatNumber(item.quantity)} />
|
||||
<DetailRow label="Weight" value={`${formatNumber(item.weight)} kg`} />
|
||||
<DetailRow label="Volume" value={item.volume == null ? '-' : formatNumber(item.volume)} />
|
||||
|
||||
@@ -18,16 +18,20 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { ChevronDown, ChevronRight, ClipboardCheck, Info, PackageSearch, Train, Truck } from 'lucide-react';
|
||||
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { firstMileService } from '@/services/first-mile.service';
|
||||
import type {
|
||||
EligibleBooking,
|
||||
ImportTrain,
|
||||
ImportTrainItem,
|
||||
ImportUnloadedItem,
|
||||
ReadyToLoadRow,
|
||||
ReceiveInventoryPayload,
|
||||
TruckEntrancePayload,
|
||||
} from '@/types/warehouse';
|
||||
import { BookingSelect } from './BookingSelect';
|
||||
import { InspectionReportModal } from './InspectionReportModal';
|
||||
@@ -49,6 +53,305 @@ interface Location {
|
||||
zoneId: string;
|
||||
}
|
||||
|
||||
interface TruckEntranceFormState {
|
||||
ownerName: string;
|
||||
consigneeDetails: string;
|
||||
edrDigitalBookingId: string;
|
||||
tin: string;
|
||||
truckPlateNumber: string;
|
||||
trailerPlateNumber: string;
|
||||
assignedEquipmentNumber: string;
|
||||
customsSealNumber: string;
|
||||
declarationNumber: string;
|
||||
incoterms: string;
|
||||
hsCodes: string;
|
||||
itemCode: string;
|
||||
itemDescription: string;
|
||||
packagingType: string;
|
||||
unitCount: number | '';
|
||||
grossWeightKg: number | '';
|
||||
netWeightKg: number | '';
|
||||
volumeDimensions: string;
|
||||
conditionAtReceipt: string;
|
||||
damagedRejectedQuantity: number | '';
|
||||
warehouseCodeLocation: string;
|
||||
driverName: string;
|
||||
driverPhone: string;
|
||||
driverLicenseNumber: string;
|
||||
truckType: string;
|
||||
entranceTareWeightKg: number | '';
|
||||
exitTareWeightKg: number | '';
|
||||
driverSignatoryName: string;
|
||||
warehouseManagerName: string;
|
||||
}
|
||||
|
||||
const emptyTruckEntrance = (): TruckEntranceFormState => ({
|
||||
ownerName: '',
|
||||
consigneeDetails: '',
|
||||
edrDigitalBookingId: '',
|
||||
tin: '',
|
||||
truckPlateNumber: '',
|
||||
trailerPlateNumber: '',
|
||||
assignedEquipmentNumber: '',
|
||||
customsSealNumber: '',
|
||||
declarationNumber: '',
|
||||
incoterms: '',
|
||||
hsCodes: '',
|
||||
itemCode: '',
|
||||
itemDescription: '',
|
||||
packagingType: '',
|
||||
unitCount: '',
|
||||
grossWeightKg: '',
|
||||
netWeightKg: '',
|
||||
volumeDimensions: '',
|
||||
conditionAtReceipt: '',
|
||||
damagedRejectedQuantity: '',
|
||||
warehouseCodeLocation: '',
|
||||
driverName: '',
|
||||
driverPhone: '',
|
||||
driverLicenseNumber: '',
|
||||
truckType: '',
|
||||
entranceTareWeightKg: '',
|
||||
exitTareWeightKg: '',
|
||||
driverSignatoryName: '',
|
||||
warehouseManagerName: '',
|
||||
});
|
||||
|
||||
const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayload => ({
|
||||
ownerName: form.ownerName.trim() || undefined,
|
||||
consigneeDetails: form.consigneeDetails.trim() || undefined,
|
||||
edrDigitalBookingId: form.edrDigitalBookingId.trim() || undefined,
|
||||
tin: form.tin.trim() || undefined,
|
||||
truckPlateNumber: form.truckPlateNumber.trim(),
|
||||
trailerPlateNumber: form.trailerPlateNumber.trim() || undefined,
|
||||
assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined,
|
||||
customsSealNumber: form.customsSealNumber.trim() || undefined,
|
||||
declarationNumber: form.declarationNumber.trim() || undefined,
|
||||
incoterms: form.incoterms.trim() || undefined,
|
||||
hsCodes: form.hsCodes.trim() || undefined,
|
||||
itemCode: form.itemCode.trim() || undefined,
|
||||
itemDescription: form.itemDescription.trim() || undefined,
|
||||
packagingType: form.packagingType.trim() || undefined,
|
||||
unitCount: form.unitCount === '' ? undefined : Number(form.unitCount),
|
||||
grossWeightKg: form.grossWeightKg === '' ? undefined : Number(form.grossWeightKg),
|
||||
netWeightKg: form.netWeightKg === '' ? undefined : Number(form.netWeightKg),
|
||||
volumeDimensions: form.volumeDimensions.trim() || undefined,
|
||||
conditionAtReceipt: form.conditionAtReceipt.trim() || undefined,
|
||||
damagedRejectedQuantity: form.damagedRejectedQuantity === '' ? undefined : Number(form.damagedRejectedQuantity),
|
||||
warehouseCodeLocation: form.warehouseCodeLocation.trim() || undefined,
|
||||
driverName: form.driverName.trim(),
|
||||
driverPhone: form.driverPhone.trim(),
|
||||
driverLicenseNumber: form.driverLicenseNumber.trim() || undefined,
|
||||
truckType: form.truckType.trim() || undefined,
|
||||
entranceTareWeightKg: Number(form.entranceTareWeightKg),
|
||||
exitTareWeightKg: form.exitTareWeightKg === '' ? undefined : Number(form.exitTareWeightKg),
|
||||
driverSignatoryName: form.driverSignatoryName.trim() || undefined,
|
||||
warehouseManagerName: form.warehouseManagerName.trim() || undefined,
|
||||
});
|
||||
|
||||
function TruckEntranceFields({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: TruckEntranceFormState;
|
||||
onChange: (next: TruckEntranceFormState) => void;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={600}>Customer and cargo ownership</Text>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Owner's name"
|
||||
value={value.ownerName}
|
||||
onChange={(e) => onChange({ ...value, ownerName: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Consignee details"
|
||||
value={value.consigneeDetails}
|
||||
onChange={(e) => onChange({ ...value, consigneeDetails: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="EDR digital booking ID"
|
||||
value={value.edrDigitalBookingId}
|
||||
onChange={(e) => onChange({ ...value, edrDigitalBookingId: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="TIN"
|
||||
value={value.tin}
|
||||
onChange={(e) => onChange({ ...value, tin: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Text size="sm" fw={600} mt="xs">Transport and equipment tracking</Text>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Truck plate number"
|
||||
required
|
||||
value={value.truckPlateNumber}
|
||||
onChange={(e) => onChange({ ...value, truckPlateNumber: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Trailer plate number"
|
||||
value={value.trailerPlateNumber}
|
||||
onChange={(e) => onChange({ ...value, trailerPlateNumber: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Assigned wagon / container number"
|
||||
value={value.assignedEquipmentNumber}
|
||||
onChange={(e) => onChange({ ...value, assignedEquipmentNumber: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Customs seal number"
|
||||
value={value.customsSealNumber}
|
||||
onChange={(e) => onChange({ ...value, customsSealNumber: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Driver name"
|
||||
required
|
||||
value={value.driverName}
|
||||
onChange={(e) => onChange({ ...value, driverName: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Driver phone"
|
||||
required
|
||||
value={value.driverPhone}
|
||||
onChange={(e) => onChange({ ...value, driverPhone: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Driver license number"
|
||||
value={value.driverLicenseNumber}
|
||||
onChange={(e) => onChange({ ...value, driverLicenseNumber: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Truck type"
|
||||
value={value.truckType}
|
||||
onChange={(e) => onChange({ ...value, truckType: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Entrance tare weight (kg)"
|
||||
required
|
||||
min={0}
|
||||
value={value.entranceTareWeightKg}
|
||||
onChange={(v) => onChange({ ...value, entranceTareWeightKg: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Exit tare weight (kg)"
|
||||
min={0}
|
||||
value={value.exitTareWeightKg}
|
||||
onChange={(v) => onChange({ ...value, exitTareWeightKg: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Text size="sm" fw={600} mt="xs">Customs and compliance</Text>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Declaration / Bill of Entry number"
|
||||
value={value.declarationNumber}
|
||||
onChange={(e) => onChange({ ...value, declarationNumber: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Incoterms"
|
||||
value={value.incoterms}
|
||||
onChange={(e) => onChange({ ...value, incoterms: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label="HS codes"
|
||||
value={value.hsCodes}
|
||||
onChange={(e) => onChange({ ...value, hsCodes: e.currentTarget.value })}
|
||||
/>
|
||||
|
||||
<Text size="sm" fw={600} mt="xs">Physical cargo specifications</Text>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Item code"
|
||||
value={value.itemCode}
|
||||
onChange={(e) => onChange({ ...value, itemCode: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Item description"
|
||||
value={value.itemDescription}
|
||||
onChange={(e) => onChange({ ...value, itemDescription: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Packaging type"
|
||||
value={value.packagingType}
|
||||
onChange={(e) => onChange({ ...value, packagingType: e.currentTarget.value })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Unit count"
|
||||
min={0}
|
||||
value={value.unitCount}
|
||||
onChange={(v) => onChange({ ...value, unitCount: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Gross weight (kg)"
|
||||
min={0}
|
||||
value={value.grossWeightKg}
|
||||
onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Net weight (kg)"
|
||||
min={0}
|
||||
value={value.netWeightKg}
|
||||
onChange={(v) => onChange({ ...value, netWeightKg: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label="Volume / dimensions"
|
||||
value={value.volumeDimensions}
|
||||
onChange={(e) => onChange({ ...value, volumeDimensions: e.currentTarget.value })}
|
||||
/>
|
||||
|
||||
<Text size="sm" fw={600} mt="xs">Quality and inspection</Text>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Condition at receipt"
|
||||
value={value.conditionAtReceipt}
|
||||
onChange={(e) => onChange({ ...value, conditionAtReceipt: e.currentTarget.value })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Damaged / rejected quantity"
|
||||
min={0}
|
||||
value={value.damagedRejectedQuantity}
|
||||
onChange={(v) => onChange({ ...value, damagedRejectedQuantity: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label="Warehouse code and location"
|
||||
value={value.warehouseCodeLocation}
|
||||
onChange={(e) => onChange({ ...value, warehouseCodeLocation: e.currentTarget.value })}
|
||||
/>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Driver signatory"
|
||||
value={value.driverSignatoryName}
|
||||
onChange={(e) => onChange({ ...value, driverSignatoryName: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="EDR warehouse manager"
|
||||
value={value.warehouseManagerName}
|
||||
onChange={(e) => onChange({ ...value, warehouseManagerName: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Cascading Warehouse → Yard → Zone selectors (ACTIVE only). */
|
||||
function LocationSelects({
|
||||
value,
|
||||
@@ -140,20 +443,105 @@ function EligibleTab({
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { data: allRows = [], isLoading } = useQuery(
|
||||
api.warehouses.eligibleBookings.queryOptions({ enabled }),
|
||||
);
|
||||
const rows = useMemo(() => allRows.filter((r) => r.direction === direction), [allRows, direction]);
|
||||
const bulkReceive = useMutation(api.warehouses.bulkReceive.mutationOptions());
|
||||
const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions());
|
||||
const requestFirstMile = useMutation({
|
||||
mutationFn: (reference: string) => firstMileService.accept(reference),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT });
|
||||
toast({ title: 'First mile requested', description: 'Booking was added to the existing First Mile workflow.' });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({ variant: 'destructive', title: 'First mile request failed', description: extractErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [statusTab, setStatusTab] = useState('ALL');
|
||||
const [truckOpen, setTruckOpen] = useState(false);
|
||||
const [pendingReceiveIds, setPendingReceiveIds] = useState<string[]>([]);
|
||||
const [truckForm, setTruckForm] = useState<TruckEntranceFormState>(emptyTruckEntrance());
|
||||
|
||||
const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId);
|
||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||||
const canReceiveBooking = (row: EligibleBooking) =>
|
||||
!(direction === 'EXPORT' && row.hasFirstMile && row.firstMileStatus !== 'RECEIVED_TO_PORT');
|
||||
const statusOptions = useMemo(() => {
|
||||
const base = [{ value: 'ALL', label: 'All bookings' }];
|
||||
if (direction === 'EXPORT') {
|
||||
return [
|
||||
...base,
|
||||
{ value: 'DIRECT', label: 'Direct truck' },
|
||||
{ value: 'FIRST_MILE', label: 'First mile' },
|
||||
{ value: 'FIRST_MILE_READY', label: 'First mile arrived' },
|
||||
{ value: 'AWAITING_FIRST_MILE', label: 'Awaiting first mile' },
|
||||
];
|
||||
}
|
||||
return [
|
||||
...base,
|
||||
{ value: 'READY_TO_RECEIVE', label: 'Ready to receive' },
|
||||
{ value: 'PAID', label: 'Paid' },
|
||||
];
|
||||
}, [direction]);
|
||||
const statusFilteredRows = useMemo(
|
||||
() =>
|
||||
rows.filter((row) => {
|
||||
switch (statusTab) {
|
||||
case 'DIRECT':
|
||||
return !row.hasFirstMile;
|
||||
case 'FIRST_MILE':
|
||||
return row.hasFirstMile;
|
||||
case 'FIRST_MILE_READY':
|
||||
return row.hasFirstMile && row.firstMileStatus === 'RECEIVED_TO_PORT';
|
||||
case 'AWAITING_FIRST_MILE':
|
||||
return row.hasFirstMile && row.firstMileStatus !== 'RECEIVED_TO_PORT';
|
||||
case 'READY_TO_RECEIVE':
|
||||
return canReceiveBooking(row);
|
||||
case 'PAID':
|
||||
return row.paymentStatus === 'PAID';
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}),
|
||||
[rows, statusTab],
|
||||
);
|
||||
const statusCounts = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
statusOptions.map((option) => [
|
||||
option.value,
|
||||
rows.filter((row) => {
|
||||
switch (option.value) {
|
||||
case 'DIRECT':
|
||||
return !row.hasFirstMile;
|
||||
case 'FIRST_MILE':
|
||||
return row.hasFirstMile;
|
||||
case 'FIRST_MILE_READY':
|
||||
return row.hasFirstMile && row.firstMileStatus === 'RECEIVED_TO_PORT';
|
||||
case 'AWAITING_FIRST_MILE':
|
||||
return row.hasFirstMile && row.firstMileStatus !== 'RECEIVED_TO_PORT';
|
||||
case 'READY_TO_RECEIVE':
|
||||
return canReceiveBooking(row);
|
||||
case 'PAID':
|
||||
return row.paymentStatus === 'PAID';
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}).length,
|
||||
]),
|
||||
),
|
||||
[rows, statusOptions],
|
||||
);
|
||||
const selectableRows = statusFilteredRows.filter(canReceiveBooking);
|
||||
const allSelected = selectableRows.length > 0 && selected.size === selectableRows.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
|
||||
const toggleAll = () =>
|
||||
setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
|
||||
setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id)));
|
||||
const toggleOne = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -161,7 +549,7 @@ function EligibleTab({
|
||||
return next;
|
||||
});
|
||||
|
||||
const receive = async (bookingIds: string[]) => {
|
||||
const openTruckReceive = (bookingIds: string[]) => {
|
||||
if (!locationReady) {
|
||||
toast({ variant: 'destructive', title: 'Select warehouse, yard and zone first' });
|
||||
return;
|
||||
@@ -170,13 +558,41 @@ function EligibleTab({
|
||||
toast({ variant: 'destructive', title: 'Select at least one booking' });
|
||||
return;
|
||||
}
|
||||
const allowedIds = new Set(selectableRows.map((row) => row.id));
|
||||
const filteredIds = bookingIds.filter((id) => allowedIds.has(id));
|
||||
if (filteredIds.length === 0) {
|
||||
toast({ variant: 'destructive', title: 'No selected booking is ready to receive' });
|
||||
return;
|
||||
}
|
||||
const row = filteredIds.length === 1 ? rows.find((item) => item.id === filteredIds[0]) : null;
|
||||
setPendingReceiveIds(filteredIds);
|
||||
setTruckForm({
|
||||
...emptyTruckEntrance(),
|
||||
truckPlateNumber: row?.firstMileTruckPlateNumber ?? '',
|
||||
trailerPlateNumber: row?.firstMileTrailerPlateNumber ?? '',
|
||||
});
|
||||
setTruckOpen(true);
|
||||
};
|
||||
|
||||
const receive = async () => {
|
||||
if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') {
|
||||
toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await bulkReceive.mutateAsync({ direction, ...location, bookingIds });
|
||||
const r = await bulkReceive.mutateAsync({
|
||||
direction,
|
||||
...location,
|
||||
bookingIds: pendingReceiveIds,
|
||||
truckEntrance: toTruckEntrancePayload(truckForm),
|
||||
});
|
||||
toast({
|
||||
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
|
||||
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
|
||||
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined),
|
||||
});
|
||||
setSelected(new Set());
|
||||
setTruckOpen(false);
|
||||
setPendingReceiveIds([]);
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) });
|
||||
@@ -198,9 +614,19 @@ function EligibleTab({
|
||||
|
||||
return (
|
||||
<Stack gap="sm" mt="sm">
|
||||
<Tabs value={statusTab} onChange={(v) => setStatusTab(v ?? 'ALL')}>
|
||||
<Tabs.List>
|
||||
{statusOptions.map((option) => (
|
||||
<Tabs.Tab key={option.value} value={option.value}>
|
||||
{option.label} ({statusCounts[option.value] ?? 0})
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Selected: <b>{selected.size}</b> / {rows.length} eligible
|
||||
Selected: <b>{selected.size}</b> / {statusFilteredRows.length} eligible
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{direction === 'EXPORT' && (
|
||||
@@ -218,9 +644,9 @@ function EligibleTab({
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
disabled={!locationReady || rows.length === 0}
|
||||
disabled={!locationReady || selectableRows.length === 0}
|
||||
loading={bulkReceive.isPending}
|
||||
onClick={() => receive(rows.map((r) => r.id))}
|
||||
onClick={() => openTruckReceive(selectableRows.map((r) => r.id))}
|
||||
>
|
||||
Receive All Eligible
|
||||
</Button>
|
||||
@@ -228,7 +654,7 @@ function EligibleTab({
|
||||
size="compact-sm"
|
||||
disabled={!locationReady || selected.size === 0}
|
||||
loading={bulkReceive.isPending}
|
||||
onClick={() => receive([...selected])}
|
||||
onClick={() => openTruckReceive([...selected])}
|
||||
>
|
||||
Receive Selected
|
||||
</Button>
|
||||
@@ -245,7 +671,7 @@ function EligibleTab({
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : rows.length === 0 ? (
|
||||
) : statusFilteredRows.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="lg" size="sm">
|
||||
No eligible PAID {direction.toLowerCase()} bookings to receive.
|
||||
</Text>
|
||||
@@ -275,16 +701,20 @@ function EligibleTab({
|
||||
<Table.Th>Payment</Table.Th>
|
||||
<Table.Th>Current Status</Table.Th>
|
||||
<Table.Th>Inspection</Table.Th>
|
||||
{direction === 'EXPORT' && <Table.Th>First Mile</Table.Th>}
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r) => (
|
||||
{statusFilteredRows.map((r) => {
|
||||
const canReceive = canReceiveBooking(r);
|
||||
return (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
aria-label={`Select ${r.reference}`}
|
||||
checked={selected.has(r.id)}
|
||||
disabled={!canReceive}
|
||||
onChange={() => toggleOne(r.id)}
|
||||
/>
|
||||
</Table.Td>
|
||||
@@ -319,23 +749,73 @@ function EligibleTab({
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>—</Table.Td>
|
||||
{direction === 'EXPORT' && (
|
||||
<Table.Td>
|
||||
{r.hasFirstMile ? (
|
||||
<Stack gap={2}>
|
||||
<Badge
|
||||
color={r.firstMileStatus === 'RECEIVED_TO_PORT' ? 'green' : r.firstMileRequestId ? 'blue' : 'orange'}
|
||||
variant="light"
|
||||
size="sm"
|
||||
>
|
||||
{r.firstMileStatus ?? 'Request needed'}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{[r.firstMileTruckPlateNumber, r.firstMileTrailerPlateNumber].filter(Boolean).join(' / ') || 'Truck not assigned'}
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Badge color="gray" variant="light" size="sm">Direct arrival</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td ta="right">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
disabled={!locationReady}
|
||||
loading={bulkReceive.isPending}
|
||||
onClick={() => receive([r.id])}
|
||||
>
|
||||
Receive
|
||||
</Button>
|
||||
{direction === 'EXPORT' && r.hasFirstMile && !r.firstMileRequestId ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
loading={requestFirstMile.isPending}
|
||||
onClick={() => requestFirstMile.mutate(r.reference)}
|
||||
>
|
||||
Request First Mile
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
disabled={!locationReady || !canReceive}
|
||||
loading={bulkReceive.isPending}
|
||||
onClick={() => openTruckReceive([r.id])}
|
||||
>
|
||||
{canReceive ? 'Receive' : 'Await First Mile'}
|
||||
</Button>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<Modal opened={truckOpen} onClose={() => setTruckOpen(false)} title="Truck Entrance Registration" centered size="lg">
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Register the arriving truck and driver before receiving {pendingReceiveIds.length} booking{pendingReceiveIds.length === 1 ? '' : 's'}.
|
||||
</Text>
|
||||
<TruckEntranceFields value={truckForm} onChange={setTruckForm} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button leftSection={<Truck size={16} />} loading={bulkReceive.isPending} onClick={receive}>
|
||||
Receive and Generate GRN
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -687,6 +1167,7 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) {
|
||||
<Table.Th>Cargo Type</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Arrival</Table.Th>
|
||||
<Table.Th>Inspection</Table.Th>
|
||||
<Table.Th>Current Status</Table.Th>
|
||||
<Table.Th>Last Mile</Table.Th>
|
||||
<Table.Th>Pickup Option</Table.Th>
|
||||
@@ -709,6 +1190,11 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) {
|
||||
<Table.Td>{it.cargoType ?? '—'}</Table.Td>
|
||||
<Table.Td>{formatNumber(Number(it.weight))}</Table.Td>
|
||||
<Table.Td>{formatDate(it.arrivalTime)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" variant="light" color={it.inspectionStatus === 'PASSED' ? 'green' : 'gray'}>
|
||||
{it.inspectionStatus ?? 'Not inspected'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" variant="light" color="gray">{it.currentStatus ?? '—'}</Badge>
|
||||
</Table.Td>
|
||||
@@ -726,6 +1212,12 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) {
|
||||
}
|
||||
|
||||
/** Import Arrive Queue: arrived IMPORT trains, with Open (detail) + Auto Unload per train. */
|
||||
const getPendingUnloadBookings = (train: ImportTrain) =>
|
||||
train.pendingUnloadBookings ?? train.totalBookings;
|
||||
|
||||
const isFullyUnloaded = (train: ImportTrain) =>
|
||||
Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0);
|
||||
|
||||
function ImportArriveQueueTab({
|
||||
enabled,
|
||||
onChanged,
|
||||
@@ -744,9 +1236,19 @@ function ImportArriveQueueTab({
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
const autoUnload = async (train: ImportTrain) => {
|
||||
if (isFullyUnloaded(train)) {
|
||||
toast({
|
||||
title: 'Already unloaded',
|
||||
description: `${train.trainNumber ?? 'This train'} has no remaining bookings to auto unload.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setBusyId(train.scheduleId);
|
||||
try {
|
||||
const r = await autoUnloadMutation.mutateAsync(train.scheduleId);
|
||||
const alreadyUnloaded = r.unloadedCount === 0 && r.skippedCount > 0 && r.failedCount === 0;
|
||||
const firstReason = r.results.find((item) => item.reason)?.reason;
|
||||
const extra = [
|
||||
r.skippedCount ? `${r.skippedCount} skipped` : '',
|
||||
r.failedCount ? `${r.failedCount} failed` : '',
|
||||
@@ -754,8 +1256,8 @@ function ImportArriveQueueTab({
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
toast({
|
||||
title: `${r.unloadedCount} unloaded`,
|
||||
description: extra || undefined,
|
||||
title: alreadyUnloaded ? 'Already unloaded' : `${r.unloadedCount} unloaded`,
|
||||
description: alreadyUnloaded ? firstReason ?? 'This train is already in warehouse inventory.' : extra || undefined,
|
||||
});
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
@@ -800,6 +1302,8 @@ function ImportArriveQueueTab({
|
||||
<Table.Tbody>
|
||||
{trains.map((t: ImportTrain) => {
|
||||
const isOpen = openId === t.scheduleId;
|
||||
const fullyUnloaded = isFullyUnloaded(t);
|
||||
const unloadedBookings = t.unloadedBookings ?? t.totalBookings - getPendingUnloadBookings(t);
|
||||
return (
|
||||
<Fragment key={t.scheduleId}>
|
||||
<Table.Tr>
|
||||
@@ -817,7 +1321,14 @@ function ImportArriveQueueTab({
|
||||
<Table.Td ta="center">{t.totalContainers}</Table.Td>
|
||||
<Table.Td ta="center">{t.totalCargoes}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color="indigo" variant="light" size="sm">{t.status}</Badge>
|
||||
<Stack gap={2}>
|
||||
<Badge color={fullyUnloaded ? 'green' : 'indigo'} variant="light" size="sm">
|
||||
{fullyUnloaded ? 'UNLOADED' : t.status}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{Math.max(unloadedBookings, 0)}/{t.totalBookings} unloaded
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
@@ -831,12 +1342,13 @@ function ImportArriveQueueTab({
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="indigo"
|
||||
color={fullyUnloaded ? 'gray' : 'indigo'}
|
||||
leftSection={<Truck size={14} />}
|
||||
loading={busyId === t.scheduleId}
|
||||
disabled={fullyUnloaded || t.totalBookings === 0}
|
||||
onClick={() => autoUnload(t)}
|
||||
>
|
||||
Auto Unload Arrived Bookings
|
||||
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload Arrived Bookings'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
@@ -1171,11 +1683,13 @@ function SingleBookingReceiveModal({
|
||||
volume: '',
|
||||
notes: '',
|
||||
});
|
||||
const [truckForm, setTruckForm] = useState<TruckEntranceFormState>(emptyTruckEntrance());
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setSelectedBooking(bookingId ?? '');
|
||||
setForm({ warehouseId: '', yardId: '', zoneId: '', quantity: '', weight: '', volume: '', notes: '' });
|
||||
setTruckForm(emptyTruckEntrance());
|
||||
}
|
||||
}, [opened, bookingId]);
|
||||
|
||||
@@ -1194,6 +1708,10 @@ function SingleBookingReceiveModal({
|
||||
toast({ variant: 'destructive', title: 'Quantity and weight are required' });
|
||||
return;
|
||||
}
|
||||
if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') {
|
||||
toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' });
|
||||
return;
|
||||
}
|
||||
const payload: ReceiveInventoryPayload = {
|
||||
bookingId: selectedBooking.trim(),
|
||||
warehouseId: form.warehouseId,
|
||||
@@ -1203,6 +1721,7 @@ function SingleBookingReceiveModal({
|
||||
weight: Number(form.weight),
|
||||
volume: form.volume === '' ? undefined : Number(form.volume),
|
||||
notes: form.notes.trim() || undefined,
|
||||
truckEntrance: toTruckEntrancePayload(truckForm),
|
||||
};
|
||||
try {
|
||||
await receiveMutation.mutateAsync(payload);
|
||||
@@ -1249,6 +1768,8 @@ function SingleBookingReceiveModal({
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<TruckEntranceFields value={truckForm} onChange={setTruckForm} />
|
||||
|
||||
<Textarea
|
||||
label="Notes"
|
||||
placeholder="Optional notes"
|
||||
@@ -1266,7 +1787,7 @@ function SingleBookingReceiveModal({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={receiveMutation.isPending}>
|
||||
Receive inventory
|
||||
Receive inventory and generate GRN
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -181,7 +181,7 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import type { WarehouseFeeInvoice, WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import type { BookingDetail } from '@/types/booking';
|
||||
|
||||
type PdfLine = {
|
||||
text: string;
|
||||
size?: number;
|
||||
bold?: boolean;
|
||||
x?: number;
|
||||
yGap?: number;
|
||||
color?: 'black' | 'green';
|
||||
align?: 'left' | 'center' | 'right';
|
||||
};
|
||||
|
||||
export interface WarehouseExitPaperContext {
|
||||
invoice: WarehouseFeeInvoice;
|
||||
releasedItem?: WarehouseInventoryItem;
|
||||
inventory?: WarehouseInventoryItem;
|
||||
booking?: BookingDetail | null;
|
||||
releasedAt?: Date;
|
||||
}
|
||||
|
||||
const escapePdfText = (value: string) =>
|
||||
value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
|
||||
const money = (amount: unknown, currency = 'USD') =>
|
||||
`${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
|
||||
|
||||
const fmtDate = (value: unknown) => {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value as string | Date);
|
||||
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
|
||||
};
|
||||
|
||||
const GREEN = '0 0.55 0.32';
|
||||
|
||||
const circlePath = (cx: number, cy: number, r: number) => {
|
||||
const k = 0.5522847498;
|
||||
const c = r * k;
|
||||
return [
|
||||
`${cx + r} ${cy} m`,
|
||||
`${cx + r} ${cy + c} ${cx + c} ${cy + r} ${cx} ${cy + r} c`,
|
||||
`${cx - c} ${cy + r} ${cx - r} ${cy + c} ${cx - r} ${cy} c`,
|
||||
`${cx - r} ${cy - c} ${cx - c} ${cy - r} ${cx} ${cy - r} c`,
|
||||
`${cx + c} ${cy - r} ${cx + r} ${cy - c} ${cx + r} ${cy} c`,
|
||||
'h',
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
const stampText = (text: string, x: number, y: number, size: number, bold = false) =>
|
||||
`BT\n${GREEN} rg\n/${bold ? 'F2' : 'F1'} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`;
|
||||
|
||||
const buildCircularSeal = (cx: number, cy: number, label: 'PAID' | 'CLEARED') =>
|
||||
[
|
||||
'q',
|
||||
`${GREEN} RG`,
|
||||
`${GREEN} rg`,
|
||||
'2.2 w',
|
||||
circlePath(cx, cy, 52),
|
||||
'S',
|
||||
'0.9 w',
|
||||
circlePath(cx, cy, 42),
|
||||
'S',
|
||||
stampText('EDR FREIGHT', cx - 33, cy + 24, 9, true),
|
||||
stampText(label, cx - (label === 'CLEARED' ? 36 : 21), cy - 4, label === 'CLEARED' ? 17 : 20, true),
|
||||
stampText(label === 'CLEARED' ? 'GATE RELEASE' : 'WAREHOUSE', cx - (label === 'CLEARED' ? 34 : 32), cy - 25, 8),
|
||||
'Q',
|
||||
].join('\n');
|
||||
|
||||
const estimateTextWidth = (text: string, size: number) => text.length * size * 0.52;
|
||||
|
||||
const textX = (text: string, size: number, align: PdfLine['align'] = 'left', x?: number) => {
|
||||
if (typeof x === 'number') return x;
|
||||
if (align === 'center') return Math.max(36, (595 - estimateTextWidth(text, size)) / 2);
|
||||
if (align === 'right') return Math.max(36, 535 - estimateTextWidth(text, size));
|
||||
return 60;
|
||||
};
|
||||
|
||||
const lineOp = (x1: number, y1: number, x2: number, y2: number, color = '0.65 0.7 0.76') =>
|
||||
`q\n${color} RG\n0.8 w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
|
||||
|
||||
const textOp = (
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
size = 10,
|
||||
bold = false,
|
||||
color = '0 0 0',
|
||||
) => `BT\n${color} rg\n/${bold ? 'F2' : 'F1'} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`;
|
||||
|
||||
const buildAuthorizationBand = (label: 'PAID' | 'CLEARED') => [
|
||||
lineOp(60, 242, 535, 242),
|
||||
textOp('AUTHORIZED SEAL', 382, 218, 9, true, GREEN),
|
||||
buildCircularSeal(452, 155, label),
|
||||
];
|
||||
|
||||
const buildWarehouseOfficerSealBand = () => [
|
||||
lineOp(60, 218, 535, 218),
|
||||
textOp('WAREHOUSE OFFICER SEAL', 92, 194, 9, true, GREEN),
|
||||
buildCircularSeal(170, 128, 'CLEARED'),
|
||||
textOp('Officer in charge name / signature / date:', 292, 154, 10),
|
||||
lineOp(292, 132, 535, 132, '0 0 0'),
|
||||
textOp('Customer or driver name / signature / date:', 292, 94, 10),
|
||||
lineOp(292, 72, 535, 72, '0 0 0'),
|
||||
];
|
||||
|
||||
function buildSimplePdf(lines: PdfLine[], rawOps: string[] = []): Blob {
|
||||
let y = 800;
|
||||
const streamLines = lines.map((line) => {
|
||||
y -= line.yGap ?? 16;
|
||||
const size = line.size ?? 10;
|
||||
const font = line.bold ? '/F2' : '/F1';
|
||||
const color = line.color === 'green' ? `${GREEN} rg` : '0 0 0 rg';
|
||||
return `BT\n${color}\n${font} ${size} Tf\n${textX(line.text, size, line.align, line.x)} ${y} Td\n(${escapePdfText(line.text)}) Tj\nET`;
|
||||
});
|
||||
const stream = [...rawOps, ...streamLines].join('\n');
|
||||
const objects = [
|
||||
'<< /Type /Catalog /Pages 2 0 R >>',
|
||||
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
||||
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>',
|
||||
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
|
||||
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>',
|
||||
`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`,
|
||||
];
|
||||
|
||||
let pdf = '%PDF-1.4\n';
|
||||
const offsets = [0];
|
||||
objects.forEach((object, index) => {
|
||||
offsets.push(pdf.length);
|
||||
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
||||
});
|
||||
const xref = pdf.length;
|
||||
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
||||
offsets.slice(1).forEach((offset) => {
|
||||
pdf += `${String(offset).padStart(10, '0')} 00000 n \n`;
|
||||
});
|
||||
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
|
||||
return new Blob([pdf], { type: 'application/pdf' });
|
||||
}
|
||||
|
||||
export function buildWarehouseInvoicePdf(invoice: WarehouseFeeInvoice, kind: 'INVOICE' | 'RECEIPT') {
|
||||
const paid = kind === 'RECEIPT' || invoice.status === 'PAID';
|
||||
const title = `Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}`;
|
||||
const bookingReference = firstText(invoice.bookingReference);
|
||||
const customerName = firstText(invoice.customerName);
|
||||
const inventoryReference = firstText(invoice.inventoryReference);
|
||||
const inventoryInfo = firstText(invoice.inventoryInfo, invoice.containerNumber, invoice.cargoDescription);
|
||||
const clearanceStatus = firstText(
|
||||
invoice.clearanceStatus,
|
||||
paid ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT',
|
||||
);
|
||||
const lines: PdfLine[] = [
|
||||
{ text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' },
|
||||
{ text: title, size: 23, bold: true, yGap: 28, align: 'center' },
|
||||
{ text: `Document No: ${invoice.invoiceNumber}`, size: 12, bold: true, yGap: 32, align: 'center' },
|
||||
{ text: `Status: ${invoice.status.replace(/_/g, ' ')} Type: ${invoice.invoiceType.replace(/_/g, ' ')}`, align: 'center' },
|
||||
{ text: `Booking Reference: ${bookingReference} Customer: ${customerName}`, align: 'center' },
|
||||
{ text: `Inventory Reference: ${inventoryReference} Inventory Info: ${inventoryInfo}`, align: 'center' },
|
||||
{ text: `Clearance: ${clearanceStatus}`, align: 'center' },
|
||||
{ text: `Issued: ${fmtDate(invoice.issuedAt)} Paid At: ${fmtDate(invoice.paidAt)}`, align: 'center' },
|
||||
{ text: 'ITEMS', size: 13, bold: true, yGap: 30, align: 'center' },
|
||||
...(invoice.items ?? []).flatMap((item) => [
|
||||
{ text: item.description, bold: true, align: 'center' as const },
|
||||
{
|
||||
text: `${item.feeType.replace(/_/g, ' ')} | Qty ${Number(item.quantity ?? 0).toLocaleString()} | Rate ${money(item.unitRate, item.currency)} | Amount ${money(item.amount, item.currency)}`,
|
||||
yGap: 13,
|
||||
align: 'center' as const,
|
||||
},
|
||||
]),
|
||||
{ text: 'TOTALS', size: 13, bold: true, yGap: 30, align: 'center' },
|
||||
{ text: `Subtotal: ${money(invoice.subtotalAmount, invoice.currency)}`, align: 'center' },
|
||||
{ text: `Tax: ${money(invoice.taxAmount, invoice.currency)}`, align: 'center' },
|
||||
{ text: `Total: ${money(invoice.totalAmount, invoice.currency)}`, bold: true, align: 'center' },
|
||||
{ text: `Paid: ${money(invoice.paidAmount, invoice.currency)}`, align: 'center' },
|
||||
{ text: `Balance: ${money(invoice.balanceAmount, invoice.currency)}`, bold: true, align: 'center' },
|
||||
];
|
||||
const authorizationOps = [
|
||||
...buildAuthorizationBand('PAID'),
|
||||
textOp('Prepared by EDR warehouse finance', 72, 196, 10),
|
||||
textOp('Finance officer name / signature / date:', 72, 164, 10),
|
||||
lineOp(245, 162, 360, 162, '0 0 0'),
|
||||
];
|
||||
const invoiceOps = [
|
||||
lineOp(60, 242, 535, 242),
|
||||
textOp('Prepared by EDR warehouse finance', 72, 196, 10),
|
||||
textOp('Finance officer name / signature / date:', 72, 164, 10),
|
||||
lineOp(245, 162, 360, 162, '0 0 0'),
|
||||
];
|
||||
return buildSimplePdf(lines, paid ? authorizationOps : invoiceOps);
|
||||
}
|
||||
|
||||
const firstText = (...values: Array<unknown>) => {
|
||||
for (const value of values) {
|
||||
if (value !== null && value !== undefined && String(value).trim()) return String(value);
|
||||
}
|
||||
return '-';
|
||||
};
|
||||
|
||||
const tons = (value: unknown) => {
|
||||
const num = Number(value ?? 0);
|
||||
if (!Number.isFinite(num) || num <= 0) return null;
|
||||
return `${num.toLocaleString(undefined, { maximumFractionDigits: 3 })} ton`;
|
||||
};
|
||||
|
||||
const containerSummary = (booking?: BookingDetail | null, inventory?: WarehouseInventoryItem) => {
|
||||
const explicitNumber = (inventory as unknown as { containerNumber?: string | null })?.containerNumber;
|
||||
if (explicitNumber) return explicitNumber;
|
||||
const containers = booking?.bookingContainers ?? [];
|
||||
if (!containers.length) return '-';
|
||||
return containers
|
||||
.map((item) => {
|
||||
const type = item.containerType?.code ?? item.containerType?.label ?? item.containerTypeId;
|
||||
return `${item.quantity} x ${type}`;
|
||||
})
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
export function buildWarehouseExitPaperPdf(input: WarehouseFeeInvoice | WarehouseExitPaperContext, releasedItemArg?: WarehouseInventoryItem) {
|
||||
const context: WarehouseExitPaperContext =
|
||||
'invoice' in input ? input : { invoice: input, releasedItem: releasedItemArg };
|
||||
const { invoice, booking } = context;
|
||||
const releasedItem = context.releasedItem;
|
||||
const inventory = context.inventory ?? releasedItem;
|
||||
const releasedAt = context.releasedAt ?? new Date();
|
||||
const releaseReference = firstText(
|
||||
inventory?.releaseOrderReference,
|
||||
releasedItem?.releaseOrderReference,
|
||||
invoice.inventoryReference,
|
||||
booking?.reference ? `REL-${booking.reference.replace(/^BK-?/i, '')}` : null,
|
||||
);
|
||||
const customerName = firstText(
|
||||
booking?.company?.name,
|
||||
booking?.company?.companyName,
|
||||
booking?.company?.label,
|
||||
booking?.company?.contactPersonName,
|
||||
invoice.customerName,
|
||||
);
|
||||
const weightTons = firstText(tons(booking?.cargoTotalWeightVgm), tons(inventory?.weight));
|
||||
const inventoryInfo = firstText(
|
||||
invoice.inventoryInfo,
|
||||
invoice.containerNumber,
|
||||
invoice.cargoDescription,
|
||||
inventory?.status,
|
||||
releasedItem?.status,
|
||||
);
|
||||
const bookingReference = firstText(
|
||||
booking?.reference,
|
||||
(inventory as unknown as { bookingReference?: string })?.bookingReference,
|
||||
invoice.bookingReference,
|
||||
releasedItem?.booking?.reference,
|
||||
);
|
||||
|
||||
return buildSimplePdf([
|
||||
{ text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' },
|
||||
{ text: 'Warehouse Release / Exit Paper', size: 23, bold: true, yGap: 28, align: 'center' },
|
||||
{ text: '[ GATE CLEARANCE ]', size: 14, bold: true, color: 'green', yGap: 24, align: 'center' },
|
||||
{ text: `Release Reference: ${releaseReference}`, bold: true, yGap: 30, align: 'center' },
|
||||
{ text: `Invoice No: ${invoice.invoiceNumber}`, align: 'center' },
|
||||
{ text: `Booking Reference: ${bookingReference}`, align: 'center' },
|
||||
{ text: `Customer: ${customerName}`, align: 'center' },
|
||||
{ text: `Inventory Info: ${inventoryInfo}`, align: 'center' },
|
||||
{ text: `Warehouse: ${firstText(inventory?.warehouse?.name, inventory?.warehouse?.code)}`, align: 'center' },
|
||||
{ text: `Yard: ${firstText(inventory?.yard?.name, inventory?.yard?.code)}`, align: 'center' },
|
||||
{ text: `Zone: ${firstText(inventory?.zone?.name, inventory?.zone?.code)}`, align: 'center' },
|
||||
{ text: `Booking Container: ${containerSummary(booking, inventory)}`, align: 'center' },
|
||||
{ text: `Weight: ${weightTons}`, align: 'center' },
|
||||
{ text: `Inventory Status: ${releasedItem?.status ?? inventory?.status ?? 'RELEASED'}`, align: 'center' },
|
||||
{ text: `Clearance: ${invoice.clearanceStatus ?? 'CLEARED FOR WAREHOUSE EXIT'}`, bold: true, color: 'green', align: 'center' },
|
||||
{ text: `Release Date & Time: ${fmtDate(releasedAt)}`, align: 'center' },
|
||||
{ text: 'This sealed document authorizes the listed booking/goods to leave the warehouse gate.', yGap: 30, align: 'center' },
|
||||
], [
|
||||
...buildWarehouseOfficerSealBand(),
|
||||
]);
|
||||
}
|
||||
@@ -358,6 +358,8 @@ export const URL_CONSTANTS = {
|
||||
WAREHOUSE_INVOICES: {
|
||||
BASE: '/warehouse-fee-invoices',
|
||||
BY_ID: (id: string) => `/warehouse-fee-invoices/${id}`,
|
||||
DOCUMENT: (id: string) => `/warehouse-fee-invoices/${id}/document`,
|
||||
RECEIPT: (id: string) => `/warehouse-fee-invoices/${id}/receipt`,
|
||||
CANCEL: (id: string) => `/warehouse-fee-invoices/${id}/cancel`,
|
||||
PAY: (id: string) => `/warehouse-fee-invoices/${id}/pay`,
|
||||
GENERATE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/generate-fee-invoice`,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
// export const API_BASE_URL = 'https://fhcdev-backend.triaplc.com';
|
||||
export const API_BASE_URL =
|
||||
import.meta.env.VITE_BASE_API_URL ||
|
||||
import.meta.env.VITE_API_URL ||
|
||||
'https://edrfreightapi.triaplc.com';
|
||||
|
||||
//export const API_BASE_URL = 'http://localhost:3001';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FleetResourceConfig } from "./resources";
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
|
||||
const VEHICLE_TYPE_OPTIONS = [
|
||||
{ label: "Truck", value: "TRUCK" },
|
||||
@@ -92,6 +93,4 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
};
|
||||
|
||||
export { VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS };
|
||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
|
||||
// export const API_BASE_URL = 'https://fhcdev-backend.triaplc.com';
|
||||
export { API_BASE_URL };
|
||||
|
||||
@@ -89,7 +89,7 @@ const bookingRef = (r: FirstMileRecord) => r.booking?.reference ?? r.bookingId;
|
||||
const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—";
|
||||
const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—";
|
||||
const cargoDesc = (r: FirstMileRecord) => {
|
||||
const parts = [r.booking?.cargoType?.name ?? r.booking?.cargoFreeText].filter(Boolean);
|
||||
const parts = [r.booking?.cargoType?.label ?? r.booking?.cargoFreeText].filter(Boolean);
|
||||
if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`);
|
||||
return parts.join(" · ") || "—";
|
||||
};
|
||||
@@ -347,6 +347,10 @@ const FirstMilePage = () => {
|
||||
const paidBookings = paidBookingsData?.items ?? [];
|
||||
|
||||
const records = listData?.data ?? [];
|
||||
const existingFirstMileBookingIds = useMemo(
|
||||
() => new Set(records.map((record) => record.bookingId)),
|
||||
[records],
|
||||
);
|
||||
|
||||
const vehicleOptions = useMemo(
|
||||
() =>
|
||||
@@ -398,16 +402,27 @@ const FirstMilePage = () => {
|
||||
[rowSelection],
|
||||
);
|
||||
|
||||
const firstMileEligiblePaidBookings = useMemo(
|
||||
() =>
|
||||
paidBookings.filter(
|
||||
(booking) =>
|
||||
booking.tradeDirection === "EXPORT" &&
|
||||
Boolean(booking.firstMilePickupAddress?.trim()) &&
|
||||
!existingFirstMileBookingIds.has(booking.id),
|
||||
),
|
||||
[existingFirstMileBookingIds, paidBookings],
|
||||
);
|
||||
|
||||
const filteredPaidBookings = useMemo(() => {
|
||||
const term = bookingSearch.trim().toLowerCase();
|
||||
if (!term) return paidBookings;
|
||||
return paidBookings.filter((b) =>
|
||||
if (!term) return firstMileEligiblePaidBookings;
|
||||
return firstMileEligiblePaidBookings.filter((b) =>
|
||||
[b.reference, b.company?.name, b.company?.companyName]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(term),
|
||||
);
|
||||
}, [paidBookings, bookingSearch]);
|
||||
}, [firstMileEligiblePaidBookings, bookingSearch]);
|
||||
|
||||
const openAccept = () => {
|
||||
setAcceptOpen(true);
|
||||
@@ -879,7 +894,7 @@ const FirstMilePage = () => {
|
||||
{bookingsLoading ? (
|
||||
<Text c="dimmed" size="sm" ta="center" py="md">Loading bookings…</Text>
|
||||
) : filteredPaidBookings.length === 0 ? (
|
||||
<Text c="dimmed" size="sm" ta="center" py="md">No paid bookings found.</Text>
|
||||
<Text c="dimmed" size="sm" ta="center" py="md">No paid export bookings need first mile.</Text>
|
||||
) : (
|
||||
filteredPaidBookings.map((b) => (
|
||||
<UnstyledButton
|
||||
|
||||
@@ -37,6 +37,12 @@ const getErrorMessage = (error: unknown) => {
|
||||
return error instanceof Error ? error.message : undefined;
|
||||
};
|
||||
|
||||
const getPendingUnloadBookings = (train: ImportTrain) =>
|
||||
train.pendingUnloadBookings ?? train.totalBookings;
|
||||
|
||||
const isFullyUnloaded = (train: ImportTrain) =>
|
||||
Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0);
|
||||
|
||||
function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
const { data: items = [], isLoading } = useImportTrainItems(scheduleId);
|
||||
|
||||
@@ -67,6 +73,7 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Arrival</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Inspection</Table.Th>
|
||||
<Table.Th>Pickup</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
@@ -88,6 +95,11 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
{item.currentStatus ?? 'PENDING'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color={item.inspectionStatus === 'PASSED' ? 'green' : 'gray'} size="sm">
|
||||
{item.inspectionStatus ?? 'Not inspected'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{item.pickupOption.replace(/_/g, ' ')}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
@@ -105,10 +117,20 @@ export default function ArrivalQueuePage() {
|
||||
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
|
||||
|
||||
const unloadTrain = async (train: ImportTrain) => {
|
||||
if (isFullyUnloaded(train)) {
|
||||
toast({
|
||||
title: 'Already unloaded',
|
||||
description: `${train.trainNumber ?? 'This train'} has no remaining bookings to auto unload.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setBusyScheduleId(train.scheduleId);
|
||||
try {
|
||||
const res = (await autoUnload.mutateAsync(train.scheduleId)) as { data: AutoUnloadArrivedResult };
|
||||
const result = res.data;
|
||||
const alreadyUnloaded = result.unloadedCount === 0 && result.skippedCount > 0 && result.failedCount === 0;
|
||||
const firstReason = result.results.find((item) => item.reason)?.reason;
|
||||
const details = [
|
||||
result.skippedCount ? `${result.skippedCount} skipped` : '',
|
||||
result.failedCount ? `${result.failedCount} failed` : '',
|
||||
@@ -117,8 +139,10 @@ export default function ArrivalQueuePage() {
|
||||
.join(', ');
|
||||
|
||||
toast({
|
||||
title: `${result.unloadedCount} booking(s) unloaded`,
|
||||
description: details || `${train.trainNumber ?? 'Train'} moved into warehouse inventory.`,
|
||||
title: alreadyUnloaded ? 'Already unloaded' : `${result.unloadedCount} booking(s) unloaded`,
|
||||
description: alreadyUnloaded
|
||||
? firstReason ?? `${train.trainNumber ?? 'Train'} is already in warehouse inventory.`
|
||||
: details || `${train.trainNumber ?? 'Train'} moved into warehouse inventory.`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
@@ -181,6 +205,8 @@ export default function ArrivalQueuePage() {
|
||||
<Table.Tbody>
|
||||
{trains.map((train: ImportTrain) => {
|
||||
const isOpen = openScheduleId === train.scheduleId;
|
||||
const fullyUnloaded = isFullyUnloaded(train);
|
||||
const unloadedBookings = train.unloadedBookings ?? train.totalBookings - getPendingUnloadBookings(train);
|
||||
return (
|
||||
<Fragment key={train.scheduleId}>
|
||||
<Table.Tr>
|
||||
@@ -204,9 +230,14 @@ export default function ArrivalQueuePage() {
|
||||
<Table.Td ta="center">{train.totalContainers}</Table.Td>
|
||||
<Table.Td ta="center">{train.totalCargoes}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color="teal" size="sm">
|
||||
{train.status}
|
||||
</Badge>
|
||||
<Stack gap={2}>
|
||||
<Badge variant="light" color={fullyUnloaded ? 'green' : 'teal'} size="sm">
|
||||
{fullyUnloaded ? 'UNLOADED' : train.status}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{Math.max(unloadedBookings, 0)}/{train.totalBookings} unloaded
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
@@ -220,12 +251,13 @@ export default function ArrivalQueuePage() {
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="orange"
|
||||
color={fullyUnloaded ? 'gray' : 'orange'}
|
||||
leftSection={busyScheduleId === train.scheduleId ? <PackageOpen size={14} /> : <Truck size={14} />}
|
||||
loading={busyScheduleId === train.scheduleId}
|
||||
disabled={fullyUnloaded || train.totalBookings === 0}
|
||||
onClick={() => unloadTrain(train)}
|
||||
>
|
||||
Auto Unload
|
||||
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
|
||||
@@ -193,14 +193,14 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
result.skippedCount ? `${result.skippedCount} skipped` : '',
|
||||
result.failedCount ? `${result.failedCount} failed` : '',
|
||||
result.interchangeDocument
|
||||
? `Interchange document ${result.interchangeDocument.documentNo} generated`
|
||||
? `Signed interchange document ${result.interchangeDocument.documentNo} generated`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
toast({
|
||||
title: `${result.unloadedCount} export item(s) unloaded`,
|
||||
title: `${result.unloadedCount} export item(s) auto unloaded`,
|
||||
description: details || `${train.trainNumber ?? 'Train'} unloaded at Djibouti Port.`,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -224,7 +224,8 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
handoverFrom: 'EDR',
|
||||
handoverTo: 'Djibouti Port Operator',
|
||||
portOperatorName: 'Doraleh Multipurpose Port',
|
||||
remarks: 'Generated after export unloading at Djibouti Port',
|
||||
generatedBy: 'EDR Operations',
|
||||
remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.',
|
||||
});
|
||||
toast({
|
||||
title: 'Interchange document generated',
|
||||
@@ -249,21 +250,21 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
<Stack gap="lg" mt="sm">
|
||||
<PageHeader
|
||||
title="Djibouti Arrival / Unloading Queue"
|
||||
subtitle="Arrived export trains at Djibouti-side destinations ready for unloading."
|
||||
subtitle="Arrived export trains at Djibouti-side destinations, auto unloading, and signed interchange handover."
|
||||
/>
|
||||
|
||||
<WarehouseHero
|
||||
variant="train"
|
||||
secondaryVariant="container"
|
||||
title="Export Unloading at Djibouti Port"
|
||||
subtitle="Review arrived export trains and unload eligible assigned export items."
|
||||
subtitle="Review arrived trains, auto unload eligible export items, then generate the EDR and Djibouti Port signed interchange document."
|
||||
/>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={600}>{trains.length} arrived export train(s)</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Open a train to review assigned export items, then auto unload it.
|
||||
Open a train, auto unload it, then view the signed interchange document.
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -368,7 +369,7 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
loading={busyScheduleId === train.scheduleId && generateInterchange.isPending}
|
||||
onClick={() => generateInterchangeDocument(train)}
|
||||
>
|
||||
Generate Interchange Document
|
||||
Generate Signed Document
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -93,6 +93,8 @@ function InterchangeDocumentDetail({ id }: { id: string }) {
|
||||
/>
|
||||
<DetailField label="Generated At" value={formatDate(document.generatedAt)} />
|
||||
<DetailField label="Acknowledged At" value={formatDate(document.acknowledgedAt)} />
|
||||
<DetailField label="Signed by EDR" value={document.generatedBy} />
|
||||
<DetailField label="Signed by Djibouti Port" value={document.acknowledgedBy} />
|
||||
<DetailField label="Customs Ref" value={document.customsReference} />
|
||||
<DetailField label="Manifest Ref" value={document.manifestReference} />
|
||||
</SimpleGrid>
|
||||
@@ -221,6 +223,7 @@ export default function InterchangeDocumentsPage() {
|
||||
<Table.Th>Handover From</Table.Th>
|
||||
<Table.Th>Handover To</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Signed By</Table.Th>
|
||||
<Table.Th>Generated At</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
@@ -251,6 +254,14 @@ export default function InterchangeDocumentsPage() {
|
||||
{document.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{document.generatedBy ?? '-'}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{document.acknowledgedBy ?? 'Awaiting Djibouti Port'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>{formatDate(document.generatedAt)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
|
||||
@@ -15,19 +15,24 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { Ban, CreditCard, Eye, Search } from 'lucide-react';
|
||||
import { Ban, CreditCard, DoorOpen, Download, Eye, Receipt, Search } from 'lucide-react';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { bookingsService } from '@/services/bookings.service';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
WAREHOUSE_INVOICE_STATUSES,
|
||||
type WarehouseFeeInvoice,
|
||||
type WarehouseInvoiceStatus,
|
||||
} from '@/types/warehouse';
|
||||
import { openPdfBlob } from '@/components/warehouses/pdf';
|
||||
import { buildWarehouseExitPaperPdf, buildWarehouseInvoicePdf } from '@/components/warehouses/warehousePdf';
|
||||
import { extractErrorMessage } from '@/components/warehouses/options';
|
||||
|
||||
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||
DRAFT: 'gray',
|
||||
@@ -158,18 +163,108 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
);
|
||||
const pay = useMutation(api.warehouses.payInvoice.mutationOptions());
|
||||
const cancel = useMutation(api.warehouses.cancelInvoice.mutationOptions());
|
||||
const gateClear = useMutation(api.warehouses.gateClearance.mutationOptions());
|
||||
const [payAmount, setPayAmount] = useState<number | ''>('');
|
||||
|
||||
const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
|
||||
const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
|
||||
|
||||
const downloadInvoicePdf = async (invoice: WarehouseFeeInvoice) => {
|
||||
const blob = buildWarehouseInvoicePdf(invoice, 'INVOICE');
|
||||
openPdfBlob(blob, `warehouse-invoice-${invoice.invoiceNumber}.pdf`);
|
||||
};
|
||||
|
||||
const downloadReceiptPdf = async (invoice: WarehouseFeeInvoice) => {
|
||||
const blob = buildWarehouseInvoicePdf(invoice, 'RECEIPT');
|
||||
openPdfBlob(blob, `warehouse-receipt-${invoice.invoiceNumber}.pdf`);
|
||||
};
|
||||
|
||||
const getExitPaperContext = async (invoice: WarehouseFeeInvoice) => {
|
||||
const [booking, inventoryRows] = await Promise.all([
|
||||
invoice.bookingId
|
||||
? bookingsService.getById(invoice.bookingId).catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
invoice.bookingId
|
||||
? warehouseService.listInventory({ bookingId: invoice.bookingId }).then((response) => response.data).catch(() => [])
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const inventory = inventoryRows.find((item) => item.id === invoice.inventoryId) ?? inventoryRows[0] ?? undefined;
|
||||
return { booking, inventory };
|
||||
};
|
||||
|
||||
const handleGateClearance = async (invoice: WarehouseFeeInvoice) => {
|
||||
if (!invoice.inventoryId) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Gate clearance failed',
|
||||
description: 'This invoice is not linked to an inventory item.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
const releasedAt = new Date();
|
||||
const releasedItem = await gateClear.mutateAsync(invoice.inventoryId);
|
||||
let documentResponse: Awaited<ReturnType<typeof warehouseService.downloadReleaseDocument>>;
|
||||
try {
|
||||
documentResponse = await warehouseService.downloadReleaseDocument(invoice.inventoryId);
|
||||
} catch (documentError) {
|
||||
const context = await getExitPaperContext(invoice);
|
||||
const fallbackBlob = buildWarehouseExitPaperPdf({
|
||||
invoice,
|
||||
releasedItem,
|
||||
inventory: context.inventory,
|
||||
booking: context.booking,
|
||||
releasedAt,
|
||||
});
|
||||
const opened = openPdfBlob(
|
||||
fallbackBlob,
|
||||
`release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? invoice.inventoryId}.pdf`,
|
||||
pdfWindow,
|
||||
);
|
||||
toast({
|
||||
title: 'Gate clearance recorded',
|
||||
description: opened
|
||||
? 'The API exit paper failed, so a sealed fallback PDF opened instead.'
|
||||
: `The API exit paper failed (${extractErrorMessage(documentError)}), so a sealed fallback PDF was downloaded.`,
|
||||
});
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? invoice.inventoryId}.pdf`;
|
||||
const opened = openPdfBlob(documentResponse.data, filename, pdfWindow);
|
||||
toast({
|
||||
title: 'Gate clearance recorded',
|
||||
description: opened
|
||||
? 'The exit paper opened in a browser tab.'
|
||||
: 'The browser blocked the preview tab, so the exit paper was downloaded.',
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
pdfWindow?.close();
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Gate clearance failed',
|
||||
description: extractErrorMessage(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!inv || !payAmount) return;
|
||||
try {
|
||||
await pay.mutateAsync({ id: inv.id, payload: { amount: Number(payAmount), method: 'MANUAL' } });
|
||||
toast({ title: 'Payment recorded' });
|
||||
const paidInvoice = await pay.mutateAsync({ id: inv.id, payload: { amount: Number(payAmount), method: 'MANUAL' } });
|
||||
setPayAmount('');
|
||||
if (paidInvoice.status === 'PAID') {
|
||||
toast({ title: 'Payment recorded', description: 'Downloading receipt, then generating gate clearance and exit paper.' });
|
||||
await downloadReceiptPdf(paidInvoice);
|
||||
await handleGateClearance(paidInvoice);
|
||||
} else {
|
||||
toast({ title: 'Payment recorded' });
|
||||
}
|
||||
} catch (e) {
|
||||
toast({ variant: 'destructive', title: 'Payment failed', description: (e as Error)?.message });
|
||||
toast({ variant: 'destructive', title: 'Payment failed', description: extractErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -180,7 +275,7 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
toast({ title: 'Invoice cancelled' });
|
||||
onClose();
|
||||
} catch (e) {
|
||||
toast({ variant: 'destructive', title: 'Cancel failed', description: (e as Error)?.message });
|
||||
toast({ variant: 'destructive', title: 'Cancel failed', description: extractErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -247,6 +342,34 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<Download size={16} />}
|
||||
onClick={() => downloadInvoicePdf(inv)}
|
||||
>
|
||||
Invoice PDF
|
||||
</Button>
|
||||
{Number(inv.paidAmount) > 0 && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<Receipt size={16} />}
|
||||
onClick={() => downloadReceiptPdf(inv)}
|
||||
>
|
||||
Receipt PDF
|
||||
</Button>
|
||||
)}
|
||||
{canGateClear && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<DoorOpen size={16} />}
|
||||
loading={gateClear.isPending}
|
||||
onClick={() => handleGateClearance(inv)}
|
||||
>
|
||||
Gate clearance & exit paper
|
||||
</Button>
|
||||
)}
|
||||
{inv.status !== 'PAID' && inv.status !== 'CANCELLED' && (
|
||||
<Button variant="light" color="red" leftSection={<Ban size={16} />} loading={cancel.isPending} onClick={handleCancel}>
|
||||
Cancel invoice
|
||||
|
||||
@@ -263,6 +263,14 @@ export const warehouseService = {
|
||||
}),
|
||||
getInvoice: (id: string) =>
|
||||
apiClient.get<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.BY_ID(id)),
|
||||
downloadInvoiceDocument: (id: string) =>
|
||||
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVOICES.DOCUMENT(id), {
|
||||
responseType: 'blob',
|
||||
}),
|
||||
downloadInvoiceReceipt: (id: string) =>
|
||||
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVOICES.RECEIPT(id), {
|
||||
responseType: 'blob',
|
||||
}),
|
||||
invoicesForInventory: (inventoryId: string) =>
|
||||
apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_INVENTORY(inventoryId)),
|
||||
invoicesForBooking: (bookingId: string) =>
|
||||
|
||||
@@ -364,6 +364,12 @@ export interface EligibleBooking {
|
||||
weight: string | null;
|
||||
paymentStatus: string;
|
||||
status: string;
|
||||
hasFirstMile: boolean;
|
||||
firstMileRequestId: string | null;
|
||||
firstMileStatus: string | null;
|
||||
firstMileVehicleId: string | null;
|
||||
firstMileTruckPlateNumber: string | null;
|
||||
firstMileTrailerPlateNumber: string | null;
|
||||
}
|
||||
|
||||
export interface BulkReceivePayload {
|
||||
@@ -372,12 +378,45 @@ export interface BulkReceivePayload {
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
bookingIds: string[];
|
||||
truckEntrance: TruckEntrancePayload;
|
||||
}
|
||||
|
||||
export interface BulkReceiveResult {
|
||||
receivedCount: number;
|
||||
skippedCount: number;
|
||||
results: { bookingId: string; status: string; inventoryId?: string; reason?: string }[];
|
||||
results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[];
|
||||
}
|
||||
|
||||
export interface TruckEntrancePayload {
|
||||
ownerName?: string;
|
||||
consigneeDetails?: string;
|
||||
edrDigitalBookingId?: string;
|
||||
tin?: string;
|
||||
truckPlateNumber: string;
|
||||
trailerPlateNumber?: string;
|
||||
assignedEquipmentNumber?: string;
|
||||
customsSealNumber?: string;
|
||||
declarationNumber?: string;
|
||||
incoterms?: string;
|
||||
hsCodes?: string;
|
||||
itemCode?: string;
|
||||
itemDescription?: string;
|
||||
packagingType?: string;
|
||||
unitCount?: number;
|
||||
grossWeightKg?: number;
|
||||
netWeightKg?: number;
|
||||
volumeDimensions?: string;
|
||||
conditionAtReceipt?: string;
|
||||
damagedRejectedQuantity?: number;
|
||||
warehouseCodeLocation?: string;
|
||||
driverName: string;
|
||||
driverPhone: string;
|
||||
driverLicenseNumber?: string;
|
||||
truckType?: string;
|
||||
entranceTareWeightKg: number;
|
||||
exitTareWeightKg?: number;
|
||||
driverSignatoryName?: string;
|
||||
warehouseManagerName?: string;
|
||||
}
|
||||
|
||||
export interface LoadPassedExportResult {
|
||||
@@ -430,6 +469,9 @@ export interface ImportTrain {
|
||||
totalBookings: number;
|
||||
totalContainers: number;
|
||||
totalCargoes: number;
|
||||
unloadedBookings?: number;
|
||||
pendingUnloadBookings?: number;
|
||||
fullyUnloaded?: boolean;
|
||||
status: string;
|
||||
}
|
||||
|
||||
@@ -510,6 +552,7 @@ export interface ImportTrainItem {
|
||||
weight: number | null;
|
||||
arrivalTime: string | null;
|
||||
currentStatus: string | null;
|
||||
inspectionStatus: string | null;
|
||||
lastMileRequested: boolean;
|
||||
pickupOption: string;
|
||||
}
|
||||
@@ -733,8 +776,16 @@ export interface WarehouseFeeInvoice {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
bookingId?: string | null;
|
||||
bookingReference?: string | null;
|
||||
customerId?: string | null;
|
||||
customerName?: string | null;
|
||||
inventoryId: string;
|
||||
inventoryReference?: string | null;
|
||||
inventoryInfo?: string | null;
|
||||
inventoryStatus?: string | null;
|
||||
containerNumber?: string | null;
|
||||
cargoDescription?: string | null;
|
||||
clearanceStatus?: string | null;
|
||||
facilityId?: string | null;
|
||||
warehouseId?: string | null;
|
||||
yardId?: string | null;
|
||||
@@ -823,6 +874,7 @@ export interface ReceiveInventoryPayload {
|
||||
weight: number;
|
||||
volume?: number;
|
||||
notes?: string;
|
||||
truckEntrance: TruckEntrancePayload;
|
||||
}
|
||||
|
||||
export interface MoveInventoryPayload {
|
||||
|
||||
@@ -1417,6 +1417,7 @@ model TravelPackage {
|
||||
returnSchedule TrainSchedule @relation("PackageReturn", fields: [returnScheduleId], references: [id])
|
||||
priceTiers PackagePriceTier[]
|
||||
bookings PackageBooking[]
|
||||
inquiries PackageInquiry[]
|
||||
|
||||
@@index([status, validFrom])
|
||||
@@schema("passenger")
|
||||
@@ -1434,6 +1435,7 @@ model PackagePriceTier {
|
||||
|
||||
package TravelPackage @relation(fields: [packageId], references: [id])
|
||||
bookings PackageBooking[]
|
||||
inquiries PackageInquiry[]
|
||||
|
||||
@@unique([packageId, seatType])
|
||||
@@schema("passenger")
|
||||
@@ -1501,3 +1503,24 @@ model PackagePaymentIntent {
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
model PackageInquiry {
|
||||
id String @id @default(uuid())
|
||||
packageId String
|
||||
priceTierId String?
|
||||
travelerCount Int
|
||||
contactName String
|
||||
contactEmail String?
|
||||
contactPhone String?
|
||||
notes String?
|
||||
status String @default("NEW")
|
||||
enquiredAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
package TravelPackage @relation(fields: [packageId], references: [id])
|
||||
priceTier PackagePriceTier? @relation(fields: [priceTierId], references: [id])
|
||||
|
||||
@@index([packageId])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -66,7 +66,9 @@ function decodePrivateJwk(base64: string): FaydaJwk {
|
||||
|
||||
export default registerAs('fayda', (): FaydaConfig => {
|
||||
const enabled = (process.env.FAYDA_ENABLED ?? 'false').toLowerCase() === 'true';
|
||||
const scope = process.env.FAYDA_SCOPE ?? 'openid profile email';
|
||||
// `profile` covers name/birthdate/gender/picture; `email`, `phone`, `address`
|
||||
// are needed so the matching essential claims aren't rejected as out-of-scope.
|
||||
const scope = process.env.FAYDA_SCOPE ?? 'openid profile email phone address';
|
||||
const acrValues = process.env.FAYDA_ACR_VALUES ?? 'mosip:idp:acr:generated-code';
|
||||
const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am';
|
||||
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
|
||||
|
||||
@@ -314,7 +314,7 @@ Payment providers send notifications to:
|
||||
.addTag("Excess Baggage", "IAM-protected agent/supervisor endpoints to log excess baggage charges, waive fees, resend payment links, and manage allowance rules per seat class. Public token-based endpoints let passengers self-pay outstanding charges.")
|
||||
.addTag("Packages", "Bundled travel packages with tiered pricing. Public endpoints for browsing and booking; JWT-authenticated endpoints for purchase history; IAM-protected endpoints for admin CRUD and tier management.")
|
||||
.addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails")
|
||||
.addTag("Auth", "Passenger registration, login, OTP, password reset, and profile management")
|
||||
.addTag("Passenger Auth", "Passenger registration, login, OTP, password reset, Fayda password setup, and profile management")
|
||||
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout. Supports ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT booking types. returnLegStatus filter for round-trip no-show management")
|
||||
.addTag("Config", "System settings, feature flags, and configuration management")
|
||||
.addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion")
|
||||
|
||||
@@ -3,10 +3,10 @@ import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nes
|
||||
import { Throttle, SkipThrottle } from '@nestjs/throttler';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { PassengerAuthService } from './passenger-auth.service';
|
||||
import { RegisterDto, LoginDto } from './auth.dto';
|
||||
import { RegisterDto, LoginDto, FaydaRequestPasswordSetupDto, FaydaVerifyAndLoginDto } from './auth.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@ApiTags('Passenger Auth')
|
||||
@Controller('auth')
|
||||
@Throttle({ auth: { limit: 5, ttl: 60_000 } })
|
||||
export class AuthController {
|
||||
@@ -118,4 +118,24 @@ export class AuthController {
|
||||
resetPassword(@Param('id') id: string, @Body() body: { tempPassword: string }) {
|
||||
return this.passengerAuthService.resetUserPassword(id, body.tempPassword);
|
||||
}
|
||||
|
||||
@Post('fayda/request-password-setup')
|
||||
@IsPublic()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Send OTP to phone for Fayda-verified account password setup' })
|
||||
@ApiResponse({ status: 200, description: 'OTP sent to registered phone number' })
|
||||
@ApiBody({ type: FaydaRequestPasswordSetupDto })
|
||||
requestFaydaPasswordSetup(@Body() dto: FaydaRequestPasswordSetupDto, @Request() req: any) {
|
||||
return this.passengerAuthService.requestFaydaPasswordSetup(dto.phoneNumber, req);
|
||||
}
|
||||
|
||||
@Post('fayda/verify-and-login')
|
||||
@IsPublic()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Verify OTP and receive session token for Fayda-verified account' })
|
||||
@ApiResponse({ status: 200, description: 'Returns token + requiresPassword flag. Use token with POST /v1/auth/set-fayda-password.' })
|
||||
@ApiBody({ type: FaydaVerifyAndLoginDto })
|
||||
verifyFaydaAndLogin(@Body() dto: FaydaVerifyAndLoginDto) {
|
||||
return this.passengerAuthService.verifyFaydaAndLogin(dto.phoneNumber, dto.otp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IsEmail, IsString, MinLength, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class NameDto {
|
||||
@ApiProperty({ example: 'ቀለሙ ቀጸላ' })
|
||||
@@ -49,3 +49,19 @@ export class LoginDto {
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class FaydaRequestPasswordSetupDto {
|
||||
@ApiProperty({ example: '+251911234567', description: 'Phone number of the Fayda-verified account' })
|
||||
@IsString()
|
||||
phoneNumber: string;
|
||||
}
|
||||
|
||||
export class FaydaVerifyAndLoginDto {
|
||||
@ApiProperty({ example: '+251911234567' })
|
||||
@IsString()
|
||||
phoneNumber: string;
|
||||
|
||||
@ApiProperty({ example: '123456', description: '6-digit OTP received via SMS' })
|
||||
@IsString()
|
||||
otp: string;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Injectable,
|
||||
ConflictException,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ModuleRef, ContextIdFactory } from '@nestjs/core';
|
||||
@@ -10,6 +11,7 @@ import { DataSource } from 'typeorm';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { AuthService as IamAuthService } from '@tria-plc/iamapi-common/module/auth/services/auth.service';
|
||||
import { EUserType } from '@tria-plc/api-common/utils/enums/user.enum';
|
||||
import { EOtpType } from '@tria-plc/iamapi-common/enums/otp.enum';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { RegisterDto, LoginDto } from './auth.dto';
|
||||
|
||||
@@ -19,10 +21,13 @@ type IamUserRow = {
|
||||
name: { en: string; am: string } | null;
|
||||
phone_number: string | null;
|
||||
metadata: Record<string, any> | null;
|
||||
verified_by: string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PassengerAuthService {
|
||||
private readonly logger = new Logger(PassengerAuthService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
@@ -165,7 +170,7 @@ export class PassengerAuthService {
|
||||
include: { loyalty: true, wallet: true },
|
||||
}),
|
||||
this.dataSource.query<IamUserRow[]>(
|
||||
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`,
|
||||
`SELECT id, email, name, phone_number, metadata, verified_by FROM iam.users WHERE id = $1 LIMIT 1`,
|
||||
[iamUserId],
|
||||
),
|
||||
]);
|
||||
@@ -178,7 +183,7 @@ export class PassengerAuthService {
|
||||
email: iam?.email ?? null,
|
||||
phone: iam?.phone_number ?? null,
|
||||
fullName: iam?.name?.en ?? iam?.name?.am ?? null,
|
||||
faydaVerified: iam?.metadata?.faydaVerified ?? false,
|
||||
faydaVerified: iam?.verified_by === 'fayda',
|
||||
createdAt: passenger.createdAt,
|
||||
passenger: {
|
||||
id: passenger.id,
|
||||
@@ -396,6 +401,121 @@ export class PassengerAuthService {
|
||||
return { success: true, message: 'Password reset successfully' };
|
||||
}
|
||||
|
||||
async requestFaydaPasswordSetup(phoneNumber: string, req: any): Promise<{ sent: boolean }> {
|
||||
const phone = this.standardizePhone(phoneNumber);
|
||||
|
||||
const users = await this.dataSource.query<{ id: string; email: string }[]>(
|
||||
`SELECT id, email FROM iam.users WHERE phone_number = $1 AND verified_by = 'fayda' LIMIT 1`,
|
||||
[phone],
|
||||
);
|
||||
this.logger.log(`requestFaydaPasswordSetup: phone=${phone} found=${users.length > 0}`);
|
||||
// Return success regardless to avoid phone enumeration
|
||||
if (!users.length) return { sent: true };
|
||||
const u = users[0];
|
||||
|
||||
const iamAuthService = await this.resolveIamAuthService(req);
|
||||
await iamAuthService.generateVerificationCode({
|
||||
email: u.email,
|
||||
phoneNumber: phone,
|
||||
type: EOtpType.SET_PASSWORD,
|
||||
});
|
||||
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
async verifyFaydaAndLogin(
|
||||
phoneNumber: string,
|
||||
otp: string,
|
||||
): Promise<{ token: string; refreshToken: string; requiresPassword: boolean; iamUserId: string }> {
|
||||
const phone = this.standardizePhone(phoneNumber);
|
||||
|
||||
const users = await this.dataSource.query<{
|
||||
id: string;
|
||||
email: string;
|
||||
name: { en: string; am: string } | null;
|
||||
username: string;
|
||||
phone_number: string | null;
|
||||
has_set_password: boolean;
|
||||
}[]>(
|
||||
`SELECT id, email, name, username, phone_number, has_set_password
|
||||
FROM iam.users WHERE phone_number = $1 AND verified_by = 'fayda' LIMIT 1`,
|
||||
[phone],
|
||||
);
|
||||
if (!users.length) throw new UnauthorizedException('Invalid phone number or OTP');
|
||||
const u = users[0];
|
||||
|
||||
const verifications = await this.dataSource.query<{
|
||||
id: string; verification_code: string; attempt_count: number;
|
||||
}[]>(
|
||||
`SELECT id, verification_code, attempt_count FROM iam.user_verifications
|
||||
WHERE user_id = $1 AND otp_type = 'set-password' AND "isUsed" = false AND expires_at > NOW()
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[u.id],
|
||||
);
|
||||
if (!verifications.length) throw new UnauthorizedException('Invalid phone number or OTP');
|
||||
const v = verifications[0];
|
||||
|
||||
if (v.attempt_count >= 5) {
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.user_verifications SET "isUsed" = true WHERE id = $1`, [v.id],
|
||||
);
|
||||
throw new UnauthorizedException('Too many attempts. Request a new code.');
|
||||
}
|
||||
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.user_verifications SET attempt_count = attempt_count + 1 WHERE id = $1`, [v.id],
|
||||
);
|
||||
|
||||
const { verifyPassword } = await import('@tria-plc/api-common/utils/argon');
|
||||
const valid = await verifyPassword(otp, v.verification_code);
|
||||
if (!valid) throw new UnauthorizedException('Invalid phone number or OTP');
|
||||
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.user_verifications SET "isUsed" = true WHERE id = $1`, [v.id],
|
||||
);
|
||||
|
||||
const userInfo = {
|
||||
id: u.id,
|
||||
email: u.email ?? '',
|
||||
name: u.name ?? { en: '', am: '' },
|
||||
userType: 'individual',
|
||||
status: 'accepted',
|
||||
hasSetPassword: u.has_set_password,
|
||||
isPhoneNumberVerified: false,
|
||||
hasFinishedRegistration: false,
|
||||
hasFinishedDMSOnboarding: false,
|
||||
username: u.username,
|
||||
phoneNumber: u.phone_number ?? '',
|
||||
roles: [],
|
||||
permissions: [],
|
||||
employee: [],
|
||||
};
|
||||
|
||||
const sessions = await this.dataSource.query<{ id: string }[]>(
|
||||
`INSERT INTO iam.sessions
|
||||
(id, email, device, "userInfo", expiry_time, refresh_count, status, user_id)
|
||||
VALUES (gen_random_uuid(), $1, 'fayda-otp-setup', $2::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $3)
|
||||
ON CONFLICT (user_id, device) DO UPDATE
|
||||
SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo",
|
||||
expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW()
|
||||
RETURNING id`,
|
||||
[u.email ?? '', JSON.stringify(userInfo), u.id],
|
||||
);
|
||||
|
||||
const { generateToken, generateRefreshToken } = await import('@tria-plc/api-common/utils/token');
|
||||
const token = generateToken({ id: sessions[0].id });
|
||||
const refreshToken = generateRefreshToken({ id: sessions[0].id });
|
||||
|
||||
return { token, refreshToken, requiresPassword: !u.has_set_password, iamUserId: u.id };
|
||||
}
|
||||
|
||||
private standardizePhone(phone: string): string {
|
||||
const digits = phone.replace(/\D/g, '');
|
||||
if (digits.startsWith('251')) return `+${digits}`;
|
||||
if (digits.startsWith('0')) return `+251${digits.slice(1)}`;
|
||||
return `+${digits}`;
|
||||
}
|
||||
|
||||
private async compensateIamSignup(email: string): Promise<void> {
|
||||
try {
|
||||
const rows = await this.dataSource.query<{ id: string }[]>(
|
||||
|
||||
@@ -80,15 +80,23 @@ export class BookingsController {
|
||||
@ApiOperation({
|
||||
description: 'Returns paginated list of bookings. Use `returnLegStatus=OUTBOUND_ONLY` to find round-trip no-shows on the return leg, `INBOUND_ONLY` for passengers who only used the return leg, `BOTH_USED` for fully completed round-trips, and `NEITHER_USED` for confirmed but not yet boarded.'
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' })
|
||||
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
|
||||
@ApiQuery({ name: 'returnLegStatus', required: false, description: 'Filter round-trip leg usage: NEITHER_USED | OUTBOUND_ONLY | INBOUND_ONLY | BOTH_USED | NOT_APPLICABLE' })
|
||||
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
|
||||
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
@ApiQuery({ name: 'status', required: false })
|
||||
@ApiQuery({ name: 'returnLegStatus', required: false })
|
||||
@ApiQuery({ name: 'bookingType', required: false })
|
||||
@ApiQuery({ name: 'paymentStatus', required: false })
|
||||
@ApiQuery({ name: 'dateFrom', required: false })
|
||||
@ApiQuery({ name: 'dateTo', required: false })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
findAll(
|
||||
@Query('search') search?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('returnLegStatus') returnLegStatus?: string,
|
||||
@Query('bookingType') bookingType?: string,
|
||||
@Query('paymentStatus') paymentStatus?: string,
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('dateTo') dateTo?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
@@ -96,6 +104,10 @@ export class BookingsController {
|
||||
search,
|
||||
status,
|
||||
returnLegStatus,
|
||||
bookingType,
|
||||
paymentStatus,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 20
|
||||
});
|
||||
|
||||
@@ -28,6 +28,10 @@ interface BookingFilters {
|
||||
search?: string;
|
||||
status?: string;
|
||||
returnLegStatus?: string;
|
||||
bookingType?: string;
|
||||
paymentStatus?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -195,7 +199,7 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
async findAll(filters: BookingFilters = {}) {
|
||||
const { search, status, returnLegStatus, page = 1, pageSize = 20 } = filters;
|
||||
const { search, status, returnLegStatus, bookingType, paymentStatus, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = {};
|
||||
@@ -227,6 +231,23 @@ export class BookingsService {
|
||||
|
||||
if (status) where.status = status;
|
||||
if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus;
|
||||
if (bookingType) where.bookingType = bookingType;
|
||||
if (dateFrom || dateTo) {
|
||||
where.createdAt = {
|
||||
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
|
||||
...(dateTo ? { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) } : {}),
|
||||
};
|
||||
}
|
||||
if (paymentStatus) {
|
||||
const statusMap: Record<string, string> = {
|
||||
PAID: 'SUCCEEDED',
|
||||
PENDING: 'REQUIRES_ACTION',
|
||||
FAILED: 'FAILED',
|
||||
REFUNDED: 'REFUNDED',
|
||||
};
|
||||
const mapped = statusMap[paymentStatus] ?? paymentStatus;
|
||||
where.paymentIntent = { is: { status: mapped } };
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.booking.findMany({
|
||||
|
||||
@@ -35,12 +35,16 @@ export class ExcessBaggageAgentController {
|
||||
getAll(
|
||||
@Query('status') status?: string,
|
||||
@Query('bookingRef') bookingRef?: string,
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('dateTo') dateTo?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.service.getAll({
|
||||
status,
|
||||
bookingRef,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
page: page ? parseInt(page) : undefined,
|
||||
pageSize: pageSize ? parseInt(pageSize) : undefined,
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { PaymentClientService } from '../payments/payment-client.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { EmailClientService } from '../notifications/email-client.service';
|
||||
import {
|
||||
LogExcessBaggageDto,
|
||||
WaiveChargeDto,
|
||||
@@ -30,6 +32,8 @@ export class ExcessBaggageService {
|
||||
private prisma: PrismaService,
|
||||
private paymentClient: PaymentClientService,
|
||||
private notifications: NotificationsService,
|
||||
private smsClient: SmsClientService,
|
||||
private emailClient: EmailClientService,
|
||||
) {}
|
||||
|
||||
async logCharge(dto: LogExcessBaggageDto) {
|
||||
@@ -101,23 +105,27 @@ export class ExcessBaggageService {
|
||||
const amountStr = (charge.totalMinor / 100).toFixed(2);
|
||||
const msg = `EDR: Excess baggage charge of ${amountStr} ETB for booking ${booking.bookingRef}. Pay here: ${payUrl} (valid 30 min)`;
|
||||
|
||||
const recipient = phone ?? email ?? booking.passengerId;
|
||||
try {
|
||||
await this.notifications['deliverSms'](recipient, msg);
|
||||
} catch (err) {
|
||||
this.logger.warn(`SMS send failed for excess baggage charge ${charge.id}: ${err}`);
|
||||
if (phone) {
|
||||
try {
|
||||
await this.smsClient.sendSms({ to: phone, message: msg });
|
||||
} catch (err) {
|
||||
this.logger.warn(`SMS send failed for excess baggage charge ${charge.id}: ${err}`);
|
||||
}
|
||||
}
|
||||
if (email) {
|
||||
try {
|
||||
await this.notifications['deliverEmail'](
|
||||
recipient,
|
||||
`EDR — Excess baggage payment required (${booking.bookingRef})`,
|
||||
msg,
|
||||
);
|
||||
await this.emailClient.sendEmail({
|
||||
to: email,
|
||||
subject: `EDR — Excess baggage payment required (${booking.bookingRef})`,
|
||||
text: msg,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`Email send failed for excess baggage charge ${charge.id}: ${err}`);
|
||||
}
|
||||
}
|
||||
if (!phone && !email) {
|
||||
this.logger.warn(`No contact info to send excess baggage payment link for charge ${charge.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getCharge(id: string) {
|
||||
@@ -227,14 +235,22 @@ export class ExcessBaggageService {
|
||||
async getAll(filters: {
|
||||
status?: string;
|
||||
bookingRef?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
const { status, bookingRef, page = 1, pageSize = 20 } = filters;
|
||||
const { status, bookingRef, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where: any = {};
|
||||
if (status) where.status = status;
|
||||
if (bookingRef) where.booking = { bookingRef: { contains: bookingRef, mode: 'insensitive' } };
|
||||
if (dateFrom || dateTo) {
|
||||
where.createdAt = {
|
||||
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
|
||||
...(dateTo ? { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.excessBaggageCharge.findMany({
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
OnApplicationBootstrap,
|
||||
} from "@nestjs/common";
|
||||
import { ClientProxy } from "@nestjs/microservices";
|
||||
import * as sgMail from "@sendgrid/mail";
|
||||
import { SendEmail } from "./dtos/email.dto";
|
||||
|
||||
@Injectable()
|
||||
@@ -14,9 +15,15 @@ export class EmailClientService implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
@Inject("EMAIL_SERVICE")
|
||||
private readonly emailServiceClient: ClientProxy,
|
||||
) {}
|
||||
) {
|
||||
const apiKey = process.env.SENDGRID_API_KEY;
|
||||
if (apiKey) sgMail.setApiKey(apiKey);
|
||||
}
|
||||
|
||||
private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
|
||||
private get sendgridEnabled() {
|
||||
return !!process.env.SENDGRID_API_KEY;
|
||||
}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
if (!this.enabled) return;
|
||||
@@ -29,22 +36,38 @@ export class EmailClientService implements OnApplicationBootstrap {
|
||||
}
|
||||
|
||||
async sendEmail(dto: SendEmail): Promise<{ queued: boolean }> {
|
||||
if (!this.enabled) {
|
||||
this.logger.warn(`RABBITMQ disabled — skipped EMAIL`);
|
||||
return { queued: false };
|
||||
if (this.enabled) {
|
||||
this.emailServiceClient.emit("send-email", {
|
||||
...dto,
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
});
|
||||
this.logger.log(
|
||||
`EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`,
|
||||
);
|
||||
this.logger.debug(
|
||||
`EMAIL payload to=${dto.to} subject="${dto.subject ?? ""}"`,
|
||||
);
|
||||
return { queued: true };
|
||||
}
|
||||
this.emailServiceClient.emit("send-email", {
|
||||
...dto,
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
});
|
||||
// Fire-and-forget enqueue: this confirms the message was handed to RabbitMQ, NOT delivered.
|
||||
this.logger.log(
|
||||
`EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`,
|
||||
);
|
||||
// Recipient + content are PII — keep them at debug level only.
|
||||
this.logger.debug(
|
||||
`EMAIL payload to=${dto.to} subject="${dto.subject ?? ""}" body="${dto.text ?? dto.body ?? dto.html ?? ""}"`,
|
||||
);
|
||||
return { queued: true };
|
||||
|
||||
if (this.sendgridEnabled) {
|
||||
try {
|
||||
await sgMail.send({
|
||||
to: dto.to,
|
||||
from: process.env.SENDGRID_FROM_EMAIL ?? "noreply@edr-platform.com",
|
||||
subject: dto.subject ?? "EDR Notification",
|
||||
text: dto.text ?? dto.body ?? "",
|
||||
...(dto.html ? { html: dto.html } : {}),
|
||||
});
|
||||
this.logger.log(`EMAIL sent via SendGrid to=${dto.to}`);
|
||||
return { queued: true };
|
||||
} catch (err: any) {
|
||||
this.logger.error(`SendGrid send failed to=${dto.to}: ${err?.message}`);
|
||||
return { queued: false };
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.warn(`EMAIL not sent (no transport) — to=${dto.to} subject="${dto.subject ?? ""}"`);
|
||||
return { queued: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export class SmsClientService implements OnApplicationBootstrap {
|
||||
|
||||
async sendSms(dto: SingleMessageDto): Promise<{ queued: boolean }> {
|
||||
if (!this.enabled) {
|
||||
this.logger.warn(`RABBITMQ disabled — skipped SMS`);
|
||||
this.logger.warn(`SMS not sent (RabbitMQ disabled) — to=${dto.to} message="${dto.message}"`);
|
||||
return { queued: false };
|
||||
}
|
||||
this.smsClient.emit("send-sms", {
|
||||
@@ -51,7 +51,7 @@ export class SmsClientService implements OnApplicationBootstrap {
|
||||
|
||||
async sendBulkMessages(dto: BulkMessagesDto): Promise<{ queued: boolean }> {
|
||||
if (!this.enabled) {
|
||||
this.logger.warn(`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`);
|
||||
this.logger.warn(`BULK SMS not sent (RabbitMQ disabled) — ${dto.messages?.length ?? 0} messages skipped`);
|
||||
return { queued: false };
|
||||
}
|
||||
const messages = (dto.messages ?? []).map((m) => ({ to: m.to, text: m.message, from: m.from }));
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Request,
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { PackagesService } from './packages.service';
|
||||
import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto } from './packages.dto';
|
||||
import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto } from './packages.dto';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
|
||||
@@ -12,6 +12,42 @@ import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
|
||||
export class PackagesController {
|
||||
constructor(private readonly service: PackagesService) {}
|
||||
|
||||
@Post('inquiries')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Submit a package inquiry (public)' })
|
||||
createInquiry(@Body() dto: CreateInquiryDto) {
|
||||
return this.service.createInquiry(dto);
|
||||
}
|
||||
|
||||
@Get('inquiries')
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'List all inquiries (backoffice)' })
|
||||
listInquiries(
|
||||
@Query('packageId') packageId?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.service.listInquiries({ packageId, status, page: page ? +page : 1, pageSize: pageSize ? +pageSize : 20 });
|
||||
}
|
||||
|
||||
@Patch('inquiries/:id/status')
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update inquiry status (backoffice)' })
|
||||
updateInquiryStatus(@Param('id') id: string, @Body() dto: UpdateInquiryStatusDto) {
|
||||
return this.service.updateInquiryStatus(id, dto.status);
|
||||
}
|
||||
|
||||
@Delete('inquiries/:id')
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete inquiry (backoffice)' })
|
||||
deleteInquiry(@Param('id') id: string) {
|
||||
return this.service.deleteInquiry(id);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'List active packages' })
|
||||
|
||||
@@ -16,6 +16,20 @@ export class CreatePriceTierDto {
|
||||
@IsInt() @Min(0) availableSeats: number;
|
||||
}
|
||||
|
||||
export class CreateInquiryDto {
|
||||
@ApiProperty() @IsUUID() packageId: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsUUID() priceTierId?: string;
|
||||
@ApiProperty({ example: 2 }) @IsInt() @Min(1) travelerCount: number;
|
||||
@ApiProperty() @IsString() contactName: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() contactEmail?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() contactPhone?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
|
||||
export class UpdateInquiryStatusDto {
|
||||
@ApiProperty({ example: 'CONTACTED' }) @IsString() status: string;
|
||||
}
|
||||
|
||||
export class UpdatePriceTierDto {
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() seatType?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() label?: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto } from './packages.dto';
|
||||
import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto, CreateInquiryDto } from './packages.dto';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
function generateRef(): string {
|
||||
@@ -17,6 +17,53 @@ export class PackagesService {
|
||||
private readonly currencyService: CurrencyService,
|
||||
) {}
|
||||
|
||||
async createInquiry(dto: CreateInquiryDto) {
|
||||
return this.prisma.packageInquiry.create({
|
||||
data: {
|
||||
packageId: dto.packageId,
|
||||
priceTierId: dto.priceTierId ?? null,
|
||||
travelerCount: dto.travelerCount,
|
||||
contactName: dto.contactName,
|
||||
contactEmail: dto.contactEmail ?? null,
|
||||
contactPhone: dto.contactPhone ?? null,
|
||||
notes: dto.notes ?? null,
|
||||
enquiredAt: new Date(),
|
||||
},
|
||||
include: { package: { select: { id: true, name: true, code: true } }, priceTier: { select: { id: true, label: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async listInquiries({ packageId, status, page = 1, pageSize = 20 }: { packageId?: string; status?: string; page?: number; pageSize?: number }) {
|
||||
const where: any = {};
|
||||
if (packageId) where.packageId = packageId;
|
||||
if (status) where.status = status;
|
||||
const skip = (page - 1) * pageSize;
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.packageInquiry.findMany({
|
||||
where,
|
||||
include: { package: { select: { id: true, name: true, code: true } }, priceTier: { select: { id: true, label: true, priceMinor: true } } },
|
||||
orderBy: { enquiredAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.packageInquiry.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async updateInquiryStatus(id: string, status: string) {
|
||||
const inquiry = await this.prisma.packageInquiry.findUnique({ where: { id } });
|
||||
if (!inquiry) throw new NotFoundException('Inquiry not found');
|
||||
return this.prisma.packageInquiry.update({ where: { id }, data: { status } });
|
||||
}
|
||||
|
||||
async deleteInquiry(id: string) {
|
||||
const inquiry = await this.prisma.packageInquiry.findUnique({ where: { id } });
|
||||
if (!inquiry) throw new NotFoundException('Inquiry not found');
|
||||
await this.prisma.packageInquiry.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
listActive() {
|
||||
const now = new Date();
|
||||
return this.prisma.travelPackage.findMany({
|
||||
|
||||
@@ -24,19 +24,31 @@ export class PassengersController {
|
||||
summary: 'List all passengers with filters (Admin/Agent)',
|
||||
description: 'Returns paginated list of passengers with search filters'
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by name, email, or phone' })
|
||||
@ApiQuery({ name: 'verified', required: false, description: 'Filter by verification status' })
|
||||
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
|
||||
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
@ApiQuery({ name: 'verified', required: false })
|
||||
@ApiQuery({ name: 'gender', required: false })
|
||||
@ApiQuery({ name: 'nationality', required: false })
|
||||
@ApiQuery({ name: 'dateFrom', required: false })
|
||||
@ApiQuery({ name: 'dateTo', required: false })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
findAll(
|
||||
@Query('search') search?: string,
|
||||
@Query('verified') verified?: string,
|
||||
@Query('gender') gender?: string,
|
||||
@Query('nationality') nationality?: string,
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('dateTo') dateTo?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.service.findAll({
|
||||
search,
|
||||
verified: verified ? verified === 'true' : undefined,
|
||||
verified: verified ? verified === 'true' : undefined,
|
||||
gender,
|
||||
nationality,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 20
|
||||
});
|
||||
|
||||
@@ -8,6 +8,10 @@ import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
interface PassengerFilters {
|
||||
search?: string;
|
||||
verified?: boolean;
|
||||
gender?: string;
|
||||
nationality?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -29,7 +33,7 @@ export class PassengersService {
|
||||
) {}
|
||||
|
||||
async findAll(filters: PassengerFilters = {}) {
|
||||
const { search, verified, page = 1, pageSize = 20 } = filters;
|
||||
const { search, verified, gender, nationality, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = {};
|
||||
@@ -48,6 +52,21 @@ export class PassengersService {
|
||||
where.user = { ...(where.user ?? {}), faydaVerified: verified };
|
||||
}
|
||||
|
||||
if (gender) {
|
||||
where.user = { ...(where.user ?? {}), gender };
|
||||
}
|
||||
|
||||
if (nationality) {
|
||||
where.user = { ...(where.user ?? {}), nationality: { contains: nationality, mode: 'insensitive' } };
|
||||
}
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
where.createdAt = {
|
||||
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
|
||||
...(dateTo ? { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.passenger.findMany({
|
||||
where,
|
||||
|
||||
@@ -37,6 +37,8 @@ export class TicketsController {
|
||||
@ApiQuery({ name: 'originStationId', required: false })
|
||||
@ApiQuery({ name: 'destinationStationId', required: false })
|
||||
@ApiQuery({ name: 'arrivalDate', required: false })
|
||||
@ApiQuery({ name: 'dateFrom', required: false })
|
||||
@ApiQuery({ name: 'dateTo', required: false })
|
||||
@ApiQuery({ name: 'skip', required: false })
|
||||
@ApiQuery({ name: 'take', required: false })
|
||||
listTickets(
|
||||
@@ -45,6 +47,8 @@ export class TicketsController {
|
||||
@Query('originStationId') originStationId?: string,
|
||||
@Query('destinationStationId') destinationStationId?: string,
|
||||
@Query('arrivalDate') arrivalDate?: string,
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('dateTo') dateTo?: string,
|
||||
@Query('skip') skip?: string,
|
||||
@Query('take') take?: string,
|
||||
) {
|
||||
@@ -54,6 +58,8 @@ export class TicketsController {
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
arrivalDate,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
skip: skip ? parseInt(skip) : 0,
|
||||
take: take ? parseInt(take) : 50,
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ export class TicketsService {
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; skip: number; take: number }) {
|
||||
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; skip: number; take: number }) {
|
||||
const where: any = {};
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
@@ -31,7 +31,7 @@ export class TicketsService {
|
||||
];
|
||||
}
|
||||
if (filters.status) {
|
||||
where.booking = { ...where.booking, status: filters.status };
|
||||
where.status = filters.status;
|
||||
}
|
||||
if (filters.originStationId) {
|
||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } };
|
||||
@@ -45,6 +45,12 @@ export class TicketsService {
|
||||
end.setDate(end.getDate() + 1);
|
||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, arrivalAt: { gte: start, lt: end } } };
|
||||
}
|
||||
if (filters.dateFrom || filters.dateTo) {
|
||||
where.issuedAt = {
|
||||
...(filters.dateFrom ? { gte: new Date(filters.dateFrom) } : {}),
|
||||
...(filters.dateTo ? { lte: new Date(new Date(filters.dateTo).setHours(23, 59, 59, 999)) } : {}),
|
||||
};
|
||||
}
|
||||
const [tickets, total] = await Promise.all([
|
||||
this.prisma.ticket.findMany({
|
||||
where,
|
||||
|
||||
@@ -74,6 +74,7 @@ export class VerifaydaController {
|
||||
purpose: dto.purpose ?? 'VERIFY',
|
||||
platform: dto.platform ?? 'WEB',
|
||||
userId: req.user?.id,
|
||||
wantsPasswordSetup: dto.wantsPasswordSetup ?? false,
|
||||
});
|
||||
return { authorizationUrl };
|
||||
}
|
||||
|
||||
@@ -21,6 +21,17 @@ export class StartVerificationDto {
|
||||
@IsOptional()
|
||||
@IsIn(['WEB', 'MOBILE'])
|
||||
platform?: 'WEB' | 'MOBILE';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
description:
|
||||
'Set to true when the user opts in to full account registration (checkbox). ' +
|
||||
'When true, the /complete response includes a short-lived token and promptPasswordSetup=true ' +
|
||||
'so the frontend can immediately prompt for a password via POST /v1/auth/set-fayda-password.',
|
||||
})
|
||||
@IsOptional()
|
||||
wantsPasswordSetup?: boolean;
|
||||
}
|
||||
|
||||
export class CompleteVerificationResultDto {
|
||||
@@ -29,9 +40,12 @@ export class CompleteVerificationResultDto {
|
||||
|
||||
@ApiProperty() verified: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'JWT (LOGIN flow only).' })
|
||||
@ApiPropertyOptional({ description: 'JWT. LOGIN: session token for the authenticated user. VERIFY: short-lived token for calling /v1/auth/set-fayda-password.' })
|
||||
token?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
refreshToken?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Authenticated user summary (LOGIN flow only; same shape as /auth/login).',
|
||||
})
|
||||
@@ -62,6 +76,19 @@ export class CompleteVerificationResultDto {
|
||||
|
||||
@ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' })
|
||||
userDataSaved?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'IAM user ID of the verified identity (VERIFY flow).' })
|
||||
iamUserId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'True when the IAM account has not yet set a password (VERIFY flow).' })
|
||||
requiresPassword?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'True when the user opted in to immediate password setup (wantsPasswordSetup=true at start) ' +
|
||||
'AND they have not yet set a password. Frontend should navigate to the set-password screen.',
|
||||
})
|
||||
promptPasswordSetup?: boolean;
|
||||
}
|
||||
|
||||
export class VerifaydaCallbackDto {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { generateToken, generateRefreshToken } from '@tria-plc/api-common/utils/token';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config';
|
||||
@@ -47,6 +48,7 @@ export interface StartVerificationInput {
|
||||
purpose: VerifaydaPurpose;
|
||||
platform?: FaydaPlatform;
|
||||
userId?: string; // iamUserId of the authenticated user, if any
|
||||
wantsPasswordSetup?: boolean;
|
||||
}
|
||||
|
||||
export interface FaydaUserSummary {
|
||||
@@ -66,6 +68,10 @@ export interface CompleteVerificationResult {
|
||||
purpose: VerifaydaPurpose;
|
||||
verified: boolean;
|
||||
token?: string;
|
||||
refreshToken?: string;
|
||||
requiresPassword?: boolean;
|
||||
promptPasswordSetup?: boolean;
|
||||
iamUserId?: string;
|
||||
user?: FaydaUserSummary;
|
||||
fullName?: string;
|
||||
email?: string;
|
||||
@@ -145,6 +151,7 @@ export class VerifaydaService {
|
||||
codeVerifier,
|
||||
purpose: input.purpose,
|
||||
platform: input.platform ?? 'WEB',
|
||||
saveToAccount: input.wantsPasswordSetup ?? false,
|
||||
iamUserId: input.userId ?? null,
|
||||
expiresAt,
|
||||
},
|
||||
@@ -220,8 +227,18 @@ export class VerifaydaService {
|
||||
const login = await this.issueLoginToken(userId);
|
||||
result = { purpose: 'LOGIN', verified: true, ...login };
|
||||
} else {
|
||||
// VERIFY — prove identity, save to IAM, return verified attributes.
|
||||
const { userDataSaved } = await this.upsertIamUser(normalized);
|
||||
// VERIFY — prove identity, save to IAM, return verified attributes + short-lived token.
|
||||
const { iamUserId, userDataSaved } = await this.upsertIamUser(normalized);
|
||||
|
||||
let sessionToken: { token: string; refreshToken: string; requiresPassword: boolean } | undefined;
|
||||
if (iamUserId) {
|
||||
try {
|
||||
sessionToken = await this.createFaydaSession(iamUserId);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Fayda session creation failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
result = {
|
||||
purpose: 'VERIFY',
|
||||
verified: true,
|
||||
@@ -231,6 +248,11 @@ export class VerifaydaService {
|
||||
birthdate: normalized.birthdate,
|
||||
gender: normalized.gender,
|
||||
userDataSaved,
|
||||
iamUserId: iamUserId ?? undefined,
|
||||
token: sessionToken?.token,
|
||||
refreshToken: sessionToken?.refreshToken,
|
||||
requiresPassword: sessionToken?.requiresPassword,
|
||||
promptPasswordSetup: session.saveToAccount && (sessionToken?.requiresPassword ?? false),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -298,14 +320,19 @@ export class VerifaydaService {
|
||||
claims_locales: this.faydaConfig.claimsLocales,
|
||||
});
|
||||
|
||||
// Every claim is marked essential so eSignet shows them locked/pre-checked
|
||||
// on the consent screen — the user cannot toggle any off; they either
|
||||
// consent to all of them or the whole flow is cancelled (?error=...).
|
||||
const claims = {
|
||||
userinfo: {
|
||||
name: { essential: true },
|
||||
phone_number: { essential: true },
|
||||
email: { essential: false },
|
||||
email: { essential: true },
|
||||
birthdate: { essential: true },
|
||||
gender: { essential: false },
|
||||
picture: { essential: false },
|
||||
gender: { essential: true },
|
||||
address: { essential: true },
|
||||
nationality: { essential: true },
|
||||
picture: { essential: true },
|
||||
},
|
||||
id_token: {},
|
||||
};
|
||||
@@ -447,12 +474,16 @@ export class VerifaydaService {
|
||||
phoneNumber: normalized.rawPhoneNumber ?? '',
|
||||
};
|
||||
|
||||
// Step 1 — already verified with same Fayda sub
|
||||
// Step 1 — already linked to this Fayda sub; ensure verified_by is set
|
||||
const bySub = await this.dataSource.query<{ id: string }[]>(
|
||||
`SELECT id FROM iam.users WHERE metadata->>'sub' = $1 LIMIT 1`,
|
||||
[normalized.sub],
|
||||
);
|
||||
if (bySub.length > 0) {
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.users SET verified_by = 'fayda', updated_at = NOW() WHERE id = $1`,
|
||||
[bySub[0].id],
|
||||
);
|
||||
return { iamUserId: bySub[0].id, userDataSaved: true };
|
||||
}
|
||||
|
||||
@@ -497,7 +528,7 @@ export class VerifaydaService {
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
gen_random_uuid(), $1::jsonb, $2, $3, $4, $5::jsonb,
|
||||
'individual', 'accepted', true, false,
|
||||
'individual', 'submitted', true, false,
|
||||
false, 'fayda',
|
||||
NOW(), NOW()
|
||||
) RETURNING id`,
|
||||
@@ -516,6 +547,60 @@ export class VerifaydaService {
|
||||
}
|
||||
}
|
||||
|
||||
private async createFaydaSession(
|
||||
iamUserId: string,
|
||||
): Promise<{ token: string; refreshToken: string; requiresPassword: boolean }> {
|
||||
const rows = await this.dataSource.query<{
|
||||
id: string;
|
||||
email: string;
|
||||
name: { en: string; am: string } | null;
|
||||
username: string;
|
||||
phone_number: string | null;
|
||||
has_set_password: boolean;
|
||||
status: string;
|
||||
}[]>(
|
||||
`SELECT id, email, name, username, phone_number, has_set_password, status
|
||||
FROM iam.users WHERE id = $1 LIMIT 1`,
|
||||
[iamUserId],
|
||||
);
|
||||
if (!rows.length) throw new Error(`IAM user ${iamUserId} not found`);
|
||||
const u = rows[0];
|
||||
|
||||
const userInfo = {
|
||||
id: u.id,
|
||||
email: u.email ?? '',
|
||||
name: u.name ?? { en: '', am: '' },
|
||||
userType: 'individual',
|
||||
status: u.status,
|
||||
hasSetPassword: u.has_set_password,
|
||||
isPhoneNumberVerified: false,
|
||||
hasFinishedRegistration: false,
|
||||
hasFinishedDMSOnboarding: false,
|
||||
username: u.username,
|
||||
phoneNumber: u.phone_number ?? '',
|
||||
roles: [],
|
||||
permissions: [],
|
||||
employee: [],
|
||||
};
|
||||
|
||||
const sessions = await this.dataSource.query<{ id: string }[]>(
|
||||
`INSERT INTO iam.sessions
|
||||
(id, email, device, "userInfo", expiry_time, refresh_count, status, user_id)
|
||||
VALUES (gen_random_uuid(), $1, 'fayda-verify', $2::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $3)
|
||||
ON CONFLICT (user_id, device) DO UPDATE
|
||||
SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo",
|
||||
expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW()
|
||||
RETURNING id`,
|
||||
[u.email ?? '', JSON.stringify(userInfo), iamUserId],
|
||||
);
|
||||
|
||||
const sessionId = sessions[0].id;
|
||||
const token = generateToken({ id: sessionId });
|
||||
const refreshToken = generateRefreshToken({ id: sessionId });
|
||||
|
||||
return { token, refreshToken, requiresPassword: !u.has_set_password };
|
||||
}
|
||||
|
||||
private async markSessionFailed(
|
||||
state: string,
|
||||
errorCode: string,
|
||||
|
||||
@@ -47,8 +47,14 @@ export default function BookingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['bookings', filters],
|
||||
queryFn: () => bookingsApi.getAll(filters),
|
||||
queryKey: ['bookings', filters, extraFilters],
|
||||
queryFn: () => bookingsApi.getAll({
|
||||
...filters,
|
||||
...(extraFilters.bookingType && { bookingType: extraFilters.bookingType }),
|
||||
...(extraFilters.paymentStatus && { paymentStatus: extraFilters.paymentStatus }),
|
||||
...(extraFilters.dateFrom && { dateFrom: extraFilters.dateFrom }),
|
||||
...(extraFilters.dateTo && { dateTo: extraFilters.dateTo }),
|
||||
}),
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
@@ -257,8 +263,8 @@ export default function BookingsPage() {
|
||||
<select className="input" value={extraFilters.paymentStatus}
|
||||
onChange={(e) => setExtraFilters({ ...extraFilters, paymentStatus: e.target.value })}>
|
||||
<option value="">All Payments</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="PAID">Paid</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="FAILED">Failed</option>
|
||||
<option value="REFUNDED">Refunded</option>
|
||||
</select>
|
||||
|
||||
@@ -20,14 +20,21 @@ const STATUS_VARIANT: Record<string, any> = {
|
||||
|
||||
export default function ExcessBaggagePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [filters, setFilters] = useState({ status: '', bookingRef: '', page: '1' });
|
||||
const [filters, setFilters] = useState({ status: '', bookingRef: '', dateFrom: '', dateTo: '', page: '1' });
|
||||
const [showExtraFilters, setShowExtraFilters] = useState(false);
|
||||
const [waiveModal, setWaiveModal] = useState<any>(null);
|
||||
const [waiveReason, setWaiveReason] = useState('');
|
||||
const [waiveError, setWaiveError] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['excess-baggage', filters],
|
||||
queryFn: () => excessBaggageApi.getAll({ status: filters.status || undefined, bookingRef: filters.bookingRef || undefined, page: filters.page }),
|
||||
queryFn: () => excessBaggageApi.getAll({
|
||||
status: filters.status || undefined,
|
||||
bookingRef: filters.bookingRef || undefined,
|
||||
dateFrom: filters.dateFrom || undefined,
|
||||
dateTo: filters.dateTo || undefined,
|
||||
page: filters.page,
|
||||
}),
|
||||
});
|
||||
|
||||
const waiveMutation = useMutation({
|
||||
@@ -104,14 +111,14 @@ export default function ExcessBaggagePage() {
|
||||
icon: Send,
|
||||
variant: 'secondary' as const,
|
||||
onClick: (c: any) => resendMutation.mutate(c.id),
|
||||
hidden: (c: any) => c.status !== 'PENDING',
|
||||
show: (c: any) => c.status === 'PENDING',
|
||||
},
|
||||
{
|
||||
label: 'Waive',
|
||||
icon: RefreshCw,
|
||||
variant: 'secondary' as const,
|
||||
onClick: (c: any) => { setWaiveModal(c); setWaiveReason(''); setWaiveError(null); },
|
||||
hidden: (c: any) => ['PAID', 'CASH_COLLECTED', 'WAIVED'].includes(c.status),
|
||||
show: (c: any) => !['PAID', 'CASH_COLLECTED', 'WAIVED'].includes(c.status),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -125,31 +132,41 @@ export default function ExcessBaggagePage() {
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="label">Booking Ref</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Search by booking ref…"
|
||||
value={filters.bookingRef}
|
||||
onChange={(e) => setFilters({ ...filters, bookingRef: e.target.value, page: '1' })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
<select
|
||||
className="input"
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value, page: '1' })}
|
||||
>
|
||||
<option value="">All</option>
|
||||
<div className="mb-4 space-y-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="flex-1 min-w-48">
|
||||
<input className="input" placeholder="Search by booking ref…"
|
||||
value={filters.bookingRef}
|
||||
onChange={(e) => setFilters({ ...filters, bookingRef: e.target.value, page: '1' })} />
|
||||
</div>
|
||||
<select className="input w-44" value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value, page: '1' })}>
|
||||
<option value="">All Status</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="PAID">Paid</option>
|
||||
<option value="CASH_COLLECTED">Cash Collected</option>
|
||||
<option value="EXPIRED">Expired</option>
|
||||
<option value="WAIVED">Waived</option>
|
||||
</select>
|
||||
<button type="button" className="input w-auto px-4 text-sm font-medium text-primary border-primary/40"
|
||||
onClick={() => setShowExtraFilters(v => !v)}>
|
||||
{showExtraFilters ? 'Hide Filters ▲' : 'More Filters ▼'}
|
||||
</button>
|
||||
</div>
|
||||
{showExtraFilters && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
|
||||
<div>
|
||||
<label className="label">Date From</label>
|
||||
<input type="date" className="input" value={filters.dateFrom}
|
||||
onChange={(e) => setFilters({ ...filters, dateFrom: e.target.value, page: '1' })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Date To</label>
|
||||
<input type="date" className="input" value={filters.dateTo}
|
||||
onChange={(e) => setFilters({ ...filters, dateTo: e.target.value, page: '1' })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function PackageInquiriesLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { packageInquiriesApi, packagesApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
|
||||
const STATUSES = ['NEW', 'CONTACTED', 'CONVERTED', 'CLOSED'];
|
||||
|
||||
const statusVariant: Record<string, string> = {
|
||||
NEW: 'info',
|
||||
CONTACTED: 'warning',
|
||||
CONVERTED: 'success',
|
||||
CLOSED: 'default',
|
||||
};
|
||||
|
||||
export default function PackageInquiriesPage() {
|
||||
const [filters, setFilters] = useState({ packageId: '', status: '' });
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<any>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['package-inquiries', filters],
|
||||
queryFn: () => packageInquiriesApi.getAll({ ...filters, pageSize: 50 }),
|
||||
});
|
||||
|
||||
const { data: packagesData } = useQuery({
|
||||
queryKey: ['packages-all-simple'],
|
||||
queryFn: () => packagesApi.getAll({ pageSize: 100 }),
|
||||
});
|
||||
|
||||
const packages: any[] = packagesData?.items || [];
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||||
packageInquiriesApi.updateStatus(id, status),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['package-inquiries'] }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => packageInquiriesApi.remove(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['package-inquiries'] });
|
||||
setDeleteConfirm(null);
|
||||
setDeleteError(null);
|
||||
},
|
||||
onError: (e: any) => setDeleteError(e?.response?.data?.message || e?.message || 'Failed to delete'),
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'contact',
|
||||
label: 'Contact',
|
||||
render: (row: any) => (
|
||||
<div>
|
||||
<div className="font-semibold">{row.contactName}</div>
|
||||
<div className="text-xs text-muted-foreground">{row.contactEmail || row.contactPhone || '—'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'package',
|
||||
label: 'Package',
|
||||
render: (row: any) => (
|
||||
<div>
|
||||
<div className="font-medium">{row.package?.name || '—'}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">{row.package?.code}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'priceTier',
|
||||
label: 'Price Tier',
|
||||
render: (row: any) => row.priceTier ? (
|
||||
<div>
|
||||
<div className="text-sm font-medium">{row.priceTier.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatCurrency(row.priceTier.priceMinor, 'ETB')} / person</div>
|
||||
</div>
|
||||
) : <span className="text-muted-foreground text-sm">—</span>,
|
||||
},
|
||||
{
|
||||
key: 'travelerCount',
|
||||
label: 'Travelers',
|
||||
render: (row: any) => (
|
||||
<span className="font-semibold">{row.travelerCount}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'enquiredAt',
|
||||
label: 'Enquired At',
|
||||
render: (row: any) => (
|
||||
<span className="text-sm">{formatDateTime(row.enquiredAt)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'notes',
|
||||
label: 'Notes',
|
||||
render: (row: any) => (
|
||||
<span className="text-sm text-muted-foreground line-clamp-2 max-w-xs">{row.notes || '—'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (row: any) => (
|
||||
<select
|
||||
className="input py-1 text-xs"
|
||||
value={row.status}
|
||||
onChange={(e) => statusMutation.mutate({ id: row.id, status: e.target.value })}
|
||||
>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: 'Delete',
|
||||
onClick: (row: any) => { setDeleteConfirm(row); setDeleteError(null); },
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Package Inquiries</h1>
|
||||
<p className="text-muted-foreground">Manage incoming package inquiries</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Package</label>
|
||||
<select
|
||||
className="input"
|
||||
value={filters.packageId}
|
||||
onChange={(e) => setFilters({ ...filters, packageId: e.target.value })}
|
||||
>
|
||||
<option value="">All Packages</option>
|
||||
{packages.map((p: any) => (
|
||||
<option key={p.id} value={p.id}>{p.name} ({p.code})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
<select
|
||||
className="input"
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={data?.items || []}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
emptyMessage="No inquiries found"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!deleteConfirm}
|
||||
onClose={() => { setDeleteConfirm(null); setDeleteError(null); }}
|
||||
onConfirm={() => deleteMutation.mutate(deleteConfirm.id)}
|
||||
title="Delete Inquiry"
|
||||
message={`Delete inquiry from ${deleteConfirm?.contactName}? This cannot be undone.`}
|
||||
confirmText="Delete"
|
||||
isDanger
|
||||
isLoading={deleteMutation.isPending}
|
||||
error={deleteError ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,11 @@ const emptyForm = {
|
||||
|
||||
export default function PackagesPage() {
|
||||
const [page] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [showExtraFilters, setShowExtraFilters] = useState(false);
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [modalMode, setModalMode] = useState<'create' | 'edit' | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
@@ -282,6 +287,15 @@ export default function PackagesPage() {
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const allItems: any[] = data?.items || [];
|
||||
const filteredItems = allItems.filter((p) => {
|
||||
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) && !p.code.toLowerCase().includes(search.toLowerCase())) return false;
|
||||
if (statusFilter && p.status !== statusFilter) return false;
|
||||
if (dateFrom && new Date(p.validFrom).toISOString().split('T')[0] < dateFrom) return false;
|
||||
if (dateTo && new Date(p.validUntil).toISOString().split('T')[0] > dateTo) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -292,13 +306,47 @@ export default function PackagesPage() {
|
||||
<ActionButton icon={Plus} onClick={openCreate}>New Package</ActionButton>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={data?.items || []}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
emptyMessage="No packages found"
|
||||
/>
|
||||
<div className="card">
|
||||
<div className="mb-4 space-y-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="flex-1 min-w-48">
|
||||
<input type="text" placeholder="Search by name or code..." className="input"
|
||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<select className="input w-44" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||
<option value="">All Status</option>
|
||||
<option value="DRAFT">Draft</option>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="SOLD_OUT">Sold Out</option>
|
||||
<option value="EXPIRED">Expired</option>
|
||||
<option value="CANCELLED">Cancelled</option>
|
||||
</select>
|
||||
<button type="button" className="input w-auto px-4 text-sm font-medium text-primary border-primary/40"
|
||||
onClick={() => setShowExtraFilters(v => !v)}>
|
||||
{showExtraFilters ? 'Hide Filters ▲' : 'More Filters ▼'}
|
||||
</button>
|
||||
</div>
|
||||
{showExtraFilters && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
|
||||
<div>
|
||||
<label className="label">Valid From</label>
|
||||
<input type="date" className="input" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Valid Until</label>
|
||||
<input type="date" className="input" value={dateTo} onChange={(e) => setDateTo(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
data={filteredItems}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
emptyMessage="No packages found"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* View Modal */}
|
||||
<Modal isOpen={!!viewPackage} onClose={() => setViewPackage(null)} title="Package Details" size="lg">
|
||||
|
||||
@@ -63,8 +63,14 @@ export default function PassengersPage() {
|
||||
});
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['passengers', filters],
|
||||
queryFn: () => passengersApi.getAll(filters),
|
||||
queryKey: ['passengers', filters, extraFilters],
|
||||
queryFn: () => passengersApi.getAll({
|
||||
...filters,
|
||||
...(extraFilters.gender && { gender: extraFilters.gender }),
|
||||
...(extraFilters.nationality && { nationality: extraFilters.nationality }),
|
||||
...(extraFilters.dateFrom && { dateFrom: extraFilters.dateFrom }),
|
||||
...(extraFilters.dateTo && { dateTo: extraFilters.dateTo }),
|
||||
}),
|
||||
});
|
||||
|
||||
const PASSENGER_COLS = [
|
||||
|
||||
@@ -78,6 +78,8 @@ export default function TicketsPage() {
|
||||
originStationId: filters.originStationId || undefined,
|
||||
destinationStationId: filters.destinationStationId || undefined,
|
||||
arrivalDate: filters.arrivalDate || undefined,
|
||||
dateFrom: filters.dateFrom || undefined,
|
||||
dateTo: filters.dateTo || undefined,
|
||||
skip: 0,
|
||||
take: 50,
|
||||
}),
|
||||
@@ -483,7 +485,7 @@ export default function TicketsPage() {
|
||||
Error loading tickets: {error instanceof Error ? error.message : 'Unknown error'}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
<div>
|
||||
<label className="label">Search</label>
|
||||
<input
|
||||
@@ -529,20 +531,40 @@ export default function TicketsPage() {
|
||||
onChange={(e) => setFilters({ ...filters, arrivalDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
<select
|
||||
className="input"
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="USED">Used</option>
|
||||
<option value="CANCELLED">Cancelled</option>
|
||||
</select>
|
||||
<div className="flex items-end">
|
||||
<button type="button" className="input w-full px-4 text-sm font-medium text-primary border-primary/40"
|
||||
onClick={() => setShowExtraFilters(v => !v)}>
|
||||
{showExtraFilters ? 'Hide Filters ▲' : 'More Filters ▼'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showExtraFilters && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-3">
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
<select
|
||||
className="input"
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="USED">Used</option>
|
||||
<option value="CANCELLED">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Issued From</label>
|
||||
<input type="date" className="input" value={filters.dateFrom}
|
||||
onChange={(e) => setFilters({ ...filters, dateFrom: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Issued To</label>
|
||||
<input type="date" className="input" value={filters.dateTo}
|
||||
onChange={(e) => setFilters({ ...filters, dateTo: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tickets Table */}
|
||||
|
||||
@@ -60,6 +60,7 @@ const navigationSections = [
|
||||
title: 'Tourism',
|
||||
items: [
|
||||
{ name: 'Packages', href: '/packages', icon: Package },
|
||||
{ name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -397,6 +397,22 @@ export const packagesApi = {
|
||||
deleteTier: (tierId: string) => apiClient.delete(`/packages/tiers/${tierId}`),
|
||||
};
|
||||
|
||||
// Package Inquiries API
|
||||
export const packageInquiriesApi = {
|
||||
getAll: async (params?: any) => {
|
||||
const cleanParams = Object.fromEntries(
|
||||
Object.entries(params || {}).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
|
||||
) as Record<string, string>;
|
||||
const query = new URLSearchParams(cleanParams).toString();
|
||||
const response = await apiClient.get<any>(`/packages/inquiries${query ? `?${query}` : ''}`);
|
||||
if (response?.data) return Array.isArray(response.data) ? { items: response.data } : response;
|
||||
return Array.isArray(response) ? { items: response } : response;
|
||||
},
|
||||
create: (data: any) => apiClient.post<any>('/packages/inquiries', data),
|
||||
updateStatus: (id: string, status: string) => apiClient.patch<any>(`/packages/inquiries/${id}/status`, { status }),
|
||||
remove: (id: string) => apiClient.delete(`/packages/inquiries/${id}`),
|
||||
};
|
||||
|
||||
// Excess Baggage API
|
||||
export const excessBaggageApi = {
|
||||
logCharge: (data: any) => apiClient.post<any>('/agents/excess-baggage', data),
|
||||
|
||||
28
pnpm-lock.yaml
generated
28
pnpm-lock.yaml
generated
@@ -479,10 +479,10 @@ importers:
|
||||
version: 8.1.6
|
||||
'@tria-plc/api-common':
|
||||
specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz
|
||||
version: file:local-packages/tria-plc-api-common-1.4.3.tgz(e6b80acddd4bb7fc40e438635b24d1bc)
|
||||
version: file:local-packages/tria-plc-api-common-1.4.3.tgz(c061d697b8a1e15b1d1aba893da7b0e6)
|
||||
'@tria-plc/iamapi-common':
|
||||
specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.4.tgz
|
||||
version: file:local-packages/tria-plc-iamapi-common-0.7.4.tgz(c97ba831ddde82920910406ab5262991)
|
||||
specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.6.tgz
|
||||
version: file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(c97ba831ddde82920910406ab5262991)
|
||||
'@types/bcrypt':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
@@ -907,7 +907,7 @@ importers:
|
||||
version: 9.1.2(eslint@8.57.1)
|
||||
eslint-plugin-import:
|
||||
specifier: ^2.31.0
|
||||
version: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
|
||||
version: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
|
||||
eslint-plugin-react:
|
||||
specifier: ^7.37.1
|
||||
version: 7.37.5(eslint@8.57.1)
|
||||
@@ -15213,7 +15213,7 @@ snapshots:
|
||||
|
||||
'@tootallnate/quickjs-emscripten@0.23.0': {}
|
||||
|
||||
'@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(e6b80acddd4bb7fc40e438635b24d1bc)':
|
||||
'@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(c061d697b8a1e15b1d1aba893da7b0e6)':
|
||||
dependencies:
|
||||
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -15224,7 +15224,7 @@ snapshots:
|
||||
'@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
|
||||
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
||||
'@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.4.tgz(c97ba831ddde82920910406ab5262991)
|
||||
'@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(c97ba831ddde82920910406ab5262991)
|
||||
argon2: 0.43.1
|
||||
axios: 1.17.0
|
||||
change-case: 5.4.4
|
||||
@@ -15301,7 +15301,7 @@ snapshots:
|
||||
- debug
|
||||
- supports-color
|
||||
|
||||
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.4.tgz(c97ba831ddde82920910406ab5262991)':
|
||||
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(578386f46cf99fd4720e3e99f196f69e)':
|
||||
dependencies:
|
||||
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -15309,10 +15309,10 @@ snapshots:
|
||||
'@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
||||
'@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||
'@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
|
||||
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
||||
'@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(e6b80acddd4bb7fc40e438635b24d1bc)
|
||||
'@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(f4d5d43aaace93ac25ed0343d18ebc9e)
|
||||
api-common: 1.2.2
|
||||
argon2: 0.43.1
|
||||
axios: 1.17.0
|
||||
@@ -15344,10 +15344,10 @@ snapshots:
|
||||
'@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
||||
'@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||
'@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
|
||||
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
||||
'@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(f4d5d43aaace93ac25ed0343d18ebc9e)
|
||||
'@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(c061d697b8a1e15b1d1aba893da7b0e6)
|
||||
api-common: 1.2.2
|
||||
argon2: 0.43.1
|
||||
axios: 1.17.0
|
||||
@@ -17874,7 +17874,7 @@ snapshots:
|
||||
eslint: 8.57.1
|
||||
eslint-import-resolver-node: 0.3.10
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
|
||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
|
||||
eslint-plugin-react: 7.37.5(eslint@8.57.1)
|
||||
eslint-plugin-react-hooks: 4.6.2(eslint@8.57.1)
|
||||
@@ -17908,7 +17908,7 @@ snapshots:
|
||||
tinyglobby: 0.2.17
|
||||
unrs-resolver: 1.12.2
|
||||
optionalDependencies:
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -17923,7 +17923,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
|
||||
Reference in New Issue
Block a user