mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
add onboarding
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) { }
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<Booking> {
|
||||
@@ -18,4 +19,76 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
findByReference(reference: string): Promise<Booking | null> {
|
||||
return this.repository.findOne({ where: { reference } });
|
||||
}
|
||||
|
||||
/** Find a booking by reference with associated files (polymorphic join). */
|
||||
async findByReferenceWithFiles(reference: string): Promise<Booking | null> {
|
||||
const booking = await this.repository
|
||||
.createQueryBuilder("booking")
|
||||
.where("booking.reference = :reference", { reference })
|
||||
.leftJoinAndMapMany(
|
||||
"booking.files",
|
||||
FileRecord,
|
||||
"file",
|
||||
"file.resource_id = booking.id AND file.resource = 'bookings'"
|
||||
)
|
||||
.getOne();
|
||||
return booking ?? null;
|
||||
}
|
||||
|
||||
/** Find a booking by ID with associated files (polymorphic join). */
|
||||
async findByIdWithFiles(id: string): Promise<Booking | null> {
|
||||
const booking = await this.repository
|
||||
.createQueryBuilder("booking")
|
||||
.where("booking.id = :id", { id })
|
||||
.leftJoinAndMapMany(
|
||||
"booking.files",
|
||||
FileRecord,
|
||||
"file",
|
||||
"file.resource_id = booking.id AND file.resource = 'bookings'"
|
||||
)
|
||||
.getOne();
|
||||
return booking ?? null;
|
||||
}
|
||||
|
||||
/** Find a compatible consolidation partner for the given booking. */
|
||||
async findConsolidationPartner(booking: Booking): Promise<Booking | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.repository.update(bookingId, {
|
||||
consolidationPartnerId: null,
|
||||
status: "PENDING_CONSOLIDATION",
|
||||
} as never);
|
||||
await this.repository.update(partnerId, {
|
||||
consolidationPartnerId: null,
|
||||
status: "PENDING_CONSOLIDATION",
|
||||
} as never);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Booking> {
|
||||
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<string, unknown> = { ...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<string, unknown> = {};
|
||||
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<Booking> {
|
||||
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<Booking> {
|
||||
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<void> {
|
||||
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<Booking> {
|
||||
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<Booking> {
|
||||
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<Booking> {
|
||||
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<Booking> {
|
||||
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<Booking> {
|
||||
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<Booking> {
|
||||
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<Booking> {
|
||||
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<Booking> {
|
||||
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<Booking> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from "@nestjs/swagger";
|
||||
|
||||
import { CreateBookingDto } from "./create-booking.dto";
|
||||
|
||||
export class UpdateBookingDto extends PartialType(CreateBookingDto) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Customer> {
|
||||
return this.customersService.create(createCustomerDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List all customers" })
|
||||
findAll() {
|
||||
findAll(): Promise<Customer[]> {
|
||||
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<Customer[]> {
|
||||
return this.customersService.searchByName(name);
|
||||
}
|
||||
|
||||
@Get("email/:email")
|
||||
findByEmail(@Param("email") email: string): Promise<Customer> {
|
||||
return this.customersService.findByEmail(email);
|
||||
}
|
||||
|
||||
@Get("vat/:vatNumber")
|
||||
findByVatNumber(@Param("vatNumber") vatNumber: string): Promise<Customer> {
|
||||
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<Customer> {
|
||||
return this.customersService.findById(id);
|
||||
}
|
||||
|
||||
@Get("user/:userId")
|
||||
findByUserId(@Param("userId", ParseUUIDPipe) userId: string): Promise<any> {
|
||||
return this.customersService.findByUserId(userId);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@ApiOperation({ summary: "Update a customer" })
|
||||
update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateCustomerDto,
|
||||
) {
|
||||
): Promise<Customer> {
|
||||
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<void> {
|
||||
return this.customersService.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -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<Customer> {
|
||||
export class CustomersRepository {
|
||||
constructor(
|
||||
@InjectRepository(Customer)
|
||||
repository: Repository<Customer>,
|
||||
) {
|
||||
super(repository);
|
||||
private readonly repository: Repository<Customer>,
|
||||
) { }
|
||||
|
||||
async create(dto: CreateCustomerDto): Promise<Customer> {
|
||||
const customer = this.repository.create(dto);
|
||||
return await this.repository.save(customer);
|
||||
}
|
||||
|
||||
/** Find a customer by their unique email. */
|
||||
findByEmail(email: string): Promise<Customer | null> {
|
||||
return this.repository.findOne({ where: { email } });
|
||||
async findAll(options?: FindManyOptions<Customer>): Promise<Customer[]> {
|
||||
return await this.repository.find(options);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Customer | null> {
|
||||
return await this.repository.findOne({ where: { id } as FindOptionsWhere<Customer> });
|
||||
}
|
||||
|
||||
async findByUserId(userId: string): Promise<Customer | null> {
|
||||
return await this.repository.findOne({ where: { userId } as FindOptionsWhere<Customer> });
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<Customer | null> {
|
||||
return await this.repository.findOne({ where: { email } as FindOptionsWhere<Customer> });
|
||||
}
|
||||
|
||||
async findByVatNumber(vatNumber: string): Promise<Customer | null> {
|
||||
return await this.repository.findOne({ where: { vatNumber } as FindOptionsWhere<Customer> });
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<Customer[]> {
|
||||
return await this.repository
|
||||
.createQueryBuilder("customer")
|
||||
.where("customer.name ILIKE :name", { name: `%${name}%` })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async findOneByEmailOrVat(email?: string, vatNumber?: string): Promise<Customer | null> {
|
||||
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<Customer>): Promise<Customer | null> {
|
||||
await this.repository.update(id, updates);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const result = await this.repository.delete(id);
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
async count(where?: any): Promise<number> {
|
||||
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<boolean> {
|
||||
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<number> {
|
||||
const count = await this.repository
|
||||
.createQueryBuilder('customer')
|
||||
.where('customer.vatNumber IS NOT NULL')
|
||||
.andWhere("customer.vatNumber != ''")
|
||||
.getCount();
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
getRepository(): Repository<Customer> {
|
||||
return this.repository;
|
||||
}
|
||||
softDelete(id: string): any {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -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<Customer> {
|
||||
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<Customer[]> {
|
||||
return this.customersRepository.findAll({ order: { name: "ASC" } });
|
||||
return this.customersRepository.findAll({
|
||||
order: { companyName: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Get customer by ID */
|
||||
async findById(id: string): Promise<Customer> {
|
||||
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<Customer> {
|
||||
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<Customer> {
|
||||
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<Customer> {
|
||||
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<Customer[]> {
|
||||
return this.customersRepository.findByName(name);
|
||||
}
|
||||
|
||||
/** Update customer */
|
||||
async update(id: string, dto: UpdateCustomerDto): Promise<Customer> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,30 +81,75 @@ 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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<Customer> {
|
||||
constructor(
|
||||
@InjectRepository(Customer)
|
||||
repository: Repository<Customer>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** Find a customer by their unique email. */
|
||||
findByEmail(email: string): Promise<Customer | null> {
|
||||
return this.repository.findOne({ where: { email } });
|
||||
}
|
||||
}
|
||||
@@ -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<Customer> {
|
||||
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<Customer[]> {
|
||||
return this.customersRepository.findAll({ order: { name: "ASC" } });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Customer> {
|
||||
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<Customer> {
|
||||
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<void> {
|
||||
await this.findById(id);
|
||||
await this.customersRepository.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from "@nestjs/swagger";
|
||||
|
||||
import { CreateCustomerDto } from "./create-customer.dto";
|
||||
|
||||
export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
const existing = await this.repository.findByCode(STATIONS_TER_CODE);
|
||||
|
||||
if (!existing) {
|
||||
await this.create({
|
||||
code: STATIONS_TER_CODE,
|
||||
label: "Stations TER",
|
||||
description:
|
||||
"Temporary freight station list used by booking origin and destination yards.",
|
||||
multiple: false,
|
||||
meta: {
|
||||
searchable: true,
|
||||
clearable: true,
|
||||
version: "temporary",
|
||||
},
|
||||
children: DEFAULT_STATION_OPTIONS,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if ((existing.children?.length ?? 0) === 0) {
|
||||
await this.repository.replaceOptions(
|
||||
existing.id,
|
||||
DEFAULT_STATION_OPTIONS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateDropdownSettingDto,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity } from "typeorm";
|
||||
|
||||
@Entity({ schema: "freight", name: "files" })
|
||||
export class FileRecord extends BaseEntity {
|
||||
@Column({ name: "resource_id", type: "uuid" })
|
||||
resourceId!: string;
|
||||
|
||||
@Column({ name: "resource", type: "varchar", length: 100 })
|
||||
resource!: string;
|
||||
|
||||
@Column({ name: "code", type: "varchar", length: 100 })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: "name", type: "varchar", length: 500 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: "url", type: "text" })
|
||||
url!: string;
|
||||
|
||||
@Column({ name: "size", type: "integer" })
|
||||
size!: number;
|
||||
|
||||
@Column({ name: "mime_type", type: "varchar", length: 255 })
|
||||
mimeType!: string;
|
||||
}
|
||||
28
apps/edr-freight-api/src/modules/files/files.controller.ts
Normal file
28
apps/edr-freight-api/src/modules/files/files.controller.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
|
||||
import { FilesService } from "./files.service";
|
||||
|
||||
@ApiTags("files")
|
||||
@Controller("files")
|
||||
export class FilesController {
|
||||
constructor(private readonly filesService: FilesService) {}
|
||||
|
||||
@Get(":fileId")
|
||||
@ApiOperation({
|
||||
summary: "Download a file by ID",
|
||||
description:
|
||||
"Global endpoint — streams any uploaded file directly from MinIO by its UUID. " +
|
||||
"No resource context (e.g. booking ID) required.",
|
||||
})
|
||||
async download(
|
||||
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { stream, record } = await this.filesService.streamById(fileId);
|
||||
res.setHeader("Content-Type", record.mimeType);
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${record.name}"`);
|
||||
stream.pipe(res);
|
||||
}
|
||||
}
|
||||
16
apps/edr-freight-api/src/modules/files/files.module.ts
Normal file
16
apps/edr-freight-api/src/modules/files/files.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { MinioModule } from "../minio/minio.module";
|
||||
import { FilesController } from "./files.controller";
|
||||
import { FilesRepository } from "./files.repository";
|
||||
import { FilesService } from "./files.service";
|
||||
import { FileRecord } from "./entities/file.entity";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([FileRecord]), MinioModule],
|
||||
controllers: [FilesController],
|
||||
providers: [FilesService, FilesRepository],
|
||||
exports: [FilesService],
|
||||
})
|
||||
export class FilesModule {}
|
||||
28
apps/edr-freight-api/src/modules/files/files.repository.ts
Normal file
28
apps/edr-freight-api/src/modules/files/files.repository.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { FileRecord } from "./entities/file.entity";
|
||||
|
||||
@Injectable()
|
||||
export class FilesRepository extends BaseRepository<FileRecord> {
|
||||
constructor(
|
||||
@InjectRepository(FileRecord)
|
||||
repository: Repository<FileRecord>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findByResource(resourceId: string, resource: string): Promise<FileRecord[]> {
|
||||
return this.repository.find({ where: { resourceId, resource } });
|
||||
}
|
||||
|
||||
findByCode(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
code: string,
|
||||
): Promise<FileRecord | null> {
|
||||
return this.repository.findOne({ where: { resourceId, resource, code } });
|
||||
}
|
||||
}
|
||||
84
apps/edr-freight-api/src/modules/files/files.service.ts
Normal file
84
apps/edr-freight-api/src/modules/files/files.service.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Readable } from "stream";
|
||||
|
||||
import { MinioService } from "../minio/minio.service";
|
||||
import { FilesRepository } from "./files.repository";
|
||||
import { FileRecord } from "./entities/file.entity";
|
||||
|
||||
export interface CreateFileInput {
|
||||
resourceId: string;
|
||||
resource: string;
|
||||
code: string;
|
||||
file: Express.Multer.File;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FilesService {
|
||||
constructor(
|
||||
private readonly filesRepository: FilesRepository,
|
||||
private readonly minioService: MinioService,
|
||||
) {}
|
||||
|
||||
async upload(input: CreateFileInput): Promise<FileRecord> {
|
||||
const { resourceId, resource, code, file } = input;
|
||||
const objectName = `${resource}/${resourceId}/${Date.now()}_${file.originalname}`;
|
||||
const url = await this.minioService.uploadFile(objectName, file.buffer, file.mimetype);
|
||||
|
||||
return this.filesRepository.create({
|
||||
resourceId,
|
||||
resource,
|
||||
code,
|
||||
name: file.originalname,
|
||||
url,
|
||||
size: file.size,
|
||||
mimeType: file.mimetype,
|
||||
});
|
||||
}
|
||||
|
||||
async uploadMany(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<FileRecord[]> {
|
||||
return Promise.all(
|
||||
files.map((file) =>
|
||||
this.upload({ resourceId, resource, code: file.fieldname, file }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<FileRecord> {
|
||||
const record = await this.filesRepository.findById(id);
|
||||
if (!record) throw new NotFoundException(`File ${id} not found`);
|
||||
return record;
|
||||
}
|
||||
|
||||
findByResource(resourceId: string, resource: string): Promise<FileRecord[]> {
|
||||
return this.filesRepository.findByResource(resourceId, resource);
|
||||
}
|
||||
|
||||
async findByCode(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
code: string,
|
||||
): Promise<FileRecord> {
|
||||
const record = await this.filesRepository.findByCode(resourceId, resource, code);
|
||||
if (!record)
|
||||
throw new NotFoundException(
|
||||
`File with code "${code}" not found for ${resource} ${resourceId}`,
|
||||
);
|
||||
return record;
|
||||
}
|
||||
|
||||
async streamById(id: string): Promise<{ stream: Readable; record: FileRecord }> {
|
||||
const record = await this.findById(id);
|
||||
const objectName = this.extractObjectName(record.url);
|
||||
const stream = await this.minioService.getFileStream(objectName);
|
||||
return { stream, record };
|
||||
}
|
||||
|
||||
private extractObjectName(url: string): string {
|
||||
const parts = url.split("/");
|
||||
return parts.slice(4).join("/");
|
||||
}
|
||||
}
|
||||
2
apps/edr-freight-api/src/modules/minio/index.ts
Normal file
2
apps/edr-freight-api/src/modules/minio/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./minio.module";
|
||||
export * from "./minio.service";
|
||||
10
apps/edr-freight-api/src/modules/minio/minio.config.ts
Normal file
10
apps/edr-freight-api/src/modules/minio/minio.config.ts
Normal file
@@ -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",
|
||||
}));
|
||||
11
apps/edr-freight-api/src/modules/minio/minio.module.ts
Normal file
11
apps/edr-freight-api/src/modules/minio/minio.module.ts
Normal file
@@ -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 {}
|
||||
75
apps/edr-freight-api/src/modules/minio/minio.service.ts
Normal file
75
apps/edr-freight-api/src/modules/minio/minio.service.ts
Normal file
@@ -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<typeof minioConfig>,
|
||||
) {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<Readable> {
|
||||
try {
|
||||
return this.client.getObject(this.bucket, objectName);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get file ${objectName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
45
apps/edr-freight-api/test-minio-upload.js
Normal file
45
apps/edr-freight-api/test-minio-upload.js
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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: <UserCircle /> },
|
||||
{ label: "Dashboard", href: "/dashboard", icon: <LayoutDashboard /> },
|
||||
{ label: "Customers", href: "/customers", icon: <Users /> },
|
||||
{ label: "Bookings", href: "/bookings", icon: <CalendarCheck /> },
|
||||
{ label: "My Bookings", href: "/bookings", icon: <CalendarCheck /> },
|
||||
{ label: "Consignments", href: "/consignments", icon: <Package /> },
|
||||
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
|
||||
{ label: "Stations", href: "/stations", icon: <MapPinned /> },
|
||||
{ label: "Trains", href: "/trains", icon: <Train /> },
|
||||
{ label: "Billing", href: "/billing", icon: <Receipt /> },
|
||||
{ label: "Documents", href: "/documents", icon: <FileText /> },
|
||||
@@ -118,9 +122,10 @@ const App = () => {
|
||||
onLogout={handleLogout}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<MyPortalPage />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/bookings" element={<BookingsPage />} />
|
||||
<Route path="/" element={<MyPortalPage />} />
|
||||
<Route path="/bookings" element={<MyBookings />} />
|
||||
<Route path="/admin/bookings" element={<BookingsPage />} />
|
||||
<Route path="/customers" element={<CustomersPage />} />
|
||||
<Route path="/customers/:id" element={<CustomerDetailPage />} />
|
||||
<Route path="/new-customer" element={<NewCustomerPage />} />
|
||||
@@ -129,13 +134,11 @@ const App = () => {
|
||||
<Route path="/consignments" element={<ConsignmentsPage />} />
|
||||
<Route path="/consignments/:id" element={<ConsignmentDetailPage />} />
|
||||
<Route path="/tracking" element={<TrackingPage />} />
|
||||
<Route path="/stations" element={<Station />} />
|
||||
<Route path="/trains" element={<TrainsPage />} />
|
||||
<Route path="/billing" element={<BillingPage />} />
|
||||
<Route path="/documents" element={<DocumentsPage />} />
|
||||
<Route
|
||||
path="/admin/dropdowns"
|
||||
element={<DropdownSettingsPage />}
|
||||
/>
|
||||
<Route path="/admin/dropdowns" element={<DropdownSettingsPage />} />
|
||||
<Route
|
||||
path="/admin/file-uploads"
|
||||
element={<FileUploadSettingsPage />}
|
||||
|
||||
228
apps/edr-freight-web/portal/src/components/stations/Station.tsx
Normal file
228
apps/edr-freight-web/portal/src/components/stations/Station.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
CircleOff,
|
||||
Loader2,
|
||||
MapPin,
|
||||
Search,
|
||||
TrainFront,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||
import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings";
|
||||
import type { DropdownOption } from "@/types/dropdownSettings";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
Input,
|
||||
type ColumnDef,
|
||||
usePagination,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
const STATION_DROPDOWN_CODE = "stations_ter";
|
||||
|
||||
export default function Station() {
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const { data, isLoading, isError, error } = useDropdownSettingByCode(
|
||||
STATION_DROPDOWN_CODE,
|
||||
);
|
||||
|
||||
const stations = useMemo<DropdownOption[]>(
|
||||
() => [...(data?.children ?? [])].sort((a, b) => a.order - b.order),
|
||||
[data?.children],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return stations;
|
||||
|
||||
return stations.filter(
|
||||
(station) =>
|
||||
station.label.toLowerCase().includes(q) ||
|
||||
station.value.toLowerCase().includes(q) ||
|
||||
(station.note ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [query, stations]);
|
||||
|
||||
const total = filtered.length;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
const end = Math.min(start + pagination.pageSize, total);
|
||||
const paginatedData = useMemo(
|
||||
() => filtered.slice(start, end),
|
||||
[end, filtered, start],
|
||||
);
|
||||
|
||||
const activeCount = stations.filter((station) => !station.disabled).length;
|
||||
const disabledCount = stations.length - activeCount;
|
||||
|
||||
const status: "loading" | "error" | "success" = isLoading
|
||||
? "loading"
|
||||
: isError
|
||||
? "error"
|
||||
: "success";
|
||||
|
||||
const columns: ColumnDef<DropdownOption>[] = [
|
||||
{
|
||||
id: "station",
|
||||
header: "Station",
|
||||
cell: ({ row }) => {
|
||||
const station = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<MapPin />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{station.label}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{station.note ?? "No station note"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "value",
|
||||
header: "Code",
|
||||
cell: ({ row }) => (
|
||||
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
|
||||
{row.original.value}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "order",
|
||||
header: "Order",
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) =>
|
||||
row.original.disabled ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
|
||||
<CircleOff className="h-3 w-3" />
|
||||
Disabled
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
<TrainFront className="h-3 w-3" />
|
||||
Active
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-6">
|
||||
<div className="space-y-6">
|
||||
<Breadcrumbs items={[{ label: "Stations" }]} />
|
||||
|
||||
<Card className="p-6 flex-row justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
||||
Stations
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-secondary-foreground">
|
||||
Station options loaded from dropdown code{" "}
|
||||
<span className="font-mono">stations_ter</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full sm:w-80">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<Input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
}}
|
||||
placeholder="Search stations..."
|
||||
className="pl-8!"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<StationStat label="Stations" value={stations.length} />
|
||||
<StationStat label="Active" value={activeCount} />
|
||||
<StationStat label="Disabled" value={disabledCount} />
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-6 text-sm text-red-600">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
Failed to load stations.{" "}
|
||||
{error instanceof Error ? error.message : "Unknown error."}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card className="gap-0">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Station List</CardTitle>
|
||||
<CardDescription>
|
||||
All configured freight stations from the dropdown service.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin text-primary" />
|
||||
Loading stations...
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={paginatedData}
|
||||
status={status}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
}}
|
||||
containerClassName="border-b shadow-none"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StationStat({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">{label}</p>
|
||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<MapPin />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export const FILE_SETTINGS = {
|
||||
CUSTOMER_REGISTRATION: "customer_registration"
|
||||
CUSTOMER_REGISTRATION: "customer_registration",
|
||||
|
||||
}
|
||||
@@ -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: {
|
||||
@@ -71,6 +72,7 @@ export const URL_CONSTANTS = {
|
||||
CUSTOMERS_API: {
|
||||
BASE: "/api/customers",
|
||||
BY_ID: (id: string) => `/api/customers/${id}`,
|
||||
BY_USER_ID: (id: string) => `/api/customers/user/${id}`
|
||||
},
|
||||
|
||||
BOOKINGS: {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
<DeleteBookingDialog
|
||||
bookingReference={booking.reference}
|
||||
onConfirm={() => navigate("/bookings")}
|
||||
onConfirm={() => {
|
||||
deleteBooking(booking.id);
|
||||
navigate("/bookings");
|
||||
}}
|
||||
>
|
||||
<Button variant="outline">
|
||||
<Trash2 />
|
||||
|
||||
333
apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx
Normal file
333
apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ArrowRight,
|
||||
Clock,
|
||||
Eye,
|
||||
Filter,
|
||||
MoreHorizontal,
|
||||
Package,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||
import DeleteBookingDialog from "./DeleteBookingDialog";
|
||||
import { getMyBookings } from "@/lib/currentCustomer";
|
||||
import { deleteBooking, type Booking, type BookingStatus } from "./bookings.mock";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
type ColumnDef,
|
||||
usePagination,
|
||||
Button,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
Input,
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
export default function MyBookings() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [myBookings, setMyBookings] = useState(() => getMyBookings());
|
||||
|
||||
const handleDeleteConfirm = (id: number) => {
|
||||
deleteBooking(id);
|
||||
setMyBookings(getMyBookings());
|
||||
};
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
return myBookings.filter((b) => {
|
||||
const term = searchTerm.toLowerCase();
|
||||
return (
|
||||
b.reference.toLowerCase().includes(term) ||
|
||||
b.originStation.toLowerCase().includes(term) ||
|
||||
b.destinationStation.toLowerCase().includes(term) ||
|
||||
b.cargoDescription.toLowerCase().includes(term) ||
|
||||
b.status.toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
}, [myBookings, searchTerm]);
|
||||
|
||||
const total = filteredData.length;
|
||||
const pageCount = Math.ceil(total / pagination.pageSize);
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
const end = Math.min(start + pagination.pageSize, total);
|
||||
|
||||
const paginatedData = useMemo(() => filteredData.slice(start, end), [filteredData, start, end]);
|
||||
|
||||
const activeCount = useMemo(() => {
|
||||
return myBookings.filter(
|
||||
(b) => b.status === "Confirmed" || b.status === "In Transit",
|
||||
).length;
|
||||
}, [myBookings]);
|
||||
|
||||
const pendingCount = useMemo(() => {
|
||||
return myBookings.filter((b) => b.status === "Pending").length;
|
||||
}, [myBookings]);
|
||||
|
||||
const columns: ColumnDef<Booking>[] = [
|
||||
{
|
||||
accessorKey: "reference",
|
||||
header: "Reference",
|
||||
cell: ({ row }) => {
|
||||
const booking = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Package className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{booking.reference}</p>
|
||||
<p className="text-sm text-slate-500">{booking.requestedDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: "Route",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<span>{row.original.originStation}</span>
|
||||
<ArrowRight className="text-slate-400" />
|
||||
<span>{row.original.destinationStation}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "cargo",
|
||||
header: "Cargo",
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
return (
|
||||
<div className="text-sm text-slate-700">
|
||||
<p>{b.cargoType}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{b.containerCount > 0 ? `${b.containerCount} × ${b.containerType} · ` : ""}{b.weightTons}t
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "transportMode",
|
||||
header: "Transport",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700">
|
||||
{row.original.transportMode}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 40,
|
||||
cell: ({ row }) => {
|
||||
const booking = row.original;
|
||||
return (
|
||||
<div
|
||||
className="flex justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate(`/bookings/${booking.id}`)}
|
||||
>
|
||||
<Eye />
|
||||
View
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DeleteBookingDialog
|
||||
bookingReference={booking.reference}
|
||||
onConfirm={() => handleDeleteConfirm(booking.id)}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e: Event) => e.preventDefault()}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DeleteBookingDialog>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-6">
|
||||
<div className="space-y-6">
|
||||
<Breadcrumbs items={[{ label: "My Bookings" }]} />
|
||||
|
||||
{/* Header Section Card */}
|
||||
<Card className="p-6 flex-row justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
||||
My Bookings
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-secondary-foreground">
|
||||
View and manage your freight booking requests.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative w-full sm:w-80">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search bookings..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8!"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Link to="/bookings/new">
|
||||
<Button>
|
||||
<Plus />
|
||||
New Booking
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Stat Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Total Bookings</p>
|
||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
||||
{myBookings.length}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<Package />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Active Bookings</p>
|
||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
||||
{activeCount}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<Truck />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Pending Approval</p>
|
||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
||||
{pendingCount}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<Clock />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
<Card className="gap-0">
|
||||
<CardHeader className="flex flex-row items-center justify-between border-b">
|
||||
<div>
|
||||
<CardTitle>Recent Requests</CardTitle>
|
||||
<CardDescription>
|
||||
A list of your recent freight bookings and their statuses.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<Button variant="secondary" size="sm">
|
||||
<Filter />
|
||||
Filter
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-0">
|
||||
{total === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 px-6 text-center">
|
||||
<Package className="h-12 w-12 text-slate-300 mb-4" />
|
||||
<h3 className="text-sm font-semibold text-slate-900">No bookings found</h3>
|
||||
<p className="text-xs text-slate-500 mt-1 max-w-sm">
|
||||
{searchTerm ? "No bookings match your current search filter." : "You haven't requested any bookings yet."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={paginatedData}
|
||||
status="success"
|
||||
onRowClick={(row) => navigate(`/bookings/${(row as Booking).id}`)}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount: pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
}}
|
||||
containerClassName="border-b shadow-none"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: BookingStatus }) {
|
||||
const styles: Record<BookingStatus, string> = {
|
||||
Pending: "bg-amber-100 text-amber-700",
|
||||
Confirmed: "bg-sky-100 text-sky-700",
|
||||
"In Transit": "bg-indigo-100 text-indigo-700",
|
||||
Delivered: "bg-emerald-100 text-emerald-700",
|
||||
Cancelled: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import { useNavigate } from "react-router-dom";
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { 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<BookingFormValues>({
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<File>(),
|
||||
z.array(z.custom<File>()),
|
||||
@@ -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<typeof bookingFormSchema>;
|
||||
|
||||
export const initialBookingFormValues: BookingFormValues = {
|
||||
contractType: "",
|
||||
export const initialBookingFormValues: Partial<BookingFormValues> = {
|
||||
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")
|
||||
|
||||
@@ -129,18 +129,24 @@ export function SelectField({
|
||||
error,
|
||||
label,
|
||||
placeholder,
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
field: ControllerRenderProps<BookingFormValues>;
|
||||
error?: RhfFieldError;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Field data-invalid={Boolean(error)}>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
<Select value={String(field.value)} onValueChange={field.onChange}>
|
||||
<Select
|
||||
value={String(field.value)}
|
||||
onValueChange={field.onChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn("w-full ", error ? "border-destructive!" : "")}
|
||||
aria-invalid={Boolean(error)}
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { Flame, MapPin, Snowflake } from "lucide-react";
|
||||
import { Field, Separator, Switch } from "@edr/ui-common";
|
||||
import { Field, SelectItem, Separator, Switch } from "@edr/ui-common";
|
||||
import { type BookingFormValues, getRouteDirection, STATIONS } from "./schema";
|
||||
import { SelectField, SelectOptions, StepHeader, StepLabel } from "./shared";
|
||||
import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings";
|
||||
import { DropdownOption } from "@/types/dropdownSettings";
|
||||
|
||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
||||
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
@@ -29,6 +41,7 @@ export function Step4Route({ form }: { form: BookingForm }) {
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<StepLabel>Route</StepLabel>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Controller
|
||||
name="originYard"
|
||||
@@ -39,9 +52,12 @@ export function Step4Route({ form }: { form: BookingForm }) {
|
||||
error={fieldState.error}
|
||||
label="Origin Yard*"
|
||||
placeholder="Select origin..."
|
||||
disabled={stationSelectDisabled}
|
||||
>
|
||||
<SelectOptions
|
||||
options={STATIONS.filter((s) => s !== destinationYard)}
|
||||
<StationSelectOptions
|
||||
options={stationOptions}
|
||||
excludeValue={destinationYard}
|
||||
isLoading={stationsLoading}
|
||||
/>
|
||||
</SelectField>
|
||||
)}
|
||||
@@ -55,14 +71,25 @@ export function Step4Route({ form }: { form: BookingForm }) {
|
||||
error={fieldState.error}
|
||||
label="Destination Yard *"
|
||||
placeholder="Select destination..."
|
||||
disabled={stationSelectDisabled}
|
||||
>
|
||||
<SelectOptions
|
||||
options={STATIONS.filter((s) => s !== originYard)}
|
||||
<StationSelectOptions
|
||||
options={stationOptions}
|
||||
excludeValue={originYard}
|
||||
isLoading={stationsLoading}
|
||||
/>
|
||||
</SelectField>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{stationsError && (
|
||||
<AlertBox tone="error">
|
||||
Failed to load stations from the API.{" "}
|
||||
{stationsFetchError instanceof Error
|
||||
? stationsFetchError.message
|
||||
: "Try again later."}
|
||||
</AlertBox>
|
||||
)}
|
||||
{direction && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium ${directionStyle[direction]}`}
|
||||
@@ -117,3 +144,53 @@ export function Step4Route({ form }: { form: BookingForm }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function getStationOptions(options?: DropdownOption[]): DropdownOption[] {
|
||||
return [...(options ?? [])].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
|
||||
function StationSelectOptions({
|
||||
options,
|
||||
excludeValue,
|
||||
isLoading,
|
||||
}: {
|
||||
options: DropdownOption[];
|
||||
excludeValue: string;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SelectItem value="__stations_loading" disabled>
|
||||
Loading stations...
|
||||
</SelectItem>
|
||||
);
|
||||
}
|
||||
|
||||
const availableOptions = options.filter(
|
||||
(option) => option.value !== excludeValue,
|
||||
);
|
||||
|
||||
if (availableOptions.length === 0) {
|
||||
return (
|
||||
<SelectItem value="__stations_empty" disabled>
|
||||
No stations available
|
||||
</SelectItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{availableOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.id}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
||||
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
||||
{/* Company Name */}
|
||||
<div className="space-y-2">
|
||||
<Label>Company Name *</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Building2 className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
|
||||
<Input
|
||||
defaultValue={customer?.companyName ?? ""}
|
||||
placeholder="Enter company name"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer Type */}
|
||||
<div className="space-y-2">
|
||||
<Label>Customer Type *</Label>
|
||||
|
||||
<select
|
||||
defaultValue={customer?.customerType ?? "Importer"}
|
||||
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
|
||||
>
|
||||
<option>Importer</option>
|
||||
<option>Exporter</option>
|
||||
<option>Supplier</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Contact Person */}
|
||||
<div className="space-y-2">
|
||||
<Label>Contact Person</Label>
|
||||
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
|
||||
<Input
|
||||
defaultValue={customer?.contactPerson ?? ""}
|
||||
placeholder="Enter contact person"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<Label>Email *</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
defaultValue={customer?.email ?? ""}
|
||||
placeholder="Enter email"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div className="space-y-2">
|
||||
<Label>Phone</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
|
||||
<Input
|
||||
defaultValue={customer?.phone ?? ""}
|
||||
placeholder="Enter phone"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TIN */}
|
||||
<div className="space-y-2">
|
||||
<Label>TIN Number</Label>
|
||||
|
||||
<div className="relative">
|
||||
<FileText className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
|
||||
<Input
|
||||
defaultValue={customer?.tinNumber ?? ""}
|
||||
placeholder="Enter TIN number"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* City */}
|
||||
<div className="space-y-2">
|
||||
<Label>City</Label>
|
||||
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
|
||||
<Input
|
||||
defaultValue={customer?.city ?? ""}
|
||||
placeholder="Enter city"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Country */}
|
||||
<div className="space-y-2">
|
||||
<Label>Country</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Globe className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
|
||||
<Input
|
||||
defaultValue={customer?.country ?? ""}
|
||||
placeholder="Enter country"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Address</Label>
|
||||
|
||||
<Textarea
|
||||
defaultValue={customer?.address ?? ""}
|
||||
placeholder="Enter address"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Notes</Label>
|
||||
|
||||
<Textarea
|
||||
defaultValue={customer?.notes ?? ""}
|
||||
placeholder="Additional notes..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline">Cancel</Button>
|
||||
|
||||
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,19 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Input,
|
||||
Label,
|
||||
Button,
|
||||
Textarea,
|
||||
SmartFileInput,
|
||||
} from "@edr/ui-common";
|
||||
} 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,
|
||||
@@ -25,360 +23,602 @@ import {
|
||||
Globe,
|
||||
MapPin,
|
||||
FileText,
|
||||
CreditCard,
|
||||
Briefcase,
|
||||
Users,
|
||||
UserCircle,
|
||||
StickyNote,
|
||||
} from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import { customersService } from "@/services/customers.service";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
useCreateCustomer,
|
||||
useUpdateCustomer,
|
||||
} from "@/hooks/useCustomers";
|
||||
import { getFileUploadSettingByCode } from "@/services/fileUploadSettings.service";
|
||||
import { FILE_SETTINGS } from "@/constants/FILE_SETTINGS";
|
||||
import type {
|
||||
CreateCustomerDto,
|
||||
Customer,
|
||||
CustomerStatus,
|
||||
CustomerType,
|
||||
} from "@/types/customers";
|
||||
|
||||
const CUSTOMER_TYPES: CustomerType[] = ["Importer", "Exporter", "Supplier"];
|
||||
const CUSTOMER_STATUSES: CustomerStatus[] = ["Active", "Pending", "Inactive"];
|
||||
export interface CustomerFormData {
|
||||
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;
|
||||
}
|
||||
|
||||
export interface NewCustomerPageProps {
|
||||
mode?: "create" | "edit";
|
||||
customer?: Customer;
|
||||
customer?: Partial<CustomerFormData>;
|
||||
children?: ReactNode;
|
||||
/** Controlled open. When omitted, the dialog manages its own open state. */
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
company: string;
|
||||
customerType: CustomerType;
|
||||
status: CustomerStatus;
|
||||
tinNumber: string;
|
||||
city: string;
|
||||
country: string;
|
||||
address: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
company: "",
|
||||
customerType: "Importer",
|
||||
status: "Active",
|
||||
tinNumber: "",
|
||||
city: "",
|
||||
country: "",
|
||||
address: "",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
const fromCustomer = (c: Customer): FormState => ({
|
||||
name: c.name ?? "",
|
||||
email: c.email ?? "",
|
||||
phone: c.phone ?? "",
|
||||
company: c.company ?? "",
|
||||
customerType: c.customerType ?? "Importer",
|
||||
status: c.status ?? "Active",
|
||||
tinNumber: c.tinNumber ?? "",
|
||||
city: c.city ?? "",
|
||||
country: c.country ?? "",
|
||||
address: c.address ?? "",
|
||||
notes: c.notes ?? "",
|
||||
});
|
||||
|
||||
export default function NewCustomerPage({
|
||||
mode = "create",
|
||||
customer,
|
||||
children,
|
||||
open: openProp,
|
||||
onOpenChange,
|
||||
}: NewCustomerPageProps = {}) {
|
||||
const isEdit = mode === "edit";
|
||||
const isControlled = openProp !== undefined;
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const open = isControlled ? openProp : internalOpen;
|
||||
const setOpen = (next: boolean) => {
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
|
||||
const [form, setForm] = useState<FormState>(
|
||||
customer ? fromCustomer(customer) : emptyForm(),
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [files, setFiles] = useState<Record<string, File | File[] | null>>({});
|
||||
|
||||
// Reset form whenever the dialog opens with a different customer.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setForm(customer ? fromCustomer(customer) : emptyForm());
|
||||
setError(null);
|
||||
}
|
||||
}, [open, customer]);
|
||||
|
||||
const { data: customerRegistrationFiles } = useQuery(
|
||||
getFileUploadSettingByCode.queryOptions({
|
||||
input: FILE_SETTINGS.CUSTOMER_REGISTRATION,
|
||||
}),
|
||||
);
|
||||
|
||||
const createMutation = useCreateCustomer();
|
||||
const updateMutation = useUpdateCustomer();
|
||||
const pending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const handleSubmit = () => {
|
||||
setError(null);
|
||||
|
||||
if (!form.name.trim() || !form.email.trim() || !form.phone.trim()) {
|
||||
setError("Name, email, and phone are required.");
|
||||
return;
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
|
||||
setError("Please enter a valid email address.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: CreateCustomerDto = {
|
||||
name: form.name.trim(),
|
||||
email: form.email.trim(),
|
||||
phone: form.phone.trim(),
|
||||
customerType: form.customerType,
|
||||
status: form.status,
|
||||
company: form.company.trim() || undefined,
|
||||
tinNumber: form.tinNumber.trim() || undefined,
|
||||
city: form.city.trim() || undefined,
|
||||
country: form.country.trim() || undefined,
|
||||
address: form.address.trim() || undefined,
|
||||
notes: form.notes.trim() || undefined,
|
||||
};
|
||||
|
||||
const onDone = () => {
|
||||
setOpen(false);
|
||||
if (!isEdit) setForm(emptyForm());
|
||||
};
|
||||
const onError = (err: unknown) => {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Something went wrong. Try again.",
|
||||
);
|
||||
};
|
||||
|
||||
if (isEdit && customer) {
|
||||
updateMutation.mutate(
|
||||
{ id: customer.id, dto: payload },
|
||||
{ onSuccess: onDone, onError },
|
||||
);
|
||||
} else {
|
||||
createMutation.mutate(payload, { onSuccess: onDone, onError });
|
||||
}
|
||||
};
|
||||
|
||||
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";
|
||||
const currentUser = JSON.parse(localStorage.getItem("currentUser")?? "{}");
|
||||
console.log(currentUser)
|
||||
const [formData, setFormData] = useState<CustomerFormData>({
|
||||
firstName: currentUser?.name?.en?.split(" ")?.[0] ?? "",
|
||||
lastName: currentUser?.name?.en?.split(" ")?.[1] ?? "",
|
||||
email: currentUser?.email ?? "",
|
||||
phone: currentUser?.phoneNumber ?? "",
|
||||
companyName: customer?.companyName ?? "",
|
||||
companyEmail: customer?.companyEmail ?? "",
|
||||
companyPhone: customer?.companyPhone ?? "",
|
||||
companyLocation: customer?.companyLocation ?? "",
|
||||
companyAddress: customer?.companyAddress ?? "",
|
||||
contactPersonName: customer?.contactPersonName ?? "",
|
||||
contactPersonPhone: customer?.contactPersonPhone ?? "",
|
||||
tinNumber: customer?.tinNumber ?? "",
|
||||
vatNumber: customer?.vatNumber ?? "",
|
||||
fanNumber: customer?.fanNumber ?? "",
|
||||
generalManagerName: customer?.generalManagerName ?? "",
|
||||
generalManagerEmail: customer?.generalManagerEmail ?? "",
|
||||
generalManagerPhone: customer?.generalManagerPhone ?? "",
|
||||
poaName: customer?.poaName ?? "",
|
||||
poaPhone: customer?.poaPhone ?? "",
|
||||
poaAddress: customer?.poaAddress ?? "",
|
||||
poaEmail: customer?.poaEmail ?? "",
|
||||
poaLocation: customer?.poaLocation ?? "",
|
||||
notes: customer?.notes ?? "",
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const {
|
||||
|
||||
companyName,
|
||||
companyEmail,
|
||||
companyPhone,
|
||||
companyLocation,
|
||||
companyAddress,
|
||||
contactPersonName,
|
||||
contactPersonPhone,
|
||||
tinNumber,
|
||||
vatNumber,
|
||||
fanNumber,
|
||||
generalManagerName,
|
||||
generalManagerEmail,
|
||||
generalManagerPhone,
|
||||
} = formData;
|
||||
|
||||
if (
|
||||
|
||||
!companyName ||
|
||||
!companyEmail ||
|
||||
!companyPhone ||
|
||||
!companyLocation ||
|
||||
!companyAddress ||
|
||||
!contactPersonName ||
|
||||
!contactPersonPhone ||
|
||||
!tinNumber ||
|
||||
!vatNumber ||
|
||||
!fanNumber ||
|
||||
!generalManagerName ||
|
||||
!generalManagerEmail ||
|
||||
!generalManagerPhone
|
||||
) {
|
||||
alert("Please fill all mandatory fields.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(companyEmail)) {
|
||||
alert("Please enter a valid email address.");
|
||||
return false;
|
||||
}
|
||||
if (!emailRegex.test(companyEmail)) {
|
||||
alert("Please enter a valid company email address.");
|
||||
return false;
|
||||
}
|
||||
if (!emailRegex.test(generalManagerEmail)) {
|
||||
alert("Please enter a valid general manager email address.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (tinNumber.length !== 10 || !/^\d+$/.test(tinNumber)) {
|
||||
alert("TIN must be exactly 10 digits.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fanNumber.length !== 16 || !/^\d+$/.test(fanNumber)) {
|
||||
alert("FAN must be exactly 16 digits.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const apiUrl = `${import.meta.env.VITE_API_URL}/api${URL_CONSTANTS.CUSTOMERS.BASE}`;
|
||||
|
||||
// const response = await fetch(apiUrl, {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// },
|
||||
// body: JSON.stringify({...formData, userId: currentUser?.id}),
|
||||
// });
|
||||
|
||||
const response = await customersService.create({...formData, userId: currentUser?.id})
|
||||
|
||||
console.log(";;;;", response)
|
||||
if(response){
|
||||
// navigate("/")
|
||||
window.navigation.reload();
|
||||
}
|
||||
if (!response) {
|
||||
// throw new Error(data.message || `Failed to ${isEdit ? 'update' : 'create'} customer`);
|
||||
}
|
||||
|
||||
// console.log(`Customer ${isEdit ? 'updated' : 'created'}:`, data);
|
||||
alert(`Customer ${isEdit ? 'updated' : 'created'} successfully!`);
|
||||
|
||||
// Close dialog or reset form here if needed
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert(error instanceof Error ? error.message : `Failed to ${isEdit ? 'update' : 'create'} customer`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
{!isControlled ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
|
||||
</DialogTrigger>
|
||||
) : null}
|
||||
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl!">
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl rounded-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
||||
<Field label="Company Name">
|
||||
<Building2 className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
{/* Personal Information Section */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<User className="h-5 w-5 text-[#10B981]" />
|
||||
<h3 className="font-semibold text-lg">Personal Information</h3>
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{/* First Name */}
|
||||
<div className="space-y-2">
|
||||
<Label>First Name <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={form.company}
|
||||
onChange={(e) => set("company", e.target.value)}
|
||||
placeholder="Enter company name"
|
||||
name="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter first name"
|
||||
className="pl-10"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Customer Type *</Label>
|
||||
<select
|
||||
value={form.customerType}
|
||||
onChange={(e) =>
|
||||
set("customerType", e.target.value as CustomerType)
|
||||
}
|
||||
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
|
||||
>
|
||||
{CUSTOMER_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="Contact Person *">
|
||||
<User className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
{/* Last Name */}
|
||||
<div className="space-y-2">
|
||||
<Label>Last Name <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
placeholder="Enter contact person"
|
||||
name="lastName"
|
||||
value={formData.lastName}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter last name"
|
||||
className="pl-10"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="Email *">
|
||||
<Mail className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<Label>Email <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => set("email", e.target.value)}
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter email"
|
||||
className="pl-10"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Phone *">
|
||||
<Phone className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={form.phone}
|
||||
onChange={(e) => set("phone", e.target.value)}
|
||||
placeholder="Enter phone"
|
||||
className="pl-10"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="TIN Number">
|
||||
<FileText className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={form.tinNumber}
|
||||
onChange={(e) => set("tinNumber", e.target.value)}
|
||||
placeholder="Enter TIN number"
|
||||
className="pl-10"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="City">
|
||||
<MapPin className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={form.city}
|
||||
onChange={(e) => set("city", e.target.value)}
|
||||
placeholder="Enter city"
|
||||
className="pl-10"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Country">
|
||||
<Globe className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={form.country}
|
||||
onChange={(e) => set("country", e.target.value)}
|
||||
placeholder="Enter country"
|
||||
className="pl-10"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div className="space-y-2">
|
||||
<Label>Status</Label>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={(e) => set("status", e.target.value as CustomerStatus)}
|
||||
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
|
||||
>
|
||||
{CUSTOMER_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Label>Phone <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter phone number"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Company Information Section */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-3 mt-2">
|
||||
<Building2 className="h-5 w-5 text-[#10B981]" />
|
||||
<h3 className="font-semibold text-lg">Company Information</h3>
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{/* Company Name */}
|
||||
<div className="space-y-2">
|
||||
<Label>Company Name <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<Building2 className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
name="companyName"
|
||||
value={formData.companyName}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter company name"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Company Email */}
|
||||
<div className="space-y-2">
|
||||
<Label>Company Email <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="email"
|
||||
name="companyEmail"
|
||||
value={formData.companyEmail}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter company email"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Company Phone */}
|
||||
<div className="space-y-2">
|
||||
<Label>Company Phone <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
name="companyPhone"
|
||||
value={formData.companyPhone}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter company phone"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Company Location */}
|
||||
<div className="space-y-2">
|
||||
<Label>Company Location <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
name="companyLocation"
|
||||
value={formData.companyLocation}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter company location"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Company Address */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Address</Label>
|
||||
<Label>Company Address <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Textarea
|
||||
value={form.address}
|
||||
onChange={(e) => set("address", e.target.value)}
|
||||
placeholder="Enter address"
|
||||
name="companyAddress"
|
||||
value={formData.companyAddress}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter company address"
|
||||
className="pl-10 resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tax & Registration Section */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-3 mt-2">
|
||||
<CreditCard className="h-5 w-5 text-[#10B981]" />
|
||||
<h3 className="font-semibold text-lg">Tax & Registration Numbers</h3>
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
{/* TIN Number */}
|
||||
<div className="space-y-2">
|
||||
<Label>TIN Number <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
name="tinNumber"
|
||||
value={formData.tinNumber}
|
||||
onChange={handleChange}
|
||||
placeholder="10-digit TIN"
|
||||
maxLength={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* VAT Number */}
|
||||
<div className="space-y-2">
|
||||
<Label>VAT Number <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
name="vatNumber"
|
||||
value={formData.vatNumber}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter VAT number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* FAN Number */}
|
||||
<div className="space-y-2">
|
||||
<Label>FAN Number <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
name="fanNumber"
|
||||
value={formData.fanNumber}
|
||||
onChange={handleChange}
|
||||
placeholder="16-digit FAN"
|
||||
maxLength={16}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* General Manager Section */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-3 mt-2">
|
||||
<Briefcase className="h-5 w-5 text-[#10B981]" />
|
||||
<h3 className="font-semibold text-lg">General Manager</h3>
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{/* General Manager Name */}
|
||||
<div className="space-y-2">
|
||||
<Label>General Manager Name <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<UserCircle className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
name="generalManagerName"
|
||||
value={formData.generalManagerName}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter general manager name"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* General Manager Email */}
|
||||
<div className="space-y-2">
|
||||
<Label>General Manager Email <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="email"
|
||||
name="generalManagerEmail"
|
||||
value={formData.generalManagerEmail}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter general manager email"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* General Manager Phone */}
|
||||
<div className="space-y-2">
|
||||
<Label>General Manager Phone <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
name="generalManagerPhone"
|
||||
value={formData.generalManagerPhone}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter general manager phone"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Person Section */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-3 mt-2">
|
||||
<Users className="h-5 w-5 text-[#10B981]" />
|
||||
<h3 className="font-semibold text-lg">Contact Person</h3>
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{/* Contact Person Name */}
|
||||
<div className="space-y-2">
|
||||
<Label>Contact Person Name <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
name="contactPersonName"
|
||||
value={formData.contactPersonName}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter contact person name"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Person Phone */}
|
||||
<div className="space-y-2">
|
||||
<Label>Contact Person Phone <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
name="contactPersonPhone"
|
||||
value={formData.contactPersonPhone}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter contact person phone"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Power of Attorney Section (Optional) */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-3 mt-2">
|
||||
<FileText className="h-5 w-5 text-[#10B981]" />
|
||||
<h3 className="font-semibold text-lg">Power of Attorney (Optional)</h3>
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{/* POA Name */}
|
||||
<div className="space-y-2">
|
||||
<Label>PoA Name</Label>
|
||||
<Input
|
||||
name="poaName"
|
||||
value={formData.poaName ?? ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter PoA name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* POA Phone */}
|
||||
<div className="space-y-2">
|
||||
<Label>PoA Phone</Label>
|
||||
<Input
|
||||
name="poaPhone"
|
||||
value={formData.poaPhone ?? ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter PoA phone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* POA Email */}
|
||||
<div className="space-y-2">
|
||||
<Label>PoA Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
name="poaEmail"
|
||||
value={formData.poaEmail ?? ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter PoA email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* POA Location */}
|
||||
<div className="space-y-2">
|
||||
<Label>PoA Location</Label>
|
||||
<Input
|
||||
name="poaLocation"
|
||||
value={formData.poaLocation ?? ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter PoA location"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* POA Address */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Notes</Label>
|
||||
<Label>PoA Address</Label>
|
||||
<Textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => set("notes", e.target.value)}
|
||||
placeholder="Additional notes..."
|
||||
name="poaAddress"
|
||||
value={formData.poaAddress ?? ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter PoA address"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes Section */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-3 mt-2">
|
||||
<StickyNote className="h-5 w-5 text-[#10B981]" />
|
||||
<h3 className="font-semibold text-lg">Additional Notes</h3>
|
||||
</div>
|
||||
<Textarea
|
||||
name="notes"
|
||||
value={formData.notes ?? ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Add any additional notes about the customer..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{customerRegistrationFiles ? (
|
||||
<div>
|
||||
<SmartFileInput
|
||||
file={customerRegistrationFiles}
|
||||
value={files}
|
||||
onChange={setFiles}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<div className="flex justify-end gap-3 mt-4">
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-[#10B981] text-white hover:bg-[#10B981]/90"
|
||||
onClick={handleSubmit}
|
||||
disabled={pending}
|
||||
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
submitLabel
|
||||
)}
|
||||
{isSubmitting ? "Submitting..." : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{label}</Label>
|
||||
<div className="relative">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
FileText,
|
||||
Globe,
|
||||
Loader2,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
StickyNote,
|
||||
Trash2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||
import NewCustomerPage from "./NewCustomerPage";
|
||||
import DeleteCustomerDialog from "./DeleteCustomerDialog";
|
||||
import { useCustomer, useDeleteCustomer } from "@/hooks/useCustomers";
|
||||
import type { CustomerStatus } from "@/types/customers";
|
||||
|
||||
export default function CustomerDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: customer, isLoading, isError, error } = useCustomer(id);
|
||||
const deleteMutation = useDeleteCustomer();
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 p-6">
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<Breadcrumbs
|
||||
items={[{ label: "Customers", href: "/customers" }, { label: "…" }]}
|
||||
/>
|
||||
<div className="flex items-center justify-center rounded-3xl bg-white p-12 text-sm text-slate-500 shadow-sm">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-[#10B981]" />
|
||||
Loading customer…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !customer) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 p-6">
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: "Customers", href: "/customers" },
|
||||
{ label: "Not found" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="rounded-3xl bg-white p-8 text-center shadow-sm">
|
||||
<AlertCircle className="mx-auto mb-3 h-6 w-6 text-red-500" />
|
||||
<h1 className="text-2xl font-bold text-slate-900">
|
||||
{isError ? "Failed to load customer" : "Customer not found"}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
{isError && error instanceof Error
|
||||
? error.message
|
||||
: "The customer you're looking for doesn't exist or has been removed."}
|
||||
</p>
|
||||
<Link
|
||||
to="/customers"
|
||||
className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Customers
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteMutation.mutate(customer.id, {
|
||||
onSuccess: () => navigate("/customers"),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 p-6">
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: "Customers", href: "/customers" },
|
||||
{ label: customer.name },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#10B981] text-white">
|
||||
<User className="h-8 w-8" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
||||
{customer.name}
|
||||
</h1>
|
||||
<div className="mt-1 flex items-center gap-3 text-sm text-slate-500">
|
||||
<span className="font-mono text-xs">#{customer.id.slice(0, 8)}</span>
|
||||
<span className="text-slate-300">•</span>
|
||||
<span>{customer.company ?? "—"}</span>
|
||||
<span className="text-slate-300">•</span>
|
||||
<StatusBadge status={customer.status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditOpen(true)}
|
||||
className="inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
|
||||
>
|
||||
Edit Customer
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-red-200 px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail grid */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<DetailCard title="Company Information">
|
||||
<DetailRow
|
||||
icon={<Building2 className="h-4 w-4" />}
|
||||
label="Company Name"
|
||||
value={customer.company ?? "—"}
|
||||
/>
|
||||
<DetailRow
|
||||
icon={<FileText className="h-4 w-4" />}
|
||||
label="Customer Type"
|
||||
value={customer.customerType}
|
||||
/>
|
||||
<DetailRow
|
||||
icon={<FileText className="h-4 w-4" />}
|
||||
label="TIN Number"
|
||||
value={customer.tinNumber ?? "—"}
|
||||
/>
|
||||
</DetailCard>
|
||||
|
||||
<DetailCard title="Contact">
|
||||
<DetailRow
|
||||
icon={<User className="h-4 w-4" />}
|
||||
label="Contact Person"
|
||||
value={customer.name}
|
||||
/>
|
||||
<DetailRow
|
||||
icon={<Mail className="h-4 w-4" />}
|
||||
label="Email"
|
||||
value={customer.email}
|
||||
/>
|
||||
<DetailRow
|
||||
icon={<Phone className="h-4 w-4" />}
|
||||
label="Phone"
|
||||
value={customer.phone}
|
||||
/>
|
||||
</DetailCard>
|
||||
|
||||
<DetailCard title="Location">
|
||||
<DetailRow
|
||||
icon={<MapPin className="h-4 w-4" />}
|
||||
label="City"
|
||||
value={customer.city ?? "—"}
|
||||
/>
|
||||
<DetailRow
|
||||
icon={<Globe className="h-4 w-4" />}
|
||||
label="Country"
|
||||
value={customer.country ?? "—"}
|
||||
/>
|
||||
<DetailRow
|
||||
icon={<MapPin className="h-4 w-4" />}
|
||||
label="Address"
|
||||
value={customer.address ?? "—"}
|
||||
/>
|
||||
</DetailCard>
|
||||
|
||||
<DetailCard title="Notes">
|
||||
<div className="flex items-start gap-3 text-sm text-slate-700">
|
||||
<StickyNote className="mt-0.5 h-4 w-4 text-[#10B981]" />
|
||||
<p className="leading-relaxed">{customer.notes ?? "—"}</p>
|
||||
</div>
|
||||
</DetailCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NewCustomerPage
|
||||
mode="edit"
|
||||
customer={customer}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
<DeleteCustomerDialog
|
||||
customerName={customer.name}
|
||||
onConfirm={handleDelete}
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailCard({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
|
||||
<div className="mt-4 space-y-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 text-[#10B981]">{icon}</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-medium text-slate-500">{label}</p>
|
||||
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: CustomerStatus }) {
|
||||
const styles: Record<CustomerStatus, string> = {
|
||||
Active: "bg-emerald-100 text-emerald-700",
|
||||
Pending: "bg-amber-100 text-amber-700",
|
||||
Inactive: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
AlertCircle,
|
||||
Clock3,
|
||||
Eye,
|
||||
Filter,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
User,
|
||||
UserCheck,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||
import NewCustomerPage from "./NewCustomerPage";
|
||||
import DeleteCustomerDialog from "./DeleteCustomerDialog";
|
||||
import { useCustomers, useDeleteCustomer } from "@/hooks/useCustomers";
|
||||
import type { Customer, CustomerStatus } from "@/types/customers";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
type ColumnDef,
|
||||
usePagination,
|
||||
Button,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
Input,
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
type ActiveDialog = "edit" | "delete";
|
||||
|
||||
export default function CustomerPage() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const [activeDialog, setActiveDialog] = useState<ActiveDialog | null>(null);
|
||||
const [activeCustomer, setActiveCustomer] = useState<Customer | null>(null);
|
||||
|
||||
const openDialogFor = (dialog: ActiveDialog, customer: Customer) => {
|
||||
// Defer past the DropdownMenu close cycle so Radix doesn't leave
|
||||
// `pointer-events: none` on <body>.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
document.body.style.pointerEvents = "";
|
||||
setActiveCustomer(customer);
|
||||
setActiveDialog(dialog);
|
||||
});
|
||||
});
|
||||
};
|
||||
const closeDialog = () => setActiveDialog(null);
|
||||
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => {
|
||||
if (document.body.style.pointerEvents === "none") {
|
||||
document.body.style.pointerEvents = "";
|
||||
}
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [activeDialog]);
|
||||
|
||||
const { data, isLoading, isError, error } = useCustomers();
|
||||
const deleteMutation = useDeleteCustomer();
|
||||
|
||||
const customers = useMemo<Customer[]>(
|
||||
() => (Array.isArray(data) ? data : []),
|
||||
[data],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return customers;
|
||||
return customers.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.email.toLowerCase().includes(q) ||
|
||||
(c.company ?? "").toLowerCase().includes(q) ||
|
||||
c.phone.toLowerCase().includes(q),
|
||||
);
|
||||
}, [customers, query]);
|
||||
|
||||
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),
|
||||
[start, end, filtered],
|
||||
);
|
||||
|
||||
const activeCount = customers.filter((c) => c.status === "Active").length;
|
||||
const pendingCount = customers.filter((c) => c.status === "Pending").length;
|
||||
|
||||
const status: "loading" | "error" | "success" = isLoading
|
||||
? "loading"
|
||||
: isError
|
||||
? "error"
|
||||
: "success";
|
||||
|
||||
const columns: ColumnDef<Customer>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Customer",
|
||||
cell: ({ row }) => {
|
||||
const customer = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-secondary text-secondary-foreground border">
|
||||
<User className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{customer.name}</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
{customer.company ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: "Email",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700">{row.original.email}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "phone",
|
||||
header: "Phone",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700">{row.original.phone}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "customerType",
|
||||
header: "Type",
|
||||
cell: ({ row }) => (
|
||||
<span className="inline-flex rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-600">
|
||||
{row.original.customerType}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 40,
|
||||
cell: ({ row }) => {
|
||||
const customer = row.original;
|
||||
return (
|
||||
<div
|
||||
className="flex justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => navigate(`/customers/${customer.id}`)}
|
||||
>
|
||||
<Eye />
|
||||
View
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => openDialogFor("edit", customer)}
|
||||
>
|
||||
<Pencil />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => openDialogFor("delete", customer)}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-6">
|
||||
<div className="space-y-6">
|
||||
<Breadcrumbs items={[{ label: "Customers" }]} />
|
||||
|
||||
<Card className="p-6 flex-row justify-between ">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
||||
Customers
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-secondary-foreground ">
|
||||
Manage and monitor your customer records.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative w-full sm:w-80">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<Input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
}}
|
||||
placeholder="Search customers..."
|
||||
className="pl-8!"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NewCustomerPage>
|
||||
<Button>
|
||||
<Plus />
|
||||
Add Customer
|
||||
</Button>
|
||||
</NewCustomerPage>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<StatCard
|
||||
title="Total Customers"
|
||||
value={customers.length}
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Accounts"
|
||||
value={activeCount}
|
||||
icon={<UserCheck className="h-5 w-5" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="Pending Requests"
|
||||
value={pendingCount}
|
||||
icon={<Clock3 className="h-5 w-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-6 text-sm text-red-600">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
Failed to load customers.{" "}
|
||||
{error instanceof Error ? error.message : "Unknown error."}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card className="gap-0">
|
||||
<CardHeader className="flex flex-row items-center justify-between border-b ">
|
||||
<div>
|
||||
<CardTitle>Customer List</CardTitle>
|
||||
<CardDescription>
|
||||
Recent customer activities and records.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<Button variant="secondary" size="sm">
|
||||
<Filter />
|
||||
Filter
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin text-primary" />
|
||||
Loading customers…
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={paginatedData}
|
||||
status={status}
|
||||
onRowClick={(row) => navigate(`/customers/${row.id}`)}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount: pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
}}
|
||||
containerClassName="border-b shadow-none"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Hoisted controlled dialogs (avoid Radix nested DropdownMenu+Dialog
|
||||
unmount + pointer-events conflict). */}
|
||||
{activeCustomer ? (
|
||||
<>
|
||||
<NewCustomerPage
|
||||
key={`edit-${activeCustomer.id}`}
|
||||
mode="edit"
|
||||
customer={activeCustomer}
|
||||
open={activeDialog === "edit"}
|
||||
onOpenChange={(next) => (next ? null : closeDialog())}
|
||||
/>
|
||||
<DeleteCustomerDialog
|
||||
key={`delete-${activeCustomer.id}`}
|
||||
customerName={activeCustomer.name}
|
||||
onConfirm={() => deleteMutation.mutate(activeCustomer.id)}
|
||||
open={activeDialog === "delete"}
|
||||
onOpenChange={(next) => (next ? null : closeDialog())}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
}: {
|
||||
title: string;
|
||||
value: number;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">{title}</p>
|
||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary text-white">
|
||||
{icon}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: CustomerStatus }) {
|
||||
const styles: Record<CustomerStatus, string> = {
|
||||
Active: "bg-emerald-100 text-emerald-700",
|
||||
Pending: "bg-amber-100 text-amber-700",
|
||||
Inactive: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Button,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
export interface DeleteCustomerDialogProps {
|
||||
customerName: string;
|
||||
onConfirm?: () => void;
|
||||
children?: ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function DeleteCustomerDialog({
|
||||
customerName,
|
||||
onConfirm,
|
||||
children,
|
||||
open: openProp,
|
||||
onOpenChange,
|
||||
}: DeleteCustomerDialogProps) {
|
||||
const isControlled = openProp !== undefined;
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const open = isControlled ? openProp : internalOpen;
|
||||
const setOpen = (next: boolean) => {
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold">
|
||||
Delete customer?
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
This will permanently remove{" "}
|
||||
<span className="font-semibold text-slate-900">{customerName}</span>{" "}
|
||||
from your records. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="mt-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Input,
|
||||
Label,
|
||||
Button,
|
||||
Textarea,
|
||||
SmartFileInput,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
import {
|
||||
Building2,
|
||||
Mail,
|
||||
Phone,
|
||||
User,
|
||||
Globe,
|
||||
MapPin,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
useCreateCustomer,
|
||||
useUpdateCustomer,
|
||||
} from "@/hooks/useCustomers";
|
||||
import { getFileUploadSettingByCode } from "@/services/fileUploadSettings.service";
|
||||
import { FILE_SETTINGS } from "@/constants/FILE_SETTINGS";
|
||||
import type {
|
||||
CreateCustomerDto,
|
||||
Customer,
|
||||
CustomerStatus,
|
||||
CustomerType,
|
||||
} from "@/types/customers";
|
||||
|
||||
const CUSTOMER_TYPES: CustomerType[] = ["Importer", "Exporter", "Supplier"];
|
||||
const CUSTOMER_STATUSES: CustomerStatus[] = ["Active", "Pending", "Inactive"];
|
||||
|
||||
export interface NewCustomerPageProps {
|
||||
mode?: "create" | "edit";
|
||||
customer?: Customer;
|
||||
children?: ReactNode;
|
||||
/** Controlled open. When omitted, the dialog manages its own open state. */
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
company: string;
|
||||
customerType: CustomerType;
|
||||
status: CustomerStatus;
|
||||
tinNumber: string;
|
||||
city: string;
|
||||
country: string;
|
||||
address: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
company: "",
|
||||
customerType: "Importer",
|
||||
status: "Active",
|
||||
tinNumber: "",
|
||||
city: "",
|
||||
country: "",
|
||||
address: "",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
const fromCustomer = (c: Customer): FormState => ({
|
||||
name: c.name ?? "",
|
||||
email: c.email ?? "",
|
||||
phone: c.phone ?? "",
|
||||
company: c.company ?? "",
|
||||
customerType: c.customerType ?? "Importer",
|
||||
status: c.status ?? "Active",
|
||||
tinNumber: c.tinNumber ?? "",
|
||||
city: c.city ?? "",
|
||||
country: c.country ?? "",
|
||||
address: c.address ?? "",
|
||||
notes: c.notes ?? "",
|
||||
});
|
||||
|
||||
export default function NewCustomerPage({
|
||||
mode = "create",
|
||||
customer,
|
||||
children,
|
||||
open: openProp,
|
||||
onOpenChange,
|
||||
}: NewCustomerPageProps = {}) {
|
||||
const isEdit = mode === "edit";
|
||||
const isControlled = openProp !== undefined;
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const open = isControlled ? openProp : internalOpen;
|
||||
const setOpen = (next: boolean) => {
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
|
||||
const [form, setForm] = useState<FormState>(
|
||||
customer ? fromCustomer(customer) : emptyForm(),
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [files, setFiles] = useState<Record<string, File | File[] | null>>({});
|
||||
|
||||
// Reset form whenever the dialog opens with a different customer.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setForm(customer ? fromCustomer(customer) : emptyForm());
|
||||
setError(null);
|
||||
}
|
||||
}, [open, customer]);
|
||||
|
||||
const { data: customerRegistrationFiles } = useQuery(
|
||||
getFileUploadSettingByCode.queryOptions({
|
||||
input: FILE_SETTINGS.CUSTOMER_REGISTRATION,
|
||||
}),
|
||||
);
|
||||
|
||||
const createMutation = useCreateCustomer();
|
||||
const updateMutation = useUpdateCustomer();
|
||||
const pending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const handleSubmit = () => {
|
||||
setError(null);
|
||||
|
||||
if (!form.name.trim() || !form.email.trim() || !form.phone.trim()) {
|
||||
setError("Name, email, and phone are required.");
|
||||
return;
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
|
||||
setError("Please enter a valid email address.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: CreateCustomerDto = {
|
||||
name: form.name.trim(),
|
||||
email: form.email.trim(),
|
||||
phone: form.phone.trim(),
|
||||
customerType: form.customerType,
|
||||
status: form.status,
|
||||
company: form.company.trim() || undefined,
|
||||
tinNumber: form.tinNumber.trim() || undefined,
|
||||
city: form.city.trim() || undefined,
|
||||
country: form.country.trim() || undefined,
|
||||
address: form.address.trim() || undefined,
|
||||
notes: form.notes.trim() || undefined,
|
||||
};
|
||||
|
||||
const onDone = () => {
|
||||
setOpen(false);
|
||||
if (!isEdit) setForm(emptyForm());
|
||||
};
|
||||
const onError = (err: unknown) => {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Something went wrong. Try again.",
|
||||
);
|
||||
};
|
||||
|
||||
if (isEdit && customer) {
|
||||
updateMutation.mutate(
|
||||
{ id: customer.id, dto: payload },
|
||||
{ onSuccess: onDone, onError },
|
||||
);
|
||||
} else {
|
||||
createMutation.mutate(payload, { onSuccess: onDone, onError });
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
{!isControlled ? (
|
||||
<DialogTrigger asChild>
|
||||
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
|
||||
</DialogTrigger>
|
||||
) : null}
|
||||
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl!">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
||||
<Field label="Company Name">
|
||||
<Building2 className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={form.company}
|
||||
onChange={(e) => set("company", e.target.value)}
|
||||
placeholder="Enter company name"
|
||||
className="pl-10"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Customer Type *</Label>
|
||||
<select
|
||||
value={form.customerType}
|
||||
onChange={(e) =>
|
||||
set("customerType", e.target.value as CustomerType)
|
||||
}
|
||||
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
|
||||
>
|
||||
{CUSTOMER_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Field label="Contact Person *">
|
||||
<User className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
placeholder="Enter contact person"
|
||||
className="pl-10"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Email *">
|
||||
<Mail className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => set("email", e.target.value)}
|
||||
placeholder="Enter email"
|
||||
className="pl-10"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Phone *">
|
||||
<Phone className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={form.phone}
|
||||
onChange={(e) => set("phone", e.target.value)}
|
||||
placeholder="Enter phone"
|
||||
className="pl-10"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="TIN Number">
|
||||
<FileText className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={form.tinNumber}
|
||||
onChange={(e) => set("tinNumber", e.target.value)}
|
||||
placeholder="Enter TIN number"
|
||||
className="pl-10"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="City">
|
||||
<MapPin className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={form.city}
|
||||
onChange={(e) => set("city", e.target.value)}
|
||||
placeholder="Enter city"
|
||||
className="pl-10"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Country">
|
||||
<Globe className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={form.country}
|
||||
onChange={(e) => set("country", e.target.value)}
|
||||
placeholder="Enter country"
|
||||
className="pl-10"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Status</Label>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={(e) => set("status", e.target.value as CustomerStatus)}
|
||||
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
|
||||
>
|
||||
{CUSTOMER_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Address</Label>
|
||||
<Textarea
|
||||
value={form.address}
|
||||
onChange={(e) => set("address", e.target.value)}
|
||||
placeholder="Enter address"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Notes</Label>
|
||||
<Textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => set("notes", e.target.value)}
|
||||
placeholder="Additional notes..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{customerRegistrationFiles ? (
|
||||
<div>
|
||||
<SmartFileInput
|
||||
file={customerRegistrationFiles}
|
||||
value={files}
|
||||
onChange={setFiles}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={pending}
|
||||
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
submitLabel
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{label}</Label>
|
||||
<div className="relative">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
export type CustomerStatus = "Active" | "Pending" | "Inactive";
|
||||
export type CustomerType = "Importer" | "Exporter" | "Supplier";
|
||||
|
||||
export interface Customer {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
company: string;
|
||||
status: CustomerStatus;
|
||||
customerType: CustomerType;
|
||||
phone: string;
|
||||
tinNumber: string;
|
||||
city: string;
|
||||
country: string;
|
||||
address: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const seedCustomers: Customer[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Abel Tesfaye",
|
||||
email: "abel@example.com",
|
||||
company: "Addis Logistics",
|
||||
status: "Active",
|
||||
customerType: "Importer",
|
||||
phone: "+251 911 234 567",
|
||||
tinNumber: "0012345678",
|
||||
city: "Addis Ababa",
|
||||
country: "Ethiopia",
|
||||
address: "Bole Road, Sub-City 03, Building 17",
|
||||
notes: "Top-tier importer. Prefers weekly invoicing.",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Sara Bekele",
|
||||
email: "sara@example.com",
|
||||
company: "Blue Nile Trading",
|
||||
status: "Pending",
|
||||
customerType: "Exporter",
|
||||
phone: "+251 922 345 678",
|
||||
tinNumber: "0023456789",
|
||||
city: "Dire Dawa",
|
||||
country: "Ethiopia",
|
||||
address: "Industrial Park, Zone B, Warehouse 4",
|
||||
notes: "Awaiting compliance documents.",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Henok Alemu",
|
||||
email: "henok@example.com",
|
||||
company: "Ethio Freight",
|
||||
status: "Inactive",
|
||||
customerType: "Supplier",
|
||||
phone: "+251 933 456 789",
|
||||
tinNumber: "0034567890",
|
||||
city: "Djibouti City",
|
||||
country: "Djibouti",
|
||||
address: "Port Quarter, Avenue 26, Block 9",
|
||||
notes: "Account paused since last quarter.",
|
||||
},
|
||||
];
|
||||
|
||||
const extras: Array<{ name: string; company: string; city: string; country: string }> = [
|
||||
{ name: "Yohannes Girma", company: "Habesha Imports", city: "Addis Ababa", country: "Ethiopia" },
|
||||
{ name: "Meron Asfaw", company: "Sheba Trading", city: "Adama", country: "Ethiopia" },
|
||||
{ name: "Daniel Kebede", company: "Awash Cargo", city: "Hawassa", country: "Ethiopia" },
|
||||
{ name: "Liya Tadesse", company: "Lalibela Logistics", city: "Bahir Dar", country: "Ethiopia" },
|
||||
{ name: "Samuel Worku", company: "Rift Valley Freight", city: "Mekelle", country: "Ethiopia" },
|
||||
{ name: "Hanna Mulugeta", company: "Simien Exports", city: "Gondar", country: "Ethiopia" },
|
||||
{ name: "Bereket Hailu", company: "Omo River Co.", city: "Jimma", country: "Ethiopia" },
|
||||
{ name: "Tigist Wolde", company: "Tana Shipping", city: "Dessie", country: "Ethiopia" },
|
||||
{ name: "Kalkidan Mesfin", company: "Coffee Belt Traders", city: "Addis Ababa", country: "Ethiopia" },
|
||||
{ name: "Nahom Solomon", company: "Highland Freight", city: "Harar", country: "Ethiopia" },
|
||||
{ name: "Ali Mohamed", company: "Red Sea Cargo", city: "Djibouti City", country: "Djibouti" },
|
||||
{ name: "Fatima Hassan", company: "Gulf Logistics", city: "Tadjoura", country: "Djibouti" },
|
||||
{ name: "Omar Ibrahim", company: "Bab-el-Mandeb Trading", city: "Ali Sabieh", country: "Djibouti" },
|
||||
{ name: "Amina Said", company: "Horn of Africa Imports", city: "Dikhil", country: "Djibouti" },
|
||||
{ name: "Yusuf Abdulahi", company: "Saharan Exports", city: "Obock", country: "Djibouti" },
|
||||
{ name: "Selam Negash", company: "Equator Freight", city: "Arba Minch", country: "Ethiopia" },
|
||||
{ name: "Mikias Lemma", company: "Gibe Trading", city: "Sodo", country: "Ethiopia" },
|
||||
];
|
||||
|
||||
const statuses: CustomerStatus[] = ["Active", "Pending", "Inactive"];
|
||||
const types: CustomerType[] = ["Importer", "Exporter", "Supplier"];
|
||||
|
||||
const generated: Customer[] = extras.map((entry, i) => {
|
||||
const id = seedCustomers.length + i + 1;
|
||||
return {
|
||||
id,
|
||||
name: entry.name,
|
||||
email: `${entry.name.toLowerCase().replace(/\s+/g, ".")}@example.com`,
|
||||
company: entry.company,
|
||||
status: statuses[i % statuses.length] as CustomerStatus,
|
||||
customerType: types[i % types.length] as CustomerType,
|
||||
phone: `+251 9${String(40 + i).padStart(2, "0")} ${String(100 + i * 13).slice(0, 3)} ${String(200 + i * 17).slice(0, 3)}`,
|
||||
tinNumber: String(40000000 + i * 12345).padStart(10, "0"),
|
||||
city: entry.city,
|
||||
country: entry.country,
|
||||
address: `Block ${i + 1}, Street ${10 + i}, Quarter ${(i % 5) + 1}`,
|
||||
notes: `Mock customer #${id}.`,
|
||||
};
|
||||
});
|
||||
|
||||
export const customers: Customer[] = [...seedCustomers, ...generated];
|
||||
|
||||
export function getCustomerById(id: number | string): Customer | undefined {
|
||||
const numericId = typeof id === "string" ? Number(id) : id;
|
||||
return customers.find((c) => c.id === numericId);
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
ArrowRight,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Eye,
|
||||
Mail,
|
||||
MapPin,
|
||||
Package,
|
||||
Phone,
|
||||
Plus,
|
||||
Receipt,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||
import {
|
||||
getCurrentCustomer,
|
||||
getMyBookings,
|
||||
getMyInvoices,
|
||||
getMyShipments,
|
||||
} from "@/lib/currentCustomer";
|
||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
||||
import type { ShipmentStatus } from "@/pages/tracking/shipments.mock";
|
||||
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
|
||||
import type { BookingStatus } from "@/pages/bookings/bookings.mock";
|
||||
import { customersService } from "@/services/customers.service";
|
||||
|
||||
export default function MyPortalPage() {
|
||||
const me = useMemo(() => getCurrentCustomer(), []);
|
||||
const myBookings = useMemo(() => getMyBookings(), []);
|
||||
const myShipments = useMemo(() => getMyShipments(), []);
|
||||
const myInvoices = useMemo(() => getMyInvoices(), []);
|
||||
|
||||
const userId = localStorage.getItem("userId");
|
||||
useEffect(() => {
|
||||
customersService.getByUserId(userId || "").then((res: any) => {
|
||||
}).catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
}, [userId]);
|
||||
|
||||
const activeBookings = myBookings.filter(
|
||||
(b) => b.status === "Confirmed" || b.status === "In Transit",
|
||||
);
|
||||
const activeShipments = myShipments.filter((s) => s.status === "In Transit");
|
||||
const outstandingInvoices = myInvoices.filter(
|
||||
(inv) => inv.status === "Sent" || inv.status === "Overdue",
|
||||
);
|
||||
const totalOutstanding = outstandingInvoices
|
||||
.filter((inv) => inv.currency === "USD")
|
||||
.reduce((sum, inv) => sum + inv.amount, 0);
|
||||
const totalSpent = myInvoices
|
||||
.filter((inv) => inv.status === "Paid" && inv.currency === "USD")
|
||||
.reduce((sum, inv) => sum + inv.amount, 0);
|
||||
|
||||
const recentBookings = [...myBookings].slice(0, 5);
|
||||
const recentInvoices = [...myInvoices].slice(0, 4);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 p-6">
|
||||
<div className="mx-auto max-w-7xl space-y-6">
|
||||
<Breadcrumbs items={[{ label: "My Portal" }]} />
|
||||
|
||||
{/* Welcome banner */}
|
||||
<div className="overflow-hidden rounded-3xl bg-gradient-to-r from-[#059669] to-[#10B981] p-6 text-white shadow-sm">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-white/15 text-2xl font-bold backdrop-blur">
|
||||
{me.company.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-white/80">Welcome back</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{me.name}
|
||||
</h1>
|
||||
<p className="mt-1 flex items-center gap-2 text-sm text-white/80">
|
||||
<Building2 className="h-4 w-4" />
|
||||
{me.company}
|
||||
<span className="text-white/40">·</span>
|
||||
<span className="rounded-full bg-white/15 px-2 py-0.5 text-xs">
|
||||
{me.customerType}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
to="/bookings/new"
|
||||
className="inline-flex items-center gap-2 rounded-2xl bg-white px-4 py-2 text-sm font-semibold text-[#10B981] transition hover:bg-slate-100"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Booking
|
||||
</Link>
|
||||
<Link
|
||||
to="/tracking"
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/40 px-4 py-2 text-sm font-medium text-white transition hover:bg-white/10"
|
||||
>
|
||||
<Truck className="h-4 w-4" />
|
||||
Track Shipment
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* My KPIs */}
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<KpiCard
|
||||
label="My Active Bookings"
|
||||
value={String(activeBookings.length)}
|
||||
sub={`${myBookings.length} total`}
|
||||
icon={<Package className="h-5 w-5" />}
|
||||
href="/bookings"
|
||||
/>
|
||||
<KpiCard
|
||||
label="In Transit"
|
||||
value={String(activeShipments.length)}
|
||||
sub={`${myShipments.length} shipments`}
|
||||
icon={<Truck className="h-5 w-5" />}
|
||||
href="/tracking"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Outstanding"
|
||||
value={formatCurrency(totalOutstanding, "USD")}
|
||||
sub={`${outstandingInvoices.length} invoices`}
|
||||
icon={<DollarSign className="h-5 w-5" />}
|
||||
href="/billing"
|
||||
tone={outstandingInvoices.some((i) => i.status === "Overdue") ? "danger" : "brand"}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Total Spent"
|
||||
value={formatCurrency(totalSpent, "USD")}
|
||||
sub="All-time, paid invoices"
|
||||
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Active Shipments */}
|
||||
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">
|
||||
Active Shipments
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
Live tracking for your in-flight cargo
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/tracking"
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-[#10B981] transition hover:underline"
|
||||
>
|
||||
View all
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{activeShipments.length === 0 ? (
|
||||
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
|
||||
No shipments currently in transit.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{activeShipments.slice(0, 4).map((shipment) => (
|
||||
<div
|
||||
key={shipment.id}
|
||||
className="rounded-2xl border border-slate-100 p-4 transition hover:border-[#10B981]/20 hover:bg-[#10B981]/5"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-slate-900">
|
||||
{shipment.reference}
|
||||
</span>
|
||||
<ShipmentBadge status={shipment.status} />
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-700">
|
||||
{shipment.originStation}
|
||||
<ArrowRight className="mx-2 inline h-3 w-3 text-slate-400" />
|
||||
{shipment.destinationStation}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3 text-[#10B981]" />
|
||||
{shipment.currentLocation}
|
||||
</span>
|
||||
<span>ETA {shipment.eta}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-[#10B981] transition-all"
|
||||
style={{ width: `${shipment.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Bookings + Invoices + Profile */}
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Recent bookings */}
|
||||
<div className="rounded-3xl bg-white p-6 shadow-sm lg:col-span-2">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">
|
||||
Recent Bookings
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
Your latest freight requests
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/bookings"
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-[#10B981] transition hover:underline"
|
||||
>
|
||||
View all
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{recentBookings.length === 0 ? (
|
||||
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
|
||||
You haven't booked any freight yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[600px] whitespace-nowrap text-left text-sm">
|
||||
<thead className="text-xs text-slate-500">
|
||||
<tr>
|
||||
<th className="py-2 font-medium">Reference</th>
|
||||
<th className="py-2 font-medium">Route</th>
|
||||
<th className="py-2 font-medium">Cargo</th>
|
||||
<th className="py-2 font-medium">Status</th>
|
||||
<th className="py-2 text-right font-medium">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentBookings.map((booking) => (
|
||||
<tr
|
||||
key={booking.id}
|
||||
className="border-t border-slate-100 transition hover:bg-[#10B981]/5"
|
||||
>
|
||||
<td className="py-3 font-medium text-slate-900">
|
||||
{booking.reference}
|
||||
</td>
|
||||
<td className="py-3 text-slate-700">
|
||||
{booking.originStation} → {booking.destinationStation}
|
||||
</td>
|
||||
<td className="py-3 text-slate-700">
|
||||
{booking.cargoType}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<BookingBadge status={booking.status} />
|
||||
</td>
|
||||
<td className="py-3 text-right">
|
||||
<Link
|
||||
to={`/bookings/${booking.id}`}
|
||||
aria-label="View booking"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 transition hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Profile card */}
|
||||
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-slate-900">
|
||||
My Profile
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">Account information</p>
|
||||
|
||||
<div className="mt-4 space-y-3 text-sm">
|
||||
<ProfileRow
|
||||
icon={<Building2 className="h-4 w-4" />}
|
||||
label="Company"
|
||||
value={me.company}
|
||||
/>
|
||||
<ProfileRow
|
||||
icon={<Mail className="h-4 w-4" />}
|
||||
label="Email"
|
||||
value={me.email}
|
||||
/>
|
||||
<ProfileRow
|
||||
icon={<Phone className="h-4 w-4" />}
|
||||
label="Phone"
|
||||
value={me.phone}
|
||||
/>
|
||||
<ProfileRow
|
||||
icon={<MapPin className="h-4 w-4" />}
|
||||
label="Location"
|
||||
value={`${me.city}, ${me.country}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to={`/customers/${me.id}`}
|
||||
className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||
>
|
||||
View full profile
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoices */}
|
||||
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">
|
||||
Recent Invoices
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
{outstandingInvoices.length} outstanding ·{" "}
|
||||
{myInvoices.length} total
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/billing"
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-[#10B981] transition hover:underline"
|
||||
>
|
||||
View all
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{recentInvoices.length === 0 ? (
|
||||
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
|
||||
No invoices yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
|
||||
{recentInvoices.map((invoice) => (
|
||||
<div
|
||||
key={invoice.id}
|
||||
className="rounded-2xl border border-slate-100 p-4 transition hover:border-[#10B981]/20 hover:bg-[#10B981]/5"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Receipt className="h-4 w-4 text-[#10B981]" />
|
||||
<InvoiceBadge status={invoice.status} />
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
{invoice.number}
|
||||
</p>
|
||||
<p className="mt-0.5 text-lg font-bold text-slate-900">
|
||||
{formatCurrency(invoice.amount, invoice.currency)}
|
||||
</p>
|
||||
<p className="mt-1 flex items-center gap-1 text-xs text-slate-500">
|
||||
<Clock className="h-3 w-3" />
|
||||
Due {invoice.dueDate}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiCard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
icon,
|
||||
href,
|
||||
tone = "brand",
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
sub: string;
|
||||
icon: React.ReactNode;
|
||||
href?: string;
|
||||
tone?: "brand" | "danger";
|
||||
}) {
|
||||
const iconWrap =
|
||||
tone === "danger"
|
||||
? "bg-red-100 text-red-600"
|
||||
: "bg-[#10B981] text-white";
|
||||
|
||||
const inner = (
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">{label}</p>
|
||||
<h3 className="mt-2 text-2xl font-bold text-slate-900">{value}</h3>
|
||||
<p className="mt-1 text-xs text-slate-500">{sub}</p>
|
||||
</div>
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-2xl ${iconWrap}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const className =
|
||||
"rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20";
|
||||
|
||||
return href ? (
|
||||
<Link to={href} className={`block ${className}`}>
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
<div className={className}>{inner}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileRow({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 text-[#10B981]">{icon}</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-medium text-slate-500">{label}</p>
|
||||
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShipmentBadge({ status }: { status: ShipmentStatus }) {
|
||||
const styles: Record<ShipmentStatus, string> = {
|
||||
"In Transit": "bg-indigo-100 text-indigo-700",
|
||||
Delivered: "bg-emerald-100 text-emerald-700",
|
||||
Delayed: "bg-red-100 text-red-700",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingBadge({ status }: { status: BookingStatus }) {
|
||||
const styles: Record<BookingStatus, string> = {
|
||||
Pending: "bg-amber-100 text-amber-700",
|
||||
Confirmed: "bg-sky-100 text-sky-700",
|
||||
"In Transit": "bg-indigo-100 text-indigo-700",
|
||||
Delivered: "bg-emerald-100 text-emerald-700",
|
||||
Cancelled: "bg-red-100 text-red-700",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function InvoiceBadge({ status }: { status: InvoiceStatus }) {
|
||||
const styles: Record<InvoiceStatus, string> = {
|
||||
Draft: "bg-slate-100 text-slate-600",
|
||||
Sent: "bg-sky-100 text-sky-700",
|
||||
Paid: "bg-emerald-100 text-emerald-700",
|
||||
Overdue: "bg-red-100 text-red-700",
|
||||
Cancelled: "bg-amber-100 text-amber-700",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,365 +1,293 @@
|
||||
import { useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
ArrowRight,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
Link,
|
||||
useNavigate,
|
||||
} from "react-router-dom";
|
||||
|
||||
import {
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Eye,
|
||||
Mail,
|
||||
MapPin,
|
||||
Package,
|
||||
Phone,
|
||||
Plus,
|
||||
Receipt,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||
|
||||
import {
|
||||
getCurrentCustomer,
|
||||
getMyBookings,
|
||||
getMyInvoices,
|
||||
getMyShipments,
|
||||
} from "@/lib/currentCustomer";
|
||||
|
||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
||||
import type { ShipmentStatus } from "@/pages/tracking/shipments.mock";
|
||||
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
|
||||
import type { BookingStatus } from "@/pages/bookings/bookings.mock";
|
||||
|
||||
import { customersService } from "@/services/customers.service";
|
||||
|
||||
import { getMyInfo } from "@/services/account";
|
||||
import NewCustomerPage from "../customers/NewCustomerPage";
|
||||
import { Button } from "@edr/ui-common";
|
||||
|
||||
type Customer = {
|
||||
id: string;
|
||||
companyName: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
};
|
||||
|
||||
export default function MyPortalPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// STATE
|
||||
// ------------------------------------------------------------
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [customer, setCustomer] = useState<any>(null);
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// MOCK DATA
|
||||
// ------------------------------------------------------------
|
||||
|
||||
const me = useMemo(() => getCurrentCustomer(), []);
|
||||
const myBookings = useMemo(() => getMyBookings(), []);
|
||||
const myShipments = useMemo(() => getMyShipments(), []);
|
||||
const myInvoices = useMemo(() => getMyInvoices(), []);
|
||||
|
||||
const activeBookings = myBookings.filter(
|
||||
(b) => b.status === "Confirmed" || b.status === "In Transit",
|
||||
);
|
||||
const activeShipments = myShipments.filter((s) => s.status === "In Transit");
|
||||
const outstandingInvoices = myInvoices.filter(
|
||||
(inv) => inv.status === "Sent" || inv.status === "Overdue",
|
||||
);
|
||||
const totalOutstanding = outstandingInvoices
|
||||
.filter((inv) => inv.currency === "USD")
|
||||
.reduce((sum, inv) => sum + inv.amount, 0);
|
||||
const totalSpent = myInvoices
|
||||
.filter((inv) => inv.status === "Paid" && inv.currency === "USD")
|
||||
.reduce((sum, inv) => sum + inv.amount, 0);
|
||||
// ------------------------------------------------------------
|
||||
// FETCH CUSTOMER
|
||||
// ------------------------------------------------------------
|
||||
|
||||
const recentBookings = [...myBookings].slice(0, 5);
|
||||
const recentInvoices = [...myInvoices].slice(0, 4);
|
||||
useEffect(() => {
|
||||
const initialize = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const userRes = await getMyInfo();
|
||||
const userId = userRes?.data?.id;
|
||||
localStorage.setItem("currentUser", JSON.stringify(userRes.data));
|
||||
// if (!userId) {
|
||||
// navigate("/login");
|
||||
// return;
|
||||
// }
|
||||
|
||||
const res = await customersService.getByUserId(userId);
|
||||
if (res) {
|
||||
setCustomer(res);
|
||||
return;
|
||||
}
|
||||
|
||||
// customer not found → onboarding
|
||||
// navigate("/customers/register");
|
||||
} catch (error: any) {
|
||||
console.error("Customer fetch failed:", error);
|
||||
|
||||
const status = error?.response?.status;
|
||||
|
||||
if (status === 404) {
|
||||
// navigate("/customers/register");
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 401) {
|
||||
// navigate("/login");
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initialize();
|
||||
}, [navigate]);
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// LOADING
|
||||
// ------------------------------------------------------------
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50">
|
||||
<div className="rounded-2xl bg-white px-6 py-4 shadow-sm">
|
||||
<p className="text-sm text-slate-600">
|
||||
Loading portal...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// CUSTOMER MISSING (extra safety)
|
||||
// ------------------------------------------------------------
|
||||
|
||||
if (!customer) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50">
|
||||
<div className="text-center space-y-4">
|
||||
<p className="text-slate-600">
|
||||
No customer profile found
|
||||
</p>
|
||||
|
||||
{/* <Link
|
||||
to="/customers/register"
|
||||
className="inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-white"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Customer Profile
|
||||
</Link> */}
|
||||
<NewCustomerPage>
|
||||
<Button
|
||||
className="inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-white"
|
||||
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Customer Profile
|
||||
</Button>
|
||||
</NewCustomerPage>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// KPI CALCULATIONS
|
||||
// ------------------------------------------------------------
|
||||
|
||||
const activeBookings = myBookings.filter(
|
||||
(b) =>
|
||||
b.status === "Confirmed" ||
|
||||
b.status === "In Transit"
|
||||
);
|
||||
|
||||
const activeShipments = myShipments.filter(
|
||||
(s) => s.status === "In Transit"
|
||||
);
|
||||
|
||||
const outstandingInvoices = myInvoices.filter(
|
||||
(i) => i.status === "Sent" || i.status === "Overdue"
|
||||
);
|
||||
|
||||
const totalOutstanding = outstandingInvoices.reduce(
|
||||
(sum, i) =>
|
||||
i.currency === "USD" ? sum + i.amount : sum,
|
||||
0
|
||||
);
|
||||
|
||||
const totalSpent = myInvoices.reduce(
|
||||
(sum, i) =>
|
||||
i.status === "Paid" && i.currency === "USD"
|
||||
? sum + i.amount
|
||||
: sum,
|
||||
0
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// RENDER
|
||||
// ------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 p-6">
|
||||
<div className="mx-auto max-w-7xl space-y-6">
|
||||
|
||||
<Breadcrumbs items={[{ label: "My Portal" }]} />
|
||||
|
||||
{/* Welcome banner */}
|
||||
<div className="overflow-hidden rounded-3xl bg-gradient-to-r from-[#059669] to-[#10B981] p-6 text-white shadow-sm">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
{/* HERO */}
|
||||
<div className="rounded-3xl bg-gradient-to-r from-[#059669] to-[#10B981] p-6 text-white">
|
||||
<div className="flex justify-between flex-col md:flex-row gap-6">
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-white/15 text-2xl font-bold backdrop-blur">
|
||||
{me.company.charAt(0)}
|
||||
<div className="h-14 w-14 flex items-center justify-center rounded-2xl bg-white/20 text-xl font-bold">
|
||||
{customer.companyName?.charAt(0)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-white/80">Welcome back</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{me.name}
|
||||
<h1 className="text-2xl font-bold">
|
||||
{customer.firstName} {customer.lastName}
|
||||
</h1>
|
||||
<p className="mt-1 flex items-center gap-2 text-sm text-white/80">
|
||||
|
||||
<p className="text-sm opacity-80 flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4" />
|
||||
{me.company}
|
||||
<span className="text-white/40">·</span>
|
||||
<span className="rounded-full bg-white/15 px-2 py-0.5 text-xs">
|
||||
{me.customerType}
|
||||
</span>
|
||||
{customer.companyName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
to="/bookings/new"
|
||||
className="inline-flex items-center gap-2 rounded-2xl bg-white px-4 py-2 text-sm font-semibold text-[#10B981] transition hover:bg-slate-100"
|
||||
className="bg-white text-[#10B981] px-4 py-2 rounded-xl font-semibold flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Booking
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/tracking"
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/40 px-4 py-2 text-sm font-medium text-white transition hover:bg-white/10"
|
||||
className="border border-white px-4 py-2 rounded-xl flex items-center gap-2"
|
||||
>
|
||||
<Truck className="h-4 w-4" />
|
||||
Track Shipment
|
||||
Track
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* My KPIs */}
|
||||
{/* KPI */}
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
|
||||
<KpiCard
|
||||
label="My Active Bookings"
|
||||
label="Active Bookings"
|
||||
value={String(activeBookings.length)}
|
||||
sub={`${myBookings.length} total`}
|
||||
icon={<Package className="h-5 w-5" />}
|
||||
href="/bookings"
|
||||
/>
|
||||
|
||||
<KpiCard
|
||||
label="In Transit"
|
||||
value={String(activeShipments.length)}
|
||||
sub={`${myShipments.length} shipments`}
|
||||
icon={<Truck className="h-5 w-5" />}
|
||||
href="/tracking"
|
||||
/>
|
||||
|
||||
<KpiCard
|
||||
label="Outstanding"
|
||||
value={formatCurrency(totalOutstanding, "USD")}
|
||||
sub={`${outstandingInvoices.length} invoices`}
|
||||
icon={<DollarSign className="h-5 w-5" />}
|
||||
href="/billing"
|
||||
tone={outstandingInvoices.some((i) => i.status === "Overdue") ? "danger" : "brand"}
|
||||
tone={
|
||||
outstandingInvoices.some((i) => i.status === "Overdue")
|
||||
? "danger"
|
||||
: "brand"
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiCard
|
||||
label="Total Spent"
|
||||
value={formatCurrency(totalSpent, "USD")}
|
||||
sub="All-time, paid invoices"
|
||||
sub="Paid invoices"
|
||||
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Active Shipments */}
|
||||
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">
|
||||
Active Shipments
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
Live tracking for your in-flight cargo
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/tracking"
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-[#10B981] transition hover:underline"
|
||||
>
|
||||
View all
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{activeShipments.length === 0 ? (
|
||||
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
|
||||
No shipments currently in transit.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{activeShipments.slice(0, 4).map((shipment) => (
|
||||
<div
|
||||
key={shipment.id}
|
||||
className="rounded-2xl border border-slate-100 p-4 transition hover:border-[#10B981]/20 hover:bg-[#10B981]/5"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-slate-900">
|
||||
{shipment.reference}
|
||||
</span>
|
||||
<ShipmentBadge status={shipment.status} />
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-700">
|
||||
{shipment.originStation}
|
||||
<ArrowRight className="mx-2 inline h-3 w-3 text-slate-400" />
|
||||
{shipment.destinationStation}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3 text-[#10B981]" />
|
||||
{shipment.currentLocation}
|
||||
</span>
|
||||
<span>ETA {shipment.eta}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-[#10B981] transition-all"
|
||||
style={{ width: `${shipment.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Bookings + Invoices + Profile */}
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Recent bookings */}
|
||||
<div className="rounded-3xl bg-white p-6 shadow-sm lg:col-span-2">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">
|
||||
Recent Bookings
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
Your latest freight requests
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/bookings"
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-[#10B981] transition hover:underline"
|
||||
>
|
||||
View all
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{recentBookings.length === 0 ? (
|
||||
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
|
||||
You haven't booked any freight yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[600px] whitespace-nowrap text-left text-sm">
|
||||
<thead className="text-xs text-slate-500">
|
||||
<tr>
|
||||
<th className="py-2 font-medium">Reference</th>
|
||||
<th className="py-2 font-medium">Route</th>
|
||||
<th className="py-2 font-medium">Cargo</th>
|
||||
<th className="py-2 font-medium">Status</th>
|
||||
<th className="py-2 text-right font-medium">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentBookings.map((booking) => (
|
||||
<tr
|
||||
key={booking.id}
|
||||
className="border-t border-slate-100 transition hover:bg-[#10B981]/5"
|
||||
>
|
||||
<td className="py-3 font-medium text-slate-900">
|
||||
{booking.reference}
|
||||
</td>
|
||||
<td className="py-3 text-slate-700">
|
||||
{booking.originStation} → {booking.destinationStation}
|
||||
</td>
|
||||
<td className="py-3 text-slate-700">
|
||||
{booking.cargoType}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<BookingBadge status={booking.status} />
|
||||
</td>
|
||||
<td className="py-3 text-right">
|
||||
<Link
|
||||
to={`/bookings/${booking.id}`}
|
||||
aria-label="View booking"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 transition hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Profile card */}
|
||||
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-slate-900">
|
||||
My Profile
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">Account information</p>
|
||||
|
||||
<div className="mt-4 space-y-3 text-sm">
|
||||
<ProfileRow
|
||||
icon={<Building2 className="h-4 w-4" />}
|
||||
label="Company"
|
||||
value={me.company}
|
||||
/>
|
||||
<ProfileRow
|
||||
icon={<Mail className="h-4 w-4" />}
|
||||
label="Email"
|
||||
value={me.email}
|
||||
/>
|
||||
<ProfileRow
|
||||
icon={<Phone className="h-4 w-4" />}
|
||||
label="Phone"
|
||||
value={me.phone}
|
||||
/>
|
||||
<ProfileRow
|
||||
icon={<MapPin className="h-4 w-4" />}
|
||||
label="Location"
|
||||
value={`${me.city}, ${me.country}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to={`/customers/${me.id}`}
|
||||
className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||
>
|
||||
View full profile
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoices */}
|
||||
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">
|
||||
Recent Invoices
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
{outstandingInvoices.length} outstanding ·{" "}
|
||||
{myInvoices.length} total
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/billing"
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-[#10B981] transition hover:underline"
|
||||
>
|
||||
View all
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{recentInvoices.length === 0 ? (
|
||||
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
|
||||
No invoices yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
|
||||
{recentInvoices.map((invoice) => (
|
||||
<div
|
||||
key={invoice.id}
|
||||
className="rounded-2xl border border-slate-100 p-4 transition hover:border-[#10B981]/20 hover:bg-[#10B981]/5"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Receipt className="h-4 w-4 text-[#10B981]" />
|
||||
<InvoiceBadge status={invoice.status} />
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
{invoice.number}
|
||||
</p>
|
||||
<p className="mt-0.5 text-lg font-bold text-slate-900">
|
||||
{formatCurrency(invoice.amount, invoice.currency)}
|
||||
</p>
|
||||
<p className="mt-1 flex items-center gap-1 text-xs text-slate-500">
|
||||
<Clock className="h-3 w-3" />
|
||||
Due {invoice.dueDate}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// KPI CARD
|
||||
// ------------------------------------------------------------
|
||||
|
||||
function KpiCard({
|
||||
label,
|
||||
value,
|
||||
@@ -375,20 +303,29 @@ function KpiCard({
|
||||
href?: string;
|
||||
tone?: "brand" | "danger";
|
||||
}) {
|
||||
const iconWrap =
|
||||
const iconClassName =
|
||||
tone === "danger"
|
||||
? "bg-red-100 text-red-600"
|
||||
: "bg-[#10B981] text-white";
|
||||
|
||||
const inner = (
|
||||
const content = (
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">{label}</p>
|
||||
<h3 className="mt-2 text-2xl font-bold text-slate-900">{value}</h3>
|
||||
<p className="mt-1 text-xs text-slate-500">{sub}</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
{label}
|
||||
</p>
|
||||
|
||||
<h3 className="mt-2 text-2xl font-bold text-slate-900">
|
||||
{value}
|
||||
</h3>
|
||||
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{sub}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-2xl ${iconWrap}`}
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-2xl ${iconClassName}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
@@ -398,80 +335,20 @@ function KpiCard({
|
||||
const className =
|
||||
"rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20";
|
||||
|
||||
return href ? (
|
||||
<Link to={href} className={`block ${className}`}>
|
||||
{inner}
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className={`block ${className}`}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div className={className}>{inner}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileRow({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 text-[#10B981]">{icon}</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-medium text-slate-500">{label}</p>
|
||||
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
|
||||
</div>
|
||||
<div className={className}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShipmentBadge({ status }: { status: ShipmentStatus }) {
|
||||
const styles: Record<ShipmentStatus, string> = {
|
||||
"In Transit": "bg-indigo-100 text-indigo-700",
|
||||
Delivered: "bg-emerald-100 text-emerald-700",
|
||||
Delayed: "bg-red-100 text-red-700",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingBadge({ status }: { status: BookingStatus }) {
|
||||
const styles: Record<BookingStatus, string> = {
|
||||
Pending: "bg-amber-100 text-amber-700",
|
||||
Confirmed: "bg-sky-100 text-sky-700",
|
||||
"In Transit": "bg-indigo-100 text-indigo-700",
|
||||
Delivered: "bg-emerald-100 text-emerald-700",
|
||||
Cancelled: "bg-red-100 text-red-700",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function InvoiceBadge({ status }: { status: InvoiceStatus }) {
|
||||
const styles: Record<InvoiceStatus, string> = {
|
||||
Draft: "bg-slate-100 text-slate-600",
|
||||
Sent: "bg-sky-100 text-sky-700",
|
||||
Paid: "bg-emerald-100 text-emerald-700",
|
||||
Overdue: "bg-red-100 text-red-700",
|
||||
Cancelled: "bg-amber-100 text-amber-700",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,17 @@ export const createUser = async (
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const getMyInfo = async () => {
|
||||
const res =
|
||||
await client.get<
|
||||
ApiResponse<any>
|
||||
>(
|
||||
URL_CONSTANTS.USERS.ME
|
||||
);
|
||||
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const generateVerificationCode = async (
|
||||
body: VerificationCodePayload
|
||||
) => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
UpdateFileUploadFieldDto,
|
||||
UpdateFileUploadSettingDto,
|
||||
} from "@/types/fileUploadSettings";
|
||||
import { bookingsService } from "./bookings.service";
|
||||
import { bookingsService, CreateBookingPayload } from "./bookings.service";
|
||||
import { consignmentsService } from "./consignments.service";
|
||||
import { trackingService } from "./tracking.service";
|
||||
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
||||
@@ -40,16 +40,11 @@ export const api = {
|
||||
({ id }) => bookingsService.get(id),
|
||||
),
|
||||
|
||||
create: endpoint<
|
||||
{
|
||||
reference: string;
|
||||
customerId: string;
|
||||
scheduledDate: string;
|
||||
totalAmount: number;
|
||||
trainId?: string;
|
||||
},
|
||||
Freight.IBooking
|
||||
>("bookings", "create", (input) => bookingsService.create(input)),
|
||||
create: endpoint<CreateBookingPayload, Freight.IBooking>(
|
||||
"bookings",
|
||||
"create",
|
||||
bookingsService.create,
|
||||
),
|
||||
|
||||
remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) =>
|
||||
bookingsService.remove(id),
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import type { Freight, PaginatedResponse } from "@edr/types";
|
||||
import { client as api } from "@/utils/api";
|
||||
|
||||
import { api } from "./crud";
|
||||
|
||||
export interface CreateBookingPayload {
|
||||
reference: string;
|
||||
customerId: string;
|
||||
scheduledDate: string;
|
||||
totalAmount: number;
|
||||
trainId?: string;
|
||||
}
|
||||
export type CreateBookingPayload = Freight.CreateBookingDto;
|
||||
|
||||
export const bookingsService = {
|
||||
list: async (): Promise<PaginatedResponse<Freight.IBooking>> => {
|
||||
@@ -20,7 +13,16 @@ export const bookingsService = {
|
||||
return data.data;
|
||||
},
|
||||
create: async (payload: CreateBookingPayload): Promise<Freight.IBooking> => {
|
||||
const { data } = await api.post("/bookings", payload);
|
||||
const fd = new FormData();
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
if (Array.isArray(value) || typeof value === "object") {
|
||||
fd.append(key, JSON.stringify(value));
|
||||
} else {
|
||||
fd.append(key, String(value));
|
||||
}
|
||||
}
|
||||
const { data } = await api.post("/api/bookings", fd);
|
||||
return data.data;
|
||||
},
|
||||
remove: async (id: string): Promise<void> => {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { Freight, PaginatedResponse } from "@edr/types";
|
||||
|
||||
import { api } from "../utils/api";
|
||||
import { client } from "../utils/api";
|
||||
|
||||
export const consignmentsService = {
|
||||
list: async (): Promise<PaginatedResponse<Freight.IConsignment>> => {
|
||||
const { data } = await api.get("/consignments");
|
||||
const { data } = await client.get("/consignments");
|
||||
return data.data;
|
||||
},
|
||||
get: async (id: string): Promise<Freight.IConsignment> => {
|
||||
const { data } = await api.get(`/consignments/${id}`);
|
||||
const { data } = await client.get(`/consignments/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -23,8 +23,15 @@ export const customersService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
create: async (payload: CreateCustomerDto): Promise<Customer> => {
|
||||
const response = await client.post<ApiResponse<Customer>>(BASE, payload);
|
||||
getByUserId: async (userId: string): Promise<Customer> => {
|
||||
const response = await client.get<ApiResponse<Customer>>(
|
||||
URL_CONSTANTS.CUSTOMERS_API.BY_USER_ID(userId),
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
create: async (payload: any): Promise<any> => {
|
||||
const response = await client.post<ApiResponse<any>>(BASE, payload);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { api } from "../utils/api";
|
||||
import { client } from "../utils/api";
|
||||
|
||||
export const trackingService = {
|
||||
forConsignment: async (
|
||||
consignmentId: string,
|
||||
): Promise<Freight.ITrackingEvent[]> => {
|
||||
const { data } = await api.get(`/tracking/${consignmentId}`);
|
||||
const { data } = await client.get(`/tracking/${consignmentId}`);
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// vite.config.ts
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "file:///home/meng/projects/edr-platform/node_modules/.pnpm/vite@5.4.21_@types+node@24.12.4_lightningcss@1.32.0_terser@5.48.0/node_modules/vite/dist/node/index.js";
|
||||
import react from "file:///home/meng/projects/edr-platform/node_modules/.pnpm/@vitejs+plugin-react@4.7.0_vite@5.4.21_@types+node@24.12.4_lightningcss@1.32.0_terser@5.48.0_/node_modules/@vitejs/plugin-react/dist/index.js";
|
||||
import tailwindcss from "file:///home/meng/projects/edr-platform/node_modules/.pnpm/@tailwindcss+vite@4.3.0_vite@5.4.21_@types+node@24.12.4_lightningcss@1.32.0_terser@5.48.0_/node_modules/@tailwindcss/vite/dist/index.mjs";
|
||||
var __vite_injected_original_import_meta_url = "file:///home/meng/projects/edr-platform/apps/edr-freight-web/portal/vite.config.ts";
|
||||
var __dirname = path.dirname(fileURLToPath(__vite_injected_original_import_meta_url));
|
||||
var vite_config_default = defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src")
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: "0.0.0.0"
|
||||
}
|
||||
});
|
||||
export {
|
||||
vite_config_default as default
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvaG9tZS9tZW5nL3Byb2plY3RzL2Vkci1wbGF0Zm9ybS9hcHBzL2Vkci1mcmVpZ2h0LXdlYi9wb3J0YWxcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9ob21lL21lbmcvcHJvamVjdHMvZWRyLXBsYXRmb3JtL2FwcHMvZWRyLWZyZWlnaHQtd2ViL3BvcnRhbC92aXRlLmNvbmZpZy50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vaG9tZS9tZW5nL3Byb2plY3RzL2Vkci1wbGF0Zm9ybS9hcHBzL2Vkci1mcmVpZ2h0LXdlYi9wb3J0YWwvdml0ZS5jb25maWcudHNcIjtpbXBvcnQgcGF0aCBmcm9tIFwibm9kZTpwYXRoXCI7XG5pbXBvcnQgeyBmaWxlVVJMVG9QYXRoIH0gZnJvbSBcIm5vZGU6dXJsXCI7XG5cbmltcG9ydCB7IGRlZmluZUNvbmZpZyB9IGZyb20gXCJ2aXRlXCI7XG5pbXBvcnQgcmVhY3QgZnJvbSBcIkB2aXRlanMvcGx1Z2luLXJlYWN0XCI7XG5pbXBvcnQgdGFpbHdpbmRjc3MgZnJvbSBcIkB0YWlsd2luZGNzcy92aXRlXCI7XG5cbmNvbnN0IF9fZGlybmFtZSA9IHBhdGguZGlybmFtZShmaWxlVVJMVG9QYXRoKGltcG9ydC5tZXRhLnVybCkpO1xuXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuICBwbHVnaW5zOiBbcmVhY3QoKSwgdGFpbHdpbmRjc3MoKV0sXG4gIHJlc29sdmU6IHtcbiAgICBhbGlhczoge1xuICAgICAgXCJAXCI6IHBhdGgucmVzb2x2ZShfX2Rpcm5hbWUsIFwiLi9zcmNcIiksXG4gICAgfSxcbiAgfSxcbiAgc2VydmVyOiB7XG4gICAgcG9ydDogNTE3MyxcbiAgICBob3N0OiBcIjAuMC4wLjBcIixcbiAgfSxcbn0pO1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUFzVyxPQUFPLFVBQVU7QUFDdlgsU0FBUyxxQkFBcUI7QUFFOUIsU0FBUyxvQkFBb0I7QUFDN0IsT0FBTyxXQUFXO0FBQ2xCLE9BQU8saUJBQWlCO0FBTHdNLElBQU0sMkNBQTJDO0FBT2pSLElBQU0sWUFBWSxLQUFLLFFBQVEsY0FBYyx3Q0FBZSxDQUFDO0FBRTdELElBQU8sc0JBQVEsYUFBYTtBQUFBLEVBQzFCLFNBQVMsQ0FBQyxNQUFNLEdBQUcsWUFBWSxDQUFDO0FBQUEsRUFDaEMsU0FBUztBQUFBLElBQ1AsT0FBTztBQUFBLE1BQ0wsS0FBSyxLQUFLLFFBQVEsV0FBVyxPQUFPO0FBQUEsSUFDdEM7QUFBQSxFQUNGO0FBQUEsRUFDQSxRQUFRO0FBQUEsSUFDTixNQUFNO0FBQUEsSUFDTixNQUFNO0FBQUEsRUFDUjtBQUNGLENBQUM7IiwKICAibmFtZXMiOiBbXQp9Cg==
|
||||
@@ -124,3 +124,45 @@ export interface IInvoice extends BaseEntity {
|
||||
issuedAt: string;
|
||||
dueAt: string;
|
||||
}
|
||||
|
||||
export interface CreateBookingDto {
|
||||
reference: string;
|
||||
customerId: string;
|
||||
trainId?: string;
|
||||
scheduledDate: string;
|
||||
totalAmount: number;
|
||||
paymentStatus?: string;
|
||||
contractType: "NEW" | "RENEWAL";
|
||||
previousContractId?: string;
|
||||
serviceType: "RAIL_ONLY" | "RAIL_AND_FORWARDING";
|
||||
|
||||
firstMileEnabled?: boolean;
|
||||
firstMilePickupAddress?: string;
|
||||
lastMileEnabled?: boolean;
|
||||
lastMileDeliveryAddress?: string;
|
||||
|
||||
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN";
|
||||
originStation: string;
|
||||
destinationStation: string;
|
||||
cargoTotalWeightVgm: number;
|
||||
|
||||
freightType: "BULK" | "BREAK_BULK";
|
||||
freightSubtype?: string;
|
||||
|
||||
isHazardous?: boolean;
|
||||
isRefrigerated?: boolean;
|
||||
|
||||
tradeDirection: "IMPORT" | "EXPORT";
|
||||
paymentCurrency: string;
|
||||
allowConsolidation?: boolean;
|
||||
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
financialTerms?: string;
|
||||
|
||||
containers?: Array<{
|
||||
type: "20FT" | "40FT";
|
||||
qty: number;
|
||||
vgm: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -89,6 +89,9 @@ importers:
|
||||
dotenv:
|
||||
specifier: ^17.4.2
|
||||
version: 17.4.2
|
||||
minio:
|
||||
specifier: 7.1.3
|
||||
version: 7.1.3
|
||||
pg:
|
||||
specifier: ^8.13.0
|
||||
version: 8.21.0
|
||||
@@ -126,6 +129,9 @@ importers:
|
||||
'@types/jest':
|
||||
specifier: ^29.5.13
|
||||
version: 29.5.14
|
||||
'@types/multer':
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
'@types/node':
|
||||
specifier: ^20.14.0
|
||||
version: 20.19.41
|
||||
@@ -3809,6 +3815,9 @@ packages:
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/multer@2.1.0':
|
||||
resolution: {integrity: sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==}
|
||||
|
||||
'@types/node@14.18.63':
|
||||
resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==}
|
||||
|
||||
@@ -13816,6 +13825,10 @@ snapshots:
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/multer@2.1.0':
|
||||
dependencies:
|
||||
'@types/express': 5.0.6
|
||||
|
||||
'@types/node@14.18.63': {}
|
||||
|
||||
'@types/node@20.19.41':
|
||||
|
||||
Reference in New Issue
Block a user