mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 17:45:42 +00:00
Merge pull request #42 from Tria-plc/freight/feature/user_registration
Freight/feature/user registration
This commit is contained in:
@@ -26,6 +26,7 @@
|
|||||||
"@tria-plc/iamapi-common": "^0.1.6",
|
"@tria-plc/iamapi-common": "^0.1.6",
|
||||||
"amqp-connection-manager": "^5.0.0",
|
"amqp-connection-manager": "^5.0.0",
|
||||||
"amqplib": "^2.0.1",
|
"amqplib": "^2.0.1",
|
||||||
|
"axios": "^1.7.7",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { BillingModule } from "./modules/billing/billing.module";
|
|||||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
||||||
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
||||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||||
|
import { OtpModule } from './modules/otp/otp.module';
|
||||||
|
import { DropdownSettingsService } from "./modules/dropdown-settings/dropdown-settings.service";
|
||||||
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
|
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
|
||||||
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
||||||
|
|
||||||
@@ -44,6 +46,7 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
|||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
FileUploadSettingsModule,
|
FileUploadSettingsModule,
|
||||||
DropdownSettingsModule,
|
DropdownSettingsModule,
|
||||||
|
OtpModule,
|
||||||
BackofficeModule,
|
BackofficeModule,
|
||||||
],
|
],
|
||||||
providers: [EdrOrgSeeder],
|
providers: [EdrOrgSeeder],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
// src/modules/customers/customers.controller.ts
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
@@ -9,49 +10,75 @@ import {
|
|||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Body,
|
||||||
|
Query,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
|
||||||
|
import { ApiOperation } from "@nestjs/swagger";
|
||||||
|
|
||||||
import { CustomersService } from "./customers.service";
|
import { CustomersService } from "./customers.service";
|
||||||
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
||||||
import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
||||||
|
import { Customer } from "./entities/customer.entity";
|
||||||
|
|
||||||
@ApiTags("customers")
|
|
||||||
@Controller("customers")
|
@Controller("customers")
|
||||||
export class CustomersController {
|
export class CustomersController {
|
||||||
constructor(private readonly customersService: CustomersService) {}
|
constructor(private readonly customersService: CustomersService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: "Create a new customer" })
|
create(@Body() createCustomerDto: CreateCustomerDto): Promise<Customer> {
|
||||||
create(@Body() dto: CreateCustomerDto) {
|
return this.customersService.create(createCustomerDto);
|
||||||
return this.customersService.create(dto);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: "List all customers" })
|
findAll(): Promise<Customer[]> {
|
||||||
findAll() {
|
|
||||||
return this.customersService.findAll();
|
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")
|
@Get(":id")
|
||||||
@ApiOperation({ summary: "Get a customer by ID" })
|
findById(@Param("id", ParseUUIDPipe) id: string): Promise<Customer> {
|
||||||
findOne(@Param("id", ParseUUIDPipe) id: string) {
|
|
||||||
return this.customersService.findById(id);
|
return this.customersService.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("user/:userId")
|
||||||
|
findByUserId(@Param("userId", ParseUUIDPipe) userId: string): Promise<any> {
|
||||||
|
return this.customersService.findByUserId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(":id")
|
@Patch(":id")
|
||||||
@ApiOperation({ summary: "Update a customer" })
|
@ApiOperation({ summary: "Update a customer" })
|
||||||
update(
|
update(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: UpdateCustomerDto,
|
@Body() dto: UpdateCustomerDto,
|
||||||
) {
|
): Promise<Customer> {
|
||||||
return this.customersService.update(id, dto);
|
return this.customersService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
@ApiOperation({ summary: "Soft-delete a customer" })
|
@ApiOperation({ summary: "Soft-delete a customer" })
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
|
||||||
return this.customersService.remove(id);
|
return this.customersService.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,117 @@
|
|||||||
import { BaseRepository } from "@edr/api-common";
|
// import { BaseRepository } from "@edr/api-common";
|
||||||
|
// import { EntityRepository } from "typeorm";
|
||||||
|
|
||||||
|
// src/modules/customers/customers.repository.ts
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { Repository } from "typeorm";
|
import { Repository, FindManyOptions, FindOptionsWhere } from "typeorm";
|
||||||
|
|
||||||
import { Customer } from "./entities/customer.entity";
|
import { Customer } from "./entities/customer.entity";
|
||||||
|
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
||||||
|
// import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomersRepository extends BaseRepository<Customer> {
|
export class CustomersRepository {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Customer)
|
@InjectRepository(Customer)
|
||||||
repository: Repository<Customer>,
|
private readonly repository: Repository<Customer>,
|
||||||
) {
|
) { }
|
||||||
super(repository);
|
|
||||||
|
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. */
|
async findAll(options?: FindManyOptions<Customer>): Promise<Customer[]> {
|
||||||
findByEmail(email: string): Promise<Customer | null> {
|
return await this.repository.find(options);
|
||||||
return this.repository.findOne({ where: { email } });
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Customer | null> {
|
||||||
|
return await this.repository.findOne({ where: { id } as FindOptionsWhere<Customer> });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUserId(userId: string): Promise<Customer | null> {
|
||||||
|
return await this.repository.findOne({ where: { userId } as FindOptionsWhere<Customer> });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByEmail(email: string): Promise<Customer | null> {
|
||||||
|
return await this.repository.findOne({ where: { email } as FindOptionsWhere<Customer> });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByVatNumber(vatNumber: string): Promise<Customer | null> {
|
||||||
|
return await this.repository.findOne({ where: { vatNumber } as FindOptionsWhere<Customer> });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByName(name: string): Promise<Customer[]> {
|
||||||
|
return await this.repository
|
||||||
|
.createQueryBuilder("customer")
|
||||||
|
.where("customer.name ILIKE :name", { name: `%${name}%` })
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOneByEmailOrVat(email?: string, vatNumber?: string): Promise<Customer | null> {
|
||||||
|
if (!email && !vatNumber) return null;
|
||||||
|
|
||||||
|
const queryBuilder = this.repository.createQueryBuilder('customer');
|
||||||
|
|
||||||
|
if (email && vatNumber) {
|
||||||
|
queryBuilder.where('customer.email = :email', { email })
|
||||||
|
.orWhere('customer.vatNumber = :vatNumber', { vatNumber });
|
||||||
|
} else if (email) {
|
||||||
|
queryBuilder.where('customer.email = :email', { email });
|
||||||
|
} else if (vatNumber) {
|
||||||
|
queryBuilder.where('customer.vatNumber = :vatNumber', { vatNumber });
|
||||||
|
}
|
||||||
|
|
||||||
|
return await queryBuilder.getOne();
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, updates: Partial<Customer>): Promise<Customer | null> {
|
||||||
|
await this.repository.update(id, updates);
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<boolean> {
|
||||||
|
const result = await this.repository.delete(id);
|
||||||
|
return (result.affected ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async count(where?: any): Promise<number> {
|
||||||
|
if (where?.createdAt) {
|
||||||
|
const result = await this.repository
|
||||||
|
.createQueryBuilder('customer')
|
||||||
|
.where('customer.createdAt >= :date', { date: where.createdAt })
|
||||||
|
.getCount();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return await this.repository.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
async existsByUniqueFields(email: string, vatNumber?: string): Promise<boolean> {
|
||||||
|
const queryBuilder = this.repository.createQueryBuilder('customer')
|
||||||
|
.where('customer.email = :email', { email });
|
||||||
|
|
||||||
|
if (vatNumber) {
|
||||||
|
queryBuilder.orWhere('customer.vatNumber = :vatNumber', { vatNumber });
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await queryBuilder.getCount();
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async countWithVatNumber(): Promise<number> {
|
||||||
|
const count = await this.repository
|
||||||
|
.createQueryBuilder('customer')
|
||||||
|
.where('customer.vatNumber IS NOT NULL')
|
||||||
|
.andWhere("customer.vatNumber != ''")
|
||||||
|
.getCount();
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRepository(): Repository<Customer> {
|
||||||
|
return this.repository;
|
||||||
|
}
|
||||||
|
softDelete(id: string): any {
|
||||||
|
return id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
ConflictException,
|
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
ConflictException,
|
||||||
|
BadRequestException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
|
||||||
import { CustomersRepository } from "./customers.repository";
|
import { CustomersRepository } from "./customers.repository";
|
||||||
@@ -13,34 +14,97 @@ import { Customer } from "./entities/customer.entity";
|
|||||||
export class CustomersService {
|
export class CustomersService {
|
||||||
constructor(private readonly customersRepository: CustomersRepository) {}
|
constructor(private readonly customersRepository: CustomersRepository) {}
|
||||||
|
|
||||||
|
/** Create a new customer */
|
||||||
async create(dto: CreateCustomerDto): Promise<Customer> {
|
async create(dto: CreateCustomerDto): Promise<Customer> {
|
||||||
const existing = await this.customersRepository.findByEmail(dto.email);
|
const exists = await this.customersRepository.existsByUniqueFields(
|
||||||
if (existing) {
|
dto.email,
|
||||||
|
dto.vatNumber,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
throw new ConflictException(
|
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);
|
return this.customersRepository.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get all customers */
|
||||||
findAll(): Promise<Customer[]> {
|
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> {
|
async findById(id: string): Promise<Customer> {
|
||||||
const customer = await this.customersRepository.findById(id);
|
const customer = await this.customersRepository.findById(id);
|
||||||
|
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
throw new NotFoundException(`Customer ${id} not found`);
|
throw new NotFoundException(`Customer with ID ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return customer;
|
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> {
|
async update(id: string, dto: UpdateCustomerDto): Promise<Customer> {
|
||||||
await this.findById(id);
|
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) {
|
if (dto.email) {
|
||||||
const conflict = await this.customersRepository.findByEmail(dto.email);
|
const existing = await this.customersRepository.findByEmail(dto.email);
|
||||||
if (conflict && conflict.id !== id) {
|
|
||||||
|
if (existing && existing.userId !== id) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`Customer with email "${dto.email}" already exists`,
|
`Customer with email "${dto.email}" already exists`,
|
||||||
);
|
);
|
||||||
@@ -48,14 +112,29 @@ export class CustomersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updated = await this.customersRepository.update(id, dto);
|
const updated = await this.customersRepository.update(id, dto);
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
throw new NotFoundException(`Customer ${id} not found`);
|
throw new NotFoundException(`Customer ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete customer (soft delete) */
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
await this.customersRepository.softDelete(id);
|
await this.customersRepository.softDelete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get customer statistics */
|
||||||
|
async getStats(): Promise<{ total: number; withVatNumber: number }> {
|
||||||
|
const total = await this.customersRepository.count();
|
||||||
|
const withVatNumber = await this.customersRepository.countWithVatNumber();
|
||||||
|
|
||||||
|
return { total, withVatNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(id: string): any {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,8 +4,12 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
|
IsNotEmpty,
|
||||||
|
Length,
|
||||||
|
Matches,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
|
|
||||||
|
// Enums
|
||||||
export enum CustomerStatusDto {
|
export enum CustomerStatusDto {
|
||||||
Active = "Active",
|
Active = "Active",
|
||||||
Pending = "Pending",
|
Pending = "Pending",
|
||||||
@@ -18,23 +22,57 @@ export enum CustomerTypeDto {
|
|||||||
Supplier = "Supplier",
|
Supplier = "Supplier",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DTO
|
||||||
export class CreateCustomerDto {
|
export class CreateCustomerDto {
|
||||||
|
// Basic identity
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(256)
|
@IsNotEmpty()
|
||||||
name!: string;
|
userId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(100)
|
||||||
|
firstName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(100)
|
||||||
|
lastName!: string;
|
||||||
|
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
|
@IsNotEmpty()
|
||||||
email!: string;
|
email!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(32)
|
@IsNotEmpty()
|
||||||
|
@MaxLength(20)
|
||||||
phone!: string;
|
phone!: string;
|
||||||
|
|
||||||
@IsOptional()
|
// Company info
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(256)
|
@IsNotEmpty()
|
||||||
company?: string;
|
@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()
|
@IsOptional()
|
||||||
@IsEnum(CustomerTypeDto)
|
@IsEnum(CustomerTypeDto)
|
||||||
customerType?: CustomerTypeDto;
|
customerType?: CustomerTypeDto;
|
||||||
@@ -43,30 +81,75 @@ export class CreateCustomerDto {
|
|||||||
@IsEnum(CustomerStatusDto)
|
@IsEnum(CustomerStatusDto)
|
||||||
status?: 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()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(64)
|
@MaxLength(100)
|
||||||
tinNumber?: string;
|
poaName?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(128)
|
@MaxLength(20)
|
||||||
city?: string;
|
poaPhone?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(128)
|
poaAddress?: string;
|
||||||
country?: string;
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
poaEmail?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
address?: string;
|
@MaxLength(100)
|
||||||
|
poaLocation?: string;
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(64)
|
|
||||||
taxId?: string;
|
|
||||||
|
|
||||||
|
// Extra
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// src/modules/customers/dto/response-customer.dto.ts
|
||||||
|
import { Customer } from '../entities/customer.entity';
|
||||||
|
|
||||||
|
export class ResponseCustomerDto {
|
||||||
|
UserId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
companyName: string;
|
||||||
|
companyEmail: string;
|
||||||
|
companyPhone: string;
|
||||||
|
companyLocation: string;
|
||||||
|
companyAddress: string;
|
||||||
|
contactPersonName: string;
|
||||||
|
contactPersonPhone: string;
|
||||||
|
tinNumber: string;
|
||||||
|
vatNumber?: string;
|
||||||
|
fanNumber: string;
|
||||||
|
generalManagerName: string;
|
||||||
|
generalManagerEmail: string;
|
||||||
|
generalManagerPhone: string;
|
||||||
|
poaName?: string;
|
||||||
|
poaPhone?: string;
|
||||||
|
poaAddress?: string;
|
||||||
|
poaEmail?: string;
|
||||||
|
poaLocation?: string;
|
||||||
|
notes?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
constructor(customer: Customer) {
|
||||||
|
this.UserId = customer.userId;
|
||||||
|
this.firstName = customer.firstName;
|
||||||
|
this.lastName = customer.lastName;
|
||||||
|
this.email = customer.email;
|
||||||
|
this.phone = customer.phone;
|
||||||
|
this.companyName = customer.companyName;
|
||||||
|
this.companyEmail = customer.companyEmail;
|
||||||
|
this.companyPhone = customer.companyPhone;
|
||||||
|
this.companyLocation = customer.companyLocation;
|
||||||
|
this.companyAddress = customer.companyAddress;
|
||||||
|
this.contactPersonName = customer.contactPersonName;
|
||||||
|
this.contactPersonPhone = customer.contactPersonPhone;
|
||||||
|
this.tinNumber = customer.tinNumber;
|
||||||
|
this.vatNumber = customer.vatNumber;
|
||||||
|
this.fanNumber = customer.fanNumber;
|
||||||
|
this.generalManagerName = customer.generalManagerName;
|
||||||
|
this.generalManagerEmail = customer.generalManagerEmail;
|
||||||
|
this.generalManagerPhone = customer.generalManagerPhone;
|
||||||
|
this.poaName = customer.poaName ?? '';
|
||||||
|
this.poaPhone = customer.poaPhone ?? '';
|
||||||
|
this.poaAddress = customer.poaAddress ?? '';
|
||||||
|
this.poaEmail = customer.poaEmail ?? '';
|
||||||
|
this.poaLocation = customer.poaLocation ?? '';
|
||||||
|
this.notes = customer.notes ?? '';
|
||||||
|
this.createdAt = customer.createdAt;
|
||||||
|
this.updatedAt = customer.updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { PartialType } from "@nestjs/swagger";
|
// src/modules/customers/dto/update-customer.dto.ts
|
||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateCustomerDto } from './create-customer.dto';
|
||||||
|
|
||||||
import { CreateCustomerDto } from "./create-customer.dto";
|
export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {
|
||||||
|
email?: string;
|
||||||
export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {}
|
vatNumber?: string;
|
||||||
|
// Add any other properties you need to access directly
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,54 +1,100 @@
|
|||||||
import { BaseEntity } from "@edr/api-common";
|
import {
|
||||||
import { Column, Entity } from "typeorm";
|
Column,
|
||||||
|
Entity,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
BaseEntity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from "typeorm";
|
||||||
|
|
||||||
export type CustomerStatus = "Active" | "Pending" | "Inactive";
|
@Entity("customers")
|
||||||
export type CustomerType = "Importer" | "Exporter" | "Supplier";
|
|
||||||
|
|
||||||
@Entity({schema:"freight", name: "customers" })
|
|
||||||
export class Customer extends BaseEntity {
|
export class Customer extends BaseEntity {
|
||||||
@Column({ name: "name", type: "varchar", length: 256 })
|
@PrimaryGeneratedColumn("uuid")
|
||||||
name!: string;
|
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;
|
email!: string;
|
||||||
|
|
||||||
@Column({ name: "phone", type: "varchar", length: 32 })
|
@Column({ length: 20 })
|
||||||
phone!: string;
|
phone!: string;
|
||||||
|
|
||||||
@Column({ name: "company", type: "varchar", length: 256, nullable: true })
|
@Column({ length: 200 })
|
||||||
company?: string | null;
|
@Index()
|
||||||
|
companyName!: string;
|
||||||
|
|
||||||
@Column({
|
@Column({ length: 150 })
|
||||||
name: "customer_type",
|
companyEmail!: string;
|
||||||
type: "varchar",
|
|
||||||
length: 32,
|
|
||||||
default: "Importer",
|
|
||||||
})
|
|
||||||
customerType!: CustomerType;
|
|
||||||
|
|
||||||
@Column({
|
@Column({ length: 20 })
|
||||||
name: "status",
|
companyPhone!: string;
|
||||||
type: "varchar",
|
|
||||||
length: 32,
|
|
||||||
default: "Active",
|
|
||||||
})
|
|
||||||
status!: CustomerStatus;
|
|
||||||
|
|
||||||
@Column({ name: "tin_number", type: "varchar", length: 64, nullable: true })
|
@Column({ length: 100 })
|
||||||
tinNumber?: string | null;
|
companyLocation!: string;
|
||||||
|
|
||||||
@Column({ name: "city", type: "varchar", length: 128, nullable: true })
|
@Column({ type: "text" })
|
||||||
city?: string | null;
|
companyAddress!: string;
|
||||||
|
|
||||||
@Column({ name: "country", type: "varchar", length: 128, nullable: true })
|
@Column({ length: 100 })
|
||||||
country?: string | null;
|
contactPersonName!: string;
|
||||||
|
|
||||||
@Column({ name: "address", type: "text", nullable: true })
|
@Column({ length: 20 })
|
||||||
address?: string | null;
|
contactPersonPhone!: string;
|
||||||
|
|
||||||
@Column({ name: "tax_id", type: "varchar", length: 64, nullable: true })
|
@Column({ length: 10, unique: true })
|
||||||
taxId?: string | null;
|
@Index()
|
||||||
|
tinNumber!: string;
|
||||||
|
|
||||||
@Column({ name: "notes", type: "text", nullable: true })
|
@Column({ length: 50, nullable: true })
|
||||||
notes?: string | null;
|
vatNumber?: string;
|
||||||
|
|
||||||
|
@Column({ length: 16, unique: true })
|
||||||
|
@Index()
|
||||||
|
fanNumber!: string;
|
||||||
|
|
||||||
|
@Column({ length: 100 })
|
||||||
|
generalManagerName!: string;
|
||||||
|
|
||||||
|
@Column({ length: 150 })
|
||||||
|
generalManagerEmail!: string;
|
||||||
|
|
||||||
|
@Column({ length: 20 })
|
||||||
|
generalManagerPhone!: string;
|
||||||
|
|
||||||
|
@Column({ length: 100, nullable: true })
|
||||||
|
poaName?: string;
|
||||||
|
|
||||||
|
@Column({ length: 20, nullable: true })
|
||||||
|
poaPhone?: string;
|
||||||
|
|
||||||
|
@Column({ type: "text", nullable: true })
|
||||||
|
poaAddress?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true, length: 150 })
|
||||||
|
poaEmail?: string;
|
||||||
|
|
||||||
|
@Column({ length: 100, nullable: true })
|
||||||
|
poaLocation?: string;
|
||||||
|
|
||||||
|
@Column({ type: "text", nullable: true })
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt!: Date;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { CustomersService } from "./customers.service";
|
||||||
|
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
||||||
|
import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
||||||
|
|
||||||
|
@ApiTags("customers")
|
||||||
|
@Controller("customers")
|
||||||
|
export class CustomersController {
|
||||||
|
constructor(private readonly customersService: CustomersService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: "Create a new customer" })
|
||||||
|
create(@Body() dto: CreateCustomerDto) {
|
||||||
|
return this.customersService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: "List all customers" })
|
||||||
|
findAll() {
|
||||||
|
return this.customersService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id")
|
||||||
|
@ApiOperation({ summary: "Get a customer by ID" })
|
||||||
|
findOne(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.customersService.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id")
|
||||||
|
@ApiOperation({ summary: "Update a customer" })
|
||||||
|
update(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateCustomerDto,
|
||||||
|
) {
|
||||||
|
return this.customersService.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":id")
|
||||||
|
@ApiOperation({ summary: "Soft-delete a customer" })
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.customersService.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
|
||||||
|
import { CustomersController } from "./customers.controller";
|
||||||
|
import { CustomersRepository } from "./customers.repository";
|
||||||
|
import { CustomersService } from "./customers.service";
|
||||||
|
import { Customer } from "./entities/customer.entity";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Customer])],
|
||||||
|
controllers: [CustomersController],
|
||||||
|
providers: [CustomersService, CustomersRepository],
|
||||||
|
exports: [CustomersService],
|
||||||
|
})
|
||||||
|
export class CustomersModule {}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { BaseRepository } from "@edr/api-common";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
|
import { Customer } from "./entities/customer.entity";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CustomersRepository extends BaseRepository<Customer> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Customer)
|
||||||
|
repository: Repository<Customer>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find a customer by their unique email. */
|
||||||
|
findByEmail(email: string): Promise<Customer | null> {
|
||||||
|
return this.repository.findOne({ where: { email } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
|
||||||
|
import { CustomersRepository } from "./customers.repository";
|
||||||
|
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
||||||
|
import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
||||||
|
import { Customer } from "./entities/customer.entity";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CustomersService {
|
||||||
|
constructor(private readonly customersRepository: CustomersRepository) {}
|
||||||
|
|
||||||
|
async create(dto: CreateCustomerDto): Promise<Customer> {
|
||||||
|
const existing = await this.customersRepository.findByEmail(dto.email);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Customer with email "${dto.email}" already exists`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.customersRepository.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
findAll(): Promise<Customer[]> {
|
||||||
|
return this.customersRepository.findAll({ order: { name: "ASC" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Customer> {
|
||||||
|
const customer = await this.customersRepository.findById(id);
|
||||||
|
if (!customer) {
|
||||||
|
throw new NotFoundException(`Customer ${id} not found`);
|
||||||
|
}
|
||||||
|
return customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateCustomerDto): Promise<Customer> {
|
||||||
|
await this.findById(id);
|
||||||
|
|
||||||
|
if (dto.email) {
|
||||||
|
const conflict = await this.customersRepository.findByEmail(dto.email);
|
||||||
|
if (conflict && conflict.id !== id) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Customer with email "${dto.email}" already exists`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.customersRepository.update(id, dto);
|
||||||
|
if (!updated) {
|
||||||
|
throw new NotFoundException(`Customer ${id} not found`);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
await this.findById(id);
|
||||||
|
await this.customersRepository.softDelete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
IsEmail,
|
||||||
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
export enum CustomerStatusDto {
|
||||||
|
Active = "Active",
|
||||||
|
Pending = "Pending",
|
||||||
|
Inactive = "Inactive",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CustomerTypeDto {
|
||||||
|
Importer = "Importer",
|
||||||
|
Exporter = "Exporter",
|
||||||
|
Supplier = "Supplier",
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateCustomerDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(256)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(32)
|
||||||
|
phone!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(256)
|
||||||
|
company?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(CustomerTypeDto)
|
||||||
|
customerType?: CustomerTypeDto;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(CustomerStatusDto)
|
||||||
|
status?: CustomerStatusDto;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
tinNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(128)
|
||||||
|
city?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(128)
|
||||||
|
country?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
address?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
taxId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PartialType } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { CreateCustomerDto } from "./create-customer.dto";
|
||||||
|
|
||||||
|
export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { BaseEntity } from "@edr/api-common";
|
||||||
|
import { Column, Entity } from "typeorm";
|
||||||
|
|
||||||
|
export type CustomerStatus = "Active" | "Pending" | "Inactive";
|
||||||
|
export type CustomerType = "Importer" | "Exporter" | "Supplier";
|
||||||
|
|
||||||
|
@Entity({schema:"freight", name: "customers" })
|
||||||
|
export class Customer extends BaseEntity {
|
||||||
|
@Column({ name: "name", type: "varchar", length: 256 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ name: "email", type: "varchar", length: 256, unique: true })
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@Column({ name: "phone", type: "varchar", length: 32 })
|
||||||
|
phone!: string;
|
||||||
|
|
||||||
|
@Column({ name: "company", type: "varchar", length: 256, nullable: true })
|
||||||
|
company?: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "customer_type",
|
||||||
|
type: "varchar",
|
||||||
|
length: 32,
|
||||||
|
default: "Importer",
|
||||||
|
})
|
||||||
|
customerType!: CustomerType;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "status",
|
||||||
|
type: "varchar",
|
||||||
|
length: 32,
|
||||||
|
default: "Active",
|
||||||
|
})
|
||||||
|
status!: CustomerStatus;
|
||||||
|
|
||||||
|
@Column({ name: "tin_number", type: "varchar", length: 64, nullable: true })
|
||||||
|
tinNumber?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "city", type: "varchar", length: 128, nullable: true })
|
||||||
|
city?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "country", type: "varchar", length: 128, nullable: true })
|
||||||
|
country?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "address", type: "text", nullable: true })
|
||||||
|
address?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "tax_id", type: "varchar", length: 64, nullable: true })
|
||||||
|
taxId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "notes", type: "text", nullable: true })
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
18
apps/edr-freight-api/src/modules/otp/otp.controller.spec.ts
Normal file
18
apps/edr-freight-api/src/modules/otp/otp.controller.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
53
apps/edr-freight-api/src/modules/otp/otp.controller.ts
Normal file
53
apps/edr-freight-api/src/modules/otp/otp.controller.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
apps/edr-freight-api/src/modules/otp/otp.entity.ts
Normal file
25
apps/edr-freight-api/src/modules/otp/otp.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
33
apps/edr-freight-api/src/modules/otp/otp.module.ts
Normal file
33
apps/edr-freight-api/src/modules/otp/otp.module.ts
Normal 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 {}
|
||||||
86
apps/edr-freight-api/src/modules/otp/otp.repository.ts
Normal file
86
apps/edr-freight-api/src/modules/otp/otp.repository.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/edr-freight-api/src/modules/otp/otp.service.spec.ts
Normal file
18
apps/edr-freight-api/src/modules/otp/otp.service.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
141
apps/edr-freight-api/src/modules/otp/otp.service.ts
Normal file
141
apps/edr-freight-api/src/modules/otp/otp.service.ts
Normal 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,11 +44,15 @@ import DocumentsPage from "./pages/documents/DocumentsPage";
|
|||||||
import DropdownSettingsPage from "./pages/admin/DropdownSettingsPage";
|
import DropdownSettingsPage from "./pages/admin/DropdownSettingsPage";
|
||||||
import FileUploadSettingsPage from "./pages/admin/FileUploadSettingsPage";
|
import FileUploadSettingsPage from "./pages/admin/FileUploadSettingsPage";
|
||||||
import MyPortalPage from "./pages/portal/MyPortalPage";
|
import MyPortalPage from "./pages/portal/MyPortalPage";
|
||||||
|
import 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";
|
import Station from "./components/stations/Station";
|
||||||
|
|
||||||
const sidebarItems: SidebarItem[] = [
|
const sidebarItems: SidebarItem[] = [
|
||||||
{ label: "My Portal", href: "/portal", icon: <UserCircle /> },
|
{ label: "My Portal", href: "/", icon: <UserCircle /> },
|
||||||
{ label: "Dashboard", href: "/", icon: <LayoutDashboard /> },
|
{ label: "Dashboard", href: "/dashboard", icon: <LayoutDashboard /> },
|
||||||
{ label: "Customers", href: "/customers", icon: <Users /> },
|
{ label: "Customers", href: "/customers", icon: <Users /> },
|
||||||
{ label: "My Bookings", href: "/bookings", icon: <CalendarCheck /> },
|
{ label: "My Bookings", href: "/bookings", icon: <CalendarCheck /> },
|
||||||
{ label: "Consignments", href: "/consignments", icon: <Package /> },
|
{ label: "Consignments", href: "/consignments", icon: <Package /> },
|
||||||
@@ -75,11 +79,15 @@ const App = () => {
|
|||||||
return <LoadingScreen />;
|
return <LoadingScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (user) {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<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="/auth" element={<IamLoginPage />} />
|
||||||
<Route path="*" element={<Navigate to="/auth" replace />} />
|
{/* <Route path="*" element={<Navigate to="/auth" replace />} /> */}
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -114,8 +122,8 @@ const App = () => {
|
|||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
>
|
>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<DashboardPage />} />
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
<Route path="/portal" element={<MyPortalPage />} />
|
<Route path="/" element={<MyPortalPage />} />
|
||||||
<Route path="/bookings" element={<MyBookings />} />
|
<Route path="/bookings" element={<MyBookings />} />
|
||||||
<Route path="/admin/bookings" element={<BookingsPage />} />
|
<Route path="/admin/bookings" element={<BookingsPage />} />
|
||||||
<Route path="/customers" element={<CustomersPage />} />
|
<Route path="/customers" element={<CustomersPage />} />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export const FILE_SETTINGS = {
|
export const FILE_SETTINGS = {
|
||||||
CUSTOMER_REGISTRATION: "customer_registration"
|
CUSTOMER_REGISTRATION: "customer_registration",
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export const QUERY_KEYS = {
|
export const QUERY_KEYS = {
|
||||||
USERS: "users",
|
USERS: "users",
|
||||||
|
ADD_USER: "add_user",
|
||||||
CUSTOMER: "Customers",
|
CUSTOMER: "Customers",
|
||||||
FILES: {
|
FILES: {
|
||||||
FILE_UPLOAD_SETTINGS: "file-upload-settings",
|
FILE_UPLOAD_SETTINGS: "file-upload-settings",
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ export const URL_CONSTANTS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
USERS: {
|
USERS: {
|
||||||
|
SIGN_UP: "/api/auth/signup",
|
||||||
|
GENERATE_VERIFICATION_CODE: "/api/auth/generate-verification-code",
|
||||||
BASE: "/users",
|
BASE: "/users",
|
||||||
BY_ID: (id: string | number) => `/users/${id}`,
|
BY_ID: (id: string | number) => `/users/${id}`,
|
||||||
|
SET_PASSWORD: "/api/auth/set-password",
|
||||||
|
ME: "/api/auth/me"
|
||||||
},
|
},
|
||||||
|
|
||||||
ROLES: {
|
ROLES: {
|
||||||
@@ -68,6 +72,7 @@ export const URL_CONSTANTS = {
|
|||||||
CUSTOMERS_API: {
|
CUSTOMERS_API: {
|
||||||
BASE: "/api/customers",
|
BASE: "/api/customers",
|
||||||
BY_ID: (id: string) => `/api/customers/${id}`,
|
BY_ID: (id: string) => `/api/customers/${id}`,
|
||||||
|
BY_USER_ID: (id: string) => `/api/customers/user/${id}`
|
||||||
},
|
},
|
||||||
|
|
||||||
BOOKINGS: {
|
BOOKINGS: {
|
||||||
@@ -76,4 +81,9 @@ export const URL_CONSTANTS = {
|
|||||||
CANCEL: (id: string | number) => `/bookings/${id}/cancel`,
|
CANCEL: (id: string | number) => `/bookings/${id}/cancel`,
|
||||||
CONFIRM: (id: string | number) => `/bookings/${id}/confirm`,
|
CONFIRM: (id: string | number) => `/bookings/${id}/confirm`,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
OTP: {
|
||||||
|
SEND: "/api/otp/send",
|
||||||
|
VERIFY: "/api/otp/verify",
|
||||||
|
}
|
||||||
};
|
};
|
||||||
5
apps/edr-freight-web/portal/src/enums/userType.ts
Normal file
5
apps/edr-freight-web/portal/src/enums/userType.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export enum userType {
|
||||||
|
externalOrganization = "external_organization",
|
||||||
|
employee = "employee",
|
||||||
|
individual = "individual"
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export enum verificationCodeType {
|
||||||
|
setPassword = "set-password",
|
||||||
|
resetPassword = "reset-password",
|
||||||
|
verifyPhoneNumber = "verify-phone-number",
|
||||||
|
mfaLogin = "mfa-login",
|
||||||
|
}
|
||||||
612
apps/edr-freight-web/portal/src/pages/EDRFreightLandingPage.tsx
Normal file
612
apps/edr-freight-web/portal/src/pages/EDRFreightLandingPage.tsx
Normal 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" />
|
||||||
|
Ethiopia–Djibouti 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 Ethiopia–Djibouti
|
||||||
|
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">
|
||||||
|
Let’s 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">
|
||||||
|
We’ll 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
534
apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx
Normal file
534
apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
Didn’t 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
User,
|
||||||
|
Globe,
|
||||||
|
MapPin,
|
||||||
|
FileText,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export interface CustomerFormData {
|
||||||
|
companyName?: string;
|
||||||
|
customerType?: string;
|
||||||
|
contactPerson?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
tinNumber?: string;
|
||||||
|
city?: string;
|
||||||
|
country?: string;
|
||||||
|
address?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewCustomerPageProps {
|
||||||
|
mode?: "create" | "edit";
|
||||||
|
customer?: CustomerFormData;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewCustomerPage({
|
||||||
|
mode = "create",
|
||||||
|
customer,
|
||||||
|
children,
|
||||||
|
}: NewCustomerPageProps = {}) {
|
||||||
|
const isEdit = mode === "edit";
|
||||||
|
const title = isEdit ? "Edit Customer" : "New Customer";
|
||||||
|
const description = isEdit
|
||||||
|
? "Update existing customer information."
|
||||||
|
: "Create and manage customer information.";
|
||||||
|
const submitLabel = isEdit ? "Save Changes" : "Create Customer";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-5 py-4 md:grid-cols-2">
|
||||||
|
{/* Company Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Company Name *</Label>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Building2 className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
|
||||||
|
<Input
|
||||||
|
defaultValue={customer?.companyName ?? ""}
|
||||||
|
placeholder="Enter company name"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Customer Type */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Customer Type *</Label>
|
||||||
|
|
||||||
|
<select
|
||||||
|
defaultValue={customer?.customerType ?? "Importer"}
|
||||||
|
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
|
||||||
|
>
|
||||||
|
<option>Importer</option>
|
||||||
|
<option>Exporter</option>
|
||||||
|
<option>Supplier</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Person */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Contact Person</Label>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
|
||||||
|
<Input
|
||||||
|
defaultValue={customer?.contactPerson ?? ""}
|
||||||
|
placeholder="Enter contact person"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Email *</Label>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
defaultValue={customer?.email ?? ""}
|
||||||
|
placeholder="Enter email"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Phone</Label>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Phone className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
|
||||||
|
<Input
|
||||||
|
defaultValue={customer?.phone ?? ""}
|
||||||
|
placeholder="Enter phone"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TIN */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>TIN Number</Label>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<FileText className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
|
||||||
|
<Input
|
||||||
|
defaultValue={customer?.tinNumber ?? ""}
|
||||||
|
placeholder="Enter TIN number"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* City */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>City</Label>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<MapPin className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
|
||||||
|
<Input
|
||||||
|
defaultValue={customer?.city ?? ""}
|
||||||
|
placeholder="Enter city"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Country */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Country</Label>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Globe className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
|
||||||
|
<Input
|
||||||
|
defaultValue={customer?.country ?? ""}
|
||||||
|
placeholder="Enter country"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label>Address</Label>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
defaultValue={customer?.address ?? ""}
|
||||||
|
placeholder="Enter address"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label>Notes</Label>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
defaultValue={customer?.notes ?? ""}
|
||||||
|
placeholder="Additional notes..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
|
||||||
|
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,19 @@
|
|||||||
import { useEffect, useState, type ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useState } from "react";
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
Input,
|
} from "@/components/ui/dialog";
|
||||||
Label,
|
|
||||||
Button,
|
import { Input } from "@/components/ui/input";
|
||||||
Textarea,
|
import { Label } from "@/components/ui/label";
|
||||||
SmartFileInput,
|
import { Button } from "@/components/ui/button";
|
||||||
} from "@edr/ui-common";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
@@ -25,360 +23,602 @@ import {
|
|||||||
Globe,
|
Globe,
|
||||||
MapPin,
|
MapPin,
|
||||||
FileText,
|
FileText,
|
||||||
|
CreditCard,
|
||||||
|
Briefcase,
|
||||||
|
Users,
|
||||||
|
UserCircle,
|
||||||
|
StickyNote,
|
||||||
} from "lucide-react";
|
} 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 {
|
export interface CustomerFormData {
|
||||||
useCreateCustomer,
|
firstName: string;
|
||||||
useUpdateCustomer,
|
lastName: string;
|
||||||
} from "@/hooks/useCustomers";
|
email: string;
|
||||||
import { getFileUploadSettingByCode } from "@/services/fileUploadSettings.service";
|
phone: string;
|
||||||
import { FILE_SETTINGS } from "@/constants/FILE_SETTINGS";
|
companyName: string;
|
||||||
import type {
|
companyEmail: string;
|
||||||
CreateCustomerDto,
|
companyPhone: string;
|
||||||
Customer,
|
companyLocation: string;
|
||||||
CustomerStatus,
|
companyAddress: string;
|
||||||
CustomerType,
|
contactPersonName: string;
|
||||||
} from "@/types/customers";
|
contactPersonPhone: string;
|
||||||
|
tinNumber: string;
|
||||||
const CUSTOMER_TYPES: CustomerType[] = ["Importer", "Exporter", "Supplier"];
|
vatNumber: string;
|
||||||
const CUSTOMER_STATUSES: CustomerStatus[] = ["Active", "Pending", "Inactive"];
|
fanNumber: string;
|
||||||
|
generalManagerName: string;
|
||||||
|
generalManagerEmail: string;
|
||||||
|
generalManagerPhone: string;
|
||||||
|
poaName?: string;
|
||||||
|
poaPhone?: string;
|
||||||
|
poaAddress?: string;
|
||||||
|
poaEmail?: string;
|
||||||
|
poaLocation?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NewCustomerPageProps {
|
export interface NewCustomerPageProps {
|
||||||
mode?: "create" | "edit";
|
mode?: "create" | "edit";
|
||||||
customer?: Customer;
|
customer?: Partial<CustomerFormData>;
|
||||||
children?: ReactNode;
|
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({
|
export default function NewCustomerPage({
|
||||||
mode = "create",
|
mode = "create",
|
||||||
customer,
|
customer,
|
||||||
children,
|
children,
|
||||||
open: openProp,
|
|
||||||
onOpenChange,
|
|
||||||
}: NewCustomerPageProps = {}) {
|
}: NewCustomerPageProps = {}) {
|
||||||
const isEdit = mode === "edit";
|
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 title = isEdit ? "Edit Customer" : "New Customer";
|
||||||
const description = isEdit
|
const description = isEdit
|
||||||
? "Update existing customer information."
|
? "Update existing customer information."
|
||||||
: "Create and manage customer information.";
|
: "Create and manage customer information.";
|
||||||
const submitLabel = isEdit ? "Save Changes" : "Create Customer";
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog>
|
||||||
{!isControlled ? (
|
<DialogTrigger asChild>
|
||||||
<DialogTrigger asChild>
|
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
|
||||||
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
|
</DialogTrigger>
|
||||||
</DialogTrigger>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl!">
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl rounded-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>{description}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
<div className="grid gap-5 py-4 md:grid-cols-2">
|
||||||
<Field label="Company Name">
|
{/* Personal Information Section */}
|
||||||
<Building2 className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
<div className="md:col-span-2">
|
||||||
<Input
|
<div className="flex items-center gap-2 mb-3">
|
||||||
value={form.company}
|
<User className="h-5 w-5 text-[#10B981]" />
|
||||||
onChange={(e) => set("company", e.target.value)}
|
<h3 className="font-semibold text-lg">Personal Information</h3>
|
||||||
placeholder="Enter company name"
|
</div>
|
||||||
className="pl-10"
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
/>
|
{/* First Name */}
|
||||||
</Field>
|
<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">
|
{/* Last Name */}
|
||||||
<Label>Customer Type *</Label>
|
<div className="space-y-2">
|
||||||
<select
|
<Label>Last Name <span className="text-red-500">*</span></Label>
|
||||||
value={form.customerType}
|
<div className="relative">
|
||||||
onChange={(e) =>
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
set("customerType", e.target.value as CustomerType)
|
<Input
|
||||||
}
|
name="lastName"
|
||||||
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"
|
value={formData.lastName}
|
||||||
>
|
onChange={handleChange}
|
||||||
{CUSTOMER_TYPES.map((t) => (
|
placeholder="Enter last name"
|
||||||
<option key={t} value={t}>
|
className="pl-10"
|
||||||
{t}
|
/>
|
||||||
</option>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</select>
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
|
|
||||||
<Field label="Contact Person *">
|
{/* Company Information Section */}
|
||||||
<User className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
<div className="md:col-span-2">
|
||||||
<Input
|
<div className="flex items-center gap-2 mb-3 mt-2">
|
||||||
value={form.name}
|
<Building2 className="h-5 w-5 text-[#10B981]" />
|
||||||
onChange={(e) => set("name", e.target.value)}
|
<h3 className="font-semibold text-lg">Company Information</h3>
|
||||||
placeholder="Enter contact person"
|
</div>
|
||||||
className="pl-10"
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
/>
|
{/* Company Name */}
|
||||||
</Field>
|
<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 *">
|
{/* Company Email */}
|
||||||
<Mail className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
<div className="space-y-2">
|
||||||
<Input
|
<Label>Company Email <span className="text-red-500">*</span></Label>
|
||||||
type="email"
|
<div className="relative">
|
||||||
value={form.email}
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
onChange={(e) => set("email", e.target.value)}
|
<Input
|
||||||
placeholder="Enter email"
|
type="email"
|
||||||
className="pl-10"
|
name="companyEmail"
|
||||||
/>
|
value={formData.companyEmail}
|
||||||
</Field>
|
onChange={handleChange}
|
||||||
|
placeholder="Enter company email"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Field label="Phone *">
|
{/* Company Phone */}
|
||||||
<Phone className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
<div className="space-y-2">
|
||||||
<Input
|
<Label>Company Phone <span className="text-red-500">*</span></Label>
|
||||||
value={form.phone}
|
<div className="relative">
|
||||||
onChange={(e) => set("phone", e.target.value)}
|
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
placeholder="Enter phone"
|
<Input
|
||||||
className="pl-10"
|
name="companyPhone"
|
||||||
/>
|
value={formData.companyPhone}
|
||||||
</Field>
|
onChange={handleChange}
|
||||||
|
placeholder="Enter company phone"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Field label="TIN Number">
|
{/* Company Location */}
|
||||||
<FileText className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
<div className="space-y-2">
|
||||||
<Input
|
<Label>Company Location <span className="text-red-500">*</span></Label>
|
||||||
value={form.tinNumber}
|
<div className="relative">
|
||||||
onChange={(e) => set("tinNumber", e.target.value)}
|
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
placeholder="Enter TIN number"
|
<Input
|
||||||
className="pl-10"
|
name="companyLocation"
|
||||||
/>
|
value={formData.companyLocation}
|
||||||
</Field>
|
onChange={handleChange}
|
||||||
|
placeholder="Enter company location"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Field label="City">
|
{/* Company Address */}
|
||||||
<MapPin className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
<div className="space-y-2 md:col-span-2">
|
||||||
<Input
|
<Label>Company Address <span className="text-red-500">*</span></Label>
|
||||||
value={form.city}
|
<div className="relative">
|
||||||
onChange={(e) => set("city", e.target.value)}
|
<MapPin className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
placeholder="Enter city"
|
<Textarea
|
||||||
className="pl-10"
|
name="companyAddress"
|
||||||
/>
|
value={formData.companyAddress}
|
||||||
</Field>
|
onChange={handleChange}
|
||||||
|
placeholder="Enter company address"
|
||||||
<Field label="Country">
|
className="pl-10 resize-none"
|
||||||
<Globe className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
rows={2}
|
||||||
<Input
|
/>
|
||||||
value={form.country}
|
</div>
|
||||||
onChange={(e) => set("country", e.target.value)}
|
</div>
|
||||||
placeholder="Enter country"
|
</div>
|
||||||
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>
|
||||||
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
{/* Tax & Registration Section */}
|
||||||
<Label>Address</Label>
|
<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
|
<Textarea
|
||||||
value={form.address}
|
name="notes"
|
||||||
onChange={(e) => set("address", e.target.value)}
|
value={formData.notes ?? ""}
|
||||||
placeholder="Enter address"
|
onChange={handleChange}
|
||||||
/>
|
placeholder="Add any additional notes about the customer..."
|
||||||
</div>
|
rows={3}
|
||||||
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{customerRegistrationFiles ? (
|
<div className="flex justify-end gap-3 mt-4">
|
||||||
<div>
|
<Button variant="outline">Cancel</Button>
|
||||||
<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
|
<Button
|
||||||
type="button"
|
className="bg-[#10B981] text-white hover:bg-[#10B981]/90"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={pending}
|
disabled={isSubmitting}
|
||||||
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
|
|
||||||
>
|
>
|
||||||
{pending ? (
|
{isSubmitting ? "Submitting..." : submitLabel}
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
submitLabel
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({
|
|
||||||
label,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{label}</Label>
|
|
||||||
<div className="relative">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ArrowLeft,
|
||||||
|
Building2,
|
||||||
|
FileText,
|
||||||
|
Globe,
|
||||||
|
Loader2,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
Phone,
|
||||||
|
StickyNote,
|
||||||
|
Trash2,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
|
import NewCustomerPage from "./NewCustomerPage";
|
||||||
|
import DeleteCustomerDialog from "./DeleteCustomerDialog";
|
||||||
|
import { useCustomer, useDeleteCustomer } from "@/hooks/useCustomers";
|
||||||
|
import type { CustomerStatus } from "@/types/customers";
|
||||||
|
|
||||||
|
export default function CustomerDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { data: customer, isLoading, isError, error } = useCustomer(id);
|
||||||
|
const deleteMutation = useDeleteCustomer();
|
||||||
|
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 p-6">
|
||||||
|
<div className="mx-auto max-w-5xl space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
items={[{ label: "Customers", href: "/customers" }, { label: "…" }]}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-center rounded-3xl bg-white p-12 text-sm text-slate-500 shadow-sm">
|
||||||
|
<Loader2 className="mr-2 h-5 w-5 animate-spin text-[#10B981]" />
|
||||||
|
Loading customer…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !customer) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 p-6">
|
||||||
|
<div className="mx-auto max-w-5xl space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "Customers", href: "/customers" },
|
||||||
|
{ label: "Not found" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="rounded-3xl bg-white p-8 text-center shadow-sm">
|
||||||
|
<AlertCircle className="mx-auto mb-3 h-6 w-6 text-red-500" />
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">
|
||||||
|
{isError ? "Failed to load customer" : "Customer not found"}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
|
{isError && error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "The customer you're looking for doesn't exist or has been removed."}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/customers"
|
||||||
|
className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to Customers
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
deleteMutation.mutate(customer.id, {
|
||||||
|
onSuccess: () => navigate("/customers"),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 p-6">
|
||||||
|
<div className="mx-auto max-w-5xl space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "Customers", href: "/customers" },
|
||||||
|
{ label: customer.name },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#10B981] text-white">
|
||||||
|
<User className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
||||||
|
{customer.name}
|
||||||
|
</h1>
|
||||||
|
<div className="mt-1 flex items-center gap-3 text-sm text-slate-500">
|
||||||
|
<span className="font-mono text-xs">#{customer.id.slice(0, 8)}</span>
|
||||||
|
<span className="text-slate-300">•</span>
|
||||||
|
<span>{customer.company ?? "—"}</span>
|
||||||
|
<span className="text-slate-300">•</span>
|
||||||
|
<StatusBadge status={customer.status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditOpen(true)}
|
||||||
|
className="inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
|
||||||
|
>
|
||||||
|
Edit Customer
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
className="inline-flex items-center gap-2 rounded-2xl border border-red-200 px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail grid */}
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<DetailCard title="Company Information">
|
||||||
|
<DetailRow
|
||||||
|
icon={<Building2 className="h-4 w-4" />}
|
||||||
|
label="Company Name"
|
||||||
|
value={customer.company ?? "—"}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
icon={<FileText className="h-4 w-4" />}
|
||||||
|
label="Customer Type"
|
||||||
|
value={customer.customerType}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
icon={<FileText className="h-4 w-4" />}
|
||||||
|
label="TIN Number"
|
||||||
|
value={customer.tinNumber ?? "—"}
|
||||||
|
/>
|
||||||
|
</DetailCard>
|
||||||
|
|
||||||
|
<DetailCard title="Contact">
|
||||||
|
<DetailRow
|
||||||
|
icon={<User className="h-4 w-4" />}
|
||||||
|
label="Contact Person"
|
||||||
|
value={customer.name}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
icon={<Mail className="h-4 w-4" />}
|
||||||
|
label="Email"
|
||||||
|
value={customer.email}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
icon={<Phone className="h-4 w-4" />}
|
||||||
|
label="Phone"
|
||||||
|
value={customer.phone}
|
||||||
|
/>
|
||||||
|
</DetailCard>
|
||||||
|
|
||||||
|
<DetailCard title="Location">
|
||||||
|
<DetailRow
|
||||||
|
icon={<MapPin className="h-4 w-4" />}
|
||||||
|
label="City"
|
||||||
|
value={customer.city ?? "—"}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
icon={<Globe className="h-4 w-4" />}
|
||||||
|
label="Country"
|
||||||
|
value={customer.country ?? "—"}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
icon={<MapPin className="h-4 w-4" />}
|
||||||
|
label="Address"
|
||||||
|
value={customer.address ?? "—"}
|
||||||
|
/>
|
||||||
|
</DetailCard>
|
||||||
|
|
||||||
|
<DetailCard title="Notes">
|
||||||
|
<div className="flex items-start gap-3 text-sm text-slate-700">
|
||||||
|
<StickyNote className="mt-0.5 h-4 w-4 text-[#10B981]" />
|
||||||
|
<p className="leading-relaxed">{customer.notes ?? "—"}</p>
|
||||||
|
</div>
|
||||||
|
</DetailCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NewCustomerPage
|
||||||
|
mode="edit"
|
||||||
|
customer={customer}
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
/>
|
||||||
|
<DeleteCustomerDialog
|
||||||
|
customerName={customer.name}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={setDeleteOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailCard({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
|
||||||
|
<div className="mt-4 space-y-3">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 text-[#10B981]">{icon}</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs font-medium text-slate-500">{label}</p>
|
||||||
|
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: CustomerStatus }) {
|
||||||
|
const styles: Record<CustomerStatus, string> = {
|
||||||
|
Active: "bg-emerald-100 text-emerald-700",
|
||||||
|
Pending: "bg-amber-100 text-amber-700",
|
||||||
|
Inactive: "bg-red-100 text-red-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Clock3,
|
||||||
|
Eye,
|
||||||
|
Filter,
|
||||||
|
Loader2,
|
||||||
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
User,
|
||||||
|
UserCheck,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
|
import NewCustomerPage from "./NewCustomerPage";
|
||||||
|
import DeleteCustomerDialog from "./DeleteCustomerDialog";
|
||||||
|
import { useCustomers, useDeleteCustomer } from "@/hooks/useCustomers";
|
||||||
|
import type { Customer, CustomerStatus } from "@/types/customers";
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
DataTableFooter,
|
||||||
|
type ColumnDef,
|
||||||
|
usePagination,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
Input,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
type ActiveDialog = "edit" | "delete";
|
||||||
|
|
||||||
|
export default function CustomerPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const [activeDialog, setActiveDialog] = useState<ActiveDialog | null>(null);
|
||||||
|
const [activeCustomer, setActiveCustomer] = useState<Customer | null>(null);
|
||||||
|
|
||||||
|
const openDialogFor = (dialog: ActiveDialog, customer: Customer) => {
|
||||||
|
// Defer past the DropdownMenu close cycle so Radix doesn't leave
|
||||||
|
// `pointer-events: none` on <body>.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
document.body.style.pointerEvents = "";
|
||||||
|
setActiveCustomer(customer);
|
||||||
|
setActiveDialog(dialog);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const closeDialog = () => setActiveDialog(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = requestAnimationFrame(() => {
|
||||||
|
if (document.body.style.pointerEvents === "none") {
|
||||||
|
document.body.style.pointerEvents = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}, [activeDialog]);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, error } = useCustomers();
|
||||||
|
const deleteMutation = useDeleteCustomer();
|
||||||
|
|
||||||
|
const customers = useMemo<Customer[]>(
|
||||||
|
() => (Array.isArray(data) ? data : []),
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return customers;
|
||||||
|
return customers.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name.toLowerCase().includes(q) ||
|
||||||
|
c.email.toLowerCase().includes(q) ||
|
||||||
|
(c.company ?? "").toLowerCase().includes(q) ||
|
||||||
|
c.phone.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [customers, query]);
|
||||||
|
|
||||||
|
const total = filtered.length;
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||||
|
const start = pagination.pageIndex * pagination.pageSize;
|
||||||
|
const end = Math.min(start + pagination.pageSize, total);
|
||||||
|
|
||||||
|
const paginatedData = useMemo(
|
||||||
|
() => filtered.slice(start, end),
|
||||||
|
[start, end, filtered],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeCount = customers.filter((c) => c.status === "Active").length;
|
||||||
|
const pendingCount = customers.filter((c) => c.status === "Pending").length;
|
||||||
|
|
||||||
|
const status: "loading" | "error" | "success" = isLoading
|
||||||
|
? "loading"
|
||||||
|
: isError
|
||||||
|
? "error"
|
||||||
|
: "success";
|
||||||
|
|
||||||
|
const columns: ColumnDef<Customer>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: "Customer",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const customer = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-secondary text-secondary-foreground border">
|
||||||
|
<User className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-900">{customer.name}</p>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{customer.company ?? "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "email",
|
||||||
|
header: "Email",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm text-slate-700">{row.original.email}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "phone",
|
||||||
|
header: "Phone",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm text-slate-700">{row.original.phone}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "customerType",
|
||||||
|
header: "Type",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="inline-flex rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-600">
|
||||||
|
{row.original.customerType}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: "Status",
|
||||||
|
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
size: 40,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const customer = row.original;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex justify-end"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<MoreHorizontal />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => navigate(`/customers/${customer.id}`)}
|
||||||
|
>
|
||||||
|
<Eye />
|
||||||
|
View
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => openDialogFor("edit", customer)}
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => openDialogFor("delete", customer)}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen p-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs items={[{ label: "Customers" }]} />
|
||||||
|
|
||||||
|
<Card className="p-6 flex-row justify-between ">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
||||||
|
Customers
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-secondary-foreground ">
|
||||||
|
Manage and monitor your customer records.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
||||||
|
<div className="relative w-full sm:w-80">
|
||||||
|
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setPagination({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="Search customers..."
|
||||||
|
className="pl-8!"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NewCustomerPage>
|
||||||
|
<Button>
|
||||||
|
<Plus />
|
||||||
|
Add Customer
|
||||||
|
</Button>
|
||||||
|
</NewCustomerPage>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<StatCard
|
||||||
|
title="Total Customers"
|
||||||
|
value={customers.length}
|
||||||
|
icon={<Users className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Active Accounts"
|
||||||
|
value={activeCount}
|
||||||
|
icon={<UserCheck className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Pending Requests"
|
||||||
|
value={pendingCount}
|
||||||
|
icon={<Clock3 className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isError ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-3 py-6 text-sm text-red-600">
|
||||||
|
<AlertCircle className="h-5 w-5" />
|
||||||
|
Failed to load customers.{" "}
|
||||||
|
{error instanceof Error ? error.message : "Unknown error."}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card className="gap-0">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between border-b ">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Customer List</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Recent customer activities and records.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="secondary" size="sm">
|
||||||
|
<Filter />
|
||||||
|
Filter
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="px-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin text-primary" />
|
||||||
|
Loading customers…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={paginatedData}
|
||||||
|
status={status}
|
||||||
|
onRowClick={(row) => navigate(`/customers/${row.id}`)}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.pageIndex,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
pageCount: pageCount,
|
||||||
|
totalCount: total,
|
||||||
|
}}
|
||||||
|
tableOptions={{
|
||||||
|
state: { pagination },
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
}}
|
||||||
|
containerClassName="border-b shadow-none"
|
||||||
|
footer={DataTableFooter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hoisted controlled dialogs (avoid Radix nested DropdownMenu+Dialog
|
||||||
|
unmount + pointer-events conflict). */}
|
||||||
|
{activeCustomer ? (
|
||||||
|
<>
|
||||||
|
<NewCustomerPage
|
||||||
|
key={`edit-${activeCustomer.id}`}
|
||||||
|
mode="edit"
|
||||||
|
customer={activeCustomer}
|
||||||
|
open={activeDialog === "edit"}
|
||||||
|
onOpenChange={(next) => (next ? null : closeDialog())}
|
||||||
|
/>
|
||||||
|
<DeleteCustomerDialog
|
||||||
|
key={`delete-${activeCustomer.id}`}
|
||||||
|
customerName={activeCustomer.name}
|
||||||
|
onConfirm={() => deleteMutation.mutate(activeCustomer.id)}
|
||||||
|
open={activeDialog === "delete"}
|
||||||
|
onOpenChange={(next) => (next ? null : closeDialog())}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: number;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">{title}</p>
|
||||||
|
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary text-white">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: CustomerStatus }) {
|
||||||
|
const styles: Record<CustomerStatus, string> = {
|
||||||
|
Active: "bg-emerald-100 text-emerald-700",
|
||||||
|
Pending: "bg-amber-100 text-amber-700",
|
||||||
|
Inactive: "bg-red-100 text-red-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
Button,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
export interface DeleteCustomerDialogProps {
|
||||||
|
customerName: string;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
children?: ReactNode;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeleteCustomerDialog({
|
||||||
|
customerName,
|
||||||
|
onConfirm,
|
||||||
|
children,
|
||||||
|
open: openProp,
|
||||||
|
onOpenChange,
|
||||||
|
}: DeleteCustomerDialogProps) {
|
||||||
|
const isControlled = openProp !== undefined;
|
||||||
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
const open = isControlled ? openProp : internalOpen;
|
||||||
|
const setOpen = (next: boolean) => {
|
||||||
|
if (!isControlled) setInternalOpen(next);
|
||||||
|
onOpenChange?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-bold">
|
||||||
|
Delete customer?
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
This will permanently remove{" "}
|
||||||
|
<span className="font-semibold text-slate-900">{customerName}</span>{" "}
|
||||||
|
from your records. This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-2">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="bg-red-600 text-white hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Button,
|
||||||
|
Textarea,
|
||||||
|
SmartFileInput,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
User,
|
||||||
|
Globe,
|
||||||
|
MapPin,
|
||||||
|
FileText,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCreateCustomer,
|
||||||
|
useUpdateCustomer,
|
||||||
|
} from "@/hooks/useCustomers";
|
||||||
|
import { getFileUploadSettingByCode } from "@/services/fileUploadSettings.service";
|
||||||
|
import { FILE_SETTINGS } from "@/constants/FILE_SETTINGS";
|
||||||
|
import type {
|
||||||
|
CreateCustomerDto,
|
||||||
|
Customer,
|
||||||
|
CustomerStatus,
|
||||||
|
CustomerType,
|
||||||
|
} from "@/types/customers";
|
||||||
|
|
||||||
|
const CUSTOMER_TYPES: CustomerType[] = ["Importer", "Exporter", "Supplier"];
|
||||||
|
const CUSTOMER_STATUSES: CustomerStatus[] = ["Active", "Pending", "Inactive"];
|
||||||
|
|
||||||
|
export interface NewCustomerPageProps {
|
||||||
|
mode?: "create" | "edit";
|
||||||
|
customer?: Customer;
|
||||||
|
children?: ReactNode;
|
||||||
|
/** Controlled open. When omitted, the dialog manages its own open state. */
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
company: string;
|
||||||
|
customerType: CustomerType;
|
||||||
|
status: CustomerStatus;
|
||||||
|
tinNumber: string;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
address: string;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyForm = (): FormState => ({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
company: "",
|
||||||
|
customerType: "Importer",
|
||||||
|
status: "Active",
|
||||||
|
tinNumber: "",
|
||||||
|
city: "",
|
||||||
|
country: "",
|
||||||
|
address: "",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fromCustomer = (c: Customer): FormState => ({
|
||||||
|
name: c.name ?? "",
|
||||||
|
email: c.email ?? "",
|
||||||
|
phone: c.phone ?? "",
|
||||||
|
company: c.company ?? "",
|
||||||
|
customerType: c.customerType ?? "Importer",
|
||||||
|
status: c.status ?? "Active",
|
||||||
|
tinNumber: c.tinNumber ?? "",
|
||||||
|
city: c.city ?? "",
|
||||||
|
country: c.country ?? "",
|
||||||
|
address: c.address ?? "",
|
||||||
|
notes: c.notes ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function NewCustomerPage({
|
||||||
|
mode = "create",
|
||||||
|
customer,
|
||||||
|
children,
|
||||||
|
open: openProp,
|
||||||
|
onOpenChange,
|
||||||
|
}: NewCustomerPageProps = {}) {
|
||||||
|
const isEdit = mode === "edit";
|
||||||
|
const isControlled = openProp !== undefined;
|
||||||
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
const open = isControlled ? openProp : internalOpen;
|
||||||
|
const setOpen = (next: boolean) => {
|
||||||
|
if (!isControlled) setInternalOpen(next);
|
||||||
|
onOpenChange?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [form, setForm] = useState<FormState>(
|
||||||
|
customer ? fromCustomer(customer) : emptyForm(),
|
||||||
|
);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [files, setFiles] = useState<Record<string, File | File[] | null>>({});
|
||||||
|
|
||||||
|
// Reset form whenever the dialog opens with a different customer.
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setForm(customer ? fromCustomer(customer) : emptyForm());
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [open, customer]);
|
||||||
|
|
||||||
|
const { data: customerRegistrationFiles } = useQuery(
|
||||||
|
getFileUploadSettingByCode.queryOptions({
|
||||||
|
input: FILE_SETTINGS.CUSTOMER_REGISTRATION,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const createMutation = useCreateCustomer();
|
||||||
|
const updateMutation = useUpdateCustomer();
|
||||||
|
const pending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!form.name.trim() || !form.email.trim() || !form.phone.trim()) {
|
||||||
|
setError("Name, email, and phone are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
|
||||||
|
setError("Please enter a valid email address.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: CreateCustomerDto = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
email: form.email.trim(),
|
||||||
|
phone: form.phone.trim(),
|
||||||
|
customerType: form.customerType,
|
||||||
|
status: form.status,
|
||||||
|
company: form.company.trim() || undefined,
|
||||||
|
tinNumber: form.tinNumber.trim() || undefined,
|
||||||
|
city: form.city.trim() || undefined,
|
||||||
|
country: form.country.trim() || undefined,
|
||||||
|
address: form.address.trim() || undefined,
|
||||||
|
notes: form.notes.trim() || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDone = () => {
|
||||||
|
setOpen(false);
|
||||||
|
if (!isEdit) setForm(emptyForm());
|
||||||
|
};
|
||||||
|
const onError = (err: unknown) => {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Something went wrong. Try again.",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit && customer) {
|
||||||
|
updateMutation.mutate(
|
||||||
|
{ id: customer.id, dto: payload },
|
||||||
|
{ onSuccess: onDone, onError },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(payload, { onSuccess: onDone, onError });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const title = isEdit ? "Edit Customer" : "New Customer";
|
||||||
|
const description = isEdit
|
||||||
|
? "Update existing customer information."
|
||||||
|
: "Create and manage customer information.";
|
||||||
|
const submitLabel = isEdit ? "Save Changes" : "Create Customer";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
{!isControlled ? (
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
|
||||||
|
</DialogTrigger>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl!">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-5 py-4 md:grid-cols-2">
|
||||||
|
<Field label="Company Name">
|
||||||
|
<Building2 className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={form.company}
|
||||||
|
onChange={(e) => set("company", e.target.value)}
|
||||||
|
placeholder="Enter company name"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Customer Type *</Label>
|
||||||
|
<select
|
||||||
|
value={form.customerType}
|
||||||
|
onChange={(e) =>
|
||||||
|
set("customerType", e.target.value as CustomerType)
|
||||||
|
}
|
||||||
|
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
|
||||||
|
>
|
||||||
|
{CUSTOMER_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{t}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Contact Person *">
|
||||||
|
<User className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => set("name", e.target.value)}
|
||||||
|
placeholder="Enter contact person"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Email *">
|
||||||
|
<Mail className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => set("email", e.target.value)}
|
||||||
|
placeholder="Enter email"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Phone *">
|
||||||
|
<Phone className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={form.phone}
|
||||||
|
onChange={(e) => set("phone", e.target.value)}
|
||||||
|
placeholder="Enter phone"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="TIN Number">
|
||||||
|
<FileText className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={form.tinNumber}
|
||||||
|
onChange={(e) => set("tinNumber", e.target.value)}
|
||||||
|
placeholder="Enter TIN number"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="City">
|
||||||
|
<MapPin className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={form.city}
|
||||||
|
onChange={(e) => set("city", e.target.value)}
|
||||||
|
placeholder="Enter city"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Country">
|
||||||
|
<Globe className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={form.country}
|
||||||
|
onChange={(e) => set("country", e.target.value)}
|
||||||
|
placeholder="Enter country"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Status</Label>
|
||||||
|
<select
|
||||||
|
value={form.status}
|
||||||
|
onChange={(e) => set("status", e.target.value as CustomerStatus)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
|
||||||
|
>
|
||||||
|
{CUSTOMER_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label>Address</Label>
|
||||||
|
<Textarea
|
||||||
|
value={form.address}
|
||||||
|
onChange={(e) => set("address", e.target.value)}
|
||||||
|
placeholder="Enter address"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label>Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => set("notes", e.target.value)}
|
||||||
|
placeholder="Additional notes..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{customerRegistrationFiles ? (
|
||||||
|
<div>
|
||||||
|
<SmartFileInput
|
||||||
|
file={customerRegistrationFiles}
|
||||||
|
value={files}
|
||||||
|
onChange={setFiles}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline" disabled={pending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={pending}
|
||||||
|
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{pending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
submitLabel
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{label}</Label>
|
||||||
|
<div className="relative">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
export type CustomerStatus = "Active" | "Pending" | "Inactive";
|
||||||
|
export type CustomerType = "Importer" | "Exporter" | "Supplier";
|
||||||
|
|
||||||
|
export interface Customer {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
company: string;
|
||||||
|
status: CustomerStatus;
|
||||||
|
customerType: CustomerType;
|
||||||
|
phone: string;
|
||||||
|
tinNumber: string;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
address: string;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seedCustomers: Customer[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Abel Tesfaye",
|
||||||
|
email: "abel@example.com",
|
||||||
|
company: "Addis Logistics",
|
||||||
|
status: "Active",
|
||||||
|
customerType: "Importer",
|
||||||
|
phone: "+251 911 234 567",
|
||||||
|
tinNumber: "0012345678",
|
||||||
|
city: "Addis Ababa",
|
||||||
|
country: "Ethiopia",
|
||||||
|
address: "Bole Road, Sub-City 03, Building 17",
|
||||||
|
notes: "Top-tier importer. Prefers weekly invoicing.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "Sara Bekele",
|
||||||
|
email: "sara@example.com",
|
||||||
|
company: "Blue Nile Trading",
|
||||||
|
status: "Pending",
|
||||||
|
customerType: "Exporter",
|
||||||
|
phone: "+251 922 345 678",
|
||||||
|
tinNumber: "0023456789",
|
||||||
|
city: "Dire Dawa",
|
||||||
|
country: "Ethiopia",
|
||||||
|
address: "Industrial Park, Zone B, Warehouse 4",
|
||||||
|
notes: "Awaiting compliance documents.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "Henok Alemu",
|
||||||
|
email: "henok@example.com",
|
||||||
|
company: "Ethio Freight",
|
||||||
|
status: "Inactive",
|
||||||
|
customerType: "Supplier",
|
||||||
|
phone: "+251 933 456 789",
|
||||||
|
tinNumber: "0034567890",
|
||||||
|
city: "Djibouti City",
|
||||||
|
country: "Djibouti",
|
||||||
|
address: "Port Quarter, Avenue 26, Block 9",
|
||||||
|
notes: "Account paused since last quarter.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const extras: Array<{ name: string; company: string; city: string; country: string }> = [
|
||||||
|
{ name: "Yohannes Girma", company: "Habesha Imports", city: "Addis Ababa", country: "Ethiopia" },
|
||||||
|
{ name: "Meron Asfaw", company: "Sheba Trading", city: "Adama", country: "Ethiopia" },
|
||||||
|
{ name: "Daniel Kebede", company: "Awash Cargo", city: "Hawassa", country: "Ethiopia" },
|
||||||
|
{ name: "Liya Tadesse", company: "Lalibela Logistics", city: "Bahir Dar", country: "Ethiopia" },
|
||||||
|
{ name: "Samuel Worku", company: "Rift Valley Freight", city: "Mekelle", country: "Ethiopia" },
|
||||||
|
{ name: "Hanna Mulugeta", company: "Simien Exports", city: "Gondar", country: "Ethiopia" },
|
||||||
|
{ name: "Bereket Hailu", company: "Omo River Co.", city: "Jimma", country: "Ethiopia" },
|
||||||
|
{ name: "Tigist Wolde", company: "Tana Shipping", city: "Dessie", country: "Ethiopia" },
|
||||||
|
{ name: "Kalkidan Mesfin", company: "Coffee Belt Traders", city: "Addis Ababa", country: "Ethiopia" },
|
||||||
|
{ name: "Nahom Solomon", company: "Highland Freight", city: "Harar", country: "Ethiopia" },
|
||||||
|
{ name: "Ali Mohamed", company: "Red Sea Cargo", city: "Djibouti City", country: "Djibouti" },
|
||||||
|
{ name: "Fatima Hassan", company: "Gulf Logistics", city: "Tadjoura", country: "Djibouti" },
|
||||||
|
{ name: "Omar Ibrahim", company: "Bab-el-Mandeb Trading", city: "Ali Sabieh", country: "Djibouti" },
|
||||||
|
{ name: "Amina Said", company: "Horn of Africa Imports", city: "Dikhil", country: "Djibouti" },
|
||||||
|
{ name: "Yusuf Abdulahi", company: "Saharan Exports", city: "Obock", country: "Djibouti" },
|
||||||
|
{ name: "Selam Negash", company: "Equator Freight", city: "Arba Minch", country: "Ethiopia" },
|
||||||
|
{ name: "Mikias Lemma", company: "Gibe Trading", city: "Sodo", country: "Ethiopia" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statuses: CustomerStatus[] = ["Active", "Pending", "Inactive"];
|
||||||
|
const types: CustomerType[] = ["Importer", "Exporter", "Supplier"];
|
||||||
|
|
||||||
|
const generated: Customer[] = extras.map((entry, i) => {
|
||||||
|
const id = seedCustomers.length + i + 1;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: entry.name,
|
||||||
|
email: `${entry.name.toLowerCase().replace(/\s+/g, ".")}@example.com`,
|
||||||
|
company: entry.company,
|
||||||
|
status: statuses[i % statuses.length] as CustomerStatus,
|
||||||
|
customerType: types[i % types.length] as CustomerType,
|
||||||
|
phone: `+251 9${String(40 + i).padStart(2, "0")} ${String(100 + i * 13).slice(0, 3)} ${String(200 + i * 17).slice(0, 3)}`,
|
||||||
|
tinNumber: String(40000000 + i * 12345).padStart(10, "0"),
|
||||||
|
city: entry.city,
|
||||||
|
country: entry.country,
|
||||||
|
address: `Block ${i + 1}, Street ${10 + i}, Quarter ${(i % 5) + 1}`,
|
||||||
|
notes: `Mock customer #${id}.`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const customers: Customer[] = [...seedCustomers, ...generated];
|
||||||
|
|
||||||
|
export function getCustomerById(id: number | string): Customer | undefined {
|
||||||
|
const numericId = typeof id === "string" ? Number(id) : id;
|
||||||
|
return customers.find((c) => c.id === numericId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Building2,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
DollarSign,
|
||||||
|
Eye,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
Package,
|
||||||
|
Phone,
|
||||||
|
Plus,
|
||||||
|
Receipt,
|
||||||
|
Truck,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
|
import {
|
||||||
|
getCurrentCustomer,
|
||||||
|
getMyBookings,
|
||||||
|
getMyInvoices,
|
||||||
|
getMyShipments,
|
||||||
|
} from "@/lib/currentCustomer";
|
||||||
|
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
||||||
|
import type { ShipmentStatus } from "@/pages/tracking/shipments.mock";
|
||||||
|
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
|
||||||
|
import type { BookingStatus } from "@/pages/bookings/bookings.mock";
|
||||||
|
import { customersService } from "@/services/customers.service";
|
||||||
|
|
||||||
|
export default function MyPortalPage() {
|
||||||
|
const me = useMemo(() => getCurrentCustomer(), []);
|
||||||
|
const myBookings = useMemo(() => getMyBookings(), []);
|
||||||
|
const myShipments = useMemo(() => getMyShipments(), []);
|
||||||
|
const myInvoices = useMemo(() => getMyInvoices(), []);
|
||||||
|
|
||||||
|
const userId = localStorage.getItem("userId");
|
||||||
|
useEffect(() => {
|
||||||
|
customersService.getByUserId(userId || "").then((res: any) => {
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error(err)
|
||||||
|
})
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
const activeBookings = myBookings.filter(
|
||||||
|
(b) => b.status === "Confirmed" || b.status === "In Transit",
|
||||||
|
);
|
||||||
|
const activeShipments = myShipments.filter((s) => s.status === "In Transit");
|
||||||
|
const outstandingInvoices = myInvoices.filter(
|
||||||
|
(inv) => inv.status === "Sent" || inv.status === "Overdue",
|
||||||
|
);
|
||||||
|
const totalOutstanding = outstandingInvoices
|
||||||
|
.filter((inv) => inv.currency === "USD")
|
||||||
|
.reduce((sum, inv) => sum + inv.amount, 0);
|
||||||
|
const totalSpent = myInvoices
|
||||||
|
.filter((inv) => inv.status === "Paid" && inv.currency === "USD")
|
||||||
|
.reduce((sum, inv) => sum + inv.amount, 0);
|
||||||
|
|
||||||
|
const recentBookings = [...myBookings].slice(0, 5);
|
||||||
|
const recentInvoices = [...myInvoices].slice(0, 4);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 p-6">
|
||||||
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
|
<Breadcrumbs items={[{ label: "My Portal" }]} />
|
||||||
|
|
||||||
|
{/* Welcome banner */}
|
||||||
|
<div className="overflow-hidden rounded-3xl bg-gradient-to-r from-[#059669] to-[#10B981] p-6 text-white shadow-sm">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-white/15 text-2xl font-bold backdrop-blur">
|
||||||
|
{me.company.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-white/80">Welcome back</p>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
|
{me.name}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 flex items-center gap-2 text-sm text-white/80">
|
||||||
|
<Building2 className="h-4 w-4" />
|
||||||
|
{me.company}
|
||||||
|
<span className="text-white/40">·</span>
|
||||||
|
<span className="rounded-full bg-white/15 px-2 py-0.5 text-xs">
|
||||||
|
{me.customerType}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Link
|
||||||
|
to="/bookings/new"
|
||||||
|
className="inline-flex items-center gap-2 rounded-2xl bg-white px-4 py-2 text-sm font-semibold text-[#10B981] transition hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
New Booking
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/tracking"
|
||||||
|
className="inline-flex items-center gap-2 rounded-2xl border border-white/40 px-4 py-2 text-sm font-medium text-white transition hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Truck className="h-4 w-4" />
|
||||||
|
Track Shipment
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* My KPIs */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<KpiCard
|
||||||
|
label="My Active Bookings"
|
||||||
|
value={String(activeBookings.length)}
|
||||||
|
sub={`${myBookings.length} total`}
|
||||||
|
icon={<Package className="h-5 w-5" />}
|
||||||
|
href="/bookings"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="In Transit"
|
||||||
|
value={String(activeShipments.length)}
|
||||||
|
sub={`${myShipments.length} shipments`}
|
||||||
|
icon={<Truck className="h-5 w-5" />}
|
||||||
|
href="/tracking"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Outstanding"
|
||||||
|
value={formatCurrency(totalOutstanding, "USD")}
|
||||||
|
sub={`${outstandingInvoices.length} invoices`}
|
||||||
|
icon={<DollarSign className="h-5 w-5" />}
|
||||||
|
href="/billing"
|
||||||
|
tone={outstandingInvoices.some((i) => i.status === "Overdue") ? "danger" : "brand"}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Total Spent"
|
||||||
|
value={formatCurrency(totalSpent, "USD")}
|
||||||
|
sub="All-time, paid invoices"
|
||||||
|
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Shipments */}
|
||||||
|
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">
|
||||||
|
Active Shipments
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Live tracking for your in-flight cargo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/tracking"
|
||||||
|
className="inline-flex items-center gap-1 text-sm font-medium text-[#10B981] transition hover:underline"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeShipments.length === 0 ? (
|
||||||
|
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
|
||||||
|
No shipments currently in transit.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{activeShipments.slice(0, 4).map((shipment) => (
|
||||||
|
<div
|
||||||
|
key={shipment.id}
|
||||||
|
className="rounded-2xl border border-slate-100 p-4 transition hover:border-[#10B981]/20 hover:bg-[#10B981]/5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-semibold text-slate-900">
|
||||||
|
{shipment.reference}
|
||||||
|
</span>
|
||||||
|
<ShipmentBadge status={shipment.status} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-slate-700">
|
||||||
|
{shipment.originStation}
|
||||||
|
<ArrowRight className="mx-2 inline h-3 w-3 text-slate-400" />
|
||||||
|
{shipment.destinationStation}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MapPin className="h-3 w-3 text-[#10B981]" />
|
||||||
|
{shipment.currentLocation}
|
||||||
|
</span>
|
||||||
|
<span>ETA {shipment.eta}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-[#10B981] transition-all"
|
||||||
|
style={{ width: `${shipment.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Bookings + Invoices + Profile */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{/* Recent bookings */}
|
||||||
|
<div className="rounded-3xl bg-white p-6 shadow-sm lg:col-span-2">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">
|
||||||
|
Recent Bookings
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Your latest freight requests
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/bookings"
|
||||||
|
className="inline-flex items-center gap-1 text-sm font-medium text-[#10B981] transition hover:underline"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recentBookings.length === 0 ? (
|
||||||
|
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
|
||||||
|
You haven't booked any freight yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[600px] whitespace-nowrap text-left text-sm">
|
||||||
|
<thead className="text-xs text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2 font-medium">Reference</th>
|
||||||
|
<th className="py-2 font-medium">Route</th>
|
||||||
|
<th className="py-2 font-medium">Cargo</th>
|
||||||
|
<th className="py-2 font-medium">Status</th>
|
||||||
|
<th className="py-2 text-right font-medium">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recentBookings.map((booking) => (
|
||||||
|
<tr
|
||||||
|
key={booking.id}
|
||||||
|
className="border-t border-slate-100 transition hover:bg-[#10B981]/5"
|
||||||
|
>
|
||||||
|
<td className="py-3 font-medium text-slate-900">
|
||||||
|
{booking.reference}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-slate-700">
|
||||||
|
{booking.originStation} → {booking.destinationStation}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-slate-700">
|
||||||
|
{booking.cargoType}
|
||||||
|
</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<BookingBadge status={booking.status} />
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-right">
|
||||||
|
<Link
|
||||||
|
to={`/bookings/${booking.id}`}
|
||||||
|
aria-label="View booking"
|
||||||
|
className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 transition hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile card */}
|
||||||
|
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">
|
||||||
|
My Profile
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-500">Account information</p>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3 text-sm">
|
||||||
|
<ProfileRow
|
||||||
|
icon={<Building2 className="h-4 w-4" />}
|
||||||
|
label="Company"
|
||||||
|
value={me.company}
|
||||||
|
/>
|
||||||
|
<ProfileRow
|
||||||
|
icon={<Mail className="h-4 w-4" />}
|
||||||
|
label="Email"
|
||||||
|
value={me.email}
|
||||||
|
/>
|
||||||
|
<ProfileRow
|
||||||
|
icon={<Phone className="h-4 w-4" />}
|
||||||
|
label="Phone"
|
||||||
|
value={me.phone}
|
||||||
|
/>
|
||||||
|
<ProfileRow
|
||||||
|
icon={<MapPin className="h-4 w-4" />}
|
||||||
|
label="Location"
|
||||||
|
value={`${me.city}, ${me.country}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to={`/customers/${me.id}`}
|
||||||
|
className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||||
|
>
|
||||||
|
View full profile
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invoices */}
|
||||||
|
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">
|
||||||
|
Recent Invoices
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{outstandingInvoices.length} outstanding ·{" "}
|
||||||
|
{myInvoices.length} total
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/billing"
|
||||||
|
className="inline-flex items-center gap-1 text-sm font-medium text-[#10B981] transition hover:underline"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recentInvoices.length === 0 ? (
|
||||||
|
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
|
||||||
|
No invoices yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{recentInvoices.map((invoice) => (
|
||||||
|
<div
|
||||||
|
key={invoice.id}
|
||||||
|
className="rounded-2xl border border-slate-100 p-4 transition hover:border-[#10B981]/20 hover:bg-[#10B981]/5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Receipt className="h-4 w-4 text-[#10B981]" />
|
||||||
|
<InvoiceBadge status={invoice.status} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-slate-500">
|
||||||
|
{invoice.number}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-lg font-bold text-slate-900">
|
||||||
|
{formatCurrency(invoice.amount, invoice.currency)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 flex items-center gap-1 text-xs text-slate-500">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
Due {invoice.dueDate}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
icon,
|
||||||
|
href,
|
||||||
|
tone = "brand",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sub: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
href?: string;
|
||||||
|
tone?: "brand" | "danger";
|
||||||
|
}) {
|
||||||
|
const iconWrap =
|
||||||
|
tone === "danger"
|
||||||
|
? "bg-red-100 text-red-600"
|
||||||
|
: "bg-[#10B981] text-white";
|
||||||
|
|
||||||
|
const inner = (
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">{label}</p>
|
||||||
|
<h3 className="mt-2 text-2xl font-bold text-slate-900">{value}</h3>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">{sub}</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`flex h-10 w-10 items-center justify-center rounded-2xl ${iconWrap}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const className =
|
||||||
|
"rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20";
|
||||||
|
|
||||||
|
return href ? (
|
||||||
|
<Link to={href} className={`block ${className}`}>
|
||||||
|
{inner}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className={className}>{inner}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileRow({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 text-[#10B981]">{icon}</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs font-medium text-slate-500">{label}</p>
|
||||||
|
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShipmentBadge({ status }: { status: ShipmentStatus }) {
|
||||||
|
const styles: Record<ShipmentStatus, string> = {
|
||||||
|
"In Transit": "bg-indigo-100 text-indigo-700",
|
||||||
|
Delivered: "bg-emerald-100 text-emerald-700",
|
||||||
|
Delayed: "bg-red-100 text-red-700",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookingBadge({ status }: { status: BookingStatus }) {
|
||||||
|
const styles: Record<BookingStatus, string> = {
|
||||||
|
Pending: "bg-amber-100 text-amber-700",
|
||||||
|
Confirmed: "bg-sky-100 text-sky-700",
|
||||||
|
"In Transit": "bg-indigo-100 text-indigo-700",
|
||||||
|
Delivered: "bg-emerald-100 text-emerald-700",
|
||||||
|
Cancelled: "bg-red-100 text-red-700",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InvoiceBadge({ status }: { status: InvoiceStatus }) {
|
||||||
|
const styles: Record<InvoiceStatus, string> = {
|
||||||
|
Draft: "bg-slate-100 text-slate-600",
|
||||||
|
Sent: "bg-sky-100 text-sky-700",
|
||||||
|
Paid: "bg-emerald-100 text-emerald-700",
|
||||||
|
Overdue: "bg-red-100 text-red-700",
|
||||||
|
Cancelled: "bg-amber-100 text-amber-700",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,365 +1,293 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Link,
|
||||||
|
useNavigate,
|
||||||
|
} from "react-router-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Eye,
|
|
||||||
Mail,
|
|
||||||
MapPin,
|
|
||||||
Package,
|
Package,
|
||||||
Phone,
|
|
||||||
Plus,
|
Plus,
|
||||||
Receipt,
|
|
||||||
Truck,
|
Truck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getCurrentCustomer,
|
getCurrentCustomer,
|
||||||
getMyBookings,
|
getMyBookings,
|
||||||
getMyInvoices,
|
getMyInvoices,
|
||||||
getMyShipments,
|
getMyShipments,
|
||||||
} from "@/lib/currentCustomer";
|
} from "@/lib/currentCustomer";
|
||||||
|
|
||||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
||||||
import type { ShipmentStatus } from "@/pages/tracking/shipments.mock";
|
|
||||||
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
|
import { customersService } from "@/services/customers.service";
|
||||||
import type { BookingStatus } from "@/pages/bookings/bookings.mock";
|
|
||||||
|
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() {
|
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 me = useMemo(() => getCurrentCustomer(), []);
|
||||||
const myBookings = useMemo(() => getMyBookings(), []);
|
const myBookings = useMemo(() => getMyBookings(), []);
|
||||||
const myShipments = useMemo(() => getMyShipments(), []);
|
const myShipments = useMemo(() => getMyShipments(), []);
|
||||||
const myInvoices = useMemo(() => getMyInvoices(), []);
|
const myInvoices = useMemo(() => getMyInvoices(), []);
|
||||||
|
|
||||||
const activeBookings = myBookings.filter(
|
// ------------------------------------------------------------
|
||||||
(b) => b.status === "Confirmed" || b.status === "In Transit",
|
// FETCH CUSTOMER
|
||||||
);
|
// ------------------------------------------------------------
|
||||||
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);
|
useEffect(() => {
|
||||||
const recentInvoices = [...myInvoices].slice(0, 4);
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 p-6">
|
<div className="min-h-screen bg-slate-50 p-6">
|
||||||
<div className="mx-auto max-w-7xl space-y-6">
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
|
|
||||||
<Breadcrumbs items={[{ label: "My Portal" }]} />
|
<Breadcrumbs items={[{ label: "My Portal" }]} />
|
||||||
|
|
||||||
{/* Welcome banner */}
|
{/* HERO */}
|
||||||
<div className="overflow-hidden rounded-3xl bg-gradient-to-r from-[#059669] to-[#10B981] p-6 text-white shadow-sm">
|
<div className="rounded-3xl bg-gradient-to-r from-[#059669] to-[#10B981] p-6 text-white">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="flex justify-between flex-col md:flex-row gap-6">
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<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">
|
<div className="h-14 w-14 flex items-center justify-center rounded-2xl bg-white/20 text-xl font-bold">
|
||||||
{me.company.charAt(0)}
|
{customer.companyName?.charAt(0)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-white/80">Welcome back</p>
|
<h1 className="text-2xl font-bold">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
{customer.firstName} {customer.lastName}
|
||||||
{me.name}
|
|
||||||
</h1>
|
</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" />
|
<Building2 className="h-4 w-4" />
|
||||||
{me.company}
|
{customer.companyName}
|
||||||
<span className="text-white/40">·</span>
|
|
||||||
<span className="rounded-full bg-white/15 px-2 py-0.5 text-xs">
|
|
||||||
{me.customerType}
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex gap-2">
|
||||||
<Link
|
<Link
|
||||||
to="/bookings/new"
|
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" />
|
<Plus className="h-4 w-4" />
|
||||||
New Booking
|
New Booking
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
to="/tracking"
|
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" />
|
<Truck className="h-4 w-4" />
|
||||||
Track Shipment
|
Track
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* My KPIs */}
|
{/* KPI */}
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="My Active Bookings"
|
label="Active Bookings"
|
||||||
value={String(activeBookings.length)}
|
value={String(activeBookings.length)}
|
||||||
sub={`${myBookings.length} total`}
|
sub={`${myBookings.length} total`}
|
||||||
icon={<Package className="h-5 w-5" />}
|
icon={<Package className="h-5 w-5" />}
|
||||||
href="/bookings"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="In Transit"
|
label="In Transit"
|
||||||
value={String(activeShipments.length)}
|
value={String(activeShipments.length)}
|
||||||
sub={`${myShipments.length} shipments`}
|
sub={`${myShipments.length} shipments`}
|
||||||
icon={<Truck className="h-5 w-5" />}
|
icon={<Truck className="h-5 w-5" />}
|
||||||
href="/tracking"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Outstanding"
|
label="Outstanding"
|
||||||
value={formatCurrency(totalOutstanding, "USD")}
|
value={formatCurrency(totalOutstanding, "USD")}
|
||||||
sub={`${outstandingInvoices.length} invoices`}
|
sub={`${outstandingInvoices.length} invoices`}
|
||||||
icon={<DollarSign className="h-5 w-5" />}
|
icon={<DollarSign className="h-5 w-5" />}
|
||||||
href="/billing"
|
tone={
|
||||||
tone={outstandingInvoices.some((i) => i.status === "Overdue") ? "danger" : "brand"}
|
outstandingInvoices.some((i) => i.status === "Overdue")
|
||||||
|
? "danger"
|
||||||
|
: "brand"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Total Spent"
|
label="Total Spent"
|
||||||
value={formatCurrency(totalSpent, "USD")}
|
value={formatCurrency(totalSpent, "USD")}
|
||||||
sub="All-time, paid invoices"
|
sub="Paid invoices"
|
||||||
icon={<CheckCircle2 className="h-5 w-5" />}
|
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// KPI CARD
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
function KpiCard({
|
function KpiCard({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -375,20 +303,29 @@ function KpiCard({
|
|||||||
href?: string;
|
href?: string;
|
||||||
tone?: "brand" | "danger";
|
tone?: "brand" | "danger";
|
||||||
}) {
|
}) {
|
||||||
const iconWrap =
|
const iconClassName =
|
||||||
tone === "danger"
|
tone === "danger"
|
||||||
? "bg-red-100 text-red-600"
|
? "bg-red-100 text-red-600"
|
||||||
: "bg-[#10B981] text-white";
|
: "bg-[#10B981] text-white";
|
||||||
|
|
||||||
const inner = (
|
const content = (
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-500">{label}</p>
|
<p className="text-sm text-slate-500">
|
||||||
<h3 className="mt-2 text-2xl font-bold text-slate-900">{value}</h3>
|
{label}
|
||||||
<p className="mt-1 text-xs text-slate-500">{sub}</p>
|
</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>
|
||||||
|
|
||||||
<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}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
@@ -398,80 +335,20 @@ function KpiCard({
|
|||||||
const className =
|
const className =
|
||||||
"rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20";
|
"rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20";
|
||||||
|
|
||||||
return href ? (
|
if (href) {
|
||||||
<Link to={href} className={`block ${className}`}>
|
return (
|
||||||
{inner}
|
<Link
|
||||||
</Link>
|
to={href}
|
||||||
) : (
|
className={`block ${className}`}
|
||||||
<div className={className}>{inner}</div>
|
>
|
||||||
);
|
{content}
|
||||||
}
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ProfileRow({
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3">
|
<div className={className}>
|
||||||
<div className="mt-0.5 text-[#10B981]">{icon}</div>
|
{content}
|
||||||
<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>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
92
apps/edr-freight-web/portal/src/services/account.ts
Normal file
92
apps/edr-freight-web/portal/src/services/account.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Freight, PaginatedResponse } from "@edr/types";
|
import type { Freight, PaginatedResponse } from "@edr/types";
|
||||||
|
import { client as api } from "@/utils/api";
|
||||||
import { api } from "./crud";
|
|
||||||
|
|
||||||
export type CreateBookingPayload = Freight.CreateBookingDto;
|
export type CreateBookingPayload = Freight.CreateBookingDto;
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
@@ -23,8 +23,15 @@ export const customersService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
create: async (payload: CreateCustomerDto): Promise<Customer> => {
|
getByUserId: async (userId: string): Promise<Customer> => {
|
||||||
const response = await client.post<ApiResponse<Customer>>(BASE, payload);
|
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);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -133,5 +133,5 @@ export const getFileUploadSettingByCode = endpoint<string, FileUploadSetting>(
|
|||||||
.get<
|
.get<
|
||||||
ApiResponse<FileUploadSetting>
|
ApiResponse<FileUploadSetting>
|
||||||
>(`${URL_CONSTANTS.FILES.FILE_UPLOAD_SETTINGS_BY_CODE}/${code}`)
|
>(`${URL_CONSTANTS.FILES.FILE_UPLOAD_SETTINGS_BY_CODE}/${code}`)
|
||||||
.then((res) => res.data.data),
|
.then((res: any) => res.data.data),
|
||||||
);
|
);
|
||||||
|
|||||||
10
apps/edr-freight-web/portal/src/types/createUser.ts
Normal file
10
apps/edr-freight-web/portal/src/types/createUser.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export type CreateUserPayload = {
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
userType: string;
|
||||||
|
name: {
|
||||||
|
en: string;
|
||||||
|
am?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export type VerificationCodePayload = {
|
||||||
|
email: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
10
apps/edr-freight-web/portal/src/types/userTypeRequest.ts
Normal file
10
apps/edr-freight-web/portal/src/types/userTypeRequest.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export type UserTypeRequest {
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
userType: string;
|
||||||
|
name: {
|
||||||
|
am?: string;
|
||||||
|
en: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
UseQueryOptions,
|
UseQueryOptions,
|
||||||
|
UseMutationOptions
|
||||||
} from "@tanstack/react-query";
|
} 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 {
|
return {
|
||||||
call,
|
call,
|
||||||
queryKey,
|
queryKey,
|
||||||
queryOptions,
|
queryOptions,
|
||||||
|
mutationOptions
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
515
pnpm-lock.yaml
generated
515
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user