Merge pull request #42 from Tria-plc/freight/feature/user_registration

Freight/feature/user registration
This commit is contained in:
yaschalew10
2026-05-28 08:45:48 +03:00
committed by GitHub
52 changed files with 6347 additions and 1103 deletions

View File

@@ -26,6 +26,7 @@
"@tria-plc/iamapi-common": "^0.1.6",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",
"axios": "^1.7.7",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^17.4.2",

View File

@@ -17,6 +17,8 @@ import { BillingModule } from "./modules/billing/billing.module";
import { NotificationsModule } from "./modules/notifications/notifications.module";
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";
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
@@ -44,6 +46,7 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder";
NotificationsModule,
FileUploadSettingsModule,
DropdownSettingsModule,
OtpModule,
BackofficeModule,
],
providers: [EdrOrgSeeder],

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,12 @@ import {
IsOptional,
IsString,
MaxLength,
IsNotEmpty,
Length,
Matches,
} from "class-validator";
// Enums
export enum CustomerStatusDto {
Active = "Active",
Pending = "Pending",
@@ -18,23 +22,57 @@ export enum CustomerTypeDto {
Supplier = "Supplier",
}
// DTO
export class CreateCustomerDto {
// Basic identity
@IsString()
@MaxLength(256)
name!: string;
@IsNotEmpty()
userId!: string;
@IsString()
@IsNotEmpty()
@MaxLength(100)
firstName!: string;
@IsString()
@IsNotEmpty()
@MaxLength(100)
lastName!: string;
@IsEmail()
@IsNotEmpty()
email!: string;
@IsString()
@MaxLength(32)
@IsNotEmpty()
@MaxLength(20)
phone!: string;
@IsOptional()
// Company info
@IsString()
@MaxLength(256)
company?: string;
@IsNotEmpty()
@MaxLength(200)
companyName!: string;
@IsEmail()
@IsNotEmpty()
companyEmail!: string;
@IsString()
@IsNotEmpty()
@MaxLength(20)
companyPhone!: string;
@IsString()
@IsNotEmpty()
@MaxLength(100)
companyLocation!: string;
@IsString()
@IsNotEmpty()
companyAddress!: string;
// Classification
@IsOptional()
@IsEnum(CustomerTypeDto)
customerType?: CustomerTypeDto;
@@ -43,31 +81,76 @@ export class CreateCustomerDto {
@IsEnum(CustomerStatusDto)
status?: CustomerStatusDto;
// Legal identifiers
@IsString()
@IsNotEmpty()
@Length(10, 10)
@Matches(/^\d+$/, { message: "TIN must contain only digits" })
tinNumber!: string;
@IsString()
@IsNotEmpty()
@Length(16, 16)
@Matches(/^\d+$/, { message: "FAN must contain only digits" })
fanNumber!: string;
@IsString()
@IsNotEmpty()
@MaxLength(50)
vatNumber!: string;
// Contact person
@IsString()
@IsNotEmpty()
@MaxLength(100)
contactPersonName!: string;
@IsString()
@IsNotEmpty()
@MaxLength(20)
contactPersonPhone!: string;
// Management
@IsString()
@IsNotEmpty()
@MaxLength(100)
generalManagerName!: string;
@IsEmail()
@IsNotEmpty()
generalManagerEmail!: string;
@IsString()
@IsNotEmpty()
@MaxLength(20)
generalManagerPhone!: string;
// POA (Power of Attorney)
@IsOptional()
@IsString()
@MaxLength(64)
tinNumber?: string;
@MaxLength(100)
poaName?: string;
@IsOptional()
@IsString()
@MaxLength(128)
city?: string;
@MaxLength(20)
poaPhone?: string;
@IsOptional()
@IsString()
@MaxLength(128)
country?: string;
poaAddress?: string;
@IsOptional()
@IsEmail()
poaEmail?: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsString()
@MaxLength(64)
taxId?: string;
@MaxLength(100)
poaLocation?: string;
// Extra
@IsOptional()
@IsString()
notes?: string;
}
}

View File

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

View File

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

View File

@@ -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({schema:"freight", 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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import { PartialType } from "@nestjs/swagger";
import { CreateCustomerDto } from "./create-customer.dto";
export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {}

View File

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

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OtpController } from './otp.controller';
describe('OtpController', () => {
let controller: OtpController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [OtpController],
}).compile();
controller = module.get<OtpController>(OtpController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -0,0 +1,53 @@
// otp.controller.ts
import {
Body,
Controller,
Post,
} from "@nestjs/common";
import { OtpService } from "./otp.service";
import { Public } from "@edr/api-common";
@Controller("otp")
@Public()
export class OtpController {
constructor(
private readonly otpService: OtpService
) {}
// ---------------------------------------------------------------------------
// Send OTP
// ---------------------------------------------------------------------------
@Post("send")
async sendOtp(
@Body("phone")
phone: string,
@Body("otp")
otp: string
) {
return this.otpService.sendOtp(
phone,otp
);
}
// ---------------------------------------------------------------------------
// Verify OTP
// ---------------------------------------------------------------------------
@Post("verify")
async verifyOtp(
@Body("phone")
phone: string,
@Body("otp")
otp: string
) {
return this.otpService.verifyOtp(
phone,
otp
);
}
}

View File

@@ -0,0 +1,25 @@
// otp.entity.ts
import {
Column,
Entity,
} from "typeorm";
import { BaseEntity } from "@edr/api-common";
@Entity({
name: "otp_verifications",
})
export class OtpVerification extends BaseEntity{
@Column({
unique: true,
})
phone!: string;
@Column()
otp!: string;
@Column({
default: false,
})
verified!: boolean;
}

View File

@@ -0,0 +1,33 @@
// otp.module.ts
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { OtpVerification } from "./otp.entity";
import { OtpController } from "./otp.controller";
import { OtpService } from "./otp.service";
import { OtpRepository } from "./otp.repository";
@Module({
imports: [
TypeOrmModule.forFeature([
OtpVerification,
]),
],
controllers: [OtpController],
providers: [
OtpService,
OtpRepository,
],
exports: [
OtpRepository,
],
})
export class OtpModule {}

View File

@@ -0,0 +1,86 @@
// otp.repository.ts
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { OtpVerification } from "./otp.entity";
@Injectable()
export class OtpRepository {
constructor(
@InjectRepository(
OtpVerification
)
private readonly repository: Repository<OtpVerification>
) {}
// ---------------------------------------------------------------------------
// Find By Phone
// ---------------------------------------------------------------------------
async findByPhone(
phone: string
) {
return this.repository.findOne({
where: {
phone,
},
});
}
// ---------------------------------------------------------------------------
// Create OTP
// ---------------------------------------------------------------------------
async createOtp(
phone: string,
otp: string
) {
const entity =
this.repository.create({
phone,
otp,
verified: false,
});
return this.repository.save(
entity
);
}
// ---------------------------------------------------------------------------
// Update OTP
// ---------------------------------------------------------------------------
async updateOtp(
otpVerification: OtpVerification,
otp: string
) {
otpVerification.otp = otp;
otpVerification.verified =
false;
return this.repository.save(
otpVerification
);
}
// ---------------------------------------------------------------------------
// Verify Phone
// ---------------------------------------------------------------------------
async verifyPhone(
otpVerification: OtpVerification
) {
otpVerification.verified =
true;
return this.repository.save(
otpVerification
);
}
}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OtpService } from './otp.service';
describe('OtpService', () => {
let service: OtpService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [OtpService],
}).compile();
service = module.get<OtpService>(OtpService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,141 @@
// otp.service.ts
import {
BadRequestException,
Injectable,
} from "@nestjs/common";
import axios from "axios";
import { OtpRepository } from "./otp.repository";
@Injectable()
export class OtpService {
constructor(
private readonly otpRepository: OtpRepository
) {}
// ---------------------------------------------------------------------------
// Generate OTP
// ---------------------------------------------------------------------------
generateOtp(): string {
return Math.floor(
100000 + Math.random() * 900000
).toString();
}
// ---------------------------------------------------------------------------
// Send OTP
// ---------------------------------------------------------------------------
async sendOtp(phone: string, otp: string) {
try {
// generate otp
// const otp =
// this.generateOtp();
// find existing phone
const existingPhone =
await this.otpRepository.findByPhone(
phone
);
// update existing otp
if (existingPhone) {
await this.otpRepository.updateOtp(
existingPhone,
otp
);
} else {
// create new otp
await this.otpRepository.createOtp(
phone,
otp
);
}
// send sms
await axios.post(
"https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms",
{
to: phone,
sourceId: "EDR",
sourceName:
"EDR Freight",
appKey:
"YOUR_APP_KEY",
text: `Your verification code is ${otp}`,
callbackUrl: "",
},
{
headers: {
accept: "*/*",
"Content-Type":
"application/json",
},
}
);
return {
success: true,
message:
"OTP sent successfully",
};
} catch (error) {
console.log(error);
throw new BadRequestException(
"Failed to send OTP"
);
}
}
// ---------------------------------------------------------------------------
// Verify OTP
// ---------------------------------------------------------------------------
async verifyOtp(
phone: string,
otp: string
) {
// find phone
const otpData =
await this.otpRepository.findByPhone(
phone
);
// phone not found
if (!otpData) {
throw new BadRequestException(
"Phone number not found"
);
}
// invalid otp
if (otpData.otp !== otp) {
throw new BadRequestException(
"Invalid OTP"
);
}
// verify phone
await this.otpRepository.verifyPhone(
otpData
);
return {
success: true,
message:
"Phone verified successfully",
};
}
}

View File

@@ -44,11 +44,15 @@ import DocumentsPage from "./pages/documents/DocumentsPage";
import DropdownSettingsPage from "./pages/admin/DropdownSettingsPage";
import FileUploadSettingsPage from "./pages/admin/FileUploadSettingsPage";
import MyPortalPage from "./pages/portal/MyPortalPage";
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: "/portal", icon: <UserCircle /> },
{ label: "Dashboard", href: "/", icon: <LayoutDashboard /> },
{ label: "My Portal", href: "/", icon: <UserCircle /> },
{ label: "Dashboard", href: "/dashboard", icon: <LayoutDashboard /> },
{ label: "Customers", href: "/customers", icon: <Users /> },
{ label: "My Bookings", href: "/bookings", icon: <CalendarCheck /> },
{ label: "Consignments", href: "/consignments", icon: <Package /> },
@@ -75,11 +79,15 @@ const App = () => {
return <LoadingScreen />;
}
if (!user) {
if (user) {
return (
<Routes>
<Route path="/" element={<EDRFreightLandingPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route path="/otp" element={<VerificationOtpPage />} />
<Route path="/set-password" element={<SetPasswordPage />} />
<Route path="/auth" element={<IamLoginPage />} />
<Route path="*" element={<Navigate to="/auth" replace />} />
{/* <Route path="*" element={<Navigate to="/auth" replace />} /> */}
</Routes>
);
}
@@ -114,8 +122,8 @@ const App = () => {
onLogout={handleLogout}
>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/portal" element={<MyPortalPage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/" element={<MyPortalPage />} />
<Route path="/bookings" element={<MyBookings />} />
<Route path="/admin/bookings" element={<BookingsPage />} />
<Route path="/customers" element={<CustomersPage />} />

View File

@@ -1,3 +1,4 @@
export const FILE_SETTINGS = {
CUSTOMER_REGISTRATION: "customer_registration"
CUSTOMER_REGISTRATION: "customer_registration",
}

View File

@@ -1,5 +1,6 @@
export const QUERY_KEYS = {
USERS: "users",
ADD_USER: "add_user",
CUSTOMER: "Customers",
FILES: {
FILE_UPLOAD_SETTINGS: "file-upload-settings",

View File

@@ -8,8 +8,12 @@ export const URL_CONSTANTS = {
},
USERS: {
SIGN_UP: "/api/auth/signup",
GENERATE_VERIFICATION_CODE: "/api/auth/generate-verification-code",
BASE: "/users",
BY_ID: (id: string | number) => `/users/${id}`,
SET_PASSWORD: "/api/auth/set-password",
ME: "/api/auth/me"
},
ROLES: {
@@ -64,10 +68,11 @@ export const URL_CONSTANTS = {
BY_ID: (id: string | number) => `/customers/${id}`,
BOOKINGS: (id: string | number) => `/customers/${id}/bookings`,
},
CUSTOMERS_API: {
BASE: "/api/customers",
BY_ID: (id: string) => `/api/customers/${id}`,
BY_USER_ID: (id: string) => `/api/customers/user/${id}`
},
BOOKINGS: {
@@ -76,4 +81,9 @@ export const URL_CONSTANTS = {
CANCEL: (id: string | number) => `/bookings/${id}/cancel`,
CONFIRM: (id: string | number) => `/bookings/${id}/confirm`,
},
OTP: {
SEND: "/api/otp/send",
VERIFY: "/api/otp/verify",
}
};

View File

@@ -0,0 +1,5 @@
export enum userType {
externalOrganization = "external_organization",
employee = "employee",
individual = "individual"
}

View File

@@ -0,0 +1,6 @@
export enum verificationCodeType {
setPassword = "set-password",
resetPassword = "reset-password",
verifyPhoneNumber = "verify-phone-number",
mfaLogin = "mfa-login",
}

View File

@@ -0,0 +1,612 @@
import {
ArrowRight,
BarChart3,
CheckCircle2,
Clock3,
Globe2,
Mail,
MapPin,
Menu,
Phone,
ShieldCheck,
Train,
Truck,
Users,
} from "lucide-react";
const stats = [
{
label: "Active Corridors",
value: "24+",
icon: Globe2,
},
{
label: "Monthly Shipments",
value: "12K+",
icon: Truck,
},
{
label: "Fleet Coverage",
value: "16 Trains",
icon: Train,
},
{
label: "On-time Delivery",
value: "100%",
icon: Clock3,
},
];
const features = [
{
title: "Real-time Shipment Tracking",
description:
"Track consignments across Addis Ababa, Dire Dawa, Djibouti, Mojo, and all major freight corridors.",
icon: Truck,
},
{
title: "Rail Freight Operations",
description:
"Monitor train schedules, operational performance, maintenance, and corridor activity.",
icon: Train,
},
{
title: "Smart Analytics Dashboard",
description:
"Visualize operational insights, shipment trends, and corridor performance in real time.",
icon: BarChart3,
},
{
title: "Enterprise-grade Security",
description:
"Secure portal access, billing workflows, document management, and customer operations.",
icon: ShieldCheck,
},
];
const corridors = [
"Addis Ababa → Djibouti",
"Dire Dawa → Djibouti",
"Adama → Dire Dawa",
"Mojo → Djibouti",
"Awash → Holhol",
"Mieso → Aysha",
];
export default function EDRFreightLandingPage() {
return (
<div className="min-h-screen bg-background text-foreground">
{/* Navbar */}
<header className="sticky top-0 z-50 border-b border-border bg-background/80 backdrop-blur-xl">
<div className="mx-auto flex h-20 max-w-7xl items-center justify-between px-6">
<div className="flex items-center gap-3">
<div className="flex size-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-lg">
<Train className="size-6" />
</div>
<div>
<h1 className="text-xl font-bold tracking-tight">
EDR Freight
</h1>
<p className="text-sm text-muted-foreground">
Rail Logistics Platform
</p>
</div>
</div>
<nav className="hidden items-center gap-8 md:flex">
<a
href="#features"
className="text-sm font-medium text-muted-foreground transition hover:text-foreground"
>
Features
</a>
<a
href="#corridors"
className="text-sm font-medium text-muted-foreground transition hover:text-foreground"
>
Corridors
</a>
<a
href="#contact"
className="text-sm font-medium text-muted-foreground transition hover:text-foreground"
>
Contact
</a>
</nav>
<div className="flex items-center gap-3">
<a
href="http://localhost:5173/auth"
className="hidden rounded-2xl border border-border bg-card px-5 py-2.5 text-sm font-medium transition hover:bg-accent md:block"
>
Login
</a>
<a
href="http://localhost:5173/signup"
className="hidden items-center gap-2 rounded-2xl bg-primary px-5 py-2.5 text-sm font-semibold text-primary-foreground shadow-lg transition hover:opacity-90 md:flex"
>
Get Started
<ArrowRight className="size-4" />
</a>
<button className="rounded-2xl border border-border p-2 md:hidden">
<Menu className="size-5" />
</button>
</div>
</div>
</header>
{/* Hero */}
<section className="relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(16,185,129,0.16),transparent_35%)]" />
<div className="mx-auto grid max-w-7xl gap-16 px-6 py-20 lg:grid-cols-2 lg:items-center">
<div>
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-border bg-accent px-4 py-2 text-sm font-medium text-primary shadow-sm">
<span className="size-2 rounded-full bg-primary" />
EthiopiaDjibouti Railway Freight Platform
</div>
<h1 className="max-w-2xl text-5xl font-black leading-tight tracking-tight md:text-6xl">
Smarter Railway Freight Logistics For Modern Operations
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-muted-foreground">
EDR Freight enables logistics companies and railway operators
to manage shipments, monitor freight corridors, optimize train
operations, and streamline enterprise logistics workflows.
</p>
<div className="mt-10 flex flex-wrap gap-4">
<a
href="http://localhost:5173/signup"
className="flex items-center gap-2 rounded-2xl bg-primary px-6 py-3 font-semibold text-primary-foreground shadow-lg transition hover:-translate-y-0.5 hover:opacity-90"
>
Get Started
<ArrowRight className="size-5" />
</a>
<a
href="http://localhost:5173/auth"
className="rounded-2xl border border-border bg-card px-6 py-3 font-semibold transition hover:bg-accent"
>
Login
</a>
</div>
<div className="mt-12 flex flex-wrap gap-6">
{[
"Real-time Tracking",
"Railway Analytics",
"Secure Operations",
"Multi-corridor Freight",
].map((item) => (
<div
key={item}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<CheckCircle2 className="size-4 text-primary" />
{item}
</div>
))}
</div>
</div>
{/* Hero Dashboard Card */}
<div className="relative">
<div className="rounded-[32px] border border-border bg-card p-8 shadow-2xl">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">
Freight Operations
</p>
<h3 className="mt-2 text-3xl font-bold">
Live Statistics
</h3>
</div>
<div className="rounded-2xl bg-primary/10 p-4 text-primary">
<BarChart3 className="size-7" />
</div>
</div>
<div className="mt-8 grid grid-cols-2 gap-4">
{stats.map((item) => {
const Icon = item.icon;
return (
<div
key={item.label}
className="rounded-3xl border border-border bg-background p-5 transition hover:-translate-y-1 hover:shadow-lg"
>
<div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-accent text-primary">
<Icon className="size-6" />
</div>
<h4 className="text-3xl font-black">
{item.value}
</h4>
<p className="mt-1 text-sm text-muted-foreground">
{item.label}
</p>
</div>
);
})}
</div>
<div className="mt-8 rounded-3xl border border-border bg-accent p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">
Corridor Performance
</p>
<h3 className="mt-2 text-4xl font-black text-primary">
99%
</h3>
</div>
<div className="rounded-2xl bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground">
Operational
</div>
</div>
<div className="mt-6 h-3 overflow-hidden rounded-full bg-primary/10">
<div className="h-full w-[99%] rounded-full bg-primary" />
</div>
</div>
</div>
<div className="absolute -bottom-6 -left-6 hidden rounded-3xl border border-border bg-card p-5 shadow-xl lg:block">
<div className="flex items-center gap-4">
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Train className="size-7" />
</div>
<div>
<h4 className="text-lg font-bold">
16 Trains Active
</h4>
<p className="text-sm text-muted-foreground">
Across all freight corridors
</p>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Features */}
<section
id="features"
className="border-t border-border bg-card/40 py-24"
>
<div className="mx-auto max-w-7xl px-6">
<div className="max-w-3xl">
<div className="inline-flex rounded-full bg-accent px-4 py-2 text-sm font-semibold text-primary">
Platform Features
</div>
<h2 className="mt-6 text-4xl font-black tracking-tight">
Everything needed for freight operations
</h2>
<p className="mt-4 text-lg leading-8 text-muted-foreground">
Centralized railway freight operations with live shipment
visibility, operational monitoring, customer management,
and intelligent logistics insights.
</p>
</div>
<div className="mt-16 grid gap-6 md:grid-cols-2 xl:grid-cols-4">
{features.map((feature) => {
const Icon = feature.icon;
return (
<div
key={feature.title}
className="group rounded-[28px] border border-border bg-background p-7 shadow-sm transition duration-300 hover:-translate-y-2 hover:shadow-2xl"
>
<div className="flex size-14 items-center justify-center rounded-2xl bg-accent text-primary transition group-hover:scale-110">
<Icon className="size-7" />
</div>
<h3 className="mt-6 text-xl font-bold">
{feature.title}
</h3>
<p className="mt-3 leading-7 text-muted-foreground">
{feature.description}
</p>
</div>
);
})}
</div>
</div>
</section>
{/* Corridors */}
<section id="corridors" className="py-24">
<div className="mx-auto max-w-7xl px-6">
<div className="grid gap-16 lg:grid-cols-2 lg:items-center">
<div>
<div className="inline-flex rounded-full bg-accent px-4 py-2 text-sm font-semibold text-primary">
Freight Corridors
</div>
<h2 className="mt-6 text-4xl font-black tracking-tight">
Connected logistics infrastructure
</h2>
<p className="mt-5 text-lg leading-8 text-muted-foreground">
Efficiently move freight across strategic EthiopiaDjibouti
railway corridors with operational visibility and optimized
transport coordination.
</p>
<div className="mt-10 grid gap-4 sm:grid-cols-2">
{corridors.map((corridor) => (
<div
key={corridor}
className="flex items-center gap-3 rounded-2xl border border-border bg-card p-4 transition hover:border-primary/30 hover:bg-accent"
>
<div className="size-3 rounded-full bg-primary" />
<span className="font-medium">{corridor}</span>
</div>
))}
</div>
</div>
<div className="rounded-[32px] border border-border bg-card p-8 shadow-xl">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">
Operational Insights
</p>
<h3 className="mt-2 text-3xl font-bold">
Freight Performance
</h3>
</div>
<div className="rounded-2xl bg-primary/10 p-4 text-primary">
<Users className="size-7" />
</div>
</div>
<div className="mt-10 space-y-6">
{[
{
label: "Bookings Processed",
value: "9,842",
progress: "92%",
},
{
label: "On-time Shipments",
value: "99%",
progress: "99%",
},
{
label: "Customer Satisfaction",
value: "99%",
progress: "99%",
},
].map((item) => (
<div key={item.label}>
<div className="mb-2 flex items-center justify-between">
<span className="font-medium">{item.label}</span>
<span className="text-muted-foreground">
{item.value}
</span>
</div>
<div className="h-3 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-primary transition-all duration-700"
style={{ width: item.progress }}
/>
</div>
</div>
))}
</div>
<div className="mt-10 rounded-3xl bg-accent p-6">
<div className="flex items-center gap-4">
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<CheckCircle2 className="size-7" />
</div>
<div>
<h4 className="text-lg font-bold">
Enterprise-ready Platform
</h4>
<p className="text-sm text-muted-foreground">
Designed for large-scale freight and railway operations.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Contact */}
<section
id="contact"
className="border-t border-border bg-card/40 py-24"
>
<div className="mx-auto max-w-7xl px-6">
<div className="grid gap-12 lg:grid-cols-2">
<div>
<div className="inline-flex rounded-full bg-accent px-4 py-2 text-sm font-semibold text-primary">
Contact Us
</div>
<h2 className="mt-6 text-4xl font-black tracking-tight">
Lets move freight smarter
</h2>
<p className="mt-5 text-lg leading-8 text-muted-foreground">
Contact EDR Freight for partnership opportunities,
enterprise onboarding, or logistics support.
</p>
<div className="mt-10 space-y-5">
<div className="flex items-center gap-4 rounded-2xl border border-border bg-background p-5">
<div className="rounded-2xl bg-accent p-3 text-primary">
<Mail className="size-5" />
</div>
<div>
<p className="font-semibold">Email</p>
<p className="text-muted-foreground">
support@edrfreight.com
</p>
</div>
</div>
<div className="flex items-center gap-4 rounded-2xl border border-border bg-background p-5">
<div className="rounded-2xl bg-accent p-3 text-primary">
<Phone className="size-5" />
</div>
<div>
<p className="font-semibold">Phone</p>
<p className="text-muted-foreground">
+251 11 000 0000
</p>
</div>
</div>
<div className="flex items-center gap-4 rounded-2xl border border-border bg-background p-5">
<div className="rounded-2xl bg-accent p-3 text-primary">
<MapPin className="size-5" />
</div>
<div>
<p className="font-semibold">Head Office</p>
<p className="text-muted-foreground">
Addis Ababa, Ethiopia
</p>
</div>
</div>
</div>
</div>
{/* Contact Form */}
<div className="rounded-[32px] border border-border bg-card p-8 shadow-xl">
<h3 className="text-2xl font-bold">
Send us a message
</h3>
<p className="mt-2 text-muted-foreground">
Well get back to you as soon as possible.
</p>
<div className="mt-8 space-y-5">
<input
type="text"
placeholder="Full Name"
className="h-12 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10"
/>
<input
type="email"
placeholder="Email Address"
className="h-12 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10"
/>
<textarea
rows={5}
placeholder="Write your message..."
className="w-full rounded-2xl border border-input bg-background px-4 py-3 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10"
/>
<button className="flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-6 py-3 font-semibold text-primary-foreground shadow-lg transition hover:opacity-90">
Send Message
<ArrowRight className="size-5" />
</button>
</div>
</div>
</div>
</div>
</section>
{/* CTA */}
<section className="border-t border-border py-24">
<div className="mx-auto max-w-5xl px-6">
<div className="rounded-[40px] bg-primary px-8 py-16 text-center text-primary-foreground shadow-2xl md:px-16">
<div className="mx-auto max-w-3xl">
<div className="inline-flex rounded-full bg-white/10 px-4 py-2 text-sm font-semibold">
EDR Freight Platform
</div>
<h2 className="mt-6 text-5xl font-black tracking-tight">
Transform railway freight operations
</h2>
<p className="mt-6 text-lg leading-8 opacity-85">
Centralize logistics workflows, optimize freight movement,
and gain real-time operational visibility across all
railway corridors.
</p>
<div className="mt-10 flex flex-wrap items-center justify-center gap-4">
<a
href="http://localhost:5173/signup"
className="flex items-center gap-2 rounded-2xl bg-white px-7 py-3 font-semibold text-primary shadow-lg transition hover:opacity-90"
>
Create Account
<ArrowRight className="size-5" />
</a>
<a
href="http://localhost:5173/auth"
className="rounded-2xl border border-white/20 bg-white/10 px-7 py-3 font-semibold backdrop-blur transition hover:bg-white/20"
>
Sign In
</a>
</div>
</div>
</div>
</div>
</section>
{/* Footer */}
<footer className="border-t border-border py-8">
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 px-6 text-sm text-muted-foreground md:flex-row">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Train className="size-5" />
</div>
<div>
<p className="font-semibold text-foreground">
EDR Freight
</p>
<p>Modern railway logistics management platform</p>
</div>
</div>
<div>
© 2026 EDR Freight. All rights reserved.
</div>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,418 @@
import { setPassword } from "@/services/account";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import {
ArrowRight,
LockKeyhole,
ShieldCheck,
Train,
Eye,
EyeOff,
} from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
// -----------------------------------------------------------------------------
// Schema
// -----------------------------------------------------------------------------
const passwordSchema = z
.object({
password: z
.string()
.min(
8,
"Password must be at least 8 characters"
),
confirmPassword: z
.string()
.min(
8,
"Confirm password is required"
),
})
.refine(
(data) =>
data.password ===
data.confirmPassword,
{
message:
"Passwords do not match",
path: ["confirmPassword"],
}
);
type FormData = z.infer<
typeof passwordSchema
>;
// -----------------------------------------------------------------------------
// Component
// -----------------------------------------------------------------------------
export default function SetPasswordPage() {
const [
showPassword,
setShowPassword,
] = useState(false);
const [
showConfirmPassword,
setShowConfirmPassword,
] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm<FormData>({
resolver:
zodResolver(passwordSchema),
defaultValues: {
password: "",
confirmPassword: "",
},
});
const naviagte = useNavigate();
// ---------------------------------------------------------------------------
// Mutation
// ---------------------------------------------------------------------------
const setPasswordMutation =
useMutation({
mutationFn: async (
data: FormData
) => setPassword({
newPassword: data?.password,
confirmPassword: data?.confirmPassword,
userId: localStorage.getItem("userId"),
email: localStorage.getItem("otp-email"),
verificationCode: localStorage.getItem("otp"),
}),
onSuccess: () => {
naviagte("/auth");
reset();
},
});
// ---------------------------------------------------------------------------
// Submit
// ---------------------------------------------------------------------------
const onSubmit = async (
data: FormData
) => {
try {
await setPasswordMutation.mutateAsync(
data
);
} catch (err) {
console.error(err);
}
};
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
return (
<div className="min-h-screen bg-background text-foreground">
<div className="grid min-h-screen lg:grid-cols-2">
{/* ------------------------------------------------------------------ */}
{/* Left Side */}
{/* ------------------------------------------------------------------ */}
<div className="relative hidden overflow-hidden bg-primary p-12 text-primary-foreground lg:flex lg:flex-col lg:justify-between">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.14),transparent_35%)]" />
<div className="relative z-10">
{/* Logo */}
<div className="flex items-center gap-3">
<div className="flex size-14 items-center justify-center rounded-3xl bg-white/10 backdrop-blur">
<Train className="size-7" />
</div>
<div>
<h1 className="text-3xl font-black tracking-tight">
EDR Freight
</h1>
<p className="mt-1 text-sm opacity-80">
Railway Logistics
Platform
</p>
</div>
</div>
{/* Hero */}
<div className="mt-20 max-w-lg">
<div className="inline-flex rounded-full bg-white/10 px-4 py-2 text-sm font-semibold backdrop-blur">
Account Security
</div>
<h2 className="mt-6 text-5xl font-black leading-tight tracking-tight">
Set your secure
password
</h2>
<p className="mt-6 text-lg leading-8 opacity-85">
Create a strong
password to secure
your EDR Freight
account and protect
railway logistics
operations and shipment
data.
</p>
</div>
{/* Features */}
<div className="mt-14 grid gap-5">
{[
"Enterprise-grade security",
"Protected account access",
"Secure freight operations",
"Advanced authentication system",
].map((item) => (
<div
key={item}
className="flex items-center gap-3"
>
<div className="flex size-10 items-center justify-center rounded-2xl bg-white/10">
<ShieldCheck className="size-5" />
</div>
<span className="font-medium">
{item}
</span>
</div>
))}
</div>
</div>
{/* Stats */}
<div className="relative z-10 rounded-[32px] border border-white/10 bg-white/10 p-6 backdrop-blur-xl">
<div className="flex items-center justify-between">
<div>
<p className="text-sm opacity-80">
Security Protection
</p>
<h3 className="mt-2 text-4xl font-black">
256-bit
</h3>
</div>
<div className="rounded-2xl bg-white/10 px-4 py-2 text-sm font-semibold">
Encrypted
</div>
</div>
<div className="mt-6 h-3 overflow-hidden rounded-full bg-white/10">
<div className="h-full w-[98%] rounded-full bg-white" />
</div>
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Right Side */}
{/* ------------------------------------------------------------------ */}
<div className="flex items-center justify-center p-6 md:p-10">
<div className="w-full max-w-xl">
{/* Mobile Logo */}
<div className="mb-8 flex items-center gap-3 lg:hidden">
<div className="flex size-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Train className="size-6" />
</div>
<div>
<h1 className="text-2xl font-bold">
EDR Freight
</h1>
<p className="text-sm text-muted-foreground">
Railway Logistics
Platform
</p>
</div>
</div>
{/* Card */}
<div className="rounded-[36px] border border-border bg-card p-8 shadow-2xl md:p-10">
{/* Header */}
<div className="mb-8">
<div className="mb-4 flex size-16 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<LockKeyhole className="size-8" />
</div>
<h2 className="text-4xl font-black tracking-tight">
Set Password
</h2>
<p className="mt-3 text-lg text-muted-foreground">
Create a secure
password for your
EDR Freight account.
</p>
</div>
{/* Success */}
{setPasswordMutation.isSuccess && (
<div className="mb-6 rounded-2xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">
Password updated
successfully.
</div>
)}
{/* Error */}
{setPasswordMutation.isError && (
<div className="mb-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
Failed to set
password. Please try
again.
</div>
)}
{/* Form */}
<form
onSubmit={handleSubmit(
onSubmit
)}
className="space-y-6"
>
{/* Password */}
<div>
<label className="mb-2 block text-sm font-semibold">
Password
</label>
<div className="relative">
<input
type={
showPassword
? "text"
: "password"
}
placeholder="Enter password"
disabled={
setPasswordMutation.isPending
}
{...register(
"password"
)}
className="h-14 w-full rounded-2xl border border-input bg-background px-4 pr-14 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
<button
type="button"
onClick={() =>
setShowPassword(
!showPassword
)
}
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground"
>
{showPassword ? (
<EyeOff className="size-5" />
) : (
<Eye className="size-5" />
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-500">
{
errors.password
.message
}
</p>
)}
</div>
{/* Confirm Password */}
<div>
<label className="mb-2 block text-sm font-semibold">
Confirm Password
</label>
<div className="relative">
<input
type={
showConfirmPassword
? "text"
: "password"
}
placeholder="Confirm password"
disabled={
setPasswordMutation.isPending
}
{...register(
"confirmPassword"
)}
className="h-14 w-full rounded-2xl border border-input bg-background px-4 pr-14 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
<button
type="button"
onClick={() =>
setShowConfirmPassword(
!showConfirmPassword
)
}
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground"
>
{showConfirmPassword ? (
<EyeOff className="size-5" />
) : (
<Eye className="size-5" />
)}
</button>
</div>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-500">
{
errors
.confirmPassword
.message
}
</p>
)}
</div>
{/* Submit */}
<button
type="submit"
disabled={
setPasswordMutation.isPending
}
className="flex h-14 w-full items-center justify-center gap-2 rounded-2xl bg-primary text-lg font-semibold text-primary-foreground shadow-lg transition hover:-translate-y-0.5 hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
>
{setPasswordMutation.isPending ? (
"Saving..."
) : (
<>
Save Password
<ArrowRight className="size-5" />
</>
)}
</button>
</form>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,534 @@
import { userType } from "@/enums/userType";
import { createOTP, createUser } from "@/services/account";
import { CreateUserPayload } from "@/types/createUser";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import {
ArrowRight,
ShieldCheck,
Train,
UserPlus,
} from "lucide-react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
// -----------------------------------------------------------------------------
// Schema
// -----------------------------------------------------------------------------
const userSchema = z.object({
email: z
.string()
.email("Invalid email address"),
username: z
.string()
.min(
3,
"Username must be at least 3 characters"
),
countryCode: z
.string()
.min(
1,
"Country code is required"
),
phone: z
.string()
.min(
9,
"Phone number is too short"
)
.max(
9,
"Phone number is too long"
),
userType: z.string(),
name: z.object({
en: z
.string()
.min(2, "Name is required"),
am: z.string().nullable(),
}),
});
type FormData = z.infer<
typeof userSchema
>;
// -----------------------------------------------------------------------------
// Component
// -----------------------------------------------------------------------------
export default function SignupPage() {
const navigate = useNavigate();
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm<FormData>({
resolver:
zodResolver(userSchema),
defaultValues: {
email: "",
username: "",
countryCode: "+251",
phone: "",
userType:
userType.individual,
name: {
en: "",
am: "",
},
},
});
// ---------------------------------------------------------------------------
// Create User Mutation
// ---------------------------------------------------------------------------
const createUserMutation =
useMutation({
mutationFn: (
user: CreateUserPayload
) => createUser(user),
onSuccess: () => {
reset();
},
});
// ---------------------------------------------------------------------------
// Submit
// ---------------------------------------------------------------------------
const onSubmit = async (
data: FormData
) => {
try {
const normalizedPhone =
data.phone.startsWith(
"0"
)
? data.phone.slice(1)
: data.phone;
const fullPhoneNumber = `${data.countryCode
}${normalizedPhone}`;
const payload: CreateUserPayload =
{
email: data.email,
username:
data.username,
phoneNumber:
fullPhoneNumber,
userType:
data.userType,
name: {
en: data.name.en,
am:
data.name.am ||
"",
},
};
const res =
await createUserMutation.mutateAsync(
payload
);
if (res?.success) {
// save auth token
// document.cookie = `auth-token=${res.data?.token}; path=/`;
localStorage.setItem(
"auth-token",
`auth-token=${res.data?.token}; path=/`
);
localStorage.setItem(
"userId",res.data?.userId
);
localStorage.setItem(
"otp",res.data?.otp?.split(" ")?.[6]
);
createOTP({ phone: payload.phoneNumber, otp:res.data?.otp?.split(" ")?.[6] })
// save phone for otp page
localStorage.setItem(
"otp-phone",
payload.phoneNumber
);
// save phone for set password page
localStorage.setItem(
"otp-email",
payload.email
);
// navigate otp page
navigate("/otp");
}
} catch (err) {
console.error(err);
}
};
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
return (
<div className="min-h-screen bg-background text-foreground">
<div className="grid min-h-screen lg:grid-cols-2">
{/* ------------------------------------------------------------------ */}
{/* Left Side */}
{/* ------------------------------------------------------------------ */}
<div className="relative hidden overflow-hidden bg-primary p-12 text-primary-foreground lg:flex lg:flex-col lg:justify-between">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.14),transparent_35%)]" />
<div className="relative z-10">
{/* Logo */}
<div className="flex items-center gap-3">
<div className="flex size-14 items-center justify-center rounded-3xl bg-white/10 backdrop-blur">
<Train className="size-7" />
</div>
<div>
<h1 className="text-3xl font-black tracking-tight">
EDR Freight
</h1>
<p className="mt-1 text-sm opacity-80">
Railway Logistics
Platform
</p>
</div>
</div>
{/* Hero */}
<div className="mt-20 max-w-lg">
<div className="inline-flex rounded-full bg-white/10 px-4 py-2 text-sm font-semibold backdrop-blur">
Smart Freight
Operations
</div>
<h2 className="mt-6 text-5xl font-black leading-tight tracking-tight">
Create your freight
operations account
</h2>
<p className="mt-6 text-lg leading-8 opacity-85">
Join EDR Freight to
manage shipments,
monitor railway
operations, track
consignments, and
streamline logistics
workflows across
Ethiopia and
Djibouti.
</p>
</div>
{/* Features */}
<div className="mt-14 grid gap-5">
{[
"Real-time shipment tracking",
"Secure logistics management",
"Enterprise-grade operations",
"Multi-corridor freight monitoring",
].map((item) => (
<div
key={item}
className="flex items-center gap-3"
>
<div className="flex size-10 items-center justify-center rounded-2xl bg-white/10">
<ShieldCheck className="size-5" />
</div>
<span className="font-medium">
{item}
</span>
</div>
))}
</div>
</div>
{/* Stats */}
<div className="relative z-10 rounded-[32px] border border-white/10 bg-white/10 p-6 backdrop-blur-xl">
<div className="flex items-center justify-between">
<div>
<p className="text-sm opacity-80">
Active Corridors
</p>
<h3 className="mt-2 text-4xl font-black">
24+
</h3>
</div>
<div className="rounded-2xl bg-white/10 px-4 py-2 text-sm font-semibold">
Operational
</div>
</div>
<div className="mt-6 h-3 overflow-hidden rounded-full bg-white/10">
<div className="h-full w-[95%] rounded-full bg-white" />
</div>
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Right Side */}
{/* ------------------------------------------------------------------ */}
<div className="flex items-center justify-center p-6 md:p-10">
<div className="w-full max-w-2xl">
{/* Mobile Logo */}
<div className="mb-8 flex items-center gap-3 lg:hidden">
<div className="flex size-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Train className="size-6" />
</div>
<div>
<h1 className="text-2xl font-bold">
EDR Freight
</h1>
<p className="text-sm text-muted-foreground">
Railway Logistics
Platform
</p>
</div>
</div>
{/* Form Card */}
<div className="rounded-[36px] border border-border bg-card p-8 shadow-2xl md:p-10">
{/* Header */}
<div className="mb-8">
<div className="mb-4 flex size-16 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<UserPlus className="size-8" />
</div>
<h2 className="text-4xl font-black tracking-tight">
Create Account
</h2>
<p className="mt-3 text-lg text-muted-foreground">
Register to access
EDR Freight
services and railway
logistics operations.
</p>
</div>
{/* Success */}
{createUserMutation.isSuccess && (
<div className="mb-6 rounded-2xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">
Account created
successfully.
</div>
)}
{/* Error */}
{createUserMutation.isError && (
<div className="mb-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
Failed to create
account. Please try
again.
</div>
)}
{/* Form */}
<form
onSubmit={handleSubmit(
onSubmit
)}
className="space-y-6"
>
{/* Full Name */}
<div>
<label className="mb-2 block text-sm font-semibold">
Full Name
</label>
<input
type="text"
placeholder="John Doe"
disabled={
createUserMutation.isPending
}
{...register(
"name.en"
)}
className="h-13 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
{errors.name?.en && (
<p className="mt-1 text-sm text-red-500">
{
errors.name.en
.message
}
</p>
)}
</div>
{/* Username */}
<div>
<label className="mb-2 block text-sm font-semibold">
Username
</label>
<input
type="text"
placeholder="john_doe"
disabled={
createUserMutation.isPending
}
{...register(
"username"
)}
className="h-13 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
{errors.username && (
<p className="mt-1 text-sm text-red-500">
{
errors.username
.message
}
</p>
)}
</div>
{/* Email */}
<div>
<label className="mb-2 block text-sm font-semibold">
Email Address
</label>
<input
type="email"
placeholder="john@example.com"
disabled={
createUserMutation.isPending
}
{...register(
"email"
)}
className="h-13 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-500">
{
errors.email
.message
}
</p>
)}
</div>
{/* Phone */}
<div>
<label className="mb-2 block text-sm font-semibold">
Phone Number
</label>
<div className="flex gap-2">
<input
type="text"
disabled={
createUserMutation.isPending
}
{...register(
"countryCode"
)}
className="h-13 w-28 rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
<input
type="tel"
placeholder="912345678"
disabled={
createUserMutation.isPending
}
{...register(
"phone"
)}
className="h-13 flex-1 rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
</div>
{(errors.countryCode ||
errors.phone) && (
<p className="mt-1 text-sm text-red-500">
{errors
.countryCode
?.message ||
errors.phone
?.message}
</p>
)}
</div>
{/* Submit */}
<button
type="submit"
disabled={
createUserMutation.isPending
}
className="flex h-14 w-full items-center justify-center gap-2 rounded-2xl bg-primary text-lg font-semibold text-primary-foreground shadow-lg transition hover:-translate-y-0.5 hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
>
{createUserMutation.isPending ? (
"Creating..."
) : (
<>
Create Account
<ArrowRight className="size-5" />
</>
)}
</button>
{/* Footer */}
<p className="text-center text-sm text-muted-foreground">
Already have an
account?
<button
type="button"
className="ml-2 font-semibold text-primary hover:underline"
>
Sign In
</button>
</p>
</form>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,454 @@
import { verificationCodeType } from "@/enums/verificationCodeType";
import {
generateVerificationCode,
verifyOTP,
} from "@/services/account";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import {
ArrowRight,
ShieldCheck,
Train,
MailCheck,
RotateCw,
} from "lucide-react";
import { useForm } from "react-hook-form";
import { z } from "zod";
// -----------------------------------------------------------------------------
// Schema
// -----------------------------------------------------------------------------
const otpSchema = z.object({
code: z
.string()
.regex(
/^\d{6}$/,
"OTP must be exactly 6 digits"
),
});
type FormData = z.infer<
typeof otpSchema
>;
// -----------------------------------------------------------------------------
// Component
// -----------------------------------------------------------------------------
export default function VerificationOtpPage() {
const navigate =
useNavigate();
// ---------------------------------------------------------------------------
// Local Storage Data
// ---------------------------------------------------------------------------
const phone =
localStorage.getItem(
"otp-phone"
) || "";
const email =
localStorage.getItem(
"otp-email"
) || "";
// ---------------------------------------------------------------------------
// Form
// ---------------------------------------------------------------------------
const {
register,
handleSubmit,
formState: { errors },
watch,
} = useForm<FormData>({
resolver:
zodResolver(otpSchema),
defaultValues: {
code: "",
},
});
const otpValue =
watch("code");
// ---------------------------------------------------------------------------
// Verify Mutation
// ---------------------------------------------------------------------------
const verifyMutation =
useMutation({
mutationFn: async (
data: {
phone: string;
otp: string;
}
) => verifyOTP(data),
onSuccess: () => {
navigate(
"/set-password"
);
},
});
// ---------------------------------------------------------------------------
// Resend Mutation
// ---------------------------------------------------------------------------
const resendMutation =
useMutation({
mutationFn: async () => {
return generateVerificationCode(
{
email,
phoneNumber:
phone,
type:
verificationCodeType.setPassword,
}
);
},
});
// ---------------------------------------------------------------------------
// Submit
// ---------------------------------------------------------------------------
const onSubmit = async (
data: FormData
) => {
try {
await verifyMutation.mutateAsync(
{
phone,
otp: data.code,
}
);
} catch (err) {
console.error(err);
}
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const maskedPhone =
phone.length > 4
? `${phone.slice(
0,
7
)}******`
: phone;
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
return (
<div className="min-h-screen bg-background text-foreground">
<div className="grid min-h-screen lg:grid-cols-2">
{/* ------------------------------------------------------------------ */}
{/* Left Side */}
{/* ------------------------------------------------------------------ */}
<div className="relative hidden overflow-hidden bg-primary p-12 text-primary-foreground lg:flex lg:flex-col lg:justify-between">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.14),transparent_35%)]" />
<div className="relative z-10">
{/* Logo */}
<div className="flex items-center gap-3">
<div className="flex size-14 items-center justify-center rounded-3xl bg-white/10 backdrop-blur">
<Train className="size-7" />
</div>
<div>
<h1 className="text-3xl font-black tracking-tight">
EDR Freight
</h1>
<p className="mt-1 text-sm opacity-80">
Railway Logistics
Platform
</p>
</div>
</div>
{/* Hero */}
<div className="mt-20 max-w-lg">
<div className="inline-flex rounded-full bg-white/10 px-4 py-2 text-sm font-semibold backdrop-blur">
Secure
Verification
</div>
<h2 className="mt-6 text-5xl font-black leading-tight tracking-tight">
Verify your
account securely
</h2>
<p className="mt-6 text-lg leading-8 opacity-85">
Enter the
verification code
sent to your phone
number to continue
using EDR Freight
logistics services.
</p>
</div>
{/* Features */}
<div className="mt-14 grid gap-5">
{[
"Secure OTP verification",
"Protected account access",
"Fast identity confirmation",
"Enterprise-grade security",
].map((item) => (
<div
key={item}
className="flex items-center gap-3"
>
<div className="flex size-10 items-center justify-center rounded-2xl bg-white/10">
<ShieldCheck className="size-5" />
</div>
<span className="font-medium">
{item}
</span>
</div>
))}
</div>
</div>
{/* Footer Stats */}
<div className="relative z-10 rounded-[32px] border border-white/10 bg-white/10 p-6 backdrop-blur-xl">
<div className="flex items-center justify-between">
<div>
<p className="text-sm opacity-80">
Verification
Security
</p>
<h3 className="mt-2 text-4xl font-black">
99.9%
</h3>
</div>
<div className="rounded-2xl bg-white/10 px-4 py-2 text-sm font-semibold">
Protected
</div>
</div>
<div className="mt-6 h-3 overflow-hidden rounded-full bg-white/10">
<div className="h-full w-[99%] rounded-full bg-white" />
</div>
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Right Side */}
{/* ------------------------------------------------------------------ */}
<div className="flex items-center justify-center p-6 md:p-10">
<div className="w-full max-w-xl">
{/* Mobile Logo */}
<div className="mb-8 flex items-center gap-3 lg:hidden">
<div className="flex size-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Train className="size-6" />
</div>
<div>
<h1 className="text-2xl font-bold">
EDR Freight
</h1>
<p className="text-sm text-muted-foreground">
Railway Logistics
Platform
</p>
</div>
</div>
{/* OTP Card */}
<div className="rounded-[36px] border border-border bg-card p-8 shadow-2xl md:p-10">
{/* Header */}
<div className="mb-8">
<div className="mb-4 flex size-16 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<MailCheck className="size-8" />
</div>
<h2 className="text-4xl font-black tracking-tight">
OTP Verification
</h2>
<p className="mt-3 text-lg text-muted-foreground">
Enter the
6-digit code sent
to:
</p>
<div className="mt-4 rounded-2xl border border-border bg-muted/50 px-4 py-3">
<p className="font-semibold">
{maskedPhone}
</p>
</div>
</div>
{/* Success */}
{verifyMutation.isSuccess && (
<div className="mb-6 rounded-2xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">
Verification
successful.
</div>
)}
{/* Error */}
{verifyMutation.isError && (
<div className="mb-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
Invalid OTP
code. Please try
again.
</div>
)}
{/* Resend Success */}
{resendMutation.isSuccess && (
<div className="mb-6 rounded-2xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-700">
New OTP code sent
successfully.
</div>
)}
{/* Form */}
<form
onSubmit={handleSubmit(
onSubmit
)}
className="space-y-6"
>
{/* OTP */}
<div>
<label className="mb-2 block text-sm font-semibold">
Verification
Code
</label>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
placeholder="123456"
disabled={
verifyMutation.isPending
}
{...register(
"code"
)}
className="h-16 w-full rounded-2xl border border-input bg-background px-5 text-center text-3xl font-black tracking-[12px] outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
<div className="mt-2 flex items-center justify-between">
{errors.code ? (
<p className="text-sm text-red-500">
{
errors.code
.message
}
</p>
) : (
<p className="text-sm text-muted-foreground">
Enter the OTP
sent to your
phone
</p>
)}
<span className="text-xs text-muted-foreground">
{
otpValue.length
}
/6
</span>
</div>
</div>
{/* Verify Button */}
<button
type="submit"
disabled={
verifyMutation.isPending ||
otpValue.length !==
6
}
className="flex h-14 w-full items-center justify-center gap-2 rounded-2xl bg-primary text-lg font-semibold text-primary-foreground shadow-lg transition hover:-translate-y-0.5 hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
>
{verifyMutation.isPending ? (
"Verifying..."
) : (
<>
Verify Account
<ArrowRight className="size-5" />
</>
)}
</button>
{/* Resend */}
<button
type="button"
onClick={() =>
resendMutation.mutate()
}
disabled={
resendMutation.isPending
}
className="flex h-14 w-full items-center justify-center gap-2 rounded-2xl border border-border bg-background text-base font-semibold transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-60"
>
{resendMutation.isPending ? (
"Sending..."
) : (
<>
<RotateCw className="size-5" />
Resend Code
</>
)}
</button>
{/* Footer */}
<p className="text-center text-sm text-muted-foreground">
Didnt receive
the code?
<button
type="button"
onClick={() =>
resendMutation.mutate()
}
className="ml-2 font-semibold text-primary hover:underline"
>
Send again
</button>
</p>
</form>
</div>
</div>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -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 ? (
<DialogTrigger asChild>
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
</DialogTrigger>
) : null}
<Dialog>
<DialogTrigger asChild>
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
</DialogTrigger>
<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" />
<Input
value={form.company}
onChange={(e) => set("company", e.target.value)}
placeholder="Enter company name"
className="pl-10"
/>
</Field>
{/* 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
name="firstName"
value={formData.firstName}
onChange={handleChange}
placeholder="Enter first name"
className="pl-10"
/>
</div>
</div>
<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>
{/* 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
name="lastName"
value={formData.lastName}
onChange={handleChange}
placeholder="Enter last name"
className="pl-10"
/>
</div>
</div>
{/* 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"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Enter email"
className="pl-10"
/>
</div>
</div>
{/* Phone */}
<div className="space-y-2">
<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>
<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>
{/* 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>
<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>
{/* 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>
<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>
{/* 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>
<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>
{/* 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>
<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>
{/* Company Address */}
<div className="space-y-2 md:col-span-2">
<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
name="companyAddress"
value={formData.companyAddress}
onChange={handleChange}
placeholder="Enter company address"
className="pl-10 resize-none"
rows={2}
/>
</div>
</div>
</div>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Address</Label>
{/* 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>PoA Address</Label>
<Textarea
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
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..."
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>
<Button
type="button"
<div className="flex justify-end gap-3 mt-4">
<Button variant="outline">Cancel</Button>
<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>
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}
</Link>
) : (
<div className={className}>{inner}</div>
);
}
if (href) {
return (
<Link
to={href}
className={`block ${className}`}
>
{content}
</Link>
);
}
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>
);
}
}

View File

@@ -0,0 +1,92 @@
import { URL_CONSTANTS } from "@/constants/URLS";
import { CreateUserPayload } from "@/types/createUser";
import { VerificationCodePayload } from "@/types/generateVerificationCode";
import { UserTypeRequest } from "@/types/userTypeRequest";
import { client } from "@/utils/api";
import { ApiResponse } from "@edr/types";
import { GenerateVerifcationCodePayload } from "node_modules/@tria-plc/iamui-common/dist/types/shared/services/authService";
// -----------------------------------------------------------------------------
// API
// -----------------------------------------------------------------------------
export const createUser = async (
body: CreateUserPayload
) => {
const res =
await client.post<
ApiResponse<any>
>(
URL_CONSTANTS.USERS.SIGN_UP,
body
);
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
) => {
const res =
await client.patch<
ApiResponse<string>
>(
URL_CONSTANTS.USERS.GENERATE_VERIFICATION_CODE,
body
);
return res.data.data;
};
export const setPassword = async (
body: any
) => {
const res =
await client.patch<
ApiResponse<string>
>(
URL_CONSTANTS.USERS.SET_PASSWORD,
body
);
return res.data.data;
};
export const createOTP = async (
body: any
) => {
const res =
await client.post<
ApiResponse<any>
>(
URL_CONSTANTS.OTP.SEND,
body
);
return res.data;
};
export const verifyOTP = async (
body: any
) => {
const res =
await client.post<
ApiResponse<any>
>(
URL_CONSTANTS.OTP.VERIFY,
body
);
return res.data;
};

View File

@@ -1,6 +1,5 @@
import type { Freight, PaginatedResponse } from "@edr/types";
import { api } from "./crud";
import { client as api } from "@/utils/api";
export type CreateBookingPayload = Freight.CreateBookingDto;

View File

@@ -1,60 +0,0 @@
import { client } from "@/utils/api";
import { AxiosRequestConfig, AxiosResponse } from "axios";
class ApiService {
async get<T = any>(
url: string,
config?: AxiosRequestConfig
): Promise<T> {
const response: AxiosResponse<T> =
await client.get(url, config);
return response.data;
}
async post<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<T> {
const response: AxiosResponse<T> =
await client.post(url, data, config);
return response.data;
}
async put<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<T> {
const response: AxiosResponse<T> =
await client.put(url, data, config);
return response.data;
}
async patch<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<T> {
const response: AxiosResponse<T> =
await client.patch(url, data, config);
return response.data;
}
async delete<T = any>(
url: string,
config?: AxiosRequestConfig
): Promise<T> {
const response: AxiosResponse<T> =
await client.delete(url, config);
return response.data;
}
}
export const api = new ApiService();

View File

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

View File

@@ -133,5 +133,5 @@ export const getFileUploadSettingByCode = endpoint<string, FileUploadSetting>(
.get<
ApiResponse<FileUploadSetting>
>(`${URL_CONSTANTS.FILES.FILE_UPLOAD_SETTINGS_BY_CODE}/${code}`)
.then((res) => res.data.data),
.then((res: any) => res.data.data),
);

View File

@@ -0,0 +1,10 @@
export type CreateUserPayload = {
email: string;
username: string;
phoneNumber: string;
userType: string;
name: {
en: string;
am?: string;
};
};

View File

@@ -0,0 +1,5 @@
export type VerificationCodePayload = {
email: string;
phoneNumber: string;
type: string;
};

View File

@@ -0,0 +1,10 @@
export type UserTypeRequest {
email: string;
username: string;
phoneNumber: string;
userType: string;
name: {
am?: string;
en: string;
};
}

View File

@@ -1,5 +1,6 @@
import {
UseQueryOptions,
UseMutationOptions
} from "@tanstack/react-query";
// ---------------------------------------------------------------------------
@@ -70,10 +71,34 @@ export function endpoint<TInput, TResponse>(
};
};
const mutationOptions = (
config?: Omit<
UseMutationOptions<
TResponse,
Error,
TInput
>,
"mutationFn"
>,
): UseMutationOptions<
TResponse,
Error,
TInput
> => {
return {
...config,
mutationFn: (
variables: TInput,
): Promise<TResponse> =>
execute(variables),
};
};
return {
call,
queryKey,
queryOptions,
mutationOptions
};
}

515
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff