diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index aa70884e6..3b80db1bf 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -30,6 +30,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^17.4.2", + "minio": "7.1.3", "pg": "^8.13.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", @@ -46,6 +47,7 @@ "@types/amqplib": "^0.10.8", "@types/express": "^5.0.0", "@types/jest": "^29.5.13", + "@types/multer": "^2.1.0", "@types/node": "^20.14.0", "@types/supertest": "^6.0.2", "jest": "^29.7.0", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index d6fd69953..d1fdc085f 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -8,6 +8,7 @@ import appConfig from "./config/app.config"; import databaseConfig from "./config/database.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; +import { FilesModule } from "./modules/files/files.module"; import { ConsignmentsModule } from "./modules/consignments/consignments.module"; import { TrainsModule } from "./modules/trains/trains.module"; import { CustomersModule } from "./modules/customers/customers.module"; @@ -17,6 +18,7 @@ import { NotificationsModule } from "./modules/notifications/notifications.modul import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { OtpModule } from './modules/otp/otp.module'; +import { DropdownSettingsService } from "./modules/dropdown-settings/dropdown-settings.service"; @Module({ imports: [ @@ -32,6 +34,7 @@ import { OtpModule } from './modules/otp/otp.module'; SharedAuthModule, IamModule.forRoot(), BookingsModule, + FilesModule, ConsignmentsModule, TrainsModule, CustomersModule, @@ -44,9 +47,13 @@ import { OtpModule } from './modules/otp/otp.module'; ], }) export class AppModule implements OnApplicationBootstrap { - constructor(private readonly seeder: DataSeeder) {} + constructor( + private readonly seeder: DataSeeder, + private readonly dropdownSettingsService: DropdownSettingsService, + ) {} async onApplicationBootstrap() { await this.seeder.run(); + await this.dropdownSettingsService.seedDefaultStations(); } } diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index 0031834f5..e2a6f7cc2 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -2,7 +2,7 @@ import { BaseEntity } from "@edr/api-common"; import { Freight } from "@edr/types"; import { Column, Entity } from "typeorm"; -@Entity({ name: "invoices" }) +@Entity({schema:"freight", name: "invoices" }) export class Invoice extends BaseEntity { @Column({ name: "booking_id", type: "uuid" }) bookingId!: string; diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index d6fd41220..13d54d8fb 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -6,42 +6,174 @@ import { HttpCode, Param, ParseUUIDPipe, + Patch, Post, Query, + UploadedFiles, + UseInterceptors, } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { AnyFilesInterceptor } from "@nestjs/platform-express"; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiTags, +} from "@nestjs/swagger"; import { BookingsService } from "./bookings.service"; import { CreateBookingDto } from "./dto/create-booking.dto"; import { FilterBookingDto } from "./dto/filter-booking.dto"; +import { UpdateBookingDto } from "./dto/update-booking.dto"; +import { UpdateStatusDto } from "./dto/update-status.dto"; @ApiTags("bookings") @Controller("bookings") +@ApiBearerAuth() export class BookingsController { - constructor(private readonly bookingsService: BookingsService) {} + constructor(private readonly bookingsService: BookingsService) { } + // ── 1. Create booking (multipart/form-data) ────────────────────────── @Post() - @ApiOperation({ summary: "Create a new freight booking" }) - create(@Body() dto: CreateBookingDto) { - return this.bookingsService.create(dto); + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: "Create a new freight booking", + description: + "Accepts all booking fields as form fields + dynamic file keys (e.g. passport, license). " + + "Auto-enables consolidation when containerType=20FT and odd quantity.", + }) + @ApiBody({ + description: + "Booking form data. Attach files with any field name (e.g. passport, tin_certificate). " + + "Each uploaded file is saved as a row in the files table (resource=bookings).", + type: CreateBookingDto, + }) + create( + @Body() dto: CreateBookingDto, + @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, + })), + ); + return this.bookingsService.create(dto, files ?? []); } + // ── 2. Update draft booking (multipart/form-data) ───────────────────── + @Patch(":id") + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: "Update a draft booking", + description: + "Only DRAFT bookings can be updated. New files are merged into existing documents.", + }) + @ApiBody({ type: UpdateBookingDto }) + update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateBookingDto, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.bookingsService.update(id, dto, files ?? []); + } + + // ── 3. List bookings (paginated + filtered) ─────────────────────────── @Get() - @ApiOperation({ summary: "List freight bookings (paginated)" }) + @ApiOperation({ + summary: "List freight bookings (paginated)", + description: + "Filter by status, customerId, contractType, serviceType, tradeDirection, " + + "paymentCurrency, freightType, containerType, allowConsolidation, consolidationPaired. " + + "Sort by createdAt or priorityScore.", + }) findAll(@Query() filter: FilterBookingDto) { return this.bookingsService.findAll(filter); } + // ── 5. Lookup by reference (must be before :id to avoid conflict) ───── + @Get("by-reference/:reference") + @ApiOperation({ + summary: "Get a freight booking by reference number", + description: "Lookup booking by its human-readable reference string.", + }) + findByReference(@Param("reference") reference: string) { + return this.bookingsService.findByReference(reference); + } + + // ── 4. Get single booking by ID ─────────────────────────────────────── @Get(":id") @ApiOperation({ summary: "Get a freight booking by ID" }) findOne(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.findById(id); } + // ── 6. Soft-delete (DRAFT only) ─────────────────────────────────────── @Delete(":id") @HttpCode(204) - @ApiOperation({ summary: "Soft-delete a freight booking" }) + @ApiOperation({ + summary: "Soft-delete a freight booking", + description: "Only DRAFT bookings can be deleted.", + }) remove(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.remove(id); } + + // ── 7. Unified status transition ────────────────────────────────────── + @Patch(":id/status") + @ApiOperation({ + summary: "Transition booking status", + description: + "Unified endpoint for all status transitions. Actions: " + + "SUBMIT, APPROVE_STAFF, APPROVE_DIRECTOR, APPROVE_CEO, REJECT, CANCEL, ACTIVATE, EXPIRE. " + + "Approval routing: Standard → LINE_STAFF → DIRECTOR → SIGNED. " + + "Bulk/high-volume → DIRECTOR → CEO → SIGNED.", + }) + updateStatus( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateStatusDto, + ) { + return this.bookingsService.updateStatus(id, dto); + } + + // ── 8. Request or auto-pair consolidation ───────────────────────────── + @Post(":id/consolidation") + @ApiOperation({ + summary: "Request freight consolidation", + description: + "Searches for a compatible 20FT partner (same origin, destination, tradeDirection). " + + "If a partner is found, both bookings are paired. If not, the booking enters the consolidation queue.", + }) + requestConsolidation(@Param("id", ParseUUIDPipe) id: string) { + return this.bookingsService.requestConsolidation(id); + } + + // ── 9. Remove consolidation pairing ─────────────────────────────────── + @Delete(":id/consolidation") + @ApiOperation({ + summary: "Remove consolidation pairing", + description: + "Unpairs both bookings and returns them to PENDING_CONSOLIDATION status.", + }) + removeConsolidation(@Param("id", ParseUUIDPipe) id: string) { + return this.bookingsService.removeConsolidation(id); + } + + // ── 10. Get consolidation details ───────────────────────────────────── + @Get(":id/consolidation") + @ApiOperation({ + summary: "Get consolidation details", + description: + "Returns partner booking details and split billing information.", + }) + getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) { + return this.bookingsService.getConsolidationDetails(id); + } + } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 98bf64196..fddb99945 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -1,13 +1,14 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; +import { FilesModule } from "../files/files.module"; import { BookingsController } from "./bookings.controller"; import { BookingsRepository } from "./bookings.repository"; import { BookingsService } from "./bookings.service"; import { Booking } from "./entities/booking.entity"; @Module({ - imports: [TypeOrmModule.forFeature([Booking])], + imports: [TypeOrmModule.forFeature([Booking]), FilesModule], controllers: [BookingsController], providers: [BookingsService, BookingsRepository], exports: [BookingsService], diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 5a3838c02..6cf3b2149 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1,9 +1,10 @@ import { BaseRepository } from "@edr/api-common"; import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { In, IsNull, Not, Repository } from "typeorm"; import { Booking } from "./entities/booking.entity"; +import { FileRecord } from "../files/entities/file.entity"; @Injectable() export class BookingsRepository extends BaseRepository { @@ -18,4 +19,76 @@ export class BookingsRepository extends BaseRepository { findByReference(reference: string): Promise { return this.repository.findOne({ where: { reference } }); } + + /** Find a booking by reference with associated files (polymorphic join). */ + async findByReferenceWithFiles(reference: string): Promise { + 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 { + 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. */ + async findConsolidationPartner(booking: Booking): Promise { + return this.repository.findOne({ + where: { + allowConsolidation: true, + // Check if containers JSONB contains at least one 20FT entry with odd qty + containers: Not(IsNull()), + originStation: booking.originStation, + destinationStation: booking.destinationStation, + tradeDirection: booking.tradeDirection, + consolidationPartnerId: IsNull(), + status: In(["DRAFT", "PENDING_CONSOLIDATION"]), + id: Not(booking.id), + }, + order: { createdAt: "ASC" }, + }); + } + + /** Pair two bookings for consolidation. */ + async pairConsolidation(bookingId: string, partnerId: string): Promise { + await this.repository.update(bookingId, { + consolidationPartnerId: partnerId, + status: "CONSOLIDATED", + } as never); + await this.repository.update(partnerId, { + consolidationPartnerId: bookingId, + status: "CONSOLIDATED", + } as never); + } + + /** Un-pair a consolidation. Returns both booking IDs. */ + async unpairConsolidation(bookingId: string, partnerId: string): Promise { + await this.repository.update(bookingId, { + consolidationPartnerId: null, + status: "PENDING_CONSOLIDATION", + } as never); + await this.repository.update(partnerId, { + consolidationPartnerId: null, + status: "PENDING_CONSOLIDATION", + } as never); + } } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index d535ed396..2493b5f89 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1,20 +1,196 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { IsNull, Not } from "typeorm"; +import { FilesService } from "../files/files.service"; import { BookingsRepository } from "./bookings.repository"; import { CreateBookingDto } from "./dto/create-booking.dto"; import { FilterBookingDto } from "./dto/filter-booking.dto"; +import { UpdateBookingDto } from "./dto/update-booking.dto"; +import { UpdateStatusDto } from "./dto/update-status.dto"; import { Booking } from "./entities/booking.entity"; +/** Weight thresholds (tons) that trigger overweight surcharge alerts. */ +const WEIGHT_LIMITS = { + IMPORT_20FT: 20, + EXPORT_20FT: 25, + ANY_40FT: 32.5, +} as const; + +/** Bookings above this total VGM are considered high-volume. */ +const HIGH_VOLUME_THRESHOLD_TONS = 500; + @Injectable() export class BookingsService { - constructor(private readonly bookingsRepository: BookingsRepository) {} + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly filesService: FilesService, + ) {} + + // ── helpers ────────────────────────────────────────────────────────── + + /** Resolve auto-consolidation flag. */ + private resolveConsolidation( + containers: Array<{ type: string; qty: number }> | undefined | null, + explicit?: boolean, + ): boolean { + if (explicit === false) return false; + 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; + } + + /** Calculate priority score based on currency and service type. */ + private calculatePriorityScore(currency: string, serviceType: string): number { + let score = 0; + if (currency === "USD") score += 100; + if (serviceType === "RAIL_AND_FORWARDING") score += 50; + else if (serviceType === "RAIL_ONLY") score += 25; + return score; + } + + /** Calculate required wagons: each 40ft = 1 wagon, each pair of 20ft = 1 wagon. */ + private calculateWagonCount( + containers: Array<{ type: string; qty: number }>, + ): number { + return containers.reduce((total, container) => { + 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 limits and return warnings if exceeded. */ + private checkOverweight( + containers: Array<{ type: string; vgm: number }>, + tradeDirection: string, + ): string[] { + const warnings: string[] = []; + for (const container of containers) { + if (container.type === "40FT" && container.vgm > WEIGHT_LIMITS.ANY_40FT) { + warnings.push( + `40FT container VGM ${container.vgm}t exceeds limit of ${WEIGHT_LIMITS.ANY_40FT}t` + ); + } + if (container.type === "20FT") { + const limit = + 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 warnings; + } + + + // ── CRUD ───────────────────────────────────────────────────────────── /** Create a new freight booking. */ - async create(dto: CreateBookingDto): Promise { - return this.bookingsRepository.create({ + async create( + dto: CreateBookingDto, + files: Express.Multer.File[], + ): Promise<{ booking: Booking; warnings: string[] }> { + const warnings: string[] = []; + + const allowConsolidation = this.resolveConsolidation( + dto.containers, + dto.allowConsolidation, + ); + + const priorityScore = this.calculatePriorityScore( + dto.paymentCurrency, + dto.serviceType, + ); + + const overweightWarnings = this.checkOverweight( + dto.containers, + dto.tradeDirection, + ); + warnings.push(...overweightWarnings); + + const wagonCount = this.calculateWagonCount(dto.containers); + warnings.push(`Estimated wagons required: ${wagonCount}`); + + const booking = await this.bookingsRepository.create({ ...dto, scheduledDate: new Date(dto.scheduledDate), + startDate: dto.startDate ? new Date(dto.startDate) : undefined, + endDate: dto.endDate ? new Date(dto.endDate) : undefined, + status: "DRAFT", + allowConsolidation, + priorityScore, }); + + if (files.length > 0) { + try { + await this.filesService.uploadMany(booking.id, "bookings", files); + } catch (err) { + console.error('[BookingsService] File upload failed, booking still created:', err); + warnings.push('File upload failed — booking was created without attached files.'); + } + } + + return { booking, warnings }; + } + + /** Update a draft booking. */ + async update( + id: string, + dto: UpdateBookingDto, + files: Express.Multer.File[], + ): Promise<{ booking: Booking; warnings: string[] }> { + const existing = await this.findById(id); + if (existing.status !== "DRAFT") { + throw new BadRequestException("Only DRAFT bookings can be updated"); + } + + const warnings: string[] = []; + const updates: Record = { ...dto }; + + if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate); + if (dto.startDate) updates.startDate = new Date(dto.startDate); + if (dto.endDate) updates.endDate = new Date(dto.endDate); + + // Re-evaluate consolidation if containers changed + const containers = dto.containers ?? existing.containers ?? []; + updates.allowConsolidation = this.resolveConsolidation( + containers, + dto.allowConsolidation, + ); + + // Recalculate priority + const currency = dto.paymentCurrency ?? existing.paymentCurrency; + const serviceType = dto.serviceType ?? existing.serviceType; + updates.priorityScore = this.calculatePriorityScore(currency, serviceType); + + // Overweight check + const direction = dto.tradeDirection ?? existing.tradeDirection; + const overweightWarnings = this.checkOverweight(containers, direction); + warnings.push(...overweightWarnings); + + if (files.length > 0) { + await this.filesService.uploadMany(id, "bookings", files); + } + + const booking = await this.bookingsRepository.update(id, updates); + if (!booking) throw new NotFoundException(`Booking ${id} not found`); + + return { booking, warnings }; } /** Return a paginated list of bookings matching the filter. */ @@ -23,30 +199,327 @@ export class BookingsService { ): Promise<{ items: Booking[]; total: number }> { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; + + const where: Record = {}; + if (filter.status) where.status = filter.status; + if (filter.customerId) where.customerId = filter.customerId; + if (filter.contractType) where.contractType = filter.contractType; + if (filter.serviceType) where.serviceType = filter.serviceType; + if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection; + if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency; + if (filter.freightType) where.freightType = filter.freightType; + if (filter.allowConsolidation !== undefined) + where.allowConsolidation = filter.allowConsolidation; + if (filter.consolidationPaired === "true") + where.consolidationPartnerId = Not(IsNull()); + else if (filter.consolidationPaired === "false") + where.consolidationPartnerId = IsNull(); + + const sortField = filter.sortBy ?? "createdAt"; + const sortDir = filter.sortOrder ?? "DESC"; + const [items, total] = await this.bookingsRepository.findAndCount({ - where: { - ...(filter.status ? { status: filter.status } : {}), - ...(filter.customerId ? { customerId: filter.customerId } : {}), - }, + where, skip: (page - 1) * pageSize, take: pageSize, - order: { createdAt: "DESC" }, + order: { [sortField]: sortDir }, }); 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 { - const booking = await this.bookingsRepository.findById(id); + const booking = await this.bookingsRepository.findByIdWithFiles(id); if (!booking) { throw new NotFoundException(`Booking ${id} not found`); } return booking; } - /** Soft-delete a booking. */ + /** Find booking by reference with files. */ + async findByReference(reference: string): Promise { + const booking = await this.bookingsRepository.findByReferenceWithFiles(reference); + if (!booking) { + throw new NotFoundException(`Booking with reference "${reference}" not found`); + } + return booking; + } + + /** Soft-delete a booking (DRAFT only). */ async remove(id: string): Promise { - await this.findById(id); + const booking = await this.findById(id); + if (booking.status !== "DRAFT") { + throw new BadRequestException("Only DRAFT bookings can be deleted"); + } await this.bookingsRepository.softDelete(id); } + + // ── status workflow ────────────────────────────────────────────────── + + /** Unified status transition handler. */ + async updateStatus(id: string, dto: UpdateStatusDto): Promise { + const booking = await this.findById(id); + const { action, actorId, reason } = dto; + + switch (action) { + case "SUBMIT": + return this.handleSubmit(booking); + case "APPROVE_STAFF": + return this.handleApproveStaff(booking, actorId); + case "APPROVE_DIRECTOR": + return this.handleApproveDirector(booking, actorId); + case "APPROVE_CEO": + return this.handleApproveCeo(booking, actorId); + case "REJECT": + return this.handleReject(booking, actorId, reason); + case "CANCEL": + return this.handleCancel(booking, actorId, reason); + case "ACTIVATE": + return this.handleActivate(booking); + case "EXPIRE": + return this.handleExpire(booking); + default: + throw new BadRequestException(`Unknown action: ${action}`); + } + } + + /** SUBMIT: DRAFT → PENDING_LINE_STAFF or PENDING_DIRECTOR (bulk). */ + private async handleSubmit(booking: Booking): Promise { + this.assertStatus(booking, ["DRAFT"]); + const isBulk = + booking.freightType === "BULK" || + booking.cargoTotalWeightVgm > HIGH_VOLUME_THRESHOLD_TONS; + const nextStatus = isBulk ? "PENDING_DIRECTOR" : "PENDING_LINE_STAFF"; + const updated = await this.bookingsRepository.update(booking.id, { + status: nextStatus, + } as never); + return updated!; + } + + /** APPROVE_STAFF: PENDING_LINE_STAFF → APPROVED_PENDING_SIGNATURE. */ + private async handleApproveStaff( + booking: Booking, + actorId?: string, + ): Promise { + this.assertStatus(booking, ["PENDING_LINE_STAFF"]); + if (!actorId) + throw new BadRequestException("actorId is required for APPROVE_STAFF"); + + // Line staff cannot approve bulk + if ( + booking.freightType === "BULK" || + booking.cargoTotalWeightVgm > HIGH_VOLUME_THRESHOLD_TONS + ) { + throw new BadRequestException( + "Line staff cannot approve bulk or high-volume bookings", + ); + } + + const updated = await this.bookingsRepository.update(booking.id, { + status: "APPROVED_PENDING_SIGNATURE", + approvedByStaffId: actorId, + approvedByStaffAt: new Date(), + } as never); + return updated!; + } + + /** APPROVE_DIRECTOR: APPROVED_PENDING_SIGNATURE|PENDING_DIRECTOR → SIGNED or PENDING_CEO. */ + private async handleApproveDirector( + booking: Booking, + actorId?: string, + ): Promise { + this.assertStatus(booking, [ + "APPROVED_PENDING_SIGNATURE", + "PENDING_DIRECTOR", + ]); + if (!actorId) + throw new BadRequestException("actorId is required for APPROVE_DIRECTOR"); + + const isBulk = + booking.freightType === "BULK" || + booking.cargoTotalWeightVgm > HIGH_VOLUME_THRESHOLD_TONS; + const nextStatus = isBulk ? "PENDING_CEO" : "SIGNED"; + + const updated = await this.bookingsRepository.update(booking.id, { + status: nextStatus, + signedByDirectorId: actorId, + signedByDirectorAt: new Date(), + } as never); + return updated!; + } + + /** APPROVE_CEO: PENDING_CEO → SIGNED. */ + private async handleApproveCeo( + booking: Booking, + actorId?: string, + ): Promise { + this.assertStatus(booking, ["PENDING_CEO"]); + if (!actorId) + throw new BadRequestException("actorId is required for APPROVE_CEO"); + + const updated = await this.bookingsRepository.update(booking.id, { + status: "SIGNED", + signedByCeoId: actorId, + signedByCeoAt: new Date(), + } as never); + return updated!; + } + + /** REJECT: PENDING_* → CANCELLED. */ + private async handleReject( + booking: Booking, + actorId?: string, + reason?: string, + ): Promise { + this.assertStatus(booking, [ + "PENDING_LINE_STAFF", + "PENDING_DIRECTOR", + "PENDING_CEO", + "APPROVED_PENDING_SIGNATURE", + ]); + if (!actorId || !reason) + throw new BadRequestException("actorId and reason are required for REJECT"); + + const updated = await this.bookingsRepository.update(booking.id, { + status: "CANCELLED", + } as never); + return updated!; + } + + /** CANCEL: DRAFT|PENDING_* → CANCELLED. */ + private async handleCancel( + booking: Booking, + _actorId?: string, + reason?: string, + ): Promise { + this.assertStatus(booking, [ + "DRAFT", + "PENDING_LINE_STAFF", + "PENDING_DIRECTOR", + "PENDING_CEO", + "APPROVED_PENDING_SIGNATURE", + ]); + if (!reason) + throw new BadRequestException("reason is required for CANCEL"); + + const updated = await this.bookingsRepository.update(booking.id, { + status: "CANCELLED", + } as never); + return updated!; + } + + /** ACTIVATE: SIGNED → ACTIVE. */ + private async handleActivate(booking: Booking): Promise { + this.assertStatus(booking, ["SIGNED"]); + const updated = await this.bookingsRepository.update(booking.id, { + status: "ACTIVE", + startDate: booking.startDate ?? new Date(), + } as never); + return updated!; + } + + /** EXPIRE: ACTIVE → EXPIRED. */ + private async handleExpire(booking: Booking): Promise { + this.assertStatus(booking, ["ACTIVE"]); + const updated = await this.bookingsRepository.update(booking.id, { + status: "EXPIRED", + endDate: new Date(), + } as never); + return updated!; + } + + /** Guard: ensure current status is one of the allowed values. */ + private assertStatus(booking: Booking, allowed: string[]): void { + if (!allowed.includes(booking.status)) { + throw new ConflictException( + `Cannot perform this action on a booking with status "${booking.status}". Allowed: ${allowed.join(", ")}`, + ); + } + } + + // ── consolidation ──────────────────────────────────────────────────── + + /** Request consolidation — auto-pair if a partner exists, else queue. */ + async requestConsolidation(id: string): Promise<{ + booking: Booking; + partner: Booking | null; + paired: boolean; + }> { + const booking = await this.findById(id); + + if (!booking.allowConsolidation) { + throw new BadRequestException("Booking is not eligible for consolidation"); + } + + // Check if any 20FT container has odd quantity + const hasOdd20FT = booking.containers?.some( + (c) => c.type === "20FT" && c.qty % 2 !== 0 + ) ?? false; + + if (!hasOdd20FT) { + throw new BadRequestException( + "Only bookings with odd-quantity 20FT containers need consolidation", + ); + } + + if (booking.consolidationPartnerId) { + throw new ConflictException("Booking is already paired for consolidation"); + } + + const partner = + await this.bookingsRepository.findConsolidationPartner(booking); + + if (partner) { + await this.bookingsRepository.pairConsolidation(booking.id, partner.id); + const updated = await this.findById(id); + const updatedPartner = await this.findById(partner.id); + return { booking: updated, partner: updatedPartner, paired: true }; + } + + // No partner found — enter queue + await this.bookingsRepository.update(booking.id, { + status: "PENDING_CONSOLIDATION", + } as never); + const updated = await this.findById(id); + return { booking: updated, partner: null, paired: false }; + } + + /** Remove consolidation pairing. */ + async removeConsolidation(id: string): Promise<{ + booking: Booking; + partner: Booking; + }> { + const booking = await this.findById(id); + if (!booking.consolidationPartnerId) { + throw new BadRequestException("Booking has no consolidation partner"); + } + + const partnerId = booking.consolidationPartnerId; + await this.bookingsRepository.unpairConsolidation(id, partnerId); + + const updated = await this.findById(id); + const updatedPartner = await this.findById(partnerId); + return { booking: updated, partner: updatedPartner }; + } + + /** Get consolidation details for a booking. */ + async getConsolidationDetails(id: string): Promise<{ + booking: Booking; + partner: Booking | null; + splitBilling: { bookingShare: number; partnerShare: number } | null; + }> { + const booking = await this.findById(id); + + if (!booking.consolidationPartnerId) { + return { booking, partner: null, splitBilling: null }; + } + + const partner = await this.findById(booking.consolidationPartnerId); + const splitBilling = { + bookingShare: booking.totalAmount, + partnerShare: partner.totalAmount, + }; + + return { booking, partner, splitBilling }; + } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index c96e4fd62..0f29adc1a 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -1,33 +1,215 @@ -import { Freight } from "@edr/types"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform, Type } from "class-transformer"; import { + IsArray, + IsBoolean, IsDateString, - IsEnum, + IsIn, + IsInt, IsNumber, IsOptional, IsString, IsUUID, Min, + ValidateNested, } from "class-validator"; +const BOOKING_STATUSES = [ + "DRAFT", + "PENDING_LINE_STAFF", + "PENDING_DIRECTOR", + "PENDING_CEO", + "APPROVED_PENDING_SIGNATURE", + "SIGNED", + "ACTIVE", + "EXPIRED", + "CANCELLED", + "PENDING_CONSOLIDATION", + "CONSOLIDATED", +] as const; + +const CONTRACT_TYPES = ["NEW", "RENEWAL"] as const; +const SERVICE_TYPES = ["RAIL_ONLY", "RAIL_AND_FORWARDING"] as const; +const EQUIPMENT_RETURNS = ["WITH_RETURN", "WITHOUT_RETURN"] as const; +const FREIGHT_TYPES = ["BULK", "BREAK_BULK"] as const; +const TRADE_DIRECTIONS = ["IMPORT", "EXPORT"] as const; +const PAYMENT_CURRENCIES = ["ETB", "USD"] as const; +const PAYMENT_STATUSES = ["PENDING", "PAID", "OVERDUE", "CANCELLED", "REFUNDED"] as const; +const CONTAINER_TYPES = ["20FT", "40FT"] as const; + +export { + BOOKING_STATUSES, + CONTRACT_TYPES, + SERVICE_TYPES, + EQUIPMENT_RETURNS, + FREIGHT_TYPES, + TRADE_DIRECTIONS, + PAYMENT_CURRENCIES, + PAYMENT_STATUSES, + 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 { + // ── core ───────────────────────────────────────────────────────────── + @ApiProperty({ description: "Unique booking reference" }) @IsString() + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) reference!: string; + @ApiProperty({ format: "uuid" }) @IsUUID() customerId!: string; + @ApiPropertyOptional({ format: "uuid" }) @IsOptional() @IsUUID() trainId?: string; + @ApiProperty({ example: "2026-06-15T00:00:00.000Z" }) @IsDateString() scheduledDate!: string; + @ApiProperty({ minimum: 0 }) @IsNumber() @Min(0) + @Transform(({ value }) => Number(value)) totalAmount!: number; + @ApiPropertyOptional({ enum: PAYMENT_STATUSES, default: "PENDING" }) @IsOptional() - @IsEnum(Freight.BookingStatus) - status?: Freight.BookingStatus; + @IsIn([...PAYMENT_STATUSES]) + paymentStatus?: string; + + // ── contract ───────────────────────────────────────────────────────── + @ApiProperty({ enum: CONTRACT_TYPES }) + @IsIn([...CONTRACT_TYPES]) + contractType!: string; + + @ApiPropertyOptional({ format: "uuid", description: "For RENEWAL — previous contract/booking ID" }) + @IsOptional() + @IsUUID() + @Transform(({ value }) => (value === "" || value == null ? undefined : value)) + previousContractId?: string; + + @ApiProperty({ enum: SERVICE_TYPES }) + @IsIn([...SERVICE_TYPES]) + serviceType!: string; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === "true" || value === true) + firstMileEnabled?: boolean; + + @ApiPropertyOptional({ description: "Required when firstMileEnabled is true" }) + @IsOptional() + @IsString() + firstMilePickupAddress?: string; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === "true" || value === true) + lastMileEnabled?: boolean; + + @ApiPropertyOptional({ description: "Required when lastMileEnabled is true" }) + @IsOptional() + @IsString() + lastMileDeliveryAddress?: string; + + @ApiProperty({ enum: EQUIPMENT_RETURNS }) + @IsIn([...EQUIPMENT_RETURNS]) + equipmentReturn!: string; + + @ApiProperty({ description: "Origin station name or code" }) + @IsString() + originStation!: string; + + @ApiProperty({ description: "Destination station name or code" }) + @IsString() + destinationStation!: string; + + @ApiProperty({ description: "Total cargo weight in VGM tons", minimum: 0 }) + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + cargoTotalWeightVgm!: number; + + @ApiProperty({ enum: FREIGHT_TYPES }) + @IsIn([...FREIGHT_TYPES]) + freightType!: string; + + @ApiPropertyOptional({ description: "Coffee, Beans, Machinery, Ro-Ro, etc." }) + @IsOptional() + @IsString() + freightSubtype?: string; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === "true" || value === true) + isHazardous?: boolean; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === "true" || value === true) + isRefrigerated?: boolean; + + @ApiProperty({ enum: TRADE_DIRECTIONS }) + @IsIn([...TRADE_DIRECTIONS]) + tradeDirection!: string; + + @ApiProperty({ enum: PAYMENT_CURRENCIES }) + @IsIn([...PAYMENT_CURRENCIES]) + paymentCurrency!: string; + + @ApiPropertyOptional({ example: "2026-06-15" }) + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiPropertyOptional({ example: "2027-06-15" }) + @IsOptional() + @IsDateString() + endDate?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + financialTerms?: string; + + // ── containers ──────────────────────────────────────────────────────── + @ApiProperty({ type: [ContainerItem], description: "Array of container specifications" }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ContainerItem) + containers!: ContainerItem[]; + + @ApiPropertyOptional({ + default: false, + description: "Auto-set to true when any 20FT container has odd quantity. User may override.", + }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === "true" || value === true) + allowConsolidation?: boolean; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index e4064b1e4..b5b23a80d 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -1,22 +1,81 @@ -import { Freight } from "@edr/types"; -import { Type } from "class-transformer"; -import { IsEnum, IsInt, IsOptional, IsUUID, Min } from "class-validator"; +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform, Type } from "class-transformer"; +import { IsBoolean, IsIn, IsInt, IsOptional, IsString, IsUUID, Min } from "class-validator"; + +import { + BOOKING_STATUSES, + CONTRACT_TYPES, + FREIGHT_TYPES, + PAYMENT_CURRENCIES, + SERVICE_TYPES, + TRADE_DIRECTIONS, +} from "./create-booking.dto"; export class FilterBookingDto { + @ApiPropertyOptional({ enum: BOOKING_STATUSES }) @IsOptional() - @IsEnum(Freight.BookingStatus) - status?: Freight.BookingStatus; + @IsIn([...BOOKING_STATUSES]) + status?: string; + @ApiPropertyOptional({ format: "uuid" }) @IsOptional() @IsUUID() customerId?: string; + @ApiPropertyOptional({ enum: CONTRACT_TYPES }) + @IsOptional() + @IsIn([...CONTRACT_TYPES]) + contractType?: string; + + @ApiPropertyOptional({ enum: SERVICE_TYPES }) + @IsOptional() + @IsIn([...SERVICE_TYPES]) + serviceType?: string; + + @ApiPropertyOptional({ enum: TRADE_DIRECTIONS }) + @IsOptional() + @IsIn([...TRADE_DIRECTIONS]) + tradeDirection?: string; + + @ApiPropertyOptional({ enum: PAYMENT_CURRENCIES }) + @IsOptional() + @IsIn([...PAYMENT_CURRENCIES]) + paymentCurrency?: string; + + @ApiPropertyOptional({ enum: FREIGHT_TYPES }) + @IsOptional() + @IsIn([...FREIGHT_TYPES]) + freightType?: string; + + @ApiPropertyOptional({ description: "Filter consolidation-eligible bookings" }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === "true" || value === true) + allowConsolidation?: boolean; + + @ApiPropertyOptional({ description: "Filter by consolidation partner presence (true = paired, false = unpaired)" }) + @IsOptional() + @IsString() + consolidationPaired?: string; + + @ApiPropertyOptional({ enum: ["createdAt", "priorityScore"], default: "createdAt" }) + @IsOptional() + @IsIn(["createdAt", "priorityScore"]) + sortBy?: string; + + @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" }) + @IsOptional() + @IsIn(["ASC", "DESC"]) + sortOrder?: "ASC" | "DESC"; + + @ApiPropertyOptional({ default: 1, minimum: 1 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number = 1; + @ApiPropertyOptional({ default: 20, minimum: 1 }) @IsOptional() @Type(() => Number) @IsInt() diff --git a/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts new file mode 100644 index 000000000..b97a624fb --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from "@nestjs/swagger"; + +import { CreateBookingDto } from "./create-booking.dto"; + +export class UpdateBookingDto extends PartialType(CreateBookingDto) {} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts new file mode 100644 index 000000000..9cd796840 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsIn, IsOptional, IsString, IsUUID } from "class-validator"; + +const STATUS_ACTIONS = [ + "SUBMIT", + "APPROVE_STAFF", + "APPROVE_DIRECTOR", + "APPROVE_CEO", + "REJECT", + "CANCEL", + "ACTIVATE", + "EXPIRE", +] as const; + +export { STATUS_ACTIONS }; + +export class UpdateStatusDto { + @ApiProperty({ + enum: STATUS_ACTIONS, + description: + "SUBMIT — send to approval queue | " + + "APPROVE_STAFF — line-staff approval | " + + "APPROVE_DIRECTOR — director approval / signature | " + + "APPROVE_CEO — CEO final signature | " + + "REJECT — reject at any pending stage | " + + "CANCEL — customer / admin cancellation | " + + "ACTIVATE — activate a signed booking | " + + "EXPIRE — mark an active booking as expired", + }) + @IsIn([...STATUS_ACTIONS]) + action!: string; + + @ApiPropertyOptional({ format: "uuid", description: "Actor performing the action (staff/director/CEO)" }) + @IsOptional() + @IsUUID() + actorId?: string; + + @ApiPropertyOptional({ description: "Required for REJECT and CANCEL actions" }) + @IsOptional() + @IsString() + reason?: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 0d848c516..75f4abe96 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -1,9 +1,10 @@ import { BaseEntity } from "@edr/api-common"; -import { Freight } from "@edr/types"; -import { Column, Entity } from "typeorm"; +import { Column, Entity, OneToMany } from "typeorm"; +import { FileRecord } from "../../files/entities/file.entity"; -@Entity({ name: "bookings" }) +@Entity({ schema:"freight",name: "bookings" }) export class Booking extends BaseEntity { + // ── core ─────────────────────────────────────────────────────────────── @Column({ name: "reference", type: "varchar", length: 64, unique: true }) reference!: string; @@ -13,13 +14,8 @@ export class Booking extends BaseEntity { @Column({ name: "train_id", type: "uuid", nullable: true }) trainId?: string | null; - @Column({ - name: "status", - type: "enum", - enum: Freight.BookingStatus, - default: Freight.BookingStatus.Draft, - }) - status!: Freight.BookingStatus; + @Column({ name: "status", type: "varchar", length: 40, default: "DRAFT" }) + status!: string; @Column({ name: "scheduled_date", type: "timestamptz" }) scheduledDate!: Date; @@ -35,9 +31,118 @@ export class Booking extends BaseEntity { @Column({ name: "payment_status", - type: "enum", - enum: Freight.PaymentStatus, - default: Freight.PaymentStatus.Pending, + type: "varchar", + length: 20, + default: "PENDING", }) - paymentStatus!: Freight.PaymentStatus; + paymentStatus!: string; + + // ── contract ─────────────────────────────────────────────────────────── + @Column({ name: "contract_type", type: "varchar", length: 20 }) + contractType!: string; + + @Column({ name: "previous_contract_id", type: "uuid", nullable: true }) + previousContractId?: string | null; + + @Column({ name: "service_type", type: "varchar", length: 30 }) + serviceType!: string; + + @Column({ name: "first_mile_enabled", type: "boolean", default: false }) + firstMileEnabled!: boolean; + + @Column({ name: "first_mile_pickup_address", type: "text", nullable: true }) + firstMilePickupAddress?: string | null; + + @Column({ name: "last_mile_enabled", type: "boolean", default: false }) + lastMileEnabled!: boolean; + + @Column({ name: "last_mile_delivery_address", type: "text", nullable: true }) + lastMileDeliveryAddress?: string | null; + + @Column({ name: "equipment_return", type: "varchar", length: 20 }) + equipmentReturn!: string; + + @Column({ name: "origin_station", type: "varchar", length: 255 }) + originStation!: string; + + @Column({ name: "destination_station", type: "varchar", length: 255 }) + destinationStation!: string; + + @Column({ + name: "cargo_total_weight_vgm", + type: "numeric", + precision: 12, + scale: 3, + }) + cargoTotalWeightVgm!: number; + + @Column({ name: "freight_type", type: "varchar", length: 20 }) + freightType!: string; + + @Column({ name: "freight_subtype", type: "varchar", length: 100, nullable: true }) + freightSubtype?: string | null; + + @Column({ name: "is_hazardous", type: "boolean", default: false }) + isHazardous!: boolean; + + @Column({ name: "is_refrigerated", type: "boolean", default: false }) + isRefrigerated!: boolean; + + @Column({ name: "trade_direction", type: "varchar", length: 10 }) + tradeDirection!: string; + + @Column({ name: "payment_currency", type: "varchar", length: 5 }) + paymentCurrency!: string; + + @Column({ name: "start_date", type: "date", nullable: true }) + startDate?: Date | null; + + @Column({ name: "end_date", type: "date", nullable: true }) + endDate?: Date | null; + + @Column({ name: "financial_terms", type: "text", nullable: true }) + financialTerms?: string | null; + + @Column({ name: "version_number", type: "int", default: 1 }) + versionNumber!: number; + + // ── containers ───────────────────────────────────────────────────────── + @Column({ name: "containers", type: "jsonb", nullable: true }) + containers!: Array<{ type: string; qty: number; vgm: number }> | null; + + // ── approval ─────────────────────────────────────────────────────────── + @Column({ name: "approved_by_staff_id", type: "uuid", nullable: true }) + approvedByStaffId?: string | null; + + @Column({ name: "approved_by_staff_at", type: "timestamptz", nullable: true }) + approvedByStaffAt?: Date | null; + + @Column({ name: "signed_by_director_id", type: "uuid", nullable: true }) + signedByDirectorId?: string | null; + + @Column({ name: "signed_by_director_at", type: "timestamptz", nullable: true }) + signedByDirectorAt?: Date | null; + + @Column({ name: "signed_by_ceo_id", type: "uuid", nullable: true }) + signedByCeoId?: string | null; + + @Column({ name: "signed_by_ceo_at", type: "timestamptz", nullable: true }) + signedByCeoAt?: Date | null; + + @Column({ name: "priority_score", type: "int", default: 0 }) + priorityScore!: number; + + // ── consolidation ────────────────────────────────────────────────────── + @Column({ name: "allow_consolidation", type: "boolean", default: false }) + allowConsolidation!: boolean; + + @Column({ name: "consolidation_partner_id", type: "uuid", nullable: true }) + consolidationPartnerId?: string | null; + + // ── files ──────────────────────────────────────────────────────────── + @OneToMany(() => FileRecord, (file) => file.resourceId, { + createForeignKeyConstraints: false, + }) + files?: FileRecord[]; + } diff --git a/apps/edr-freight-api/src/modules/consignments/entities/consignment.entity.ts b/apps/edr-freight-api/src/modules/consignments/entities/consignment.entity.ts index 6224db4cc..3b7ee222b 100644 --- a/apps/edr-freight-api/src/modules/consignments/entities/consignment.entity.ts +++ b/apps/edr-freight-api/src/modules/consignments/entities/consignment.entity.ts @@ -2,7 +2,7 @@ import { BaseEntity } from "@edr/api-common"; import { Freight } from "@edr/types"; import { Column, Entity } from "typeorm"; -@Entity({ name: "consignments" }) +@Entity({schema:"freight", name: "consignments" }) export class Consignment extends BaseEntity { @Column({ name: "booking_id", type: "uuid" }) bookingId!: string; diff --git a/apps/edr-freight-api/src/modules/customers/customers.controller.ts b/apps/edr-freight-api/src/modules/customers/customers.controller.ts index 85b470453..03b5a5549 100644 --- a/apps/edr-freight-api/src/modules/customers/customers.controller.ts +++ b/apps/edr-freight-api/src/modules/customers/customers.controller.ts @@ -1,5 +1,6 @@ +// src/modules/customers/customers.controller.ts + import { - Body, Controller, Delete, Get, @@ -9,49 +10,75 @@ import { ParseUUIDPipe, Patch, Post, + Body, + Query, } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { ApiOperation } from "@nestjs/swagger"; import { CustomersService } from "./customers.service"; import { CreateCustomerDto } from "./dto/create-customer.dto"; import { UpdateCustomerDto } from "./dto/update-customer.dto"; +import { Customer } from "./entities/customer.entity"; -@ApiTags("customers") @Controller("customers") export class CustomersController { constructor(private readonly customersService: CustomersService) {} @Post() - @ApiOperation({ summary: "Create a new customer" }) - create(@Body() dto: CreateCustomerDto) { - return this.customersService.create(dto); + create(@Body() createCustomerDto: CreateCustomerDto): Promise { + return this.customersService.create(createCustomerDto); } @Get() - @ApiOperation({ summary: "List all customers" }) - findAll() { + findAll(): Promise { return this.customersService.findAll(); } + @Get("stats") + @ApiOperation({ summary: "Get customer statistics" }) + getStats(): Promise<{ total: number; withVatNumber: number }> { + return this.customersService.getStats(); + } + + @Get("search") + searchByName(@Query("name") name: string): Promise { + return this.customersService.searchByName(name); + } + + @Get("email/:email") + findByEmail(@Param("email") email: string): Promise { + return this.customersService.findByEmail(email); + } + + @Get("vat/:vatNumber") + findByVatNumber(@Param("vatNumber") vatNumber: string): Promise { + return this.customersService.findByVatNumber(vatNumber); + } + @Get(":id") - @ApiOperation({ summary: "Get a customer by ID" }) - findOne(@Param("id", ParseUUIDPipe) id: string) { + findById(@Param("id", ParseUUIDPipe) id: string): Promise { return this.customersService.findById(id); } + @Get("user/:userId") + findByUserId(@Param("userId", ParseUUIDPipe) userId: string): Promise { + return this.customersService.findByUserId(userId); + } + @Patch(":id") @ApiOperation({ summary: "Update a customer" }) update( @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateCustomerDto, - ) { + ): Promise { return this.customersService.update(id, dto); } @Delete(":id") @ApiOperation({ summary: "Soft-delete a customer" }) @HttpCode(HttpStatus.NO_CONTENT) - remove(@Param("id", ParseUUIDPipe) id: string) { - return this.customersService.remove(id); + remove(@Param("id", ParseUUIDPipe) id: string): Promise { + return this.customersService.delete(id); } -} +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/customers.repository.ts b/apps/edr-freight-api/src/modules/customers/customers.repository.ts index c6cb72fcf..83a64b9da 100644 --- a/apps/edr-freight-api/src/modules/customers/customers.repository.ts +++ b/apps/edr-freight-api/src/modules/customers/customers.repository.ts @@ -1,21 +1,117 @@ -import { BaseRepository } from "@edr/api-common"; +// import { BaseRepository } from "@edr/api-common"; +// import { EntityRepository } from "typeorm"; + +// src/modules/customers/customers.repository.ts import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; - +import { Repository, FindManyOptions, FindOptionsWhere } from "typeorm"; import { Customer } from "./entities/customer.entity"; +import { CreateCustomerDto } from "./dto/create-customer.dto"; +// import { UpdateCustomerDto } from "./dto/update-customer.dto"; @Injectable() -export class CustomersRepository extends BaseRepository { +export class CustomersRepository { constructor( @InjectRepository(Customer) - repository: Repository, - ) { - super(repository); + private readonly repository: Repository, + ) { } + + async create(dto: CreateCustomerDto): Promise { + const customer = this.repository.create(dto); + return await this.repository.save(customer); } - /** Find a customer by their unique email. */ - findByEmail(email: string): Promise { - return this.repository.findOne({ where: { email } }); + async findAll(options?: FindManyOptions): Promise { + return await this.repository.find(options); } -} + + async findById(id: string): Promise { + return await this.repository.findOne({ where: { id } as FindOptionsWhere }); + } + + async findByUserId(userId: string): Promise { + return await this.repository.findOne({ where: { userId } as FindOptionsWhere }); + } + + async findByEmail(email: string): Promise { + return await this.repository.findOne({ where: { email } as FindOptionsWhere }); + } + + async findByVatNumber(vatNumber: string): Promise { + return await this.repository.findOne({ where: { vatNumber } as FindOptionsWhere }); + } + + async findByName(name: string): Promise { + return await this.repository + .createQueryBuilder("customer") + .where("customer.name ILIKE :name", { name: `%${name}%` }) + .getMany(); + } + + async findOneByEmailOrVat(email?: string, vatNumber?: string): Promise { + if (!email && !vatNumber) return null; + + const queryBuilder = this.repository.createQueryBuilder('customer'); + + if (email && vatNumber) { + queryBuilder.where('customer.email = :email', { email }) + .orWhere('customer.vatNumber = :vatNumber', { vatNumber }); + } else if (email) { + queryBuilder.where('customer.email = :email', { email }); + } else if (vatNumber) { + queryBuilder.where('customer.vatNumber = :vatNumber', { vatNumber }); + } + + return await queryBuilder.getOne(); + } + + async update(id: string, updates: Partial): Promise { + await this.repository.update(id, updates); + return this.findById(id); + } + + async delete(id: string): Promise { + const result = await this.repository.delete(id); + return (result.affected ?? 0) > 0; + } + + async count(where?: any): Promise { + if (where?.createdAt) { + const result = await this.repository + .createQueryBuilder('customer') + .where('customer.createdAt >= :date', { date: where.createdAt }) + .getCount(); + return result; + } + return await this.repository.count(); + } + + async existsByUniqueFields(email: string, vatNumber?: string): Promise { + const queryBuilder = this.repository.createQueryBuilder('customer') + .where('customer.email = :email', { email }); + + if (vatNumber) { + queryBuilder.orWhere('customer.vatNumber = :vatNumber', { vatNumber }); + } + + const count = await queryBuilder.getCount(); + return count > 0; + } + + async countWithVatNumber(): Promise { + const count = await this.repository + .createQueryBuilder('customer') + .where('customer.vatNumber IS NOT NULL') + .andWhere("customer.vatNumber != ''") + .getCount(); + + return count; + } + + getRepository(): Repository { + return this.repository; + } + softDelete(id: string): any { + return id; + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/customers.service.ts b/apps/edr-freight-api/src/modules/customers/customers.service.ts index 6394e1ad9..7a5238439 100644 --- a/apps/edr-freight-api/src/modules/customers/customers.service.ts +++ b/apps/edr-freight-api/src/modules/customers/customers.service.ts @@ -1,7 +1,8 @@ import { - ConflictException, Injectable, NotFoundException, + ConflictException, + BadRequestException, } from "@nestjs/common"; import { CustomersRepository } from "./customers.repository"; @@ -13,34 +14,97 @@ import { Customer } from "./entities/customer.entity"; export class CustomersService { constructor(private readonly customersRepository: CustomersRepository) {} + /** Create a new customer */ async create(dto: CreateCustomerDto): Promise { - const existing = await this.customersRepository.findByEmail(dto.email); - if (existing) { + const exists = await this.customersRepository.existsByUniqueFields( + dto.email, + dto.vatNumber, + ); + + if (exists) { throw new ConflictException( - `Customer with email "${dto.email}" already exists`, + "Customer with same email or VAT number already exists", ); } + + if (dto.vatNumber && dto.vatNumber.length !== 10) { + throw new BadRequestException("VAT number must be exactly 10 digits"); + } + return this.customersRepository.create(dto); } + /** Get all customers */ findAll(): Promise { - return this.customersRepository.findAll({ order: { name: "ASC" } }); + return this.customersRepository.findAll({ + order: { companyName: "ASC" }, + }); } + /** Get customer by ID */ async findById(id: string): Promise { const customer = await this.customersRepository.findById(id); + if (!customer) { - throw new NotFoundException(`Customer ${id} not found`); + throw new NotFoundException(`Customer with ID ${id} not found`); } + return customer; } + async findByUserId(userId: string): Promise { + const customer = await this.customersRepository.findByUserId(userId); + + if (!customer) { + throw new NotFoundException(`Customer with ID ${userId} not found`); + } + + return customer; + } + + /** Get customer by email */ + async findByEmail(email: string): Promise { + const customer = await this.customersRepository.findByEmail(email); + + if (!customer) { + throw new NotFoundException(`Customer with email ${email} not found`); + } + + return customer; + } + + /** Get customer by VAT number */ + async findByVatNumber(vatNumber: string): Promise { + const customer = await this.customersRepository.findByVatNumber(vatNumber); + + if (!customer) { + throw new NotFoundException( + `Customer with VAT number ${vatNumber} not found`, + ); + } + + return customer; + } + + /** Search customers by name */ + searchByName(name: string): Promise { + return this.customersRepository.findByName(name); + } + + /** Update customer */ async update(id: string, dto: UpdateCustomerDto): Promise { await this.findById(id); + // Validate VAT number if provided + if (dto.vatNumber && dto.vatNumber.length !== 10) { + throw new BadRequestException("VAT number must be exactly 10 digits"); + } + + // Check email conflict if (dto.email) { - const conflict = await this.customersRepository.findByEmail(dto.email); - if (conflict && conflict.id !== id) { + const existing = await this.customersRepository.findByEmail(dto.email); + + if (existing && existing.userId !== id) { throw new ConflictException( `Customer with email "${dto.email}" already exists`, ); @@ -48,14 +112,29 @@ export class CustomersService { } const updated = await this.customersRepository.update(id, dto); + if (!updated) { throw new NotFoundException(`Customer ${id} not found`); } + return updated; } + /** Delete customer (soft delete) */ async remove(id: string): Promise { await this.findById(id); await this.customersRepository.softDelete(id); } -} + + /** Get customer statistics */ + async getStats(): Promise<{ total: number; withVatNumber: number }> { + const total = await this.customersRepository.count(); + const withVatNumber = await this.customersRepository.countWithVatNumber(); + + return { total, withVatNumber }; + } + + delete(id: string): any { + return id; + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts b/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts index 854b3eaf1..39fc16414 100644 --- a/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts +++ b/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts @@ -4,8 +4,12 @@ import { IsOptional, IsString, MaxLength, + IsNotEmpty, + Length, + Matches, } from "class-validator"; +// Enums export enum CustomerStatusDto { Active = "Active", Pending = "Pending", @@ -18,23 +22,57 @@ export enum CustomerTypeDto { Supplier = "Supplier", } +// DTO export class CreateCustomerDto { + // Basic identity @IsString() - @MaxLength(256) - name!: string; + @IsNotEmpty() + userId!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(100) + firstName!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(100) + lastName!: string; @IsEmail() + @IsNotEmpty() email!: string; @IsString() - @MaxLength(32) + @IsNotEmpty() + @MaxLength(20) phone!: string; - @IsOptional() + // Company info @IsString() - @MaxLength(256) - company?: string; + @IsNotEmpty() + @MaxLength(200) + companyName!: string; + @IsEmail() + @IsNotEmpty() + companyEmail!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(20) + companyPhone!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(100) + companyLocation!: string; + + @IsString() + @IsNotEmpty() + companyAddress!: string; + + // Classification @IsOptional() @IsEnum(CustomerTypeDto) customerType?: CustomerTypeDto; @@ -43,31 +81,76 @@ export class CreateCustomerDto { @IsEnum(CustomerStatusDto) status?: CustomerStatusDto; + // Legal identifiers + @IsString() + @IsNotEmpty() + @Length(10, 10) + @Matches(/^\d+$/, { message: "TIN must contain only digits" }) + tinNumber!: string; + + @IsString() + @IsNotEmpty() + @Length(16, 16) + @Matches(/^\d+$/, { message: "FAN must contain only digits" }) + fanNumber!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(50) + vatNumber!: string; + + // Contact person + @IsString() + @IsNotEmpty() + @MaxLength(100) + contactPersonName!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(20) + contactPersonPhone!: string; + + // Management + @IsString() + @IsNotEmpty() + @MaxLength(100) + generalManagerName!: string; + + @IsEmail() + @IsNotEmpty() + generalManagerEmail!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(20) + generalManagerPhone!: string; + + // POA (Power of Attorney) @IsOptional() @IsString() - @MaxLength(64) - tinNumber?: string; + @MaxLength(100) + poaName?: string; @IsOptional() @IsString() - @MaxLength(128) - city?: string; + @MaxLength(20) + poaPhone?: string; @IsOptional() @IsString() - @MaxLength(128) - country?: string; + poaAddress?: string; + + @IsOptional() + @IsEmail() + poaEmail?: string; @IsOptional() @IsString() - address?: string; - - @IsOptional() - @IsString() - @MaxLength(64) - taxId?: string; + @MaxLength(100) + poaLocation?: string; + // Extra @IsOptional() @IsString() notes?: string; -} +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/dto/response-customer.dto.ts b/apps/edr-freight-api/src/modules/customers/dto/response-customer.dto.ts new file mode 100644 index 000000000..d6e9b9e17 --- /dev/null +++ b/apps/edr-freight-api/src/modules/customers/dto/response-customer.dto.ts @@ -0,0 +1,60 @@ +// src/modules/customers/dto/response-customer.dto.ts +import { Customer } from '../entities/customer.entity'; + +export class ResponseCustomerDto { + UserId: string; + firstName: string; + lastName: string; + email: string; + phone: string; + companyName: string; + companyEmail: string; + companyPhone: string; + companyLocation: string; + companyAddress: string; + contactPersonName: string; + contactPersonPhone: string; + tinNumber: string; + vatNumber?: string; + fanNumber: string; + generalManagerName: string; + generalManagerEmail: string; + generalManagerPhone: string; + poaName?: string; + poaPhone?: string; + poaAddress?: string; + poaEmail?: string; + poaLocation?: string; + notes?: string; + createdAt: Date; + updatedAt: Date; + + constructor(customer: Customer) { + this.UserId = customer.userId; + this.firstName = customer.firstName; + this.lastName = customer.lastName; + this.email = customer.email; + this.phone = customer.phone; + this.companyName = customer.companyName; + this.companyEmail = customer.companyEmail; + this.companyPhone = customer.companyPhone; + this.companyLocation = customer.companyLocation; + this.companyAddress = customer.companyAddress; + this.contactPersonName = customer.contactPersonName; + this.contactPersonPhone = customer.contactPersonPhone; + this.tinNumber = customer.tinNumber; + this.vatNumber = customer.vatNumber; + this.fanNumber = customer.fanNumber; + this.generalManagerName = customer.generalManagerName; + this.generalManagerEmail = customer.generalManagerEmail; + this.generalManagerPhone = customer.generalManagerPhone; + this.poaName = customer.poaName ?? ''; + this.poaPhone = customer.poaPhone ?? ''; + this.poaAddress = customer.poaAddress ?? ''; + this.poaEmail = customer.poaEmail ?? ''; + this.poaLocation = customer.poaLocation ?? ''; + this.notes = customer.notes ?? ''; + this.createdAt = customer.createdAt; + this.updatedAt = customer.updatedAt; + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts b/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts index 94b49d0fd..3651f4b44 100644 --- a/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts +++ b/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts @@ -1,5 +1,9 @@ -import { PartialType } from "@nestjs/swagger"; +// src/modules/customers/dto/update-customer.dto.ts +import { PartialType } from '@nestjs/swagger'; +import { CreateCustomerDto } from './create-customer.dto'; -import { CreateCustomerDto } from "./create-customer.dto"; - -export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {} +export class UpdateCustomerDto extends PartialType(CreateCustomerDto) { + email?: string; + vatNumber?: string; + // Add any other properties you need to access directly +} diff --git a/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts b/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts index 8a632f508..80041432e 100644 --- a/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts +++ b/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts @@ -1,54 +1,100 @@ -import { BaseEntity } from "@edr/api-common"; -import { Column, Entity } from "typeorm"; +import { + Column, + Entity, + CreateDateColumn, + UpdateDateColumn, + Index, + BaseEntity, + PrimaryGeneratedColumn, +} from "typeorm"; -export type CustomerStatus = "Active" | "Pending" | "Inactive"; -export type CustomerType = "Importer" | "Exporter" | "Supplier"; - -@Entity({ name: "customers" }) +@Entity("customers") export class Customer extends BaseEntity { - @Column({ name: "name", type: "varchar", length: 256 }) - name!: string; + @PrimaryGeneratedColumn("uuid") + id!: string; - @Column({ name: "email", type: "varchar", length: 256, unique: true }) + @Column({ type: "uuid" }) + @Index() + userId!: string; + + @Column({ length: 100 }) + @Index() + firstName!: string; + + @Column({ length: 100 }) + @Index() + lastName!: string; + + @Column({ unique: true, length: 150 }) + @Index() email!: string; - @Column({ name: "phone", type: "varchar", length: 32 }) + @Column({ length: 20 }) phone!: string; - @Column({ name: "company", type: "varchar", length: 256, nullable: true }) - company?: string | null; + @Column({ length: 200 }) + @Index() + companyName!: string; - @Column({ - name: "customer_type", - type: "varchar", - length: 32, - default: "Importer", - }) - customerType!: CustomerType; + @Column({ length: 150 }) + companyEmail!: string; - @Column({ - name: "status", - type: "varchar", - length: 32, - default: "Active", - }) - status!: CustomerStatus; + @Column({ length: 20 }) + companyPhone!: string; - @Column({ name: "tin_number", type: "varchar", length: 64, nullable: true }) - tinNumber?: string | null; + @Column({ length: 100 }) + companyLocation!: string; - @Column({ name: "city", type: "varchar", length: 128, nullable: true }) - city?: string | null; + @Column({ type: "text" }) + companyAddress!: string; - @Column({ name: "country", type: "varchar", length: 128, nullable: true }) - country?: string | null; + @Column({ length: 100 }) + contactPersonName!: string; - @Column({ name: "address", type: "text", nullable: true }) - address?: string | null; + @Column({ length: 20 }) + contactPersonPhone!: string; - @Column({ name: "tax_id", type: "varchar", length: 64, nullable: true }) - taxId?: string | null; + @Column({ length: 10, unique: true }) + @Index() + tinNumber!: string; - @Column({ name: "notes", type: "text", nullable: true }) - notes?: string | null; -} + @Column({ length: 50, nullable: true }) + vatNumber?: string; + + @Column({ length: 16, unique: true }) + @Index() + fanNumber!: string; + + @Column({ length: 100 }) + generalManagerName!: string; + + @Column({ length: 150 }) + generalManagerEmail!: string; + + @Column({ length: 20 }) + generalManagerPhone!: string; + + @Column({ length: 100, nullable: true }) + poaName?: string; + + @Column({ length: 20, nullable: true }) + poaPhone?: string; + + @Column({ type: "text", nullable: true }) + poaAddress?: string; + + @Column({ nullable: true, length: 150 }) + poaEmail?: string; + + @Column({ length: 100, nullable: true }) + poaLocation?: string; + + @Column({ type: "text", nullable: true }) + notes?: string; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers2/customers.controller.ts b/apps/edr-freight-api/src/modules/customers2/customers.controller.ts new file mode 100644 index 000000000..85b470453 --- /dev/null +++ b/apps/edr-freight-api/src/modules/customers2/customers.controller.ts @@ -0,0 +1,57 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { CustomersService } from "./customers.service"; +import { CreateCustomerDto } from "./dto/create-customer.dto"; +import { UpdateCustomerDto } from "./dto/update-customer.dto"; + +@ApiTags("customers") +@Controller("customers") +export class CustomersController { + constructor(private readonly customersService: CustomersService) {} + + @Post() + @ApiOperation({ summary: "Create a new customer" }) + create(@Body() dto: CreateCustomerDto) { + return this.customersService.create(dto); + } + + @Get() + @ApiOperation({ summary: "List all customers" }) + findAll() { + return this.customersService.findAll(); + } + + @Get(":id") + @ApiOperation({ summary: "Get a customer by ID" }) + findOne(@Param("id", ParseUUIDPipe) id: string) { + return this.customersService.findById(id); + } + + @Patch(":id") + @ApiOperation({ summary: "Update a customer" }) + update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateCustomerDto, + ) { + return this.customersService.update(id, dto); + } + + @Delete(":id") + @ApiOperation({ summary: "Soft-delete a customer" }) + @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param("id", ParseUUIDPipe) id: string) { + return this.customersService.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/customers2/customers.module.ts b/apps/edr-freight-api/src/modules/customers2/customers.module.ts new file mode 100644 index 000000000..28c6b7c89 --- /dev/null +++ b/apps/edr-freight-api/src/modules/customers2/customers.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { CustomersController } from "./customers.controller"; +import { CustomersRepository } from "./customers.repository"; +import { CustomersService } from "./customers.service"; +import { Customer } from "./entities/customer.entity"; + +@Module({ + imports: [TypeOrmModule.forFeature([Customer])], + controllers: [CustomersController], + providers: [CustomersService, CustomersRepository], + exports: [CustomersService], +}) +export class CustomersModule {} diff --git a/apps/edr-freight-api/src/modules/customers2/customers.repository.ts b/apps/edr-freight-api/src/modules/customers2/customers.repository.ts new file mode 100644 index 000000000..c6cb72fcf --- /dev/null +++ b/apps/edr-freight-api/src/modules/customers2/customers.repository.ts @@ -0,0 +1,21 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { Customer } from "./entities/customer.entity"; + +@Injectable() +export class CustomersRepository extends BaseRepository { + constructor( + @InjectRepository(Customer) + repository: Repository, + ) { + super(repository); + } + + /** Find a customer by their unique email. */ + findByEmail(email: string): Promise { + return this.repository.findOne({ where: { email } }); + } +} diff --git a/apps/edr-freight-api/src/modules/customers2/customers.service.ts b/apps/edr-freight-api/src/modules/customers2/customers.service.ts new file mode 100644 index 000000000..6394e1ad9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/customers2/customers.service.ts @@ -0,0 +1,61 @@ +import { + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; + +import { CustomersRepository } from "./customers.repository"; +import { CreateCustomerDto } from "./dto/create-customer.dto"; +import { UpdateCustomerDto } from "./dto/update-customer.dto"; +import { Customer } from "./entities/customer.entity"; + +@Injectable() +export class CustomersService { + constructor(private readonly customersRepository: CustomersRepository) {} + + async create(dto: CreateCustomerDto): Promise { + const existing = await this.customersRepository.findByEmail(dto.email); + if (existing) { + throw new ConflictException( + `Customer with email "${dto.email}" already exists`, + ); + } + return this.customersRepository.create(dto); + } + + findAll(): Promise { + return this.customersRepository.findAll({ order: { name: "ASC" } }); + } + + async findById(id: string): Promise { + const customer = await this.customersRepository.findById(id); + if (!customer) { + throw new NotFoundException(`Customer ${id} not found`); + } + return customer; + } + + async update(id: string, dto: UpdateCustomerDto): Promise { + await this.findById(id); + + if (dto.email) { + const conflict = await this.customersRepository.findByEmail(dto.email); + if (conflict && conflict.id !== id) { + throw new ConflictException( + `Customer with email "${dto.email}" already exists`, + ); + } + } + + const updated = await this.customersRepository.update(id, dto); + if (!updated) { + throw new NotFoundException(`Customer ${id} not found`); + } + return updated; + } + + async remove(id: string): Promise { + await this.findById(id); + await this.customersRepository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/customers2/dto/create-customer.dto.ts b/apps/edr-freight-api/src/modules/customers2/dto/create-customer.dto.ts new file mode 100644 index 000000000..854b3eaf1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/customers2/dto/create-customer.dto.ts @@ -0,0 +1,73 @@ +import { + IsEmail, + IsEnum, + IsOptional, + IsString, + MaxLength, +} from "class-validator"; + +export enum CustomerStatusDto { + Active = "Active", + Pending = "Pending", + Inactive = "Inactive", +} + +export enum CustomerTypeDto { + Importer = "Importer", + Exporter = "Exporter", + Supplier = "Supplier", +} + +export class CreateCustomerDto { + @IsString() + @MaxLength(256) + name!: string; + + @IsEmail() + email!: string; + + @IsString() + @MaxLength(32) + phone!: string; + + @IsOptional() + @IsString() + @MaxLength(256) + company?: string; + + @IsOptional() + @IsEnum(CustomerTypeDto) + customerType?: CustomerTypeDto; + + @IsOptional() + @IsEnum(CustomerStatusDto) + status?: CustomerStatusDto; + + @IsOptional() + @IsString() + @MaxLength(64) + tinNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(128) + city?: string; + + @IsOptional() + @IsString() + @MaxLength(128) + country?: string; + + @IsOptional() + @IsString() + address?: string; + + @IsOptional() + @IsString() + @MaxLength(64) + taxId?: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/customers2/dto/update-customer.dto.ts b/apps/edr-freight-api/src/modules/customers2/dto/update-customer.dto.ts new file mode 100644 index 000000000..94b49d0fd --- /dev/null +++ b/apps/edr-freight-api/src/modules/customers2/dto/update-customer.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from "@nestjs/swagger"; + +import { CreateCustomerDto } from "./create-customer.dto"; + +export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {} diff --git a/apps/edr-freight-api/src/modules/customers2/entities/customer.entity.ts b/apps/edr-freight-api/src/modules/customers2/entities/customer.entity.ts new file mode 100644 index 000000000..98a248c97 --- /dev/null +++ b/apps/edr-freight-api/src/modules/customers2/entities/customer.entity.ts @@ -0,0 +1,54 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity } from "typeorm"; + +export type CustomerStatus = "Active" | "Pending" | "Inactive"; +export type CustomerType = "Importer" | "Exporter" | "Supplier"; + +@Entity({schema:"freight", name: "customers" }) +export class Customer extends BaseEntity { + @Column({ name: "name", type: "varchar", length: 256 }) + name!: string; + + @Column({ name: "email", type: "varchar", length: 256, unique: true }) + email!: string; + + @Column({ name: "phone", type: "varchar", length: 32 }) + phone!: string; + + @Column({ name: "company", type: "varchar", length: 256, nullable: true }) + company?: string | null; + + @Column({ + name: "customer_type", + type: "varchar", + length: 32, + default: "Importer", + }) + customerType!: CustomerType; + + @Column({ + name: "status", + type: "varchar", + length: 32, + default: "Active", + }) + status!: CustomerStatus; + + @Column({ name: "tin_number", type: "varchar", length: 64, nullable: true }) + tinNumber?: string | null; + + @Column({ name: "city", type: "varchar", length: 128, nullable: true }) + city?: string | null; + + @Column({ name: "country", type: "varchar", length: 128, nullable: true }) + country?: string | null; + + @Column({ name: "address", type: "text", nullable: true }) + address?: string | null; + + @Column({ name: "tax_id", type: "varchar", length: 64, nullable: true }) + taxId?: string | null; + + @Column({ name: "notes", type: "text", nullable: true }) + notes?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.service.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.service.ts index f310795c3..530cfded4 100644 --- a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.service.ts +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.service.ts @@ -16,6 +16,71 @@ import { IDropdownSettingsRepository, } 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() export class DropdownSettingsService { constructor( @@ -62,6 +127,34 @@ export class DropdownSettingsService { return this.getById(setting.id); } + async seedDefaultStations(): Promise { + 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( id: string, dto: UpdateDropdownSettingDto, diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-option.entity.ts b/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-option.entity.ts index 6f88c27a5..619031672 100644 --- a/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-option.entity.ts +++ b/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-option.entity.ts @@ -15,7 +15,7 @@ export interface DropdownOptionMeta { badge?: string; } -@Entity({ name: "dropdown_options" }) +@Entity({schema:"freight", name: "dropdown_options" }) @Index(["settingId", "value"], { unique: true }) export class DropdownOption extends BaseEntity { @ManyToOne(() => DropdownSetting, (setting) => setting.children, { diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-setting.entity.ts b/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-setting.entity.ts index 9d4a7a7e7..5c87c8125 100644 --- a/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-setting.entity.ts +++ b/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-setting.entity.ts @@ -12,7 +12,7 @@ export interface DropdownSettingMeta { version?: string; } -@Entity({ name: "dropdown_settings" }) +@Entity({schema:"freight", name: "dropdown_settings" }) @Index(["code"], { unique: true }) export class DropdownSetting extends BaseEntity { @Column({ name: "code", type: "varchar", length: 128, unique: true }) diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts b/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts index 157778c0e..e76c5716b 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts @@ -10,7 +10,7 @@ import { import { FileUploadSetting } from "./file-upload-setting.entity"; -@Entity({ name: "file_upload_fields" }) +@Entity({schema:"freight", name: "file_upload_fields" }) @Index(["settingId", "fileKey"], { unique: true }) @Check(`"max_files" > 0`) @Check(`"max_size_mb" > 0`) diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-setting.entity.ts b/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-setting.entity.ts index 5009cad98..2318078c1 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-setting.entity.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-setting.entity.ts @@ -8,7 +8,7 @@ import { import { FileUploadField } from "./file-upload-field.entity"; -@Entity({ name: "file_upload_settings" }) +@Entity({ schema:"freight",name: "file_upload_settings" }) @Index(["code"], { unique: true }) export class FileUploadSetting extends BaseEntity { @Column({ diff --git a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts new file mode 100644 index 000000000..221b7c29b --- /dev/null +++ b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/files/files.controller.ts b/apps/edr-freight-api/src/modules/files/files.controller.ts new file mode 100644 index 000000000..acf274ff0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/files/files.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/files/files.module.ts b/apps/edr-freight-api/src/modules/files/files.module.ts new file mode 100644 index 000000000..fa04c9fa7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/files/files.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/files/files.repository.ts b/apps/edr-freight-api/src/modules/files/files.repository.ts new file mode 100644 index 000000000..2ec2b94a2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/files/files.repository.ts @@ -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 { + constructor( + @InjectRepository(FileRecord) + repository: Repository, + ) { + super(repository); + } + + findByResource(resourceId: string, resource: string): Promise { + return this.repository.find({ where: { resourceId, resource } }); + } + + findByCode( + resourceId: string, + resource: string, + code: string, + ): Promise { + return this.repository.findOne({ where: { resourceId, resource, code } }); + } +} diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts new file mode 100644 index 000000000..e08fce72b --- /dev/null +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -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 { + 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 { + return Promise.all( + files.map((file) => + this.upload({ resourceId, resource, code: file.fieldname, file }), + ), + ); + } + + async findById(id: string): Promise { + const record = await this.filesRepository.findById(id); + if (!record) throw new NotFoundException(`File ${id} not found`); + return record; + } + + findByResource(resourceId: string, resource: string): Promise { + return this.filesRepository.findByResource(resourceId, resource); + } + + async findByCode( + resourceId: string, + resource: string, + code: string, + ): Promise { + 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("/"); + } +} diff --git a/apps/edr-freight-api/src/modules/minio/index.ts b/apps/edr-freight-api/src/modules/minio/index.ts new file mode 100644 index 000000000..c5891e495 --- /dev/null +++ b/apps/edr-freight-api/src/modules/minio/index.ts @@ -0,0 +1,2 @@ +export * from "./minio.module"; +export * from "./minio.service"; diff --git a/apps/edr-freight-api/src/modules/minio/minio.config.ts b/apps/edr-freight-api/src/modules/minio/minio.config.ts new file mode 100644 index 000000000..10482a325 --- /dev/null +++ b/apps/edr-freight-api/src/modules/minio/minio.config.ts @@ -0,0 +1,10 @@ +import { registerAs } from "@nestjs/config"; + +export const minioConfig = registerAs("minio", () => ({ + endPoint: process.env.MINIO_ENDPOINT || "minio-dev.smart.aaca.gov.et", + port: parseInt(process.env.MINIO_PORT || "443", 10), + useSSL: process.env.MINIO_USE_SSL !== "false", + accessKey: process.env.MINIO_ACCESS_KEY || "", + secretKey: process.env.MINIO_SECRET_KEY || "", + bucket: process.env.MINIO_BUCKET || "fhc", +})); diff --git a/apps/edr-freight-api/src/modules/minio/minio.module.ts b/apps/edr-freight-api/src/modules/minio/minio.module.ts new file mode 100644 index 000000000..d4745beaf --- /dev/null +++ b/apps/edr-freight-api/src/modules/minio/minio.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { minioConfig } from "./minio.config"; +import { MinioService } from "./minio.service"; + +@Module({ + imports: [ConfigModule.forFeature(minioConfig)], + providers: [MinioService], + exports: [MinioService], +}) +export class MinioModule {} diff --git a/apps/edr-freight-api/src/modules/minio/minio.service.ts b/apps/edr-freight-api/src/modules/minio/minio.service.ts new file mode 100644 index 000000000..eb75f18a8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/minio/minio.service.ts @@ -0,0 +1,75 @@ +import { Inject, Injectable, Logger } from "@nestjs/common"; +import { ConfigType } from "@nestjs/config"; +import { Client } from "minio"; +import { Readable } from "stream"; +import { minioConfig } from "./minio.config"; + +@Injectable() +export class MinioService { + private readonly client: Client; + private readonly logger = new Logger(MinioService.name); + private readonly bucket: string; + + constructor( + @Inject(minioConfig.KEY) + private readonly config: ConfigType, + ) { + console.log('[MinioService] Configuration loaded:', { + endPoint: config.endPoint, + port: config.port, + useSSL: config.useSSL, + accessKey: config.accessKey, + secretKey: config.secretKey ? '***HIDDEN***' : 'EMPTY', + bucket: config.bucket, + }); + this.bucket = config.bucket; + this.client = new Client({ + endPoint: config.endPoint, + port: config.port, + useSSL: config.useSSL, + accessKey: config.accessKey, + secretKey: config.secretKey, + }); + } + + async uploadFile( + objectName: string, + buffer: Buffer, + contentType: string, + ): Promise { + try { + await this.client.putObject(this.bucket, objectName, buffer, buffer.length, { + "Content-Type": contentType, + }); + this.logger.log(`File uploaded successfully: ${objectName}`); + return this.getPublicUrl(objectName); + } catch (error) { + this.logger.error(`Failed to upload file ${objectName}:`, error); + throw error; + } + } + + getPublicUrl(objectName: string): string { + const protocol = this.config.useSSL ? "https" : "http"; + return `${protocol}://${this.config.endPoint}:${this.config.port}/${this.bucket}/${objectName}`; + } + + async deleteFile(objectName: string): Promise { + try { + await this.client.removeObject(this.bucket, objectName); + this.logger.log(`File deleted successfully: ${objectName}`); + } catch (error) { + this.logger.error(`Failed to delete file ${objectName}:`, error); + throw error; + } + } + + async getFileStream(objectName: string): Promise { + try { + return this.client.getObject(this.bucket, objectName); + } catch (error) { + this.logger.error(`Failed to get file ${objectName}:`, error); + throw error; + } + } +} diff --git a/apps/edr-freight-api/src/modules/tracking/entities/tracking-event.entity.ts b/apps/edr-freight-api/src/modules/tracking/entities/tracking-event.entity.ts index aed5420d7..40e0594ae 100644 --- a/apps/edr-freight-api/src/modules/tracking/entities/tracking-event.entity.ts +++ b/apps/edr-freight-api/src/modules/tracking/entities/tracking-event.entity.ts @@ -2,7 +2,7 @@ import { BaseEntity } from "@edr/api-common"; import { Freight } from "@edr/types"; import { Column, Entity } from "typeorm"; -@Entity({ name: "tracking_events" }) +@Entity({schema:"freight", name: "tracking_events" }) export class TrackingEvent extends BaseEntity { @Column({ name: "consignment_id", type: "uuid" }) consignmentId!: string; diff --git a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts index c14b66ad0..c12478ec2 100644 --- a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts +++ b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts @@ -2,7 +2,7 @@ import { BaseEntity } from "@edr/api-common"; import { Freight } from "@edr/types"; import { Column, Entity } from "typeorm"; -@Entity({ name: "trains" }) +@Entity({ schema:"freight",name: "trains" }) export class Train extends BaseEntity { @Column({ name: "code", type: "varchar", length: 32, unique: true }) code!: string; diff --git a/apps/edr-freight-api/test-minio-upload.js b/apps/edr-freight-api/test-minio-upload.js new file mode 100644 index 000000000..4b793b504 --- /dev/null +++ b/apps/edr-freight-api/test-minio-upload.js @@ -0,0 +1,45 @@ +const { Client } = require('minio'); + +const config = { + endPoint: 'minio-dev.smart.aaca.gov.et', + port: 443, + useSSL: true, + accessKey: 'f2f22b0ea929cebd5567ed0c71ec351b', + secretKey: 'xxHnjRsb90suQZZdOtEcXJXls4nj0A2anMetb1kY', + bucket: 'fhc', +}; + +const filePath = '/home/marshal/Desktop/EDR/bash/download.jpeg'; +const objectName = `test-upload-${Date.now()}.jpeg`; + +console.log('Testing MinIO upload...'); +console.log('Endpoint:', `${config.useSSL ? 'https' : 'http'}://${config.endPoint}:${config.port}`); +console.log('Bucket:', config.bucket); +console.log('File:', filePath); +console.log('Object:', objectName); +console.log(''); + +const client = new Client(config); + +const fs = require('fs'); + +try { + const fileBuffer = fs.readFileSync(filePath); + console.log('File size:', fileBuffer.length, 'bytes'); + + client.putObject(config.bucket, objectName, fileBuffer, fileBuffer.length, { 'Content-Type': 'image/jpeg' }) + .then(() => { + const url = `${config.useSSL ? 'https' : 'http'}://${config.endPoint}:${config.port}/${config.bucket}/${objectName}`; + console.log('✓ Upload successful!'); + console.log('URL:', url); + }) + .catch(err => { + console.error('✗ Upload failed:', err.message); + if (err.code === 'InvalidAccessKeyId') { + console.error('The access key does not exist on the MinIO server.'); + console.error('Contact your MinIO administrator for valid credentials.'); + } + }); +} catch (err) { + console.error('Error reading file:', err.message); +} diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 0dd4560d5..66cbd1c46 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -18,9 +18,11 @@ import { Settings, UserCircle, FileUp, + MapPinned, } from "lucide-react"; import BookingsPage from "./pages/bookings/BookingsPage"; +import MyBookings from "./pages/bookings/MyBookings"; import BookingDetailPage from "./pages/bookings/BookingDetailPage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; import ConsignmentsPage from "./pages/consignments/ConsignmentsPage"; @@ -46,14 +48,16 @@ import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import SignupPage from "./pages/accounts/SignupPage"; import VerificationOtpPage from "./pages/accounts/VerificationOtpPage"; import SetPasswordPage from "./pages/accounts/SetPasswordPage"; +import Station from "./components/stations/Station"; const sidebarItems: SidebarItem[] = [ { label: "My Portal", href: "/", icon: }, { label: "Dashboard", href: "/dashboard", icon: }, { label: "Customers", href: "/customers", icon: }, - { label: "Bookings", href: "/bookings", icon: }, + { label: "My Bookings", href: "/bookings", icon: }, { label: "Consignments", href: "/consignments", icon: }, { label: "Tracking", href: "/tracking", icon: }, + { label: "Stations", href: "/stations", icon: }, { label: "Trains", href: "/trains", icon: }, { label: "Billing", href: "/billing", icon: }, { label: "Documents", href: "/documents", icon: }, @@ -118,9 +122,10 @@ const App = () => { onLogout={handleLogout} > - } /> } /> - } /> + } /> + } /> + } /> } /> } /> } /> @@ -129,13 +134,11 @@ const App = () => { } /> } /> } /> + } /> } /> } /> } /> - } - /> + } /> } diff --git a/apps/edr-freight-web/portal/src/components/stations/Station.tsx b/apps/edr-freight-web/portal/src/components/stations/Station.tsx new file mode 100644 index 000000000..da65b3094 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/stations/Station.tsx @@ -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( + () => [...(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[] = [ + { + id: "station", + header: "Station", + cell: ({ row }) => { + const station = row.original; + return ( +
+
+ +
+
+

{station.label}

+

+ {station.note ?? "No station note"} +

+
+
+ ); + }, + }, + { + id: "value", + header: "Code", + cell: ({ row }) => ( + + {row.original.value} + + ), + }, + { + accessorKey: "order", + header: "Order", + }, + { + id: "status", + header: "Status", + cell: ({ row }) => + row.original.disabled ? ( + + + Disabled + + ) : ( + + + Active + + ), + }, + ]; + + return ( +
+
+ + + +
+

+ Stations +

+

+ Station options loaded from dropdown code{" "} + stations_ter. +

+
+ +
+ + { + setQuery(event.target.value); + setPagination({ + pageIndex: 0, + pageSize: pagination.pageSize, + }); + }} + placeholder="Search stations..." + className="pl-8!" + /> +
+
+ +
+ + + +
+ + {isError ? ( + + + + Failed to load stations.{" "} + {error instanceof Error ? error.message : "Unknown error."} + + + ) : null} + + + + Station List + + All configured freight stations from the dropdown service. + + + + + {isLoading ? ( +
+ + Loading stations... +
+ ) : ( + + )} +
+
+
+
+ ); +} + +function StationStat({ label, value }: { label: string; value: number }) { + return ( + + +
+

{label}

+

{value}

+
+
+ +
+
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/constants/FILE_SETTINGS.ts b/apps/edr-freight-web/portal/src/constants/FILE_SETTINGS.ts index 20a6c08ee..a07e2a2a5 100644 --- a/apps/edr-freight-web/portal/src/constants/FILE_SETTINGS.ts +++ b/apps/edr-freight-web/portal/src/constants/FILE_SETTINGS.ts @@ -1,3 +1,4 @@ export const FILE_SETTINGS = { - CUSTOMER_REGISTRATION: "customer_registration" + CUSTOMER_REGISTRATION: "customer_registration", + } \ No newline at end of file diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 3c17dc473..f22b31e39 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -12,7 +12,8 @@ export const URL_CONSTANTS = { GENERATE_VERIFICATION_CODE: "/api/auth/generate-verification-code", BASE: "/users", BY_ID: (id: string | number) => `/users/${id}`, - SET_PASSWORD: "/api/auth/set-password" + SET_PASSWORD: "/api/auth/set-password", + ME: "/api/auth/me" }, ROLES: { @@ -67,10 +68,11 @@ export const URL_CONSTANTS = { BY_ID: (id: string | number) => `/customers/${id}`, BOOKINGS: (id: string | number) => `/customers/${id}/bookings`, }, - + CUSTOMERS_API: { BASE: "/api/customers", BY_ID: (id: string) => `/api/customers/${id}`, + BY_USER_ID: (id: string) => `/api/customers/user/${id}` }, BOOKINGS: { diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx index 7eb038196..1ce8f76d5 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx @@ -15,7 +15,7 @@ import { import Breadcrumbs from "@/components/Breadcrumbs"; 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"; export default function BookingDetailPage() { @@ -88,7 +88,10 @@ export default function BookingDetailPage() { navigate("/bookings")} + onConfirm={() => { + deleteBooking(booking.id); + navigate("/bookings"); + }} > + + + navigate(`/bookings/${booking.id}`)} + > + + View + + + handleDeleteConfirm(booking.id)} + > + e.preventDefault()} + variant="destructive" + > + + Delete + + + + + + ); + }, + }, + ]; + + return ( +
+
+ + + {/* Header Section Card */} + +
+

+ My Bookings +

+

+ View and manage your freight booking requests. +

+
+ +
+
+ + setSearchTerm(e.target.value)} + className="pl-8!" + /> +
+ + + + +
+
+ + {/* Stat Cards */} +
+ + +
+

Total Bookings

+

+ {myBookings.length} +

+
+
+ +
+
+
+ + + +
+

Active Bookings

+

+ {activeCount} +

+
+
+ +
+
+
+ + + +
+

Pending Approval

+

+ {pendingCount} +

+
+
+ +
+
+
+
+ + {/* Data Table */} + + +
+ Recent Requests + + A list of your recent freight bookings and their statuses. + +
+ + +
+ + + {total === 0 ? ( +
+ +

No bookings found

+

+ {searchTerm ? "No bookings match your current search filter." : "You haven't requested any bookings yet."} +

+
+ ) : ( + 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} + /> + )} +
+
+
+
+ ); +} + +function StatusBadge({ status }: { status: BookingStatus }) { + const styles: Record = { + 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 ( + + {status} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index e23b44cb2..49d233167 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -5,6 +5,10 @@ import { useNavigate } from "react-router-dom"; import { CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react"; import { Button } from "@edr/ui-common"; 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 { MOCK_VALID_CONTRACTS, STEPS, @@ -35,8 +39,8 @@ export default function NewBookingPage() { const [submitted, setSubmitted] = useState(false); const form = useForm({ - resolver: zodResolver(bookingFormSchema), defaultValues: initialBookingFormValues, + resolver: zodResolver(bookingFormSchema), mode: "onChange", }); @@ -119,6 +123,123 @@ export default function NewBookingPage() { 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); setTimeout(() => navigate("/bookings"), 2500); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/bookings.mock.ts b/apps/edr-freight-web/portal/src/pages/bookings/bookings.mock.ts index 7740f7364..11bb4fc1c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/bookings.mock.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/bookings.mock.ts @@ -83,7 +83,7 @@ function pickStation(i: number, offset: number) { 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 id = i + 1; 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 { const numericId = typeof id === "string" ? Number(id) : id; return bookings.find((b) => b.id === numericId); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index 04f3f6621..1549606ed 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -46,7 +46,7 @@ export const REQUIRED_DOC_KEYS = [ "tin_certificate", "business_license", "business_registration", - "national_id", + // "national_id", ] as const; 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([ z.custom(), z.array(z.custom()), @@ -152,10 +149,10 @@ const fileValueSchema = z.union([ export const bookingFormSchema = z .object({ - contractType: z.enum(["new", "renewal", ""]), + contractType: z.enum(["new", "renewal"], "Select a contract type."), previousContractRef: z.string(), draftContractId: z.string(), - serviceType: z.enum(["rail", "rail_forwarding", ""]), + serviceType: z.enum(["rail", "rail_forwarding"], "Select a service type."), firstMileEnabled: z.boolean(), pickUpAddress: z.string(), lastMileEnabled: z.boolean(), @@ -163,9 +160,9 @@ export const bookingFormSchema = z equipmentReturn: z.enum(["with_return", "without_return"]), originYard: z.string(), destinationYard: z.string(), - cargoType: z.enum(["container", "bulk", ""]), + cargoType: z.enum(["container", "bulk"], "Select a cargo type."), cargoWeight: z.string(), - freightType: z.enum(["bulk", "break_bulk", ""]), + freightType: z.enum(["bulk", "break_bulk", ""]).default(""), bulkCommodity: z.string(), bulkCommodityOther: z.string(), breakBulkType: z.string(), @@ -191,14 +188,6 @@ export const bookingFormSchema = z termsAccepted: z.boolean(), }) .superRefine((data, ctx) => { - if (!data.contractType) { - ctx.addIssue({ - code: "custom", - path: ["contractType"], - message: "Select a contract type.", - }); - } - if (data.contractType === "new" && !data.draftContractId.trim()) { ctx.addIssue({ 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()) { ctx.addIssue({ 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.freightType) { ctx.addIssue({ @@ -385,11 +358,9 @@ export const bookingFormSchema = z export type BookingFormValues = z.infer; -export const initialBookingFormValues: BookingFormValues = { - contractType: "", +export const initialBookingFormValues: Partial = { previousContractRef: "", draftContractId: "", - serviceType: "", firstMileEnabled: false, pickUpAddress: "", lastMileEnabled: false, @@ -397,9 +368,7 @@ export const initialBookingFormValues: BookingFormValues = { equipmentReturn: "with_return", originYard: "", destinationYard: "", - cargoType: "", cargoWeight: "", - freightType: "", bulkCommodity: "", bulkCommodityOther: "", breakBulkType: "", @@ -471,6 +440,12 @@ export function getRouteDirection( dest: string, ): RouteDirection { 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 dEth = ETHIOPIA_STATIONS.has(dest); if (oEth && !dEth) return "export"; @@ -479,6 +454,13 @@ export function getRouteDirection( 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 { const Ft40Wagons = containers .filter((c) => c.type === "40ft") diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx index f9fd5bdda..6cfff801a 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx @@ -129,18 +129,24 @@ export function SelectField({ error, label, placeholder, + disabled, children, }: { field: ControllerRenderProps; error?: RhfFieldError; label: string; placeholder: string; + disabled?: boolean; children: ReactNode; }) { return ( {label} - ; +const STATION_DROPDOWN_CODE = "stations_ter"; + export function Step4Route({ form }: { form: BookingForm }) { const originYard = form.watch("originYard"); 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 directionStyle: Record = { 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", }; const directionLabel: Record = { - export: "Export workflow (Ethiopia to Djibouti)", - import: "Import workflow (Djibouti to Ethiopia)", + export: "Export workflow (inside country to outside country)", + import: "Import workflow (outside country to inside country)", domestic: "Domestic corridor", }; + const stationSelectDisabled = stationsLoading || stationOptions.length === 0; return (
@@ -29,6 +41,7 @@ export function Step4Route({ form }: { form: BookingForm }) { />
+ Route
- s !== destinationYard)} + )} @@ -55,14 +71,25 @@ export function Step4Route({ form }: { form: BookingForm }) { error={fieldState.error} label="Destination Yard *" placeholder="Select destination..." + disabled={stationSelectDisabled} > - s !== originYard)} + )} />
+ {stationsError && ( + + Failed to load stations from the API.{" "} + {stationsFetchError instanceof Error + ? stationsFetchError.message + : "Try again later."} + + )} {direction && (
); } + + +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 ( + + Loading stations... + + ); + } + + const availableOptions = options.filter( + (option) => option.value !== excludeValue, + ); + + if (availableOptions.length === 0) { + return ( + + No stations available + + ); + } + + return ( + <> + {availableOptions.map((option) => ( + + {option.label} + + ))} + + ); +} \ No newline at end of file diff --git a/apps/edr-freight-web/portal/src/pages/customers/NewCustomerPage copy.tsx b/apps/edr-freight-web/portal/src/pages/customers/NewCustomerPage copy.tsx new file mode 100644 index 000000000..459732ad8 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/customers/NewCustomerPage copy.tsx @@ -0,0 +1,223 @@ +import type { ReactNode } from "react"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; + +import { + Building2, + Mail, + Phone, + User, + Globe, + MapPin, + FileText, +} from "lucide-react"; + +export interface CustomerFormData { + companyName?: string; + customerType?: string; + contactPerson?: string; + email?: string; + phone?: string; + tinNumber?: string; + city?: string; + country?: string; + address?: string; + notes?: string; +} + +export interface NewCustomerPageProps { + mode?: "create" | "edit"; + customer?: CustomerFormData; + children?: ReactNode; +} + +export default function NewCustomerPage({ + mode = "create", + customer, + children, +}: NewCustomerPageProps = {}) { + const isEdit = mode === "edit"; + const title = isEdit ? "Edit Customer" : "New Customer"; + const description = isEdit + ? "Update existing customer information." + : "Create and manage customer information."; + const submitLabel = isEdit ? "Save Changes" : "Create Customer"; + + return ( + + + {children ?? } + + + + + {title} + + {description} + + +
+ {/* Company Name */} +
+ + +
+ + + +
+
+ + {/* Customer Type */} +
+ + + +
+ + {/* Contact Person */} +
+ + +
+ + + +
+
+ + {/* Email */} +
+ + +
+ + + +
+
+ + {/* Phone */} +
+ + +
+ + + +
+
+ + {/* TIN */} +
+ + +
+ + + +
+
+ + {/* City */} +
+ + +
+ + + +
+
+ + {/* Country */} +
+ + +
+ + + +
+
+ + {/* Address */} +
+ + +