resolve conflict

This commit is contained in:
yaschalew
2026-06-27 08:14:05 +03:00
69 changed files with 3289 additions and 299 deletions

View File

@@ -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"

View File

@@ -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(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/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, '\\)');
}
}

View File

@@ -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,

View File

@@ -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');
}
}
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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()}`,

View File

@@ -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() {

View File

@@ -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()

View File

@@ -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()

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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, '-');
}
}

View File

@@ -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(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/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');
}
}

View File

@@ -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,

View 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);
});

View File

@@ -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",

View File

@@ -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 />}>

View File

@@ -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,

View File

@@ -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;

View File

@@ -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();

View File

@@ -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>

View File

@@ -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)} />

View File

@@ -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>

View File

@@ -181,7 +181,7 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
</ActionIcon>
</Group>
),
},
},
];
return (

View File

@@ -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(),
]);
}

View File

@@ -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`,

View File

@@ -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';

View File

@@ -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 };

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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

View File

@@ -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) =>

View File

@@ -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 {

View File

@@ -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")
}

View File

@@ -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);

View File

@@ -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")

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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 }[]>(

View File

@@ -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
});

View File

@@ -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({

View File

@@ -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,
});

View File

@@ -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({

View File

@@ -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 };
}
}

View File

@@ -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 }));

View File

@@ -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' })

View File

@@ -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;

View File

@@ -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({

View File

@@ -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
});

View File

@@ -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,

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -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 };
}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,7 @@
'use client';
import DashboardLayout from '../dashboard/layout';
export default function PackageInquiriesLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -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>
);
}

View File

@@ -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">

View File

@@ -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 = [

View File

@@ -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 */}

View File

@@ -60,6 +60,7 @@ const navigationSections = [
title: 'Tourism',
items: [
{ name: 'Packages', href: '/packages', icon: Package },
{ name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare },
]
},
{

View File

@@ -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
View File

@@ -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