mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
company detail api and frontend
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
// src/modules/customers/customers.controller.ts
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
@@ -9,33 +10,54 @@ import {
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Body,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { ApiOperation } from "@nestjs/swagger";
|
||||
|
||||
import { CustomersService } from "./customers.service";
|
||||
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
||||
import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
||||
import { Customer } from "./entities/customer.entity";
|
||||
|
||||
@ApiTags("customers")
|
||||
@Controller("customers")
|
||||
export class CustomersController {
|
||||
constructor(private readonly customersService: CustomersService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: "Create a new customer" })
|
||||
create(@Body() dto: CreateCustomerDto) {
|
||||
return this.customersService.create(dto);
|
||||
create(@Body() createCustomerDto: CreateCustomerDto): Promise<Customer> {
|
||||
return this.customersService.create(createCustomerDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List all customers" })
|
||||
findAll() {
|
||||
findAll(): Promise<Customer[]> {
|
||||
return this.customersService.findAll();
|
||||
}
|
||||
|
||||
@Get("stats")
|
||||
@ApiOperation({ summary: "Get customer statistics" })
|
||||
getStats(): Promise<{ total: number; withVatNumber: number }> {
|
||||
return this.customersService.getStats();
|
||||
}
|
||||
|
||||
@Get("search")
|
||||
searchByName(@Query("name") name: string): Promise<Customer[]> {
|
||||
return this.customersService.searchByName(name);
|
||||
}
|
||||
|
||||
@Get("email/:email")
|
||||
findByEmail(@Param("email") email: string): Promise<Customer> {
|
||||
return this.customersService.findByEmail(email);
|
||||
}
|
||||
|
||||
@Get("vat/:vatNumber")
|
||||
findByVatNumber(@Param("vatNumber") vatNumber: string): Promise<Customer> {
|
||||
return this.customersService.findByVatNumber(vatNumber);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "Get a customer by ID" })
|
||||
findOne(@Param("id", ParseUUIDPipe) id: string) {
|
||||
findById(@Param("id", ParseUUIDPipe) id: string): Promise<Customer> {
|
||||
return this.customersService.findById(id);
|
||||
}
|
||||
|
||||
@@ -44,14 +66,14 @@ export class CustomersController {
|
||||
update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateCustomerDto,
|
||||
) {
|
||||
): Promise<Customer> {
|
||||
return this.customersService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@ApiOperation({ summary: "Soft-delete a customer" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.customersService.remove(id);
|
||||
remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
|
||||
return this.customersService.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,110 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
// import { BaseRepository } from "@edr/api-common";
|
||||
// import { EntityRepository } from "typeorm";
|
||||
|
||||
// src/modules/customers/customers.repository.ts
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { Repository, FindManyOptions, FindOptionsWhere } from "typeorm";
|
||||
import { Customer } from "./entities/customer.entity";
|
||||
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
||||
// import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
||||
|
||||
@Injectable()
|
||||
export class CustomersRepository extends BaseRepository<Customer> {
|
||||
export class CustomersRepository {
|
||||
constructor(
|
||||
@InjectRepository(Customer)
|
||||
repository: Repository<Customer>,
|
||||
) {
|
||||
super(repository);
|
||||
private readonly repository: Repository<Customer>,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateCustomerDto): Promise<Customer> {
|
||||
const customer = this.repository.create(dto);
|
||||
return await this.repository.save(customer);
|
||||
}
|
||||
|
||||
/** Find a customer by their unique email. */
|
||||
findByEmail(email: string): Promise<Customer | null> {
|
||||
return this.repository.findOne({ where: { email } });
|
||||
async findAll(options?: FindManyOptions<Customer>): Promise<Customer[]> {
|
||||
return await this.repository.find(options);
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Customer | null> {
|
||||
return await this.repository.findOne({ where: { id } as FindOptionsWhere<Customer> });
|
||||
}
|
||||
|
||||
async 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;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
|
||||
import { CustomersRepository } from "./customers.repository";
|
||||
@@ -13,34 +14,87 @@ import { Customer } from "./entities/customer.entity";
|
||||
export class CustomersService {
|
||||
constructor(private readonly customersRepository: CustomersRepository) {}
|
||||
|
||||
/** Create a new customer */
|
||||
async create(dto: CreateCustomerDto): Promise<Customer> {
|
||||
const existing = await this.customersRepository.findByEmail(dto.email);
|
||||
if (existing) {
|
||||
const exists = await this.customersRepository.existsByUniqueFields(
|
||||
dto.email,
|
||||
dto.vatNumber,
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
throw new ConflictException(
|
||||
`Customer with email "${dto.email}" already exists`,
|
||||
"Customer with same email or VAT number already exists",
|
||||
);
|
||||
}
|
||||
|
||||
if (dto.vatNumber && dto.vatNumber.length !== 10) {
|
||||
throw new BadRequestException("VAT number must be exactly 10 digits");
|
||||
}
|
||||
|
||||
return this.customersRepository.create(dto);
|
||||
}
|
||||
|
||||
/** Get all customers */
|
||||
findAll(): Promise<Customer[]> {
|
||||
return this.customersRepository.findAll({ order: { name: "ASC" } });
|
||||
return this.customersRepository.findAll({
|
||||
order: { companyName: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Get customer by ID */
|
||||
async findById(id: string): Promise<Customer> {
|
||||
const customer = await this.customersRepository.findById(id);
|
||||
|
||||
if (!customer) {
|
||||
throw new NotFoundException(`Customer ${id} not found`);
|
||||
throw new NotFoundException(`Customer with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
/** Get customer by email */
|
||||
async findByEmail(email: string): Promise<Customer> {
|
||||
const customer = await this.customersRepository.findByEmail(email);
|
||||
|
||||
if (!customer) {
|
||||
throw new NotFoundException(`Customer with email ${email} not found`);
|
||||
}
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
/** Get customer by VAT number */
|
||||
async findByVatNumber(vatNumber: string): Promise<Customer> {
|
||||
const customer = await this.customersRepository.findByVatNumber(vatNumber);
|
||||
|
||||
if (!customer) {
|
||||
throw new NotFoundException(
|
||||
`Customer with VAT number ${vatNumber} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
/** Search customers by name */
|
||||
searchByName(name: string): Promise<Customer[]> {
|
||||
return this.customersRepository.findByName(name);
|
||||
}
|
||||
|
||||
/** Update customer */
|
||||
async update(id: string, dto: UpdateCustomerDto): Promise<Customer> {
|
||||
await this.findById(id);
|
||||
|
||||
// Validate VAT number if provided
|
||||
if (dto.vatNumber && dto.vatNumber.length !== 10) {
|
||||
throw new BadRequestException("VAT number must be exactly 10 digits");
|
||||
}
|
||||
|
||||
// Check email conflict
|
||||
if (dto.email) {
|
||||
const conflict = await this.customersRepository.findByEmail(dto.email);
|
||||
if (conflict && conflict.id !== id) {
|
||||
const existing = await this.customersRepository.findByEmail(dto.email);
|
||||
|
||||
if (existing && existing.userId !== id) {
|
||||
throw new ConflictException(
|
||||
`Customer with email "${dto.email}" already exists`,
|
||||
);
|
||||
@@ -48,14 +102,25 @@ export class CustomersService {
|
||||
}
|
||||
|
||||
const updated = await this.customersRepository.update(id, dto);
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Customer ${id} not found`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Delete customer (soft delete) */
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.customersRepository.softDelete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get customer statistics */
|
||||
async getStats(): Promise<{ total: number; withVatNumber: number }> {
|
||||
const total = await this.customersRepository.count();
|
||||
const withVatNumber = await this.customersRepository.countWithVatNumber();
|
||||
|
||||
return { total, withVatNumber };
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,12 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
IsNotEmpty,
|
||||
Length,
|
||||
Matches,
|
||||
} from "class-validator";
|
||||
|
||||
// Enums
|
||||
export enum CustomerStatusDto {
|
||||
Active = "Active",
|
||||
Pending = "Pending",
|
||||
@@ -18,23 +22,53 @@ export enum CustomerTypeDto {
|
||||
Supplier = "Supplier",
|
||||
}
|
||||
|
||||
// DTO
|
||||
export class CreateCustomerDto {
|
||||
// Basic identity
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
name!: string;
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
firstName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
lastName!: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
phone!: string;
|
||||
|
||||
@IsOptional()
|
||||
// Company info
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
company?: string;
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
companyName!: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
companyEmail!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
companyPhone!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
companyLocation!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
companyAddress!: string;
|
||||
|
||||
// Classification
|
||||
@IsOptional()
|
||||
@IsEnum(CustomerTypeDto)
|
||||
customerType?: CustomerTypeDto;
|
||||
@@ -43,31 +77,76 @@ export class CreateCustomerDto {
|
||||
@IsEnum(CustomerStatusDto)
|
||||
status?: CustomerStatusDto;
|
||||
|
||||
// Legal identifiers
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(10, 10)
|
||||
@Matches(/^\d+$/, { message: "TIN must contain only digits" })
|
||||
tinNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(16, 16)
|
||||
@Matches(/^\d+$/, { message: "FAN must contain only digits" })
|
||||
fanNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(50)
|
||||
vatNumber!: string;
|
||||
|
||||
// Contact person
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
contactPersonName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
contactPersonPhone!: string;
|
||||
|
||||
// Management
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
generalManagerName!: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
generalManagerEmail!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
generalManagerPhone!: string;
|
||||
|
||||
// POA (Power of Attorney)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
tinNumber?: string;
|
||||
@MaxLength(100)
|
||||
poaName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
city?: string;
|
||||
@MaxLength(20)
|
||||
poaPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
country?: string;
|
||||
poaAddress?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
poaEmail?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
taxId?: string;
|
||||
@MaxLength(100)
|
||||
poaLocation?: string;
|
||||
|
||||
// Extra
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// src/modules/customers/dto/response-customer.dto.ts
|
||||
import { Customer } from '../entities/customer.entity';
|
||||
|
||||
export class ResponseCustomerDto {
|
||||
UserId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
companyName: string;
|
||||
companyEmail: string;
|
||||
companyPhone: string;
|
||||
companyLocation: string;
|
||||
companyAddress: string;
|
||||
contactPersonName: string;
|
||||
contactPersonPhone: string;
|
||||
tinNumber: string;
|
||||
vatNumber: string;
|
||||
fanNumber: string;
|
||||
generalManagerName: string;
|
||||
generalManagerEmail: string;
|
||||
generalManagerPhone: string;
|
||||
poaName?: string;
|
||||
poaPhone?: string;
|
||||
poaAddress?: string;
|
||||
poaEmail?: string;
|
||||
poaLocation?: string;
|
||||
notes?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(customer: Customer) {
|
||||
this.UserId = customer.userId;
|
||||
this.firstName = customer.firstName;
|
||||
this.lastName = customer.lastName;
|
||||
this.email = customer.email;
|
||||
this.phone = customer.phone;
|
||||
this.companyName = customer.companyName;
|
||||
this.companyEmail = customer.companyEmail;
|
||||
this.companyPhone = customer.companyPhone;
|
||||
this.companyLocation = customer.companyLocation;
|
||||
this.companyAddress = customer.companyAddress;
|
||||
this.contactPersonName = customer.contactPersonName;
|
||||
this.contactPersonPhone = customer.contactPersonPhone;
|
||||
this.tinNumber = customer.tinNumber;
|
||||
this.vatNumber = customer.vatNumber;
|
||||
this.fanNumber = customer.fanNumber;
|
||||
this.generalManagerName = customer.generalManagerName;
|
||||
this.generalManagerEmail = customer.generalManagerEmail;
|
||||
this.generalManagerPhone = customer.generalManagerPhone;
|
||||
this.poaName = customer.poaName ?? '';
|
||||
this.poaPhone = customer.poaPhone ?? '';
|
||||
this.poaAddress = customer.poaAddress ?? '';
|
||||
this.poaEmail = customer.poaEmail ?? '';
|
||||
this.poaLocation = customer.poaLocation ?? '';
|
||||
this.notes = customer.notes ?? '';
|
||||
this.createdAt = customer.createdAt;
|
||||
this.updatedAt = customer.updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { PartialType } from "@nestjs/swagger";
|
||||
// src/modules/customers/dto/update-customer.dto.ts
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateCustomerDto } from './create-customer.dto';
|
||||
|
||||
import { CreateCustomerDto } from "./create-customer.dto";
|
||||
|
||||
export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {}
|
||||
export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {
|
||||
email?: string;
|
||||
vatNumber?: string;
|
||||
// Add any other properties you need to access directly
|
||||
}
|
||||
|
||||
@@ -1,54 +1,94 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity } from "typeorm";
|
||||
|
||||
export type CustomerStatus = "Active" | "Pending" | "Inactive";
|
||||
export type CustomerType = "Importer" | "Exporter" | "Supplier";
|
||||
import { Column, Entity ,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from "typeorm";
|
||||
|
||||
@Entity({schema:"freight", name: "customers" })
|
||||
export class Customer extends BaseEntity {
|
||||
@Column({ name: "name", type: "varchar", length: 256 })
|
||||
name!: string;
|
||||
@Entity("customers")
|
||||
export class Customer {
|
||||
@PrimaryGeneratedColumn("uuid")
|
||||
userId!: string;
|
||||
|
||||
@Column({ name: "email", type: "varchar", length: 256, unique: true })
|
||||
@Column({ name: "firstName", length: 100 })
|
||||
@Index()
|
||||
firstName!: string;
|
||||
|
||||
@Column({ name: "lastName", length: 100 })
|
||||
@Index()
|
||||
lastName!: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
@Index()
|
||||
email!: string;
|
||||
|
||||
@Column({ name: "phone", type: "varchar", length: 32 })
|
||||
@Column({ length: 20 })
|
||||
phone!: string;
|
||||
|
||||
@Column({ name: "company", type: "varchar", length: 256, nullable: true })
|
||||
company?: string | null;
|
||||
@Column({ name: "companyName", length: 200 })
|
||||
@Index()
|
||||
companyName!: string;
|
||||
|
||||
@Column({
|
||||
name: "customer_type",
|
||||
type: "varchar",
|
||||
length: 32,
|
||||
default: "Importer",
|
||||
})
|
||||
customerType!: CustomerType;
|
||||
@Column({ name: "companyEmail" })
|
||||
companyEmail!: string;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "varchar",
|
||||
length: 32,
|
||||
default: "Active",
|
||||
})
|
||||
status!: CustomerStatus;
|
||||
@Column({ name: "companyPhone", length: 20 })
|
||||
companyPhone!: string;
|
||||
|
||||
@Column({ name: "tin_number", type: "varchar", length: 64, nullable: true })
|
||||
tinNumber?: string | null;
|
||||
@Column({ name: "companyLocation", length: 100 })
|
||||
companyLocation!: string;
|
||||
|
||||
@Column({ name: "city", type: "varchar", length: 128, nullable: true })
|
||||
city?: string | null;
|
||||
@Column({ name: "companyAddress", type: "text" })
|
||||
companyAddress!: string;
|
||||
|
||||
@Column({ name: "country", type: "varchar", length: 128, nullable: true })
|
||||
country?: string | null;
|
||||
@Column({ name: "contactPersonName", length: 100 })
|
||||
contactPersonName!: string;
|
||||
|
||||
@Column({ name: "address", type: "text", nullable: true })
|
||||
address?: string | null;
|
||||
@Column({ name: "contactPersonPhone", length: 20 })
|
||||
contactPersonPhone!: string;
|
||||
|
||||
@Column({ name: "tax_id", type: "varchar", length: 64, nullable: true })
|
||||
taxId?: string | null;
|
||||
@Column({ name: "tinNumber", length: 10, unique: true })
|
||||
@Index()
|
||||
tinNumber!: string;
|
||||
|
||||
@Column({ name: "notes", type: "text", nullable: true })
|
||||
notes?: string | null;
|
||||
@Column({ name: "vatNumber", length: 50 })
|
||||
vatNumber!: string;
|
||||
|
||||
@Column({ name: "fanNumber", length: 16, unique: true })
|
||||
@Index()
|
||||
fanNumber!: string;
|
||||
|
||||
@Column({ name: "generalManagerName", length: 100 })
|
||||
generalManagerName!: string;
|
||||
|
||||
@Column({ name: "generalManagerEmail" })
|
||||
generalManagerEmail!: string;
|
||||
|
||||
@Column({ name: "generalManagerPhone", length: 20 })
|
||||
generalManagerPhone!: string;
|
||||
|
||||
@Column({ name: "poaName", length: 100, nullable: true })
|
||||
poaName?: string;
|
||||
|
||||
@Column({ name: "poaPhone", length: 20, nullable: true })
|
||||
poaPhone?: string;
|
||||
|
||||
@Column({ name: "poaAddress", type: "text", nullable: true })
|
||||
poaAddress?: string;
|
||||
|
||||
@Column({ name: "poaEmail", nullable: true })
|
||||
poaEmail?: string;
|
||||
|
||||
@Column({ name: "poaLocation", length: 100, nullable: true })
|
||||
poaLocation?: string;
|
||||
|
||||
@Column({ type: "text", nullable: true })
|
||||
notes?: string;
|
||||
|
||||
@CreateDateColumn({ name: "createdAt" })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: "updatedAt" })
|
||||
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;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export const FILE_SETTINGS = {
|
||||
CUSTOMER_REGISTRATION: "customer_registration"
|
||||
CUSTOMER_REGISTRATION: "customer_registration",
|
||||
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
import {
|
||||
Building2,
|
||||
Mail,
|
||||
Phone,
|
||||
User,
|
||||
Globe,
|
||||
MapPin,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface CustomerFormData {
|
||||
companyName?: string;
|
||||
customerType?: string;
|
||||
contactPerson?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
tinNumber?: string;
|
||||
city?: string;
|
||||
country?: string;
|
||||
address?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface NewCustomerPageProps {
|
||||
mode?: "create" | "edit";
|
||||
customer?: CustomerFormData;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export default function NewCustomerPage({
|
||||
mode = "create",
|
||||
customer,
|
||||
children,
|
||||
}: NewCustomerPageProps = {}) {
|
||||
const isEdit = mode === "edit";
|
||||
const title = isEdit ? "Edit Customer" : "New Customer";
|
||||
const description = isEdit
|
||||
? "Update existing customer information."
|
||||
: "Create and manage customer information.";
|
||||
const submitLabel = isEdit ? "Save Changes" : "Create Customer";
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
||||
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
||||
{/* Company Name */}
|
||||
<div className="space-y-2">
|
||||
<Label>Company Name *</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Building2 className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
|
||||
<Input
|
||||
defaultValue={customer?.companyName ?? ""}
|
||||
placeholder="Enter company name"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer Type */}
|
||||
<div className="space-y-2">
|
||||
<Label>Customer Type *</Label>
|
||||
|
||||
<select
|
||||
defaultValue={customer?.customerType ?? "Importer"}
|
||||
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
|
||||
>
|
||||
<option>Importer</option>
|
||||
<option>Exporter</option>
|
||||
<option>Supplier</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Contact Person */}
|
||||
<div className="space-y-2">
|
||||
<Label>Contact Person</Label>
|
||||
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
|
||||
<Input
|
||||
defaultValue={customer?.contactPerson ?? ""}
|
||||
placeholder="Enter contact person"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<Label>Email *</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
defaultValue={customer?.email ?? ""}
|
||||
placeholder="Enter email"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div className="space-y-2">
|
||||
<Label>Phone</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
|
||||
<Input
|
||||
defaultValue={customer?.phone ?? ""}
|
||||
placeholder="Enter phone"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TIN */}
|
||||
<div className="space-y-2">
|
||||
<Label>TIN Number</Label>
|
||||
|
||||
<div className="relative">
|
||||
<FileText className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
|
||||
<Input
|
||||
defaultValue={customer?.tinNumber ?? ""}
|
||||
placeholder="Enter TIN number"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* City */}
|
||||
<div className="space-y-2">
|
||||
<Label>City</Label>
|
||||
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
|
||||
<Input
|
||||
defaultValue={customer?.city ?? ""}
|
||||
placeholder="Enter city"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Country */}
|
||||
<div className="space-y-2">
|
||||
<Label>Country</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Globe className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
|
||||
<Input
|
||||
defaultValue={customer?.country ?? ""}
|
||||
placeholder="Enter country"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Address</Label>
|
||||
|
||||
<Textarea
|
||||
defaultValue={customer?.address ?? ""}
|
||||
placeholder="Enter address"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Notes</Label>
|
||||
|
||||
<Textarea
|
||||
defaultValue={customer?.notes ?? ""}
|
||||
placeholder="Additional notes..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline">Cancel</Button>
|
||||
|
||||
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,19 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Input,
|
||||
Label,
|
||||
Button,
|
||||
Textarea,
|
||||
SmartFileInput,
|
||||
} from "@edr/ui-common";
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
import {
|
||||
Building2,
|
||||
@@ -25,360 +23,593 @@ import {
|
||||
Globe,
|
||||
MapPin,
|
||||
FileText,
|
||||
CreditCard,
|
||||
Briefcase,
|
||||
Users,
|
||||
UserCircle,
|
||||
StickyNote,
|
||||
} from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
|
||||
import {
|
||||
useCreateCustomer,
|
||||
useUpdateCustomer,
|
||||
} from "@/hooks/useCustomers";
|
||||
import { getFileUploadSettingByCode } from "@/services/fileUploadSettings.service";
|
||||
import { FILE_SETTINGS } from "@/constants/FILE_SETTINGS";
|
||||
import type {
|
||||
CreateCustomerDto,
|
||||
Customer,
|
||||
CustomerStatus,
|
||||
CustomerType,
|
||||
} from "@/types/customers";
|
||||
|
||||
const CUSTOMER_TYPES: CustomerType[] = ["Importer", "Exporter", "Supplier"];
|
||||
const CUSTOMER_STATUSES: CustomerStatus[] = ["Active", "Pending", "Inactive"];
|
||||
export interface CustomerFormData {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
companyName: string;
|
||||
companyEmail: string;
|
||||
companyPhone: string;
|
||||
companyLocation: string;
|
||||
companyAddress: string;
|
||||
contactPersonName: string;
|
||||
contactPersonPhone: string;
|
||||
tinNumber: string;
|
||||
vatNumber: string;
|
||||
fanNumber: string;
|
||||
generalManagerName: string;
|
||||
generalManagerEmail: string;
|
||||
generalManagerPhone: string;
|
||||
poaName?: string;
|
||||
poaPhone?: string;
|
||||
poaAddress?: string;
|
||||
poaEmail?: string;
|
||||
poaLocation?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface NewCustomerPageProps {
|
||||
mode?: "create" | "edit";
|
||||
customer?: Customer;
|
||||
customer?: Partial<CustomerFormData>;
|
||||
children?: ReactNode;
|
||||
/** Controlled open. When omitted, the dialog manages its own open state. */
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
company: string;
|
||||
customerType: CustomerType;
|
||||
status: CustomerStatus;
|
||||
tinNumber: string;
|
||||
city: string;
|
||||
country: string;
|
||||
address: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
company: "",
|
||||
customerType: "Importer",
|
||||
status: "Active",
|
||||
tinNumber: "",
|
||||
city: "",
|
||||
country: "",
|
||||
address: "",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
const fromCustomer = (c: Customer): FormState => ({
|
||||
name: c.name ?? "",
|
||||
email: c.email ?? "",
|
||||
phone: c.phone ?? "",
|
||||
company: c.company ?? "",
|
||||
customerType: c.customerType ?? "Importer",
|
||||
status: c.status ?? "Active",
|
||||
tinNumber: c.tinNumber ?? "",
|
||||
city: c.city ?? "",
|
||||
country: c.country ?? "",
|
||||
address: c.address ?? "",
|
||||
notes: c.notes ?? "",
|
||||
});
|
||||
|
||||
export default function NewCustomerPage({
|
||||
mode = "create",
|
||||
customer,
|
||||
children,
|
||||
open: openProp,
|
||||
onOpenChange,
|
||||
}: NewCustomerPageProps = {}) {
|
||||
const isEdit = mode === "edit";
|
||||
const isControlled = openProp !== undefined;
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const open = isControlled ? openProp : internalOpen;
|
||||
const setOpen = (next: boolean) => {
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
|
||||
const [form, setForm] = useState<FormState>(
|
||||
customer ? fromCustomer(customer) : emptyForm(),
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [files, setFiles] = useState<Record<string, File | File[] | null>>({});
|
||||
|
||||
// Reset form whenever the dialog opens with a different customer.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setForm(customer ? fromCustomer(customer) : emptyForm());
|
||||
setError(null);
|
||||
}
|
||||
}, [open, customer]);
|
||||
|
||||
const { data: customerRegistrationFiles } = useQuery(
|
||||
getFileUploadSettingByCode.queryOptions({
|
||||
input: FILE_SETTINGS.CUSTOMER_REGISTRATION,
|
||||
}),
|
||||
);
|
||||
|
||||
const createMutation = useCreateCustomer();
|
||||
const updateMutation = useUpdateCustomer();
|
||||
const pending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const handleSubmit = () => {
|
||||
setError(null);
|
||||
|
||||
if (!form.name.trim() || !form.email.trim() || !form.phone.trim()) {
|
||||
setError("Name, email, and phone are required.");
|
||||
return;
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
|
||||
setError("Please enter a valid email address.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: CreateCustomerDto = {
|
||||
name: form.name.trim(),
|
||||
email: form.email.trim(),
|
||||
phone: form.phone.trim(),
|
||||
customerType: form.customerType,
|
||||
status: form.status,
|
||||
company: form.company.trim() || undefined,
|
||||
tinNumber: form.tinNumber.trim() || undefined,
|
||||
city: form.city.trim() || undefined,
|
||||
country: form.country.trim() || undefined,
|
||||
address: form.address.trim() || undefined,
|
||||
notes: form.notes.trim() || undefined,
|
||||
};
|
||||
|
||||
const onDone = () => {
|
||||
setOpen(false);
|
||||
if (!isEdit) setForm(emptyForm());
|
||||
};
|
||||
const onError = (err: unknown) => {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Something went wrong. Try again.",
|
||||
);
|
||||
};
|
||||
|
||||
if (isEdit && customer) {
|
||||
updateMutation.mutate(
|
||||
{ id: customer.id, dto: payload },
|
||||
{ onSuccess: onDone, onError },
|
||||
);
|
||||
} else {
|
||||
createMutation.mutate(payload, { onSuccess: onDone, onError });
|
||||
}
|
||||
};
|
||||
|
||||
const title = isEdit ? "Edit Customer" : "New Customer";
|
||||
const description = isEdit
|
||||
? "Update existing customer information."
|
||||
: "Create and manage customer information.";
|
||||
const submitLabel = isEdit ? "Save Changes" : "Create Customer";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
{!isControlled ? (
|
||||
<DialogTrigger asChild>
|
||||
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
|
||||
</DialogTrigger>
|
||||
) : null}
|
||||
const [formData, setFormData] = useState<CustomerFormData>({
|
||||
firstName: customer?.firstName ?? "",
|
||||
lastName: customer?.lastName ?? "",
|
||||
email: customer?.email ?? "",
|
||||
phone: customer?.phone ?? "",
|
||||
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 ?? "",
|
||||
});
|
||||
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl!">
|
||||
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}`;
|
||||
|
||||
alert(apiUrl);
|
||||
const response = await fetch(apiUrl, {
|
||||
method: isEdit ? 'PATCH' : 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || `Failed to ${isEdit ? 'update' : 'create'} customer`);
|
||||
}
|
||||
|
||||
console.log(`Customer ${isEdit ? 'updated' : 'created'}:`, data);
|
||||
alert(`Customer ${isEdit ? 'updated' : 'created'} successfully!`);
|
||||
|
||||
// Close dialog or reset form here if needed
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert(error instanceof Error ? error.message : `Failed to ${isEdit ? 'update' : 'create'} customer`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl rounded-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
||||
<Field label="Company Name">
|
||||
<Building2 className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={form.company}
|
||||
onChange={(e) => set("company", e.target.value)}
|
||||
placeholder="Enter company name"
|
||||
className="pl-10"
|
||||
/>
|
||||
</Field>
|
||||
{/* Personal Information Section */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<User className="h-5 w-5 text-[#10B981]" />
|
||||
<h3 className="font-semibold text-lg">Personal Information</h3>
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{/* First Name */}
|
||||
<div className="space-y-2">
|
||||
<Label>First Name <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
name="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter first name"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Customer Type *</Label>
|
||||
<select
|
||||
value={form.customerType}
|
||||
onChange={(e) =>
|
||||
set("customerType", e.target.value as CustomerType)
|
||||
}
|
||||
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
|
||||
>
|
||||
{CUSTOMER_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Last Name */}
|
||||
<div className="space-y-2">
|
||||
<Label>Last Name <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
name="lastName"
|
||||
value={formData.lastName}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter last name"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<Label>Email <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter email"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div className="space-y-2">
|
||||
<Label>Phone <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter phone number"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="Contact Person *">
|
||||
<User className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
placeholder="Enter contact person"
|
||||
className="pl-10"
|
||||
/>
|
||||
</Field>
|
||||
{/* Company Information Section */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-3 mt-2">
|
||||
<Building2 className="h-5 w-5 text-[#10B981]" />
|
||||
<h3 className="font-semibold text-lg">Company Information</h3>
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{/* Company Name */}
|
||||
<div className="space-y-2">
|
||||
<Label>Company Name <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<Building2 className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
name="companyName"
|
||||
value={formData.companyName}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter company name"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="Email *">
|
||||
<Mail className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => set("email", e.target.value)}
|
||||
placeholder="Enter email"
|
||||
className="pl-10"
|
||||
/>
|
||||
</Field>
|
||||
{/* Company Email */}
|
||||
<div className="space-y-2">
|
||||
<Label>Company Email <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="email"
|
||||
name="companyEmail"
|
||||
value={formData.companyEmail}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter company email"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="Phone *">
|
||||
<Phone className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={form.phone}
|
||||
onChange={(e) => set("phone", e.target.value)}
|
||||
placeholder="Enter phone"
|
||||
className="pl-10"
|
||||
/>
|
||||
</Field>
|
||||
{/* Company Phone */}
|
||||
<div className="space-y-2">
|
||||
<Label>Company Phone <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
name="companyPhone"
|
||||
value={formData.companyPhone}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter company phone"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="TIN Number">
|
||||
<FileText className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={form.tinNumber}
|
||||
onChange={(e) => set("tinNumber", e.target.value)}
|
||||
placeholder="Enter TIN number"
|
||||
className="pl-10"
|
||||
/>
|
||||
</Field>
|
||||
{/* Company Location */}
|
||||
<div className="space-y-2">
|
||||
<Label>Company Location <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
name="companyLocation"
|
||||
value={formData.companyLocation}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter company location"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="City">
|
||||
<MapPin className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={form.city}
|
||||
onChange={(e) => set("city", e.target.value)}
|
||||
placeholder="Enter city"
|
||||
className="pl-10"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Country">
|
||||
<Globe className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={form.country}
|
||||
onChange={(e) => set("country", e.target.value)}
|
||||
placeholder="Enter country"
|
||||
className="pl-10"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Status</Label>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={(e) => set("status", e.target.value as CustomerStatus)}
|
||||
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
|
||||
>
|
||||
{CUSTOMER_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Company Address */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Company Address <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Textarea
|
||||
name="companyAddress"
|
||||
value={formData.companyAddress}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter company address"
|
||||
className="pl-10 resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Address</Label>
|
||||
{/* Tax & Registration Section */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-3 mt-2">
|
||||
<CreditCard className="h-5 w-5 text-[#10B981]" />
|
||||
<h3 className="font-semibold text-lg">Tax & Registration Numbers</h3>
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
{/* TIN Number */}
|
||||
<div className="space-y-2">
|
||||
<Label>TIN Number <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
name="tinNumber"
|
||||
value={formData.tinNumber}
|
||||
onChange={handleChange}
|
||||
placeholder="10-digit TIN"
|
||||
maxLength={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* VAT Number */}
|
||||
<div className="space-y-2">
|
||||
<Label>VAT Number <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
name="vatNumber"
|
||||
value={formData.vatNumber}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter VAT number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* FAN Number */}
|
||||
<div className="space-y-2">
|
||||
<Label>FAN Number <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
name="fanNumber"
|
||||
value={formData.fanNumber}
|
||||
onChange={handleChange}
|
||||
placeholder="16-digit FAN"
|
||||
maxLength={16}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* General Manager Section */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-3 mt-2">
|
||||
<Briefcase className="h-5 w-5 text-[#10B981]" />
|
||||
<h3 className="font-semibold text-lg">General Manager</h3>
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{/* General Manager Name */}
|
||||
<div className="space-y-2">
|
||||
<Label>General Manager Name <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<UserCircle className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
name="generalManagerName"
|
||||
value={formData.generalManagerName}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter general manager name"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* General Manager Email */}
|
||||
<div className="space-y-2">
|
||||
<Label>General Manager Email <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="email"
|
||||
name="generalManagerEmail"
|
||||
value={formData.generalManagerEmail}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter general manager email"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* General Manager Phone */}
|
||||
<div className="space-y-2">
|
||||
<Label>General Manager Phone <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
name="generalManagerPhone"
|
||||
value={formData.generalManagerPhone}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter general manager phone"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Person Section */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-3 mt-2">
|
||||
<Users className="h-5 w-5 text-[#10B981]" />
|
||||
<h3 className="font-semibold text-lg">Contact Person</h3>
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{/* Contact Person Name */}
|
||||
<div className="space-y-2">
|
||||
<Label>Contact Person Name <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
name="contactPersonName"
|
||||
value={formData.contactPersonName}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter contact person name"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Person Phone */}
|
||||
<div className="space-y-2">
|
||||
<Label>Contact Person Phone <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
name="contactPersonPhone"
|
||||
value={formData.contactPersonPhone}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter contact person phone"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Power of Attorney Section (Optional) */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-3 mt-2">
|
||||
<FileText className="h-5 w-5 text-[#10B981]" />
|
||||
<h3 className="font-semibold text-lg">Power of Attorney (Optional)</h3>
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{/* POA Name */}
|
||||
<div className="space-y-2">
|
||||
<Label>PoA Name</Label>
|
||||
<Input
|
||||
name="poaName"
|
||||
value={formData.poaName ?? ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter PoA name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* POA Phone */}
|
||||
<div className="space-y-2">
|
||||
<Label>PoA Phone</Label>
|
||||
<Input
|
||||
name="poaPhone"
|
||||
value={formData.poaPhone ?? ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter PoA phone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* POA Email */}
|
||||
<div className="space-y-2">
|
||||
<Label>PoA Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
name="poaEmail"
|
||||
value={formData.poaEmail ?? ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter PoA email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* POA Location */}
|
||||
<div className="space-y-2">
|
||||
<Label>PoA Location</Label>
|
||||
<Input
|
||||
name="poaLocation"
|
||||
value={formData.poaLocation ?? ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter PoA location"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* POA Address */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>PoA Address</Label>
|
||||
<Textarea
|
||||
name="poaAddress"
|
||||
value={formData.poaAddress ?? ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter PoA address"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes Section */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-3 mt-2">
|
||||
<StickyNote className="h-5 w-5 text-[#10B981]" />
|
||||
<h3 className="font-semibold text-lg">Additional Notes</h3>
|
||||
</div>
|
||||
<Textarea
|
||||
value={form.address}
|
||||
onChange={(e) => set("address", e.target.value)}
|
||||
placeholder="Enter address"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Notes</Label>
|
||||
<Textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => set("notes", e.target.value)}
|
||||
placeholder="Additional notes..."
|
||||
name="notes"
|
||||
value={formData.notes ?? ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Add any additional notes about the customer..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{customerRegistrationFiles ? (
|
||||
<div>
|
||||
<SmartFileInput
|
||||
file={customerRegistrationFiles}
|
||||
value={files}
|
||||
onChange={setFiles}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="button"
|
||||
<div className="flex justify-end gap-3 mt-4">
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button
|
||||
className="bg-[#10B981] text-white hover:bg-[#10B981]/90"
|
||||
onClick={handleSubmit}
|
||||
disabled={pending}
|
||||
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
submitLabel
|
||||
)}
|
||||
{isSubmitting ? "Submitting..." : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{label}</Label>
|
||||
<div className="relative">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user