mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 09:35:44 +00:00
Merge branch 'freight/develop' into freight/feature/bootstrap_backoffice
This commit is contained in:
@@ -8,6 +8,7 @@ import appConfig from "./config/app.config";
|
|||||||
import databaseConfig from "./config/database.config";
|
import databaseConfig from "./config/database.config";
|
||||||
|
|
||||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||||
|
import { FilesModule } from "./modules/files/files.module";
|
||||||
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||||
import { TrainsModule } from "./modules/trains/trains.module";
|
import { TrainsModule } from "./modules/trains/trains.module";
|
||||||
import { CustomersModule } from "./modules/customers/customers.module";
|
import { CustomersModule } from "./modules/customers/customers.module";
|
||||||
@@ -16,8 +17,7 @@ import { BillingModule } from "./modules/billing/billing.module";
|
|||||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
||||||
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
||||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||||
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
|
|
||||||
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -33,6 +33,7 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
|||||||
SharedAuthModule,
|
SharedAuthModule,
|
||||||
IamModule.forRoot(),
|
IamModule.forRoot(),
|
||||||
BookingsModule,
|
BookingsModule,
|
||||||
|
FilesModule,
|
||||||
ConsignmentsModule,
|
ConsignmentsModule,
|
||||||
TrainsModule,
|
TrainsModule,
|
||||||
CustomersModule,
|
CustomersModule,
|
||||||
@@ -48,11 +49,11 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
|||||||
export class AppModule implements OnApplicationBootstrap {
|
export class AppModule implements OnApplicationBootstrap {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly seeder: DataSeeder,
|
private readonly seeder: DataSeeder,
|
||||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onApplicationBootstrap() {
|
async onApplicationBootstrap() {
|
||||||
await this.seeder.run();
|
await this.seeder.run();
|
||||||
await this.edrOrgSeeder.run();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ import {
|
|||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
||||||
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger";
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiConsumes,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
|
|
||||||
import { BookingsService } from "./bookings.service";
|
import { BookingsService } from "./bookings.service";
|
||||||
import { CreateBookingDto } from "./dto/create-booking.dto";
|
import { CreateBookingDto } from "./dto/create-booking.dto";
|
||||||
@@ -23,8 +29,9 @@ import { UpdateStatusDto } from "./dto/update-status.dto";
|
|||||||
|
|
||||||
@ApiTags("bookings")
|
@ApiTags("bookings")
|
||||||
@Controller("bookings")
|
@Controller("bookings")
|
||||||
|
@ApiBearerAuth()
|
||||||
export class BookingsController {
|
export class BookingsController {
|
||||||
constructor(private readonly bookingsService: BookingsService) {}
|
constructor(private readonly bookingsService: BookingsService) { }
|
||||||
|
|
||||||
// ── 1. Create booking (multipart/form-data) ──────────────────────────
|
// ── 1. Create booking (multipart/form-data) ──────────────────────────
|
||||||
@Post()
|
@Post()
|
||||||
@@ -39,14 +46,23 @@ export class BookingsController {
|
|||||||
@ApiBody({
|
@ApiBody({
|
||||||
description:
|
description:
|
||||||
"Booking form data. Attach files with any field name (e.g. passport, tin_certificate). " +
|
"Booking form data. Attach files with any field name (e.g. passport, tin_certificate). " +
|
||||||
"File metadata is stored in the documents JSONB column.",
|
"Each uploaded file is saved as a row in the files table (resource=bookings).",
|
||||||
type: CreateBookingDto,
|
type: CreateBookingDto,
|
||||||
})
|
})
|
||||||
create(
|
create(
|
||||||
@Body() dto: CreateBookingDto,
|
@Body() dto: CreateBookingDto,
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
) {
|
) {
|
||||||
console.log('[BookingsController] Files received:', files?.length, files?.map(f => ({ fieldname: f.fieldname, originalname: f.originalname, size: f.size, mimetype: f.mimetype })));
|
console.log(
|
||||||
|
"[BookingsController] Files received:",
|
||||||
|
files?.length,
|
||||||
|
files?.map((f) => ({
|
||||||
|
fieldname: f.fieldname,
|
||||||
|
originalname: f.originalname,
|
||||||
|
size: f.size,
|
||||||
|
mimetype: f.mimetype,
|
||||||
|
})),
|
||||||
|
);
|
||||||
return this.bookingsService.create(dto, files ?? []);
|
return this.bookingsService.create(dto, files ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +72,8 @@ export class BookingsController {
|
|||||||
@ApiConsumes("multipart/form-data")
|
@ApiConsumes("multipart/form-data")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Update a draft booking",
|
summary: "Update a draft booking",
|
||||||
description: "Only DRAFT bookings can be updated. New files are merged into existing documents.",
|
description:
|
||||||
|
"Only DRAFT bookings can be updated. New files are merged into existing documents.",
|
||||||
})
|
})
|
||||||
@ApiBody({ type: UpdateBookingDto })
|
@ApiBody({ type: UpdateBookingDto })
|
||||||
update(
|
update(
|
||||||
@@ -141,7 +158,8 @@ export class BookingsController {
|
|||||||
@Delete(":id/consolidation")
|
@Delete(":id/consolidation")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Remove consolidation pairing",
|
summary: "Remove consolidation pairing",
|
||||||
description: "Unpairs both bookings and returns them to PENDING_CONSOLIDATION status.",
|
description:
|
||||||
|
"Unpairs both bookings and returns them to PENDING_CONSOLIDATION status.",
|
||||||
})
|
})
|
||||||
removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.bookingsService.removeConsolidation(id);
|
return this.bookingsService.removeConsolidation(id);
|
||||||
@@ -151,9 +169,11 @@ export class BookingsController {
|
|||||||
@Get(":id/consolidation")
|
@Get(":id/consolidation")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get consolidation details",
|
summary: "Get consolidation details",
|
||||||
description: "Returns partner booking details and split billing information.",
|
description:
|
||||||
|
"Returns partner booking details and split billing information.",
|
||||||
})
|
})
|
||||||
getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
|
getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.bookingsService.getConsolidationDetails(id);
|
return this.bookingsService.getConsolidationDetails(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
|
||||||
|
import { FilesModule } from "../files/files.module";
|
||||||
import { MinioModule } from "../minio/minio.module";
|
import { MinioModule } from "../minio/minio.module";
|
||||||
import { BookingsController } from "./bookings.controller";
|
import { BookingsController } from "./bookings.controller";
|
||||||
import { BookingsRepository } from "./bookings.repository";
|
import { BookingsRepository } from "./bookings.repository";
|
||||||
@@ -8,7 +9,7 @@ import { BookingsService } from "./bookings.service";
|
|||||||
import { Booking } from "./entities/booking.entity";
|
import { Booking } from "./entities/booking.entity";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Booking]), MinioModule],
|
imports: [TypeOrmModule.forFeature([Booking]), FilesModule, MinioModule],
|
||||||
controllers: [BookingsController],
|
controllers: [BookingsController],
|
||||||
providers: [BookingsService, BookingsRepository],
|
providers: [BookingsService, BookingsRepository],
|
||||||
exports: [BookingsService],
|
exports: [BookingsService],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { InjectRepository } from "@nestjs/typeorm";
|
|||||||
import { In, IsNull, Not, Repository } from "typeorm";
|
import { In, IsNull, Not, Repository } from "typeorm";
|
||||||
|
|
||||||
import { Booking } from "./entities/booking.entity";
|
import { Booking } from "./entities/booking.entity";
|
||||||
|
import { FileRecord } from "../files/entities/file.entity";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingsRepository extends BaseRepository<Booking> {
|
export class BookingsRepository extends BaseRepository<Booking> {
|
||||||
@@ -19,12 +20,43 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
return this.repository.findOne({ where: { reference } });
|
return this.repository.findOne({ where: { reference } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Find a booking by reference with associated files (polymorphic join). */
|
||||||
|
async findByReferenceWithFiles(reference: string): Promise<Booking | null> {
|
||||||
|
const booking = await this.repository
|
||||||
|
.createQueryBuilder("booking")
|
||||||
|
.where("booking.reference = :reference", { reference })
|
||||||
|
.leftJoinAndMapMany(
|
||||||
|
"booking.files",
|
||||||
|
FileRecord,
|
||||||
|
"file",
|
||||||
|
"file.resource_id = booking.id AND file.resource = 'bookings'"
|
||||||
|
)
|
||||||
|
.getOne();
|
||||||
|
return booking ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find a booking by ID with associated files (polymorphic join). */
|
||||||
|
async findByIdWithFiles(id: string): Promise<Booking | null> {
|
||||||
|
const booking = await this.repository
|
||||||
|
.createQueryBuilder("booking")
|
||||||
|
.where("booking.id = :id", { id })
|
||||||
|
.leftJoinAndMapMany(
|
||||||
|
"booking.files",
|
||||||
|
FileRecord,
|
||||||
|
"file",
|
||||||
|
"file.resource_id = booking.id AND file.resource = 'bookings'"
|
||||||
|
)
|
||||||
|
.getOne();
|
||||||
|
return booking ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Find a compatible consolidation partner for the given booking. */
|
/** Find a compatible consolidation partner for the given booking. */
|
||||||
async findConsolidationPartner(booking: Booking): Promise<Booking | null> {
|
async findConsolidationPartner(booking: Booking): Promise<Booking | null> {
|
||||||
return this.repository.findOne({
|
return this.repository.findOne({
|
||||||
where: {
|
where: {
|
||||||
allowConsolidation: true,
|
allowConsolidation: true,
|
||||||
containerType: "20FT",
|
// Check if containers JSONB contains at least one 20FT entry with odd qty
|
||||||
|
containers: Not(IsNull()),
|
||||||
originStation: booking.originStation,
|
originStation: booking.originStation,
|
||||||
destinationStation: booking.destinationStation,
|
destinationStation: booking.destinationStation,
|
||||||
tradeDirection: booking.tradeDirection,
|
tradeDirection: booking.tradeDirection,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { IsNull, Not } from "typeorm";
|
import { IsNull, Not } from "typeorm";
|
||||||
|
|
||||||
|
import { FilesService } from "../files/files.service";
|
||||||
import { MinioService } from "../minio/minio.service";
|
import { MinioService } from "../minio/minio.service";
|
||||||
import { BookingsRepository } from "./bookings.repository";
|
import { BookingsRepository } from "./bookings.repository";
|
||||||
import { CreateBookingDto } from "./dto/create-booking.dto";
|
import { CreateBookingDto } from "./dto/create-booking.dto";
|
||||||
@@ -13,6 +14,7 @@ import { FilterBookingDto } from "./dto/filter-booking.dto";
|
|||||||
import { UpdateBookingDto } from "./dto/update-booking.dto";
|
import { UpdateBookingDto } from "./dto/update-booking.dto";
|
||||||
import { UpdateStatusDto } from "./dto/update-status.dto";
|
import { UpdateStatusDto } from "./dto/update-status.dto";
|
||||||
import { Booking } from "./entities/booking.entity";
|
import { Booking } from "./entities/booking.entity";
|
||||||
|
import { FileRecord } from "../files/entities/file.entity";
|
||||||
|
|
||||||
/** Weight thresholds (tons) that trigger overweight surcharge alerts. */
|
/** Weight thresholds (tons) that trigger overweight surcharge alerts. */
|
||||||
const WEIGHT_LIMITS = {
|
const WEIGHT_LIMITS = {
|
||||||
@@ -28,6 +30,7 @@ const HIGH_VOLUME_THRESHOLD_TONS = 500;
|
|||||||
export class BookingsService {
|
export class BookingsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly bookingsRepository: BookingsRepository,
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly filesService: FilesService,
|
||||||
private readonly minioService: MinioService,
|
private readonly minioService: MinioService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -35,12 +38,16 @@ export class BookingsService {
|
|||||||
|
|
||||||
/** Resolve auto-consolidation flag. */
|
/** Resolve auto-consolidation flag. */
|
||||||
private resolveConsolidation(
|
private resolveConsolidation(
|
||||||
containerType: string,
|
containers: Array<{ type: string; qty: number }> | undefined | null,
|
||||||
containerQuantity: number,
|
|
||||||
explicit?: boolean,
|
explicit?: boolean,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (explicit === false) return false;
|
if (explicit === false) return false;
|
||||||
if (containerType === "20FT" && containerQuantity % 2 !== 0) return true;
|
if (!containers || containers.length === 0) return explicit ?? false;
|
||||||
|
// Auto-enable if any 20FT container has odd quantity
|
||||||
|
const needsConsolidation = containers.some(
|
||||||
|
(c) => c.type === "20FT" && c.qty % 2 !== 0
|
||||||
|
);
|
||||||
|
if (needsConsolidation) return true;
|
||||||
return explicit ?? false;
|
return explicit ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,57 +62,44 @@ export class BookingsService {
|
|||||||
|
|
||||||
/** Calculate required wagons: each 40ft = 1 wagon, each pair of 20ft = 1 wagon. */
|
/** Calculate required wagons: each 40ft = 1 wagon, each pair of 20ft = 1 wagon. */
|
||||||
private calculateWagonCount(
|
private calculateWagonCount(
|
||||||
containerType: string,
|
containers: Array<{ type: string; qty: number }>,
|
||||||
containerQuantity: number,
|
|
||||||
): number {
|
): number {
|
||||||
if (containerType === "40FT") return containerQuantity;
|
return containers.reduce((total, container) => {
|
||||||
return Math.ceil(containerQuantity / 2);
|
if (container.type === "40FT") {
|
||||||
|
return total + container.qty;
|
||||||
|
}
|
||||||
|
// 20FT: 1 wagon per 2 containers (rounded up)
|
||||||
|
return total + Math.ceil(container.qty / 2);
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check per-container weight limit and return a warning if exceeded. */
|
/** Check per-container weight limits and return warnings if exceeded. */
|
||||||
private checkOverweight(
|
private checkOverweight(
|
||||||
containerType: string,
|
containers: Array<{ type: string; vgm: number }>,
|
||||||
vgmPerUnit: number,
|
|
||||||
tradeDirection: string,
|
tradeDirection: string,
|
||||||
): string | null {
|
): string[] {
|
||||||
if (containerType === "40FT" && vgmPerUnit > WEIGHT_LIMITS.ANY_40FT) {
|
const warnings: string[] = [];
|
||||||
return `40FT container VGM ${vgmPerUnit}t exceeds limit of ${WEIGHT_LIMITS.ANY_40FT}t`;
|
for (const container of containers) {
|
||||||
}
|
if (container.type === "40FT" && container.vgm > WEIGHT_LIMITS.ANY_40FT) {
|
||||||
if (containerType === "20FT") {
|
warnings.push(
|
||||||
const limit =
|
`40FT container VGM ${container.vgm}t exceeds limit of ${WEIGHT_LIMITS.ANY_40FT}t`
|
||||||
tradeDirection === "IMPORT"
|
);
|
||||||
? WEIGHT_LIMITS.IMPORT_20FT
|
}
|
||||||
: WEIGHT_LIMITS.EXPORT_20FT;
|
if (container.type === "20FT") {
|
||||||
if (vgmPerUnit > limit) {
|
const limit =
|
||||||
return `20FT ${tradeDirection} container VGM ${vgmPerUnit}t exceeds limit of ${limit}t`;
|
tradeDirection === "IMPORT"
|
||||||
|
? WEIGHT_LIMITS.IMPORT_20FT
|
||||||
|
: WEIGHT_LIMITS.EXPORT_20FT;
|
||||||
|
if (container.vgm > limit) {
|
||||||
|
warnings.push(
|
||||||
|
`20FT ${tradeDirection} container VGM ${container.vgm}t exceeds limit of ${limit}t`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return warnings;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build file metadata from uploaded files and upload to MinIO. */
|
|
||||||
private async buildDocuments(
|
|
||||||
bookingId: string,
|
|
||||||
files: Express.Multer.File[],
|
|
||||||
): Promise<Record<string, { originalName: string; size: number; mimeType: string; url: string }>> {
|
|
||||||
console.log('[BookingsService] buildDocuments called with bookingId:', bookingId, 'files count:', files.length);
|
|
||||||
const docs: Record<string, { originalName: string; size: number; mimeType: string; url: string }> = {};
|
|
||||||
for (const file of files) {
|
|
||||||
console.log('[BookingsService] Processing file:', file.fieldname, file.originalname, 'size:', file.size);
|
|
||||||
const objectName = `bookings/${bookingId}/${Date.now()}_${file.originalname}`;
|
|
||||||
console.log('[BookingsService] Uploading to MinIO:', objectName);
|
|
||||||
const url = await this.minioService.uploadFile(objectName, file.buffer, file.mimetype);
|
|
||||||
console.log('[BookingsService] Upload successful, URL:', url);
|
|
||||||
docs[file.fieldname] = {
|
|
||||||
originalName: file.originalname,
|
|
||||||
size: file.size,
|
|
||||||
mimeType: file.mimetype,
|
|
||||||
url,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
console.log('[BookingsService] All files processed, docs:', docs);
|
|
||||||
return docs;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CRUD ─────────────────────────────────────────────────────────────
|
// ── CRUD ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -117,8 +111,7 @@ export class BookingsService {
|
|||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
const allowConsolidation = this.resolveConsolidation(
|
const allowConsolidation = this.resolveConsolidation(
|
||||||
dto.containerType,
|
dto.containers,
|
||||||
dto.containerQuantity,
|
|
||||||
dto.allowConsolidation,
|
dto.allowConsolidation,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -127,17 +120,13 @@ export class BookingsService {
|
|||||||
dto.serviceType,
|
dto.serviceType,
|
||||||
);
|
);
|
||||||
|
|
||||||
const overweightWarning = this.checkOverweight(
|
const overweightWarnings = this.checkOverweight(
|
||||||
dto.containerType,
|
dto.containers,
|
||||||
dto.containerVgmPerUnit,
|
|
||||||
dto.tradeDirection,
|
dto.tradeDirection,
|
||||||
);
|
);
|
||||||
if (overweightWarning) warnings.push(overweightWarning);
|
warnings.push(...overweightWarnings);
|
||||||
|
|
||||||
const wagonCount = this.calculateWagonCount(
|
const wagonCount = this.calculateWagonCount(dto.containers);
|
||||||
dto.containerType,
|
|
||||||
dto.containerQuantity,
|
|
||||||
);
|
|
||||||
warnings.push(`Estimated wagons required: ${wagonCount}`);
|
warnings.push(`Estimated wagons required: ${wagonCount}`);
|
||||||
|
|
||||||
const booking = await this.bookingsRepository.create({
|
const booking = await this.bookingsRepository.create({
|
||||||
@@ -148,15 +137,11 @@ export class BookingsService {
|
|||||||
status: "DRAFT",
|
status: "DRAFT",
|
||||||
allowConsolidation,
|
allowConsolidation,
|
||||||
priorityScore,
|
priorityScore,
|
||||||
documents: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upload files to MinIO and update booking with documents
|
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
try {
|
try {
|
||||||
const documents = await this.buildDocuments(booking.id, files);
|
await this.filesService.uploadMany(booking.id, "bookings", files);
|
||||||
await this.bookingsRepository.update(booking.id, { documents });
|
|
||||||
booking.documents = documents;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[BookingsService] File upload failed, booking still created:', err);
|
console.error('[BookingsService] File upload failed, booking still created:', err);
|
||||||
warnings.push('File upload failed — booking was created without attached files.');
|
warnings.push('File upload failed — booking was created without attached files.');
|
||||||
@@ -184,12 +169,10 @@ export class BookingsService {
|
|||||||
if (dto.startDate) updates.startDate = new Date(dto.startDate);
|
if (dto.startDate) updates.startDate = new Date(dto.startDate);
|
||||||
if (dto.endDate) updates.endDate = new Date(dto.endDate);
|
if (dto.endDate) updates.endDate = new Date(dto.endDate);
|
||||||
|
|
||||||
// Re-evaluate consolidation if container fields changed
|
// Re-evaluate consolidation if containers changed
|
||||||
const containerType = dto.containerType ?? existing.containerType;
|
const containers = dto.containers ?? existing.containers ?? [];
|
||||||
const containerQuantity = dto.containerQuantity ?? existing.containerQuantity;
|
|
||||||
updates.allowConsolidation = this.resolveConsolidation(
|
updates.allowConsolidation = this.resolveConsolidation(
|
||||||
containerType,
|
containers,
|
||||||
containerQuantity,
|
|
||||||
dto.allowConsolidation,
|
dto.allowConsolidation,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -199,15 +182,12 @@ export class BookingsService {
|
|||||||
updates.priorityScore = this.calculatePriorityScore(currency, serviceType);
|
updates.priorityScore = this.calculatePriorityScore(currency, serviceType);
|
||||||
|
|
||||||
// Overweight check
|
// Overweight check
|
||||||
const vgm = dto.containerVgmPerUnit ?? existing.containerVgmPerUnit;
|
|
||||||
const direction = dto.tradeDirection ?? existing.tradeDirection;
|
const direction = dto.tradeDirection ?? existing.tradeDirection;
|
||||||
const ow = this.checkOverweight(containerType, vgm, direction);
|
const overweightWarnings = this.checkOverweight(containers, direction);
|
||||||
if (ow) warnings.push(ow);
|
warnings.push(...overweightWarnings);
|
||||||
|
|
||||||
// Merge documents - upload new files to MinIO
|
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
const newDocs = await this.buildDocuments(id, files);
|
await this.filesService.uploadMany(id, "bookings", files);
|
||||||
updates.documents = { ...(existing.documents ?? {}), ...newDocs };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const booking = await this.bookingsRepository.update(id, updates);
|
const booking = await this.bookingsRepository.update(id, updates);
|
||||||
@@ -231,7 +211,6 @@ export class BookingsService {
|
|||||||
if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection;
|
if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection;
|
||||||
if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency;
|
if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency;
|
||||||
if (filter.freightType) where.freightType = filter.freightType;
|
if (filter.freightType) where.freightType = filter.freightType;
|
||||||
if (filter.containerType) where.containerType = filter.containerType;
|
|
||||||
if (filter.allowConsolidation !== undefined)
|
if (filter.allowConsolidation !== undefined)
|
||||||
where.allowConsolidation = filter.allowConsolidation;
|
where.allowConsolidation = filter.allowConsolidation;
|
||||||
if (filter.consolidationPaired === "true")
|
if (filter.consolidationPaired === "true")
|
||||||
@@ -251,21 +230,51 @@ export class BookingsService {
|
|||||||
return { items, total };
|
return { items, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get a single booking by ID, throwing if not found. */
|
/** Get a single booking by ID with files, throwing if not found. */
|
||||||
async findById(id: string): Promise<Booking> {
|
async findById(id: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsRepository.findById(id);
|
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
throw new NotFoundException(`Booking ${id} not found`);
|
throw new NotFoundException(`Booking ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add signed URLs for files (5-minute expiration)
|
||||||
|
if (booking.files && booking.files.length > 0) {
|
||||||
|
booking.files = await Promise.all(
|
||||||
|
booking.files.map(async (file: FileRecord) => {
|
||||||
|
const objectName = this.extractObjectName(file.url);
|
||||||
|
const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
|
||||||
|
return { ...file, signedUrl };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return booking;
|
return booking;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Find booking by reference. */
|
/** Extract object name from Minio URL. */
|
||||||
|
private extractObjectName(url: string): string {
|
||||||
|
const parts = url.split("/");
|
||||||
|
return parts.slice(4).join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find booking by reference with files. */
|
||||||
async findByReference(reference: string): Promise<Booking> {
|
async findByReference(reference: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsRepository.findByReference(reference);
|
const booking = await this.bookingsRepository.findByReferenceWithFiles(reference);
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
throw new NotFoundException(`Booking with reference "${reference}" not found`);
|
throw new NotFoundException(`Booking with reference "${reference}" not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add signed URLs for files (5-minute expiration)
|
||||||
|
if (booking.files && booking.files.length > 0) {
|
||||||
|
booking.files = await Promise.all(
|
||||||
|
booking.files.map(async (file: FileRecord) => {
|
||||||
|
const objectName = this.extractObjectName(file.url);
|
||||||
|
const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
|
||||||
|
return { ...file, signedUrl };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return booking;
|
return booking;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,14 +483,18 @@ export class BookingsService {
|
|||||||
if (!booking.allowConsolidation) {
|
if (!booking.allowConsolidation) {
|
||||||
throw new BadRequestException("Booking is not eligible for consolidation");
|
throw new BadRequestException("Booking is not eligible for consolidation");
|
||||||
}
|
}
|
||||||
if (booking.containerType !== "20FT") {
|
|
||||||
throw new BadRequestException("Only 20FT containers can be consolidated");
|
// Check if any 20FT container has odd quantity
|
||||||
}
|
const hasOdd20FT = booking.containers?.some(
|
||||||
if (booking.containerQuantity % 2 === 0) {
|
(c) => c.type === "20FT" && c.qty % 2 !== 0
|
||||||
|
) ?? false;
|
||||||
|
|
||||||
|
if (!hasOdd20FT) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Only odd-quantity 20FT bookings need consolidation",
|
"Only bookings with odd-quantity 20FT containers need consolidation",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (booking.consolidationPartnerId) {
|
if (booking.consolidationPartnerId) {
|
||||||
throw new ConflictException("Booking is already paired for consolidation");
|
throw new ConflictException("Booking is already paired for consolidation");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { Transform } from "class-transformer";
|
import { Transform, Type } from "class-transformer";
|
||||||
import {
|
import {
|
||||||
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
IsIn,
|
IsIn,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
Min,
|
Min,
|
||||||
|
ValidateNested,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
|
|
||||||
const BOOKING_STATUSES = [
|
const BOOKING_STATUSES = [
|
||||||
@@ -47,6 +49,24 @@ export {
|
|||||||
CONTAINER_TYPES,
|
CONTAINER_TYPES,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export class ContainerItem {
|
||||||
|
@ApiProperty({ enum: CONTAINER_TYPES, description: "Container type (20FT or 40FT)" })
|
||||||
|
@IsIn([...CONTAINER_TYPES])
|
||||||
|
type!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Quantity of containers", minimum: 1 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Transform(({ value }) => Number(value))
|
||||||
|
qty!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "VGM per container in tons", minimum: 0 })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Transform(({ value }) => Number(value))
|
||||||
|
vgm!: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class CreateBookingDto {
|
export class CreateBookingDto {
|
||||||
// ── core ─────────────────────────────────────────────────────────────
|
// ── core ─────────────────────────────────────────────────────────────
|
||||||
@ApiProperty({ description: "Unique booking reference" })
|
@ApiProperty({ description: "Unique booking reference" })
|
||||||
@@ -177,26 +197,16 @@ export class CreateBookingDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
financialTerms?: string;
|
financialTerms?: string;
|
||||||
|
|
||||||
// ── container ────────────────────────────────────────────────────────
|
// ── containers ────────────────────────────────────────────────────────
|
||||||
@ApiProperty({ enum: CONTAINER_TYPES })
|
@ApiProperty({ type: [ContainerItem], description: "Array of container specifications" })
|
||||||
@IsIn([...CONTAINER_TYPES])
|
@IsArray()
|
||||||
containerType!: string;
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ContainerItem)
|
||||||
@ApiProperty({ minimum: 1 })
|
containers!: ContainerItem[];
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
@Transform(({ value }) => Number(value))
|
|
||||||
containerQuantity!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: "VGM per container in tons", minimum: 0 })
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
@Transform(({ value }) => Number(value))
|
|
||||||
containerVgmPerUnit!: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
default: false,
|
default: false,
|
||||||
description: "Auto-set to true when containerType=20FT and odd quantity. User may override.",
|
description: "Auto-set to true when any 20FT container has odd quantity. User may override.",
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { IsBoolean, IsIn, IsInt, IsOptional, IsString, IsUUID, Min } from "class
|
|||||||
import {
|
import {
|
||||||
BOOKING_STATUSES,
|
BOOKING_STATUSES,
|
||||||
CONTRACT_TYPES,
|
CONTRACT_TYPES,
|
||||||
CONTAINER_TYPES,
|
|
||||||
FREIGHT_TYPES,
|
FREIGHT_TYPES,
|
||||||
PAYMENT_CURRENCIES,
|
PAYMENT_CURRENCIES,
|
||||||
SERVICE_TYPES,
|
SERVICE_TYPES,
|
||||||
@@ -48,11 +47,6 @@ export class FilterBookingDto {
|
|||||||
@IsIn([...FREIGHT_TYPES])
|
@IsIn([...FREIGHT_TYPES])
|
||||||
freightType?: string;
|
freightType?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: CONTAINER_TYPES })
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn([...CONTAINER_TYPES])
|
|
||||||
containerType?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: "Filter consolidation-eligible bookings" })
|
@ApiPropertyOptional({ description: "Filter consolidation-eligible bookings" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BaseEntity } from "@edr/api-common";
|
import { BaseEntity } from "@edr/api-common";
|
||||||
import { Column, Entity } from "typeorm";
|
import { Column, Entity, OneToMany } from "typeorm";
|
||||||
|
import { FileRecord } from "../../files/entities/file.entity";
|
||||||
|
|
||||||
@Entity({ schema:"freight",name: "bookings" })
|
@Entity({ schema:"freight",name: "bookings" })
|
||||||
export class Booking extends BaseEntity {
|
export class Booking extends BaseEntity {
|
||||||
@@ -105,20 +106,9 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: "version_number", type: "int", default: 1 })
|
@Column({ name: "version_number", type: "int", default: 1 })
|
||||||
versionNumber!: number;
|
versionNumber!: number;
|
||||||
|
|
||||||
// ── container ──────────────────────────────────────────────────────────
|
// ── containers ─────────────────────────────────────────────────────────
|
||||||
@Column({ name: "container_type", type: "varchar", length: 10 })
|
@Column({ name: "containers", type: "jsonb", nullable: true })
|
||||||
containerType!: string;
|
containers!: Array<{ type: string; qty: number; vgm: number }> | null;
|
||||||
|
|
||||||
@Column({ name: "container_quantity", type: "int" })
|
|
||||||
containerQuantity!: number;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: "container_vgm_per_unit",
|
|
||||||
type: "numeric",
|
|
||||||
precision: 10,
|
|
||||||
scale: 3,
|
|
||||||
})
|
|
||||||
containerVgmPerUnit!: number;
|
|
||||||
|
|
||||||
// ── approval ───────────────────────────────────────────────────────────
|
// ── approval ───────────────────────────────────────────────────────────
|
||||||
@Column({ name: "approved_by_staff_id", type: "uuid", nullable: true })
|
@Column({ name: "approved_by_staff_id", type: "uuid", nullable: true })
|
||||||
@@ -149,7 +139,10 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: "consolidation_partner_id", type: "uuid", nullable: true })
|
@Column({ name: "consolidation_partner_id", type: "uuid", nullable: true })
|
||||||
consolidationPartnerId?: string | null;
|
consolidationPartnerId?: string | null;
|
||||||
|
|
||||||
// ── documents (JSONB) ──────────────────────────────────────────────────
|
// ── files ────────────────────────────────────────────────────────────
|
||||||
@Column({ name: "documents", type: "jsonb", nullable: true })
|
@OneToMany(() => FileRecord, (file) => file.resourceId, {
|
||||||
documents?: Record<string, { originalName: string; size: number; mimeType: string; url?: string }> | null;
|
createForeignKeyConstraints: false,
|
||||||
|
})
|
||||||
|
files?: FileRecord[];
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,71 @@ import {
|
|||||||
IDropdownSettingsRepository,
|
IDropdownSettingsRepository,
|
||||||
} from "./interfaces/dropdown-settings.repository.interface";
|
} from "./interfaces/dropdown-settings.repository.interface";
|
||||||
|
|
||||||
|
const STATIONS_TER_CODE = "stations_ter";
|
||||||
|
|
||||||
|
const DEFAULT_STATION_OPTIONS: CreateDropdownOptionDto[] = [
|
||||||
|
{
|
||||||
|
value: "inside_addis_ababa",
|
||||||
|
label: "Addis Ababa",
|
||||||
|
note: "Inside country",
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "inside_adama",
|
||||||
|
label: "Adama",
|
||||||
|
note: "Inside country",
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "inside_mojo",
|
||||||
|
label: "Mojo",
|
||||||
|
note: "Inside country",
|
||||||
|
order: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "inside_awash",
|
||||||
|
label: "Awash",
|
||||||
|
note: "Inside country",
|
||||||
|
order: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "inside_mieso",
|
||||||
|
label: "Mieso",
|
||||||
|
note: "Inside country",
|
||||||
|
order: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "inside_dire_dawa",
|
||||||
|
label: "Dire Dawa",
|
||||||
|
note: "Inside country",
|
||||||
|
order: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "outside_ali_sabieh",
|
||||||
|
label: "Ali Sabieh",
|
||||||
|
note: "Outside country",
|
||||||
|
order: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "outside_holhol",
|
||||||
|
label: "Holhol",
|
||||||
|
note: "Outside country",
|
||||||
|
order: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "outside_djibouti_city",
|
||||||
|
label: "Djibouti City",
|
||||||
|
note: "Outside country",
|
||||||
|
order: 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "outside_doraleh_terminal",
|
||||||
|
label: "Doraleh Terminal",
|
||||||
|
note: "Outside country",
|
||||||
|
order: 10,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DropdownSettingsService {
|
export class DropdownSettingsService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -62,6 +127,34 @@ export class DropdownSettingsService {
|
|||||||
return this.getById(setting.id);
|
return this.getById(setting.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async seedDefaultStations(): Promise<void> {
|
||||||
|
const existing = await this.repository.findByCode(STATIONS_TER_CODE);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
await this.create({
|
||||||
|
code: STATIONS_TER_CODE,
|
||||||
|
label: "Stations TER",
|
||||||
|
description:
|
||||||
|
"Temporary freight station list used by booking origin and destination yards.",
|
||||||
|
multiple: false,
|
||||||
|
meta: {
|
||||||
|
searchable: true,
|
||||||
|
clearable: true,
|
||||||
|
version: "temporary",
|
||||||
|
},
|
||||||
|
children: DEFAULT_STATION_OPTIONS,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((existing.children?.length ?? 0) === 0) {
|
||||||
|
await this.repository.replaceOptions(
|
||||||
|
existing.id,
|
||||||
|
DEFAULT_STATION_OPTIONS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
id: string,
|
id: string,
|
||||||
dto: UpdateDropdownSettingDto,
|
dto: UpdateDropdownSettingDto,
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { BaseEntity } from "@edr/api-common";
|
||||||
|
import { Column, Entity } from "typeorm";
|
||||||
|
|
||||||
|
@Entity({ schema: "freight", name: "files" })
|
||||||
|
export class FileRecord extends BaseEntity {
|
||||||
|
@Column({ name: "resource_id", type: "uuid" })
|
||||||
|
resourceId!: string;
|
||||||
|
|
||||||
|
@Column({ name: "resource", type: "varchar", length: 100 })
|
||||||
|
resource!: string;
|
||||||
|
|
||||||
|
@Column({ name: "code", type: "varchar", length: 100 })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@Column({ name: "name", type: "varchar", length: 500 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ name: "url", type: "text" })
|
||||||
|
url!: string;
|
||||||
|
|
||||||
|
@Column({ name: "size", type: "integer" })
|
||||||
|
size!: number;
|
||||||
|
|
||||||
|
@Column({ name: "mime_type", type: "varchar", length: 255 })
|
||||||
|
mimeType!: string;
|
||||||
|
}
|
||||||
28
apps/edr-freight-api/src/modules/files/files.controller.ts
Normal file
28
apps/edr-freight-api/src/modules/files/files.controller.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common";
|
||||||
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { Response } from "express";
|
||||||
|
|
||||||
|
import { FilesService } from "./files.service";
|
||||||
|
|
||||||
|
@ApiTags("files")
|
||||||
|
@Controller("files")
|
||||||
|
export class FilesController {
|
||||||
|
constructor(private readonly filesService: FilesService) {}
|
||||||
|
|
||||||
|
@Get(":fileId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Download a file by ID",
|
||||||
|
description:
|
||||||
|
"Global endpoint — streams any uploaded file directly from MinIO by its UUID. " +
|
||||||
|
"No resource context (e.g. booking ID) required.",
|
||||||
|
})
|
||||||
|
async download(
|
||||||
|
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const { stream, record } = await this.filesService.streamById(fileId);
|
||||||
|
res.setHeader("Content-Type", record.mimeType);
|
||||||
|
res.setHeader("Content-Disposition", `attachment; filename="${record.name}"`);
|
||||||
|
stream.pipe(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/edr-freight-api/src/modules/files/files.module.ts
Normal file
16
apps/edr-freight-api/src/modules/files/files.module.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
|
||||||
|
import { MinioModule } from "../minio/minio.module";
|
||||||
|
import { FilesController } from "./files.controller";
|
||||||
|
import { FilesRepository } from "./files.repository";
|
||||||
|
import { FilesService } from "./files.service";
|
||||||
|
import { FileRecord } from "./entities/file.entity";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([FileRecord]), MinioModule],
|
||||||
|
controllers: [FilesController],
|
||||||
|
providers: [FilesService, FilesRepository],
|
||||||
|
exports: [FilesService],
|
||||||
|
})
|
||||||
|
export class FilesModule {}
|
||||||
28
apps/edr-freight-api/src/modules/files/files.repository.ts
Normal file
28
apps/edr-freight-api/src/modules/files/files.repository.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { BaseRepository } from "@edr/api-common";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
|
import { FileRecord } from "./entities/file.entity";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FilesRepository extends BaseRepository<FileRecord> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(FileRecord)
|
||||||
|
repository: Repository<FileRecord>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
findByResource(resourceId: string, resource: string): Promise<FileRecord[]> {
|
||||||
|
return this.repository.find({ where: { resourceId, resource } });
|
||||||
|
}
|
||||||
|
|
||||||
|
findByCode(
|
||||||
|
resourceId: string,
|
||||||
|
resource: string,
|
||||||
|
code: string,
|
||||||
|
): Promise<FileRecord | null> {
|
||||||
|
return this.repository.findOne({ where: { resourceId, resource, code } });
|
||||||
|
}
|
||||||
|
}
|
||||||
84
apps/edr-freight-api/src/modules/files/files.service.ts
Normal file
84
apps/edr-freight-api/src/modules/files/files.service.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { Readable } from "stream";
|
||||||
|
|
||||||
|
import { MinioService } from "../minio/minio.service";
|
||||||
|
import { FilesRepository } from "./files.repository";
|
||||||
|
import { FileRecord } from "./entities/file.entity";
|
||||||
|
|
||||||
|
export interface CreateFileInput {
|
||||||
|
resourceId: string;
|
||||||
|
resource: string;
|
||||||
|
code: string;
|
||||||
|
file: Express.Multer.File;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FilesService {
|
||||||
|
constructor(
|
||||||
|
private readonly filesRepository: FilesRepository,
|
||||||
|
private readonly minioService: MinioService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async upload(input: CreateFileInput): Promise<FileRecord> {
|
||||||
|
const { resourceId, resource, code, file } = input;
|
||||||
|
const objectName = `${resource}/${resourceId}/${Date.now()}_${file.originalname}`;
|
||||||
|
const url = await this.minioService.uploadFile(objectName, file.buffer, file.mimetype);
|
||||||
|
|
||||||
|
return this.filesRepository.create({
|
||||||
|
resourceId,
|
||||||
|
resource,
|
||||||
|
code,
|
||||||
|
name: file.originalname,
|
||||||
|
url,
|
||||||
|
size: file.size,
|
||||||
|
mimeType: file.mimetype,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadMany(
|
||||||
|
resourceId: string,
|
||||||
|
resource: string,
|
||||||
|
files: Express.Multer.File[],
|
||||||
|
): Promise<FileRecord[]> {
|
||||||
|
return Promise.all(
|
||||||
|
files.map((file) =>
|
||||||
|
this.upload({ resourceId, resource, code: file.fieldname, file }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<FileRecord> {
|
||||||
|
const record = await this.filesRepository.findById(id);
|
||||||
|
if (!record) throw new NotFoundException(`File ${id} not found`);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
findByResource(resourceId: string, resource: string): Promise<FileRecord[]> {
|
||||||
|
return this.filesRepository.findByResource(resourceId, resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByCode(
|
||||||
|
resourceId: string,
|
||||||
|
resource: string,
|
||||||
|
code: string,
|
||||||
|
): Promise<FileRecord> {
|
||||||
|
const record = await this.filesRepository.findByCode(resourceId, resource, code);
|
||||||
|
if (!record)
|
||||||
|
throw new NotFoundException(
|
||||||
|
`File with code "${code}" not found for ${resource} ${resourceId}`,
|
||||||
|
);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
async streamById(id: string): Promise<{ stream: Readable; record: FileRecord }> {
|
||||||
|
const record = await this.findById(id);
|
||||||
|
const objectName = this.extractObjectName(record.url);
|
||||||
|
const stream = await this.minioService.getFileStream(objectName);
|
||||||
|
return { stream, record };
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractObjectName(url: string): string {
|
||||||
|
const parts = url.split("/");
|
||||||
|
return parts.slice(4).join("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||||
import { ConfigType } from "@nestjs/config";
|
import { ConfigType } from "@nestjs/config";
|
||||||
import { Client } from "minio";
|
import { Client } from "minio";
|
||||||
|
import { Readable } from "stream";
|
||||||
import { minioConfig } from "./minio.config";
|
import { minioConfig } from "./minio.config";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -62,4 +63,22 @@ export class MinioService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getFileStream(objectName: string): Promise<Readable> {
|
||||||
|
try {
|
||||||
|
return this.client.getObject(this.bucket, objectName);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to get file ${objectName}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSignedUrl(objectName: string, expirySeconds: number = 300): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await this.client.presignedGetObject(this.bucket, objectName, expirySeconds);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to generate signed URL for ${objectName}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
UserCircle,
|
UserCircle,
|
||||||
FileUp,
|
FileUp,
|
||||||
|
MapPinned,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import BookingsPage from "./pages/bookings/BookingsPage";
|
import BookingsPage from "./pages/bookings/BookingsPage";
|
||||||
|
import MyBookings from "./pages/bookings/MyBookings";
|
||||||
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
||||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||||
import ConsignmentsPage from "./pages/consignments/ConsignmentsPage";
|
import ConsignmentsPage from "./pages/consignments/ConsignmentsPage";
|
||||||
@@ -42,14 +44,16 @@ import DocumentsPage from "./pages/documents/DocumentsPage";
|
|||||||
import DropdownSettingsPage from "./pages/admin/DropdownSettingsPage";
|
import DropdownSettingsPage from "./pages/admin/DropdownSettingsPage";
|
||||||
import FileUploadSettingsPage from "./pages/admin/FileUploadSettingsPage";
|
import FileUploadSettingsPage from "./pages/admin/FileUploadSettingsPage";
|
||||||
import MyPortalPage from "./pages/portal/MyPortalPage";
|
import MyPortalPage from "./pages/portal/MyPortalPage";
|
||||||
|
import Station from "./components/stations/Station";
|
||||||
|
|
||||||
const sidebarItems: SidebarItem[] = [
|
const sidebarItems: SidebarItem[] = [
|
||||||
{ label: "My Portal", href: "/portal", icon: <UserCircle /> },
|
{ label: "My Portal", href: "/portal", icon: <UserCircle /> },
|
||||||
{ label: "Dashboard", href: "/", icon: <LayoutDashboard /> },
|
{ label: "Dashboard", href: "/", icon: <LayoutDashboard /> },
|
||||||
{ label: "Customers", href: "/customers", icon: <Users /> },
|
{ label: "Customers", href: "/customers", icon: <Users /> },
|
||||||
{ label: "Bookings", href: "/bookings", icon: <CalendarCheck /> },
|
{ label: "My Bookings", href: "/bookings", icon: <CalendarCheck /> },
|
||||||
{ label: "Consignments", href: "/consignments", icon: <Package /> },
|
{ label: "Consignments", href: "/consignments", icon: <Package /> },
|
||||||
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
|
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
|
||||||
|
{ label: "Stations", href: "/stations", icon: <MapPinned /> },
|
||||||
{ label: "Trains", href: "/trains", icon: <Train /> },
|
{ label: "Trains", href: "/trains", icon: <Train /> },
|
||||||
{ label: "Billing", href: "/billing", icon: <Receipt /> },
|
{ label: "Billing", href: "/billing", icon: <Receipt /> },
|
||||||
{ label: "Documents", href: "/documents", icon: <FileText /> },
|
{ label: "Documents", href: "/documents", icon: <FileText /> },
|
||||||
@@ -112,7 +116,8 @@ const App = () => {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<DashboardPage />} />
|
<Route path="/" element={<DashboardPage />} />
|
||||||
<Route path="/portal" element={<MyPortalPage />} />
|
<Route path="/portal" element={<MyPortalPage />} />
|
||||||
<Route path="/bookings" element={<BookingsPage />} />
|
<Route path="/bookings" element={<MyBookings />} />
|
||||||
|
<Route path="/admin/bookings" element={<BookingsPage />} />
|
||||||
<Route path="/customers" element={<CustomersPage />} />
|
<Route path="/customers" element={<CustomersPage />} />
|
||||||
<Route path="/customers/:id" element={<CustomerDetailPage />} />
|
<Route path="/customers/:id" element={<CustomerDetailPage />} />
|
||||||
<Route path="/new-customer" element={<NewCustomerPage />} />
|
<Route path="/new-customer" element={<NewCustomerPage />} />
|
||||||
@@ -121,13 +126,11 @@ const App = () => {
|
|||||||
<Route path="/consignments" element={<ConsignmentsPage />} />
|
<Route path="/consignments" element={<ConsignmentsPage />} />
|
||||||
<Route path="/consignments/:id" element={<ConsignmentDetailPage />} />
|
<Route path="/consignments/:id" element={<ConsignmentDetailPage />} />
|
||||||
<Route path="/tracking" element={<TrackingPage />} />
|
<Route path="/tracking" element={<TrackingPage />} />
|
||||||
|
<Route path="/stations" element={<Station />} />
|
||||||
<Route path="/trains" element={<TrainsPage />} />
|
<Route path="/trains" element={<TrainsPage />} />
|
||||||
<Route path="/billing" element={<BillingPage />} />
|
<Route path="/billing" element={<BillingPage />} />
|
||||||
<Route path="/documents" element={<DocumentsPage />} />
|
<Route path="/documents" element={<DocumentsPage />} />
|
||||||
<Route
|
<Route path="/admin/dropdowns" element={<DropdownSettingsPage />} />
|
||||||
path="/admin/dropdowns"
|
|
||||||
element={<DropdownSettingsPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="/admin/file-uploads"
|
path="/admin/file-uploads"
|
||||||
element={<FileUploadSettingsPage />}
|
element={<FileUploadSettingsPage />}
|
||||||
|
|||||||
228
apps/edr-freight-web/portal/src/components/stations/Station.tsx
Normal file
228
apps/edr-freight-web/portal/src/components/stations/Station.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CircleOff,
|
||||||
|
Loader2,
|
||||||
|
MapPin,
|
||||||
|
Search,
|
||||||
|
TrainFront,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
|
import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings";
|
||||||
|
import type { DropdownOption } from "@/types/dropdownSettings";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
DataTable,
|
||||||
|
DataTableFooter,
|
||||||
|
Input,
|
||||||
|
type ColumnDef,
|
||||||
|
usePagination,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
const STATION_DROPDOWN_CODE = "stations_ter";
|
||||||
|
|
||||||
|
export default function Station() {
|
||||||
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const { data, isLoading, isError, error } = useDropdownSettingByCode(
|
||||||
|
STATION_DROPDOWN_CODE,
|
||||||
|
);
|
||||||
|
|
||||||
|
const stations = useMemo<DropdownOption[]>(
|
||||||
|
() => [...(data?.children ?? [])].sort((a, b) => a.order - b.order),
|
||||||
|
[data?.children],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return stations;
|
||||||
|
|
||||||
|
return stations.filter(
|
||||||
|
(station) =>
|
||||||
|
station.label.toLowerCase().includes(q) ||
|
||||||
|
station.value.toLowerCase().includes(q) ||
|
||||||
|
(station.note ?? "").toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [query, stations]);
|
||||||
|
|
||||||
|
const total = filtered.length;
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||||
|
const start = pagination.pageIndex * pagination.pageSize;
|
||||||
|
const end = Math.min(start + pagination.pageSize, total);
|
||||||
|
const paginatedData = useMemo(
|
||||||
|
() => filtered.slice(start, end),
|
||||||
|
[end, filtered, start],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeCount = stations.filter((station) => !station.disabled).length;
|
||||||
|
const disabledCount = stations.length - activeCount;
|
||||||
|
|
||||||
|
const status: "loading" | "error" | "success" = isLoading
|
||||||
|
? "loading"
|
||||||
|
: isError
|
||||||
|
? "error"
|
||||||
|
: "success";
|
||||||
|
|
||||||
|
const columns: ColumnDef<DropdownOption>[] = [
|
||||||
|
{
|
||||||
|
id: "station",
|
||||||
|
header: "Station",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const station = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||||
|
<MapPin />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-900">{station.label}</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{station.note ?? "No station note"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "value",
|
||||||
|
header: "Code",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
|
||||||
|
{row.original.value}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "order",
|
||||||
|
header: "Order",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "Status",
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.disabled ? (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
|
||||||
|
<CircleOff className="h-3 w-3" />
|
||||||
|
Disabled
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||||
|
<TrainFront className="h-3 w-3" />
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen p-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs items={[{ label: "Stations" }]} />
|
||||||
|
|
||||||
|
<Card className="p-6 flex-row justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
||||||
|
Stations
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-secondary-foreground">
|
||||||
|
Station options loaded from dropdown code{" "}
|
||||||
|
<span className="font-mono">stations_ter</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full sm:w-80">
|
||||||
|
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => {
|
||||||
|
setQuery(event.target.value);
|
||||||
|
setPagination({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="Search stations..."
|
||||||
|
className="pl-8!"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<StationStat label="Stations" value={stations.length} />
|
||||||
|
<StationStat label="Active" value={activeCount} />
|
||||||
|
<StationStat label="Disabled" value={disabledCount} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isError ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-3 py-6 text-sm text-red-600">
|
||||||
|
<AlertCircle className="h-5 w-5" />
|
||||||
|
Failed to load stations.{" "}
|
||||||
|
{error instanceof Error ? error.message : "Unknown error."}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card className="gap-0">
|
||||||
|
<CardHeader className="border-b">
|
||||||
|
<CardTitle>Station List</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
All configured freight stations from the dropdown service.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="px-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin text-primary" />
|
||||||
|
Loading stations...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={paginatedData}
|
||||||
|
status={status}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.pageIndex,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
pageCount,
|
||||||
|
totalCount: total,
|
||||||
|
}}
|
||||||
|
tableOptions={{
|
||||||
|
state: { pagination },
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
}}
|
||||||
|
containerClassName="border-b shadow-none"
|
||||||
|
footer={DataTableFooter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StationStat({ label, value }: { label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">{label}</p>
|
||||||
|
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||||
|
<MapPin />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
import DeleteBookingDialog from "./DeleteBookingDialog";
|
import DeleteBookingDialog from "./DeleteBookingDialog";
|
||||||
import { getBookingById, type BookingStatus } from "./bookings.mock";
|
import { getBookingById, deleteBooking, type BookingStatus } from "./bookings.mock";
|
||||||
import { Button, Card } from "@edr/ui-common";
|
import { Button, Card } from "@edr/ui-common";
|
||||||
|
|
||||||
export default function BookingDetailPage() {
|
export default function BookingDetailPage() {
|
||||||
@@ -88,7 +88,10 @@ export default function BookingDetailPage() {
|
|||||||
|
|
||||||
<DeleteBookingDialog
|
<DeleteBookingDialog
|
||||||
bookingReference={booking.reference}
|
bookingReference={booking.reference}
|
||||||
onConfirm={() => navigate("/bookings")}
|
onConfirm={() => {
|
||||||
|
deleteBooking(booking.id);
|
||||||
|
navigate("/bookings");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Button variant="outline">
|
<Button variant="outline">
|
||||||
<Trash2 />
|
<Trash2 />
|
||||||
|
|||||||
333
apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx
Normal file
333
apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Clock,
|
||||||
|
Eye,
|
||||||
|
Filter,
|
||||||
|
MoreHorizontal,
|
||||||
|
Package,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Truck,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
|
import DeleteBookingDialog from "./DeleteBookingDialog";
|
||||||
|
import { getMyBookings } from "@/lib/currentCustomer";
|
||||||
|
import { deleteBooking, type Booking, type BookingStatus } from "./bookings.mock";
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
DataTableFooter,
|
||||||
|
type ColumnDef,
|
||||||
|
usePagination,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
Input,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
export default function MyBookings() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [myBookings, setMyBookings] = useState(() => getMyBookings());
|
||||||
|
|
||||||
|
const handleDeleteConfirm = (id: number) => {
|
||||||
|
deleteBooking(id);
|
||||||
|
setMyBookings(getMyBookings());
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
return myBookings.filter((b) => {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return (
|
||||||
|
b.reference.toLowerCase().includes(term) ||
|
||||||
|
b.originStation.toLowerCase().includes(term) ||
|
||||||
|
b.destinationStation.toLowerCase().includes(term) ||
|
||||||
|
b.cargoDescription.toLowerCase().includes(term) ||
|
||||||
|
b.status.toLowerCase().includes(term)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [myBookings, searchTerm]);
|
||||||
|
|
||||||
|
const total = filteredData.length;
|
||||||
|
const pageCount = Math.ceil(total / pagination.pageSize);
|
||||||
|
const start = pagination.pageIndex * pagination.pageSize;
|
||||||
|
const end = Math.min(start + pagination.pageSize, total);
|
||||||
|
|
||||||
|
const paginatedData = useMemo(() => filteredData.slice(start, end), [filteredData, start, end]);
|
||||||
|
|
||||||
|
const activeCount = useMemo(() => {
|
||||||
|
return myBookings.filter(
|
||||||
|
(b) => b.status === "Confirmed" || b.status === "In Transit",
|
||||||
|
).length;
|
||||||
|
}, [myBookings]);
|
||||||
|
|
||||||
|
const pendingCount = useMemo(() => {
|
||||||
|
return myBookings.filter((b) => b.status === "Pending").length;
|
||||||
|
}, [myBookings]);
|
||||||
|
|
||||||
|
const columns: ColumnDef<Booking>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "reference",
|
||||||
|
header: "Reference",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const booking = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||||
|
<Package className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-900">{booking.reference}</p>
|
||||||
|
<p className="text-sm text-slate-500">{booking.requestedDate}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "route",
|
||||||
|
header: "Route",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<span>{row.original.originStation}</span>
|
||||||
|
<ArrowRight className="text-slate-400" />
|
||||||
|
<span>{row.original.destinationStation}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cargo",
|
||||||
|
header: "Cargo",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const b = row.original;
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-slate-700">
|
||||||
|
<p>{b.cargoType}</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{b.containerCount > 0 ? `${b.containerCount} × ${b.containerType} · ` : ""}{b.weightTons}t
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "transportMode",
|
||||||
|
header: "Transport",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm text-slate-700">
|
||||||
|
{row.original.transportMode}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: "Status",
|
||||||
|
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
size: 40,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const booking = row.original;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex justify-end"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<MoreHorizontal />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => navigate(`/bookings/${booking.id}`)}
|
||||||
|
>
|
||||||
|
<Eye />
|
||||||
|
View
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DeleteBookingDialog
|
||||||
|
bookingReference={booking.reference}
|
||||||
|
onConfirm={() => handleDeleteConfirm(booking.id)}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(e: Event) => e.preventDefault()}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DeleteBookingDialog>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen p-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs items={[{ label: "My Bookings" }]} />
|
||||||
|
|
||||||
|
{/* Header Section Card */}
|
||||||
|
<Card className="p-6 flex-row justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
||||||
|
My Bookings
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-secondary-foreground">
|
||||||
|
View and manage your freight booking requests.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
||||||
|
<div className="relative w-full sm:w-80">
|
||||||
|
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search bookings..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-8!"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link to="/bookings/new">
|
||||||
|
<Button>
|
||||||
|
<Plus />
|
||||||
|
New Booking
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Stat Cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Total Bookings</p>
|
||||||
|
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
||||||
|
{myBookings.length}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||||
|
<Package />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Active Bookings</p>
|
||||||
|
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
||||||
|
{activeCount}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||||
|
<Truck />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Pending Approval</p>
|
||||||
|
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
||||||
|
{pendingCount}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||||
|
<Clock />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data Table */}
|
||||||
|
<Card className="gap-0">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between border-b">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Recent Requests</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
A list of your recent freight bookings and their statuses.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="secondary" size="sm">
|
||||||
|
<Filter />
|
||||||
|
Filter
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="px-0">
|
||||||
|
{total === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 px-6 text-center">
|
||||||
|
<Package className="h-12 w-12 text-slate-300 mb-4" />
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900">No bookings found</h3>
|
||||||
|
<p className="text-xs text-slate-500 mt-1 max-w-sm">
|
||||||
|
{searchTerm ? "No bookings match your current search filter." : "You haven't requested any bookings yet."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={paginatedData}
|
||||||
|
status="success"
|
||||||
|
onRowClick={(row) => navigate(`/bookings/${(row as Booking).id}`)}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.pageIndex,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
pageCount: pageCount,
|
||||||
|
totalCount: total,
|
||||||
|
}}
|
||||||
|
tableOptions={{
|
||||||
|
state: { pagination },
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
}}
|
||||||
|
containerClassName="border-b shadow-none"
|
||||||
|
footer={DataTableFooter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: BookingStatus }) {
|
||||||
|
const styles: Record<BookingStatus, string> = {
|
||||||
|
Pending: "bg-amber-100 text-amber-700",
|
||||||
|
Confirmed: "bg-sky-100 text-sky-700",
|
||||||
|
"In Transit": "bg-indigo-100 text-indigo-700",
|
||||||
|
Delivered: "bg-emerald-100 text-emerald-700",
|
||||||
|
Cancelled: "bg-red-100 text-red-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,10 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react";
|
import { CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { Button } from "@edr/ui-common";
|
import { Button } from "@edr/ui-common";
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
|
import { addBooking } from "./bookings.mock";
|
||||||
|
import { getCurrentCustomer } from "@/lib/currentCustomer";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import type { CreateBookingPayload } from "@/services/bookings.service";
|
||||||
import {
|
import {
|
||||||
MOCK_VALID_CONTRACTS,
|
MOCK_VALID_CONTRACTS,
|
||||||
STEPS,
|
STEPS,
|
||||||
@@ -35,8 +39,8 @@ export default function NewBookingPage() {
|
|||||||
const [submitted, setSubmitted] = useState(false);
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
const form = useForm<BookingFormValues>({
|
const form = useForm<BookingFormValues>({
|
||||||
resolver: zodResolver(bookingFormSchema),
|
|
||||||
defaultValues: initialBookingFormValues,
|
defaultValues: initialBookingFormValues,
|
||||||
|
resolver: zodResolver(bookingFormSchema),
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,6 +123,123 @@ export default function NewBookingPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const me = getCurrentCustomer();
|
||||||
|
const reference =
|
||||||
|
data.draftContractId ||
|
||||||
|
data.previousContractRef ||
|
||||||
|
`EDR-DRAFT-${Date.now()}`;
|
||||||
|
|
||||||
|
const qtyCount =
|
||||||
|
data.cargoType === "container"
|
||||||
|
? data.containers.reduce((acc, c) => acc + Number(c.qty || 0), 0)
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
const totalWeight =
|
||||||
|
data.cargoType === "container"
|
||||||
|
? data.containers.reduce(
|
||||||
|
(acc, c) => acc + Number(c.vgm || 0) * Number(c.qty || 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
: Number(data.cargoWeight || 0);
|
||||||
|
|
||||||
|
const description =
|
||||||
|
data.cargoType === "container"
|
||||||
|
? data.containers.map((c) => `${c.qty} × ${c.type}`).join(", ")
|
||||||
|
: data.freightType === "bulk"
|
||||||
|
? `Bulk - ${data.bulkCommodity === "Others" ? data.bulkCommodityOther : data.bulkCommodity}`
|
||||||
|
: `Break-Bulk - ${data.breakBulkType === "Others" ? data.breakBulkTypeOther : data.breakBulkType}`;
|
||||||
|
|
||||||
|
const newBooking = {
|
||||||
|
id: Date.now(),
|
||||||
|
reference,
|
||||||
|
customerId: me.id,
|
||||||
|
customer: me.company,
|
||||||
|
cargoType: (data.cargoType === "container"
|
||||||
|
? "Containerized"
|
||||||
|
: "Bulk") as any,
|
||||||
|
originStation: data.originYard,
|
||||||
|
destinationStation: data.destinationYard,
|
||||||
|
transportMode: (data.serviceType === "rail"
|
||||||
|
? "Rail"
|
||||||
|
: "Multimodal") as any,
|
||||||
|
containerType: (data.cargoType === "container" &&
|
||||||
|
data.containers[0]?.type === "40ft"
|
||||||
|
? "40FT"
|
||||||
|
: "20FT") as any,
|
||||||
|
containerCount: qtyCount,
|
||||||
|
weightTons: totalWeight,
|
||||||
|
requestedDate: new Date().toISOString().slice(0, 10),
|
||||||
|
priority: (data.isHazardous ? "High" : "Normal") as any,
|
||||||
|
cargoDescription: description,
|
||||||
|
specialInstructions: data.notes || "Standard handling required",
|
||||||
|
status: "Pending" as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
addBooking(newBooking);
|
||||||
|
|
||||||
|
// Call API using api.bookings.create.call
|
||||||
|
const apiPayload = {
|
||||||
|
reference,
|
||||||
|
customerId: String(me.id),
|
||||||
|
scheduledDate: new Date().toISOString().slice(0, 10),
|
||||||
|
totalAmount: 0,
|
||||||
|
contractType: data.contractType.toUpperCase(),
|
||||||
|
previousContractId: data.previousContractRef || undefined,
|
||||||
|
serviceType:
|
||||||
|
data.serviceType === "rail" ? "RAIL_ONLY" : "RAIL_AND_FORWARDING",
|
||||||
|
firstMileEnabled: data.firstMileEnabled,
|
||||||
|
firstMilePickupAddress: data.firstMileEnabled
|
||||||
|
? data.pickUpAddress
|
||||||
|
: undefined,
|
||||||
|
lastMileEnabled: data.lastMileEnabled,
|
||||||
|
lastMileDeliveryAddress: data.lastMileEnabled
|
||||||
|
? data.deliveryAddress
|
||||||
|
: undefined,
|
||||||
|
equipmentReturn:
|
||||||
|
data.equipmentReturn === "with_return"
|
||||||
|
? "WITH_RETURN"
|
||||||
|
: "WITHOUT_RETURN",
|
||||||
|
originStation: data.originYard,
|
||||||
|
destinationStation: data.destinationYard,
|
||||||
|
cargoTotalWeightVgm: totalWeight,
|
||||||
|
freightType: data.cargoType === "container" ? "BREAK_BULK" : "BULK",
|
||||||
|
freightSubtype:
|
||||||
|
data.cargoType === "container"
|
||||||
|
? undefined
|
||||||
|
: data.freightType === "bulk"
|
||||||
|
? data.bulkCommodity
|
||||||
|
: data.breakBulkType,
|
||||||
|
isHazardous: data.isHazardous,
|
||||||
|
isRefrigerated: data.isRefrigerated,
|
||||||
|
tradeDirection:
|
||||||
|
getRouteDirection(data.originYard, data.destinationYard) === "export"
|
||||||
|
? "EXPORT"
|
||||||
|
: "IMPORT",
|
||||||
|
paymentCurrency: "USD",
|
||||||
|
allowConsolidation: data.consolidationEnabled,
|
||||||
|
...(data.cargoType === "container" && data.containers.length > 0
|
||||||
|
? {
|
||||||
|
containers: data.containers.map((c) => ({
|
||||||
|
type: c.type === "40ft" ? "40FT" as const : "20FT" as const,
|
||||||
|
qty: Number(c.qty || 1),
|
||||||
|
vgm: Number(c.vgm || 0),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
api.bookings.create
|
||||||
|
.call(apiPayload as CreateBookingPayload)
|
||||||
|
.then((created) => {
|
||||||
|
console.log("Successfully created booking via API:", created);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn(
|
||||||
|
"API call failed (expected if API server is offline), falling back to mock storage:",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
setTimeout(() => navigate("/bookings"), 2500);
|
setTimeout(() => navigate("/bookings"), 2500);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ function pickStation(i: number, offset: number) {
|
|||||||
return stations[(i + offset) % stations.length] as string;
|
return stations[(i + offset) % stations.length] as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const bookings: Booking[] = Array.from({ length: 22 }, (_, i) => {
|
const INITIAL_BOOKINGS: Booking[] = Array.from({ length: 22 }, (_, i) => {
|
||||||
const customer = customers[i % customers.length] as (typeof customers)[number];
|
const customer = customers[i % customers.length] as (typeof customers)[number];
|
||||||
const id = i + 1;
|
const id = i + 1;
|
||||||
const requested = new Date(2026, 4, 1 + (i % 28));
|
const requested = new Date(2026, 4, 1 + (i % 28));
|
||||||
@@ -125,6 +125,43 @@ export const bookings: Booking[] = Array.from({ length: 22 }, (_, i) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getStoredBookings = (): Booking[] => {
|
||||||
|
if (typeof window === "undefined" || !window.localStorage) {
|
||||||
|
return INITIAL_BOOKINGS;
|
||||||
|
}
|
||||||
|
const data = localStorage.getItem("edr_bookings");
|
||||||
|
if (!data) {
|
||||||
|
localStorage.setItem("edr_bookings", JSON.stringify(INITIAL_BOOKINGS));
|
||||||
|
return INITIAL_BOOKINGS;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch (e) {
|
||||||
|
return INITIAL_BOOKINGS;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bookings: Booking[] = getStoredBookings();
|
||||||
|
|
||||||
|
export function saveBookingsToStorage() {
|
||||||
|
if (typeof window !== "undefined" && window.localStorage) {
|
||||||
|
localStorage.setItem("edr_bookings", JSON.stringify(bookings));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addBooking(booking: Booking) {
|
||||||
|
bookings.unshift(booking);
|
||||||
|
saveBookingsToStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBooking(id: number) {
|
||||||
|
const index = bookings.findIndex((b) => b.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
bookings.splice(index, 1);
|
||||||
|
saveBookingsToStorage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getBookingById(id: number | string): Booking | undefined {
|
export function getBookingById(id: number | string): Booking | undefined {
|
||||||
const numericId = typeof id === "string" ? Number(id) : id;
|
const numericId = typeof id === "string" ? Number(id) : id;
|
||||||
return bookings.find((b) => b.id === numericId);
|
return bookings.find((b) => b.id === numericId);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const REQUIRED_DOC_KEYS = [
|
|||||||
"tin_certificate",
|
"tin_certificate",
|
||||||
"business_license",
|
"business_license",
|
||||||
"business_registration",
|
"business_registration",
|
||||||
"national_id",
|
// "national_id",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const STEPS = [
|
export const STEPS = [
|
||||||
@@ -141,9 +141,6 @@ export const BOOKING_DOCS_SETTING = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const requiredString = (message: string) =>
|
|
||||||
z.string().trim().min(1, { message });
|
|
||||||
|
|
||||||
const fileValueSchema = z.union([
|
const fileValueSchema = z.union([
|
||||||
z.custom<File>(),
|
z.custom<File>(),
|
||||||
z.array(z.custom<File>()),
|
z.array(z.custom<File>()),
|
||||||
@@ -152,10 +149,10 @@ const fileValueSchema = z.union([
|
|||||||
|
|
||||||
export const bookingFormSchema = z
|
export const bookingFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
contractType: z.enum(["new", "renewal", ""]),
|
contractType: z.enum(["new", "renewal"], "Select a contract type."),
|
||||||
previousContractRef: z.string(),
|
previousContractRef: z.string(),
|
||||||
draftContractId: z.string(),
|
draftContractId: z.string(),
|
||||||
serviceType: z.enum(["rail", "rail_forwarding", ""]),
|
serviceType: z.enum(["rail", "rail_forwarding"], "Select a service type."),
|
||||||
firstMileEnabled: z.boolean(),
|
firstMileEnabled: z.boolean(),
|
||||||
pickUpAddress: z.string(),
|
pickUpAddress: z.string(),
|
||||||
lastMileEnabled: z.boolean(),
|
lastMileEnabled: z.boolean(),
|
||||||
@@ -163,9 +160,9 @@ export const bookingFormSchema = z
|
|||||||
equipmentReturn: z.enum(["with_return", "without_return"]),
|
equipmentReturn: z.enum(["with_return", "without_return"]),
|
||||||
originYard: z.string(),
|
originYard: z.string(),
|
||||||
destinationYard: z.string(),
|
destinationYard: z.string(),
|
||||||
cargoType: z.enum(["container", "bulk", ""]),
|
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
|
||||||
cargoWeight: z.string(),
|
cargoWeight: z.string(),
|
||||||
freightType: z.enum(["bulk", "break_bulk", ""]),
|
freightType: z.enum(["bulk", "break_bulk", ""]).default(""),
|
||||||
bulkCommodity: z.string(),
|
bulkCommodity: z.string(),
|
||||||
bulkCommodityOther: z.string(),
|
bulkCommodityOther: z.string(),
|
||||||
breakBulkType: z.string(),
|
breakBulkType: z.string(),
|
||||||
@@ -191,14 +188,6 @@ export const bookingFormSchema = z
|
|||||||
termsAccepted: z.boolean(),
|
termsAccepted: z.boolean(),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
if (!data.contractType) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
path: ["contractType"],
|
|
||||||
message: "Select a contract type.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.contractType === "new" && !data.draftContractId.trim()) {
|
if (data.contractType === "new" && !data.draftContractId.trim()) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
@@ -215,14 +204,6 @@ export const bookingFormSchema = z
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.serviceType) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
path: ["serviceType"],
|
|
||||||
message: "Select a service type.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.firstMileEnabled && !data.pickUpAddress.trim()) {
|
if (data.firstMileEnabled && !data.pickUpAddress.trim()) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
@@ -267,14 +248,6 @@ export const bookingFormSchema = z
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.cargoType) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
path: ["cargoType"],
|
|
||||||
message: "Select a cargo type.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.cargoType === "bulk") {
|
if (data.cargoType === "bulk") {
|
||||||
if (!data.freightType) {
|
if (!data.freightType) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
@@ -385,11 +358,9 @@ export const bookingFormSchema = z
|
|||||||
|
|
||||||
export type BookingFormValues = z.infer<typeof bookingFormSchema>;
|
export type BookingFormValues = z.infer<typeof bookingFormSchema>;
|
||||||
|
|
||||||
export const initialBookingFormValues: BookingFormValues = {
|
export const initialBookingFormValues: Partial<BookingFormValues> = {
|
||||||
contractType: "",
|
|
||||||
previousContractRef: "",
|
previousContractRef: "",
|
||||||
draftContractId: "",
|
draftContractId: "",
|
||||||
serviceType: "",
|
|
||||||
firstMileEnabled: false,
|
firstMileEnabled: false,
|
||||||
pickUpAddress: "",
|
pickUpAddress: "",
|
||||||
lastMileEnabled: false,
|
lastMileEnabled: false,
|
||||||
@@ -397,9 +368,7 @@ export const initialBookingFormValues: BookingFormValues = {
|
|||||||
equipmentReturn: "with_return",
|
equipmentReturn: "with_return",
|
||||||
originYard: "",
|
originYard: "",
|
||||||
destinationYard: "",
|
destinationYard: "",
|
||||||
cargoType: "",
|
|
||||||
cargoWeight: "",
|
cargoWeight: "",
|
||||||
freightType: "",
|
|
||||||
bulkCommodity: "",
|
bulkCommodity: "",
|
||||||
bulkCommodityOther: "",
|
bulkCommodityOther: "",
|
||||||
breakBulkType: "",
|
breakBulkType: "",
|
||||||
@@ -471,6 +440,12 @@ export function getRouteDirection(
|
|||||||
dest: string,
|
dest: string,
|
||||||
): RouteDirection {
|
): RouteDirection {
|
||||||
if (!origin || !dest) return null;
|
if (!origin || !dest) return null;
|
||||||
|
const oLocation = getStationLocation(origin);
|
||||||
|
const dLocation = getStationLocation(dest);
|
||||||
|
if (oLocation === "inside" && dLocation === "outside") return "export";
|
||||||
|
if (oLocation === "outside" && dLocation === "inside") return "import";
|
||||||
|
if (oLocation === "inside" && dLocation === "inside") return "domestic";
|
||||||
|
|
||||||
const oEth = ETHIOPIA_STATIONS.has(origin);
|
const oEth = ETHIOPIA_STATIONS.has(origin);
|
||||||
const dEth = ETHIOPIA_STATIONS.has(dest);
|
const dEth = ETHIOPIA_STATIONS.has(dest);
|
||||||
if (oEth && !dEth) return "export";
|
if (oEth && !dEth) return "export";
|
||||||
@@ -479,6 +454,13 @@ export function getRouteDirection(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStationLocation(value: string): "inside" | "outside" | null {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (normalized.startsWith("inside")) return "inside";
|
||||||
|
if (normalized.startsWith("outside")) return "outside";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function calcWagons(containers: ContainerConfig[]): WagonCalcResult {
|
export function calcWagons(containers: ContainerConfig[]): WagonCalcResult {
|
||||||
const Ft40Wagons = containers
|
const Ft40Wagons = containers
|
||||||
.filter((c) => c.type === "40ft")
|
.filter((c) => c.type === "40ft")
|
||||||
|
|||||||
@@ -129,18 +129,24 @@ export function SelectField({
|
|||||||
error,
|
error,
|
||||||
label,
|
label,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
disabled,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
field: ControllerRenderProps<BookingFormValues>;
|
field: ControllerRenderProps<BookingFormValues>;
|
||||||
error?: RhfFieldError;
|
error?: RhfFieldError;
|
||||||
label: string;
|
label: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
|
disabled?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Field data-invalid={Boolean(error)}>
|
<Field data-invalid={Boolean(error)}>
|
||||||
<FieldLabel>{label}</FieldLabel>
|
<FieldLabel>{label}</FieldLabel>
|
||||||
<Select value={String(field.value)} onValueChange={field.onChange}>
|
<Select
|
||||||
|
value={String(field.value)}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className={cn("w-full ", error ? "border-destructive!" : "")}
|
className={cn("w-full ", error ? "border-destructive!" : "")}
|
||||||
aria-invalid={Boolean(error)}
|
aria-invalid={Boolean(error)}
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||||
import { Flame, MapPin, Snowflake } from "lucide-react";
|
import { Flame, MapPin, Snowflake } from "lucide-react";
|
||||||
import { Field, Separator, Switch } from "@edr/ui-common";
|
import { Field, SelectItem, Separator, Switch } from "@edr/ui-common";
|
||||||
import { type BookingFormValues, getRouteDirection, STATIONS } from "./schema";
|
import { type BookingFormValues, getRouteDirection, STATIONS } from "./schema";
|
||||||
import { SelectField, SelectOptions, StepHeader, StepLabel } from "./shared";
|
import { SelectField, SelectOptions, StepHeader, StepLabel } from "./shared";
|
||||||
|
import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings";
|
||||||
|
import { DropdownOption } from "@/types/dropdownSettings";
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
type BookingForm = UseFormReturn<BookingFormValues>;
|
||||||
|
|
||||||
|
const STATION_DROPDOWN_CODE = "stations_ter";
|
||||||
|
|
||||||
export function Step4Route({ form }: { form: BookingForm }) {
|
export function Step4Route({ form }: { form: BookingForm }) {
|
||||||
const originYard = form.watch("originYard");
|
const originYard = form.watch("originYard");
|
||||||
const destinationYard = form.watch("destinationYard");
|
const destinationYard = form.watch("destinationYard");
|
||||||
|
const {
|
||||||
|
data: stationSetting,
|
||||||
|
isLoading: stationsLoading,
|
||||||
|
isError: stationsError,
|
||||||
|
error: stationsFetchError,
|
||||||
|
} = useDropdownSettingByCode(STATION_DROPDOWN_CODE);
|
||||||
|
const stationOptions = getStationOptions(stationSetting?.children);
|
||||||
const direction = getRouteDirection(originYard, destinationYard);
|
const direction = getRouteDirection(originYard, destinationYard);
|
||||||
const directionStyle: Record<string, string> = {
|
const directionStyle: Record<string, string> = {
|
||||||
export: "bg-sky-50 text-sky-800 border-sky-200",
|
export: "bg-sky-50 text-sky-800 border-sky-200",
|
||||||
@@ -16,10 +27,11 @@ export function Step4Route({ form }: { form: BookingForm }) {
|
|||||||
domestic: "bg-muted text-muted-foreground border-border",
|
domestic: "bg-muted text-muted-foreground border-border",
|
||||||
};
|
};
|
||||||
const directionLabel: Record<string, string> = {
|
const directionLabel: Record<string, string> = {
|
||||||
export: "Export workflow (Ethiopia to Djibouti)",
|
export: "Export workflow (inside country to outside country)",
|
||||||
import: "Import workflow (Djibouti to Ethiopia)",
|
import: "Import workflow (outside country to inside country)",
|
||||||
domestic: "Domestic corridor",
|
domestic: "Domestic corridor",
|
||||||
};
|
};
|
||||||
|
const stationSelectDisabled = stationsLoading || stationOptions.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -29,6 +41,7 @@ export function Step4Route({ form }: { form: BookingForm }) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<StepLabel>Route</StepLabel>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<Controller
|
<Controller
|
||||||
name="originYard"
|
name="originYard"
|
||||||
@@ -39,9 +52,12 @@ export function Step4Route({ form }: { form: BookingForm }) {
|
|||||||
error={fieldState.error}
|
error={fieldState.error}
|
||||||
label="Origin Yard*"
|
label="Origin Yard*"
|
||||||
placeholder="Select origin..."
|
placeholder="Select origin..."
|
||||||
|
disabled={stationSelectDisabled}
|
||||||
>
|
>
|
||||||
<SelectOptions
|
<StationSelectOptions
|
||||||
options={STATIONS.filter((s) => s !== destinationYard)}
|
options={stationOptions}
|
||||||
|
excludeValue={destinationYard}
|
||||||
|
isLoading={stationsLoading}
|
||||||
/>
|
/>
|
||||||
</SelectField>
|
</SelectField>
|
||||||
)}
|
)}
|
||||||
@@ -55,14 +71,25 @@ export function Step4Route({ form }: { form: BookingForm }) {
|
|||||||
error={fieldState.error}
|
error={fieldState.error}
|
||||||
label="Destination Yard *"
|
label="Destination Yard *"
|
||||||
placeholder="Select destination..."
|
placeholder="Select destination..."
|
||||||
|
disabled={stationSelectDisabled}
|
||||||
>
|
>
|
||||||
<SelectOptions
|
<StationSelectOptions
|
||||||
options={STATIONS.filter((s) => s !== originYard)}
|
options={stationOptions}
|
||||||
|
excludeValue={originYard}
|
||||||
|
isLoading={stationsLoading}
|
||||||
/>
|
/>
|
||||||
</SelectField>
|
</SelectField>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{stationsError && (
|
||||||
|
<AlertBox tone="error">
|
||||||
|
Failed to load stations from the API.{" "}
|
||||||
|
{stationsFetchError instanceof Error
|
||||||
|
? stationsFetchError.message
|
||||||
|
: "Try again later."}
|
||||||
|
</AlertBox>
|
||||||
|
)}
|
||||||
{direction && (
|
{direction && (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium ${directionStyle[direction]}`}
|
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium ${directionStyle[direction]}`}
|
||||||
@@ -117,3 +144,53 @@ export function Step4Route({ form }: { form: BookingForm }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function getStationOptions(options?: DropdownOption[]): DropdownOption[] {
|
||||||
|
return [...(options ?? [])].sort((a, b) => a.order - b.order);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function StationSelectOptions({
|
||||||
|
options,
|
||||||
|
excludeValue,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
options: DropdownOption[];
|
||||||
|
excludeValue: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
}) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<SelectItem value="__stations_loading" disabled>
|
||||||
|
Loading stations...
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableOptions = options.filter(
|
||||||
|
(option) => option.value !== excludeValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (availableOptions.length === 0) {
|
||||||
|
return (
|
||||||
|
<SelectItem value="__stations_empty" disabled>
|
||||||
|
No stations available
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{availableOptions.map((option) => (
|
||||||
|
<SelectItem
|
||||||
|
key={option.id}
|
||||||
|
value={option.value}
|
||||||
|
disabled={option.disabled}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
UpdateFileUploadFieldDto,
|
UpdateFileUploadFieldDto,
|
||||||
UpdateFileUploadSettingDto,
|
UpdateFileUploadSettingDto,
|
||||||
} from "@/types/fileUploadSettings";
|
} from "@/types/fileUploadSettings";
|
||||||
import { bookingsService } from "./bookings.service";
|
import { bookingsService, CreateBookingPayload } from "./bookings.service";
|
||||||
import { consignmentsService } from "./consignments.service";
|
import { consignmentsService } from "./consignments.service";
|
||||||
import { trackingService } from "./tracking.service";
|
import { trackingService } from "./tracking.service";
|
||||||
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
||||||
@@ -40,16 +40,11 @@ export const api = {
|
|||||||
({ id }) => bookingsService.get(id),
|
({ id }) => bookingsService.get(id),
|
||||||
),
|
),
|
||||||
|
|
||||||
create: endpoint<
|
create: endpoint<CreateBookingPayload, Freight.IBooking>(
|
||||||
{
|
"bookings",
|
||||||
reference: string;
|
"create",
|
||||||
customerId: string;
|
bookingsService.create,
|
||||||
scheduledDate: string;
|
),
|
||||||
totalAmount: number;
|
|
||||||
trainId?: string;
|
|
||||||
},
|
|
||||||
Freight.IBooking
|
|
||||||
>("bookings", "create", (input) => bookingsService.create(input)),
|
|
||||||
|
|
||||||
remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) =>
|
remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) =>
|
||||||
bookingsService.remove(id),
|
bookingsService.remove(id),
|
||||||
|
|||||||
@@ -2,13 +2,7 @@ import type { Freight, PaginatedResponse } from "@edr/types";
|
|||||||
|
|
||||||
import { api } from "./crud";
|
import { api } from "./crud";
|
||||||
|
|
||||||
export interface CreateBookingPayload {
|
export type CreateBookingPayload = Freight.CreateBookingDto;
|
||||||
reference: string;
|
|
||||||
customerId: string;
|
|
||||||
scheduledDate: string;
|
|
||||||
totalAmount: number;
|
|
||||||
trainId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const bookingsService = {
|
export const bookingsService = {
|
||||||
list: async (): Promise<PaginatedResponse<Freight.IBooking>> => {
|
list: async (): Promise<PaginatedResponse<Freight.IBooking>> => {
|
||||||
@@ -20,7 +14,16 @@ export const bookingsService = {
|
|||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
create: async (payload: CreateBookingPayload): Promise<Freight.IBooking> => {
|
create: async (payload: CreateBookingPayload): Promise<Freight.IBooking> => {
|
||||||
const { data } = await api.post("/bookings", payload);
|
const fd = new FormData();
|
||||||
|
for (const [key, value] of Object.entries(payload)) {
|
||||||
|
if (value === undefined || value === null) continue;
|
||||||
|
if (Array.isArray(value) || typeof value === "object") {
|
||||||
|
fd.append(key, JSON.stringify(value));
|
||||||
|
} else {
|
||||||
|
fd.append(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { data } = await api.post("/api/bookings", fd);
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
remove: async (id: string): Promise<void> => {
|
remove: async (id: string): Promise<void> => {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import type { Freight, PaginatedResponse } from "@edr/types";
|
import type { Freight, PaginatedResponse } from "@edr/types";
|
||||||
|
|
||||||
import { api } from "../utils/api";
|
import { client } from "../utils/api";
|
||||||
|
|
||||||
export const consignmentsService = {
|
export const consignmentsService = {
|
||||||
list: async (): Promise<PaginatedResponse<Freight.IConsignment>> => {
|
list: async (): Promise<PaginatedResponse<Freight.IConsignment>> => {
|
||||||
const { data } = await api.get("/consignments");
|
const { data } = await client.get("/consignments");
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
get: async (id: string): Promise<Freight.IConsignment> => {
|
get: async (id: string): Promise<Freight.IConsignment> => {
|
||||||
const { data } = await api.get(`/consignments/${id}`);
|
const { data } = await client.get(`/consignments/${id}`);
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { api } from "../utils/api";
|
import { client } from "../utils/api";
|
||||||
|
|
||||||
export const trackingService = {
|
export const trackingService = {
|
||||||
forConsignment: async (
|
forConsignment: async (
|
||||||
consignmentId: string,
|
consignmentId: string,
|
||||||
): Promise<Freight.ITrackingEvent[]> => {
|
): Promise<Freight.ITrackingEvent[]> => {
|
||||||
const { data } = await api.get(`/tracking/${consignmentId}`);
|
const { data } = await client.get(`/tracking/${consignmentId}`);
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// vite.config.ts
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { defineConfig } from "file:///home/meng/projects/edr-platform/node_modules/.pnpm/vite@5.4.21_@types+node@24.12.4_lightningcss@1.32.0_terser@5.48.0/node_modules/vite/dist/node/index.js";
|
||||||
|
import react from "file:///home/meng/projects/edr-platform/node_modules/.pnpm/@vitejs+plugin-react@4.7.0_vite@5.4.21_@types+node@24.12.4_lightningcss@1.32.0_terser@5.48.0_/node_modules/@vitejs/plugin-react/dist/index.js";
|
||||||
|
import tailwindcss from "file:///home/meng/projects/edr-platform/node_modules/.pnpm/@tailwindcss+vite@4.3.0_vite@5.4.21_@types+node@24.12.4_lightningcss@1.32.0_terser@5.48.0_/node_modules/@tailwindcss/vite/dist/index.mjs";
|
||||||
|
var __vite_injected_original_import_meta_url = "file:///home/meng/projects/edr-platform/apps/edr-freight-web/portal/vite.config.ts";
|
||||||
|
var __dirname = path.dirname(fileURLToPath(__vite_injected_original_import_meta_url));
|
||||||
|
var vite_config_default = defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
host: "0.0.0.0"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
export {
|
||||||
|
vite_config_default as default
|
||||||
|
};
|
||||||
|
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvaG9tZS9tZW5nL3Byb2plY3RzL2Vkci1wbGF0Zm9ybS9hcHBzL2Vkci1mcmVpZ2h0LXdlYi9wb3J0YWxcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9ob21lL21lbmcvcHJvamVjdHMvZWRyLXBsYXRmb3JtL2FwcHMvZWRyLWZyZWlnaHQtd2ViL3BvcnRhbC92aXRlLmNvbmZpZy50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vaG9tZS9tZW5nL3Byb2plY3RzL2Vkci1wbGF0Zm9ybS9hcHBzL2Vkci1mcmVpZ2h0LXdlYi9wb3J0YWwvdml0ZS5jb25maWcudHNcIjtpbXBvcnQgcGF0aCBmcm9tIFwibm9kZTpwYXRoXCI7XG5pbXBvcnQgeyBmaWxlVVJMVG9QYXRoIH0gZnJvbSBcIm5vZGU6dXJsXCI7XG5cbmltcG9ydCB7IGRlZmluZUNvbmZpZyB9IGZyb20gXCJ2aXRlXCI7XG5pbXBvcnQgcmVhY3QgZnJvbSBcIkB2aXRlanMvcGx1Z2luLXJlYWN0XCI7XG5pbXBvcnQgdGFpbHdpbmRjc3MgZnJvbSBcIkB0YWlsd2luZGNzcy92aXRlXCI7XG5cbmNvbnN0IF9fZGlybmFtZSA9IHBhdGguZGlybmFtZShmaWxlVVJMVG9QYXRoKGltcG9ydC5tZXRhLnVybCkpO1xuXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuICBwbHVnaW5zOiBbcmVhY3QoKSwgdGFpbHdpbmRjc3MoKV0sXG4gIHJlc29sdmU6IHtcbiAgICBhbGlhczoge1xuICAgICAgXCJAXCI6IHBhdGgucmVzb2x2ZShfX2Rpcm5hbWUsIFwiLi9zcmNcIiksXG4gICAgfSxcbiAgfSxcbiAgc2VydmVyOiB7XG4gICAgcG9ydDogNTE3MyxcbiAgICBob3N0OiBcIjAuMC4wLjBcIixcbiAgfSxcbn0pO1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUFzVyxPQUFPLFVBQVU7QUFDdlgsU0FBUyxxQkFBcUI7QUFFOUIsU0FBUyxvQkFBb0I7QUFDN0IsT0FBTyxXQUFXO0FBQ2xCLE9BQU8saUJBQWlCO0FBTHdNLElBQU0sMkNBQTJDO0FBT2pSLElBQU0sWUFBWSxLQUFLLFFBQVEsY0FBYyx3Q0FBZSxDQUFDO0FBRTdELElBQU8sc0JBQVEsYUFBYTtBQUFBLEVBQzFCLFNBQVMsQ0FBQyxNQUFNLEdBQUcsWUFBWSxDQUFDO0FBQUEsRUFDaEMsU0FBUztBQUFBLElBQ1AsT0FBTztBQUFBLE1BQ0wsS0FBSyxLQUFLLFFBQVEsV0FBVyxPQUFPO0FBQUEsSUFDdEM7QUFBQSxFQUNGO0FBQUEsRUFDQSxRQUFRO0FBQUEsSUFDTixNQUFNO0FBQUEsSUFDTixNQUFNO0FBQUEsRUFDUjtBQUNGLENBQUM7IiwKICAibmFtZXMiOiBbXQp9Cg==
|
||||||
@@ -124,3 +124,45 @@ export interface IInvoice extends BaseEntity {
|
|||||||
issuedAt: string;
|
issuedAt: string;
|
||||||
dueAt: string;
|
dueAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateBookingDto {
|
||||||
|
reference: string;
|
||||||
|
customerId: string;
|
||||||
|
trainId?: string;
|
||||||
|
scheduledDate: string;
|
||||||
|
totalAmount: number;
|
||||||
|
paymentStatus?: string;
|
||||||
|
contractType: "NEW" | "RENEWAL";
|
||||||
|
previousContractId?: string;
|
||||||
|
serviceType: "RAIL_ONLY" | "RAIL_AND_FORWARDING";
|
||||||
|
|
||||||
|
firstMileEnabled?: boolean;
|
||||||
|
firstMilePickupAddress?: string;
|
||||||
|
lastMileEnabled?: boolean;
|
||||||
|
lastMileDeliveryAddress?: string;
|
||||||
|
|
||||||
|
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN";
|
||||||
|
originStation: string;
|
||||||
|
destinationStation: string;
|
||||||
|
cargoTotalWeightVgm: number;
|
||||||
|
|
||||||
|
freightType: "BULK" | "BREAK_BULK";
|
||||||
|
freightSubtype?: string;
|
||||||
|
|
||||||
|
isHazardous?: boolean;
|
||||||
|
isRefrigerated?: boolean;
|
||||||
|
|
||||||
|
tradeDirection: "IMPORT" | "EXPORT";
|
||||||
|
paymentCurrency: string;
|
||||||
|
allowConsolidation?: boolean;
|
||||||
|
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
financialTerms?: string;
|
||||||
|
|
||||||
|
containers?: Array<{
|
||||||
|
type: "20FT" | "40FT";
|
||||||
|
qty: number;
|
||||||
|
vgm: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|||||||
2827
pnpm-lock.yaml
generated
2827
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user