mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 17:45:42 +00:00
Merge pull request #18 from Tria-plc/feature/file_settings
customer registration api integration
This commit is contained in:
@@ -15,6 +15,7 @@ import { TrackingModule } from "./modules/tracking/tracking.module";
|
|||||||
import { BillingModule } from "./modules/billing/billing.module";
|
import { BillingModule } from "./modules/billing/billing.module";
|
||||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
||||||
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
||||||
|
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -37,6 +38,7 @@ import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-up
|
|||||||
BillingModule,
|
BillingModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
FileUploadSettingsModule,
|
FileUploadSettingsModule,
|
||||||
|
DropdownSettingsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements OnApplicationBootstrap {
|
export class AppModule implements OnApplicationBootstrap {
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
import { CustomersService } from "./customers.service";
|
import { CustomersService } from "./customers.service";
|
||||||
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
||||||
|
import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
||||||
|
|
||||||
@ApiTags("customers")
|
@ApiTags("customers")
|
||||||
@Controller("customers")
|
@Controller("customers")
|
||||||
@@ -33,4 +38,20 @@ export class CustomersController {
|
|||||||
findOne(@Param("id", ParseUUIDPipe) id: string) {
|
findOne(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.customersService.findById(id);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,32 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
|
||||||
import { CustomersRepository } from "./customers.repository";
|
import { CustomersRepository } from "./customers.repository";
|
||||||
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
||||||
|
import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
||||||
import { Customer } from "./entities/customer.entity";
|
import { Customer } from "./entities/customer.entity";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomersService {
|
export class CustomersService {
|
||||||
constructor(private readonly customersRepository: CustomersRepository) {}
|
constructor(private readonly customersRepository: CustomersRepository) {}
|
||||||
|
|
||||||
/** Create a new freight customer. */
|
async create(dto: CreateCustomerDto): Promise<Customer> {
|
||||||
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);
|
return this.customersRepository.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** List every customer (alphabetical). */
|
|
||||||
findAll(): Promise<Customer[]> {
|
findAll(): Promise<Customer[]> {
|
||||||
return this.customersRepository.findAll({ order: { name: "ASC" } });
|
return this.customersRepository.findAll({ order: { name: "ASC" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get a single customer by ID. */
|
|
||||||
async findById(id: string): Promise<Customer> {
|
async findById(id: string): Promise<Customer> {
|
||||||
const customer = await this.customersRepository.findById(id);
|
const customer = await this.customersRepository.findById(id);
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
@@ -26,4 +34,28 @@ export class CustomersService {
|
|||||||
}
|
}
|
||||||
return customer;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,73 @@
|
|||||||
import { IsEmail, IsOptional, IsString } from "class-validator";
|
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 {
|
export class CreateCustomerDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@MaxLength(256)
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
email!: string;
|
email!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@MaxLength(32)
|
||||||
phone!: string;
|
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()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
address?: string;
|
address?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
taxId?: string;
|
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) {}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { BaseEntity } from "@edr/api-common";
|
import { BaseEntity } from "@edr/api-common";
|
||||||
import { Column, Entity } from "typeorm";
|
import { Column, Entity } from "typeorm";
|
||||||
|
|
||||||
|
export type CustomerStatus = "Active" | "Pending" | "Inactive";
|
||||||
|
export type CustomerType = "Importer" | "Exporter" | "Supplier";
|
||||||
|
|
||||||
@Entity({ name: "customers" })
|
@Entity({ name: "customers" })
|
||||||
export class Customer extends BaseEntity {
|
export class Customer extends BaseEntity {
|
||||||
@Column({ name: "name", type: "varchar", length: 256 })
|
@Column({ name: "name", type: "varchar", length: 256 })
|
||||||
@@ -12,9 +15,40 @@ export class Customer extends BaseEntity {
|
|||||||
@Column({ name: "phone", type: "varchar", length: 32 })
|
@Column({ name: "phone", type: "varchar", length: 32 })
|
||||||
phone!: string;
|
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 })
|
@Column({ name: "address", type: "text", nullable: true })
|
||||||
address?: string | null;
|
address?: string | null;
|
||||||
|
|
||||||
@Column({ name: "tax_id", type: "varchar", length: 64, nullable: true })
|
@Column({ name: "tax_id", type: "varchar", length: 64, nullable: true })
|
||||||
taxId?: string | null;
|
taxId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "notes", type: "text", nullable: true })
|
||||||
|
notes?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
|
||||||
|
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
|
||||||
|
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
|
||||||
|
import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto";
|
||||||
|
import { DropdownSettingsService } from "./dropdown-settings.service";
|
||||||
|
|
||||||
|
@ApiTags("dropdown-settings")
|
||||||
|
@Controller("dropdown-settings")
|
||||||
|
export class DropdownSettingsController {
|
||||||
|
constructor(private readonly service: DropdownSettingsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: "List all dropdown settings" })
|
||||||
|
list() {
|
||||||
|
return this.service.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id")
|
||||||
|
@ApiOperation({ summary: "Get a dropdown setting by ID" })
|
||||||
|
getById(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.service.getById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("by-code/:code")
|
||||||
|
@ApiOperation({ summary: "Get a dropdown setting by its stable code" })
|
||||||
|
getByCode(@Param("code") code: string) {
|
||||||
|
return this.service.getByCode(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: "Create a new dropdown setting" })
|
||||||
|
create(@Body() dto: CreateDropdownSettingDto) {
|
||||||
|
return this.service.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id")
|
||||||
|
@ApiOperation({ summary: "Update a dropdown setting's metadata" })
|
||||||
|
update(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateDropdownSettingDto,
|
||||||
|
) {
|
||||||
|
return this.service.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":id")
|
||||||
|
@ApiOperation({ summary: "Soft-delete a dropdown setting" })
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.service.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------- option routes ------------------------- */
|
||||||
|
|
||||||
|
@Put(":id/options")
|
||||||
|
@ApiOperation({ summary: "Replace the full option list for a setting" })
|
||||||
|
replaceOptions(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Body() options: CreateDropdownOptionDto[],
|
||||||
|
) {
|
||||||
|
return this.service.replaceOptions(id, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/options")
|
||||||
|
@ApiOperation({ summary: "Append a single option to a setting" })
|
||||||
|
addOption(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: CreateDropdownOptionDto,
|
||||||
|
) {
|
||||||
|
return this.service.addOption(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("options/:optionId")
|
||||||
|
@ApiOperation({ summary: "Update a single option" })
|
||||||
|
updateOption(
|
||||||
|
@Param("optionId", ParseUUIDPipe) optionId: string,
|
||||||
|
@Body() dto: UpdateDropdownOptionDto,
|
||||||
|
) {
|
||||||
|
return this.service.updateOption(optionId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete("options/:optionId")
|
||||||
|
@ApiOperation({ summary: "Soft-delete a single option" })
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
removeOption(@Param("optionId", ParseUUIDPipe) optionId: string) {
|
||||||
|
return this.service.removeOption(optionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
|
||||||
|
import { DropdownOption } from "./entities/dropdown-option.entity";
|
||||||
|
import { DropdownSetting } from "./entities/dropdown-setting.entity";
|
||||||
|
import { DropdownSettingsController } from "./dropdown-settings.controller";
|
||||||
|
import { DropdownSettingsRepository } from "./dropdown-settings.repository";
|
||||||
|
import { DropdownSettingsService } from "./dropdown-settings.service";
|
||||||
|
import { DROPDOWN_SETTINGS_REPOSITORY } from "./interfaces/dropdown-settings.repository.interface";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([DropdownSetting, DropdownOption])],
|
||||||
|
controllers: [DropdownSettingsController],
|
||||||
|
providers: [
|
||||||
|
DropdownSettingsRepository,
|
||||||
|
{
|
||||||
|
provide: DROPDOWN_SETTINGS_REPOSITORY,
|
||||||
|
useExisting: DropdownSettingsRepository,
|
||||||
|
},
|
||||||
|
DropdownSettingsService,
|
||||||
|
],
|
||||||
|
exports: [DropdownSettingsService],
|
||||||
|
})
|
||||||
|
export class DropdownSettingsModule {}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { BaseRepository } from "@edr/api-common";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
|
import { DropdownOption } from "./entities/dropdown-option.entity";
|
||||||
|
import { DropdownSetting } from "./entities/dropdown-setting.entity";
|
||||||
|
import type { IDropdownSettingsRepository } from "./interfaces/dropdown-settings.repository.interface";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DropdownSettingsRepository
|
||||||
|
extends BaseRepository<DropdownSetting>
|
||||||
|
implements IDropdownSettingsRepository
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(DropdownSetting)
|
||||||
|
repository: Repository<DropdownSetting>,
|
||||||
|
@InjectRepository(DropdownOption)
|
||||||
|
private readonly optionsRepository: Repository<DropdownOption>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
findByCode(code: string): Promise<DropdownSetting | null> {
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: { code },
|
||||||
|
relations: { children: true },
|
||||||
|
order: { children: { order: "ASC" } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
override findById(id: string): Promise<DropdownSetting | null> {
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: { children: true },
|
||||||
|
order: { children: { order: "ASC" } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
override findAll(): Promise<DropdownSetting[]> {
|
||||||
|
return this.repository.find({
|
||||||
|
order: { label: "ASC", children: { order: "ASC" } },
|
||||||
|
relations: { children: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async replaceOptions(
|
||||||
|
settingId: string,
|
||||||
|
options: Array<Partial<DropdownOption>>,
|
||||||
|
): Promise<DropdownOption[]> {
|
||||||
|
await this.optionsRepository.delete({ settingId });
|
||||||
|
if (options.length === 0) return [];
|
||||||
|
const entities = options.map((o, idx) =>
|
||||||
|
this.optionsRepository.create({
|
||||||
|
...o,
|
||||||
|
settingId,
|
||||||
|
order: o.order ?? idx + 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return this.optionsRepository.save(entities);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addOption(
|
||||||
|
settingId: string,
|
||||||
|
option: Partial<DropdownOption>,
|
||||||
|
): Promise<DropdownOption> {
|
||||||
|
const entity = this.optionsRepository.create({ ...option, settingId });
|
||||||
|
return this.optionsRepository.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOption(
|
||||||
|
optionId: string,
|
||||||
|
data: Partial<DropdownOption>,
|
||||||
|
): Promise<DropdownOption | null> {
|
||||||
|
await this.optionsRepository.update(optionId, data as never);
|
||||||
|
return this.optionsRepository.findOne({ where: { id: optionId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeOption(optionId: string): Promise<void> {
|
||||||
|
await this.optionsRepository.softDelete(optionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
|
||||||
|
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
|
||||||
|
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
|
||||||
|
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
|
||||||
|
import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto";
|
||||||
|
import { DropdownOption } from "./entities/dropdown-option.entity";
|
||||||
|
import { DropdownSetting } from "./entities/dropdown-setting.entity";
|
||||||
|
import {
|
||||||
|
DROPDOWN_SETTINGS_REPOSITORY,
|
||||||
|
IDropdownSettingsRepository,
|
||||||
|
} from "./interfaces/dropdown-settings.repository.interface";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DropdownSettingsService {
|
||||||
|
constructor(
|
||||||
|
@Inject(DROPDOWN_SETTINGS_REPOSITORY)
|
||||||
|
private readonly repository: IDropdownSettingsRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
list(): Promise<DropdownSetting[]> {
|
||||||
|
return this.repository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(id: string): Promise<DropdownSetting> {
|
||||||
|
const setting = await this.repository.findById(id);
|
||||||
|
if (!setting) throw new NotFoundException(`Setting ${id} not found`);
|
||||||
|
return setting;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByCode(code: string): Promise<DropdownSetting> {
|
||||||
|
const setting = await this.repository.findByCode(code);
|
||||||
|
if (!setting) throw new NotFoundException(`Setting "${code}" not found`);
|
||||||
|
return setting;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateDropdownSettingDto): Promise<DropdownSetting> {
|
||||||
|
const existing = await this.repository.findByCode(dto.code);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Dropdown setting with code "${dto.code}" already exists`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setting = await this.repository.create({
|
||||||
|
code: dto.code,
|
||||||
|
label: dto.label,
|
||||||
|
description: dto.description ?? null,
|
||||||
|
multiple: dto.multiple ?? false,
|
||||||
|
meta: dto.meta ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dto.children && dto.children.length > 0) {
|
||||||
|
await this.repository.replaceOptions(setting.id, dto.children);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getById(setting.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateDropdownSettingDto,
|
||||||
|
): Promise<DropdownSetting> {
|
||||||
|
await this.getById(id);
|
||||||
|
const updated = await this.repository.update(id, dto);
|
||||||
|
if (!updated) throw new NotFoundException(`Setting ${id} not found`);
|
||||||
|
return this.getById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
await this.getById(id);
|
||||||
|
await this.repository.softDelete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------ option operations ------------------------ */
|
||||||
|
|
||||||
|
async replaceOptions(
|
||||||
|
settingId: string,
|
||||||
|
options: CreateDropdownOptionDto[],
|
||||||
|
): Promise<DropdownOption[]> {
|
||||||
|
await this.getById(settingId);
|
||||||
|
return this.repository.replaceOptions(settingId, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addOption(
|
||||||
|
settingId: string,
|
||||||
|
dto: CreateDropdownOptionDto,
|
||||||
|
): Promise<DropdownOption> {
|
||||||
|
await this.getById(settingId);
|
||||||
|
return this.repository.addOption(settingId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOption(
|
||||||
|
optionId: string,
|
||||||
|
dto: UpdateDropdownOptionDto,
|
||||||
|
): Promise<DropdownOption> {
|
||||||
|
const updated = await this.repository.updateOption(optionId, dto);
|
||||||
|
if (!updated) throw new NotFoundException(`Option ${optionId} not found`);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeOption(optionId: string): Promise<void> {
|
||||||
|
await this.repository.removeOption(optionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Type } from "class-transformer";
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
import { DropdownOptionMetaDto } from "./dropdown-option-meta.dto";
|
||||||
|
|
||||||
|
export class CreateDropdownOptionDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(256)
|
||||||
|
value!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(256)
|
||||||
|
label!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
note?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
disabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
order?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => DropdownOptionMetaDto)
|
||||||
|
meta?: DropdownOptionMetaDto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Type } from "class-transformer";
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
import { CreateDropdownOptionDto } from "./create-dropdown-option.dto";
|
||||||
|
import { DropdownSettingMetaDto } from "./dropdown-setting-meta.dto";
|
||||||
|
|
||||||
|
export class CreateDropdownSettingDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(128)
|
||||||
|
@Matches(/^[a-z][a-z0-9_]*$/i, {
|
||||||
|
message: "code must be snake_case-friendly (letters, digits, underscores)",
|
||||||
|
})
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(256)
|
||||||
|
label!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
multiple?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => DropdownSettingMetaDto)
|
||||||
|
meta?: DropdownSettingMetaDto;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreateDropdownOptionDto)
|
||||||
|
children?: CreateDropdownOptionDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { IsOptional, IsString, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
export class DropdownOptionMetaDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(32)
|
||||||
|
color?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
badge?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Transform } from "class-transformer";
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
export class DropdownSettingMetaDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(32)
|
||||||
|
color?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
searchable?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
clearable?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
Array.isArray(value)
|
||||||
|
? (value as string[]).map((s) => s.trim()).filter(Boolean)
|
||||||
|
: value,
|
||||||
|
)
|
||||||
|
permissions?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(32)
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { PartialType } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { CreateDropdownOptionDto } from "./create-dropdown-option.dto";
|
||||||
|
|
||||||
|
export class UpdateDropdownOptionDto extends PartialType(
|
||||||
|
CreateDropdownOptionDto,
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { OmitType, PartialType } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { CreateDropdownSettingDto } from "./create-dropdown-setting.dto";
|
||||||
|
|
||||||
|
export class UpdateDropdownSettingDto extends PartialType(
|
||||||
|
OmitType(CreateDropdownSettingDto, ["children"] as const),
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { BaseEntity } from "@edr/api-common";
|
||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
} from "typeorm";
|
||||||
|
|
||||||
|
import { DropdownSetting } from "./dropdown-setting.entity";
|
||||||
|
|
||||||
|
export interface DropdownOptionMeta {
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
badge?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ name: "dropdown_options" })
|
||||||
|
@Index(["settingId", "value"], { unique: true })
|
||||||
|
export class DropdownOption extends BaseEntity {
|
||||||
|
@ManyToOne(() => DropdownSetting, (setting) => setting.children, {
|
||||||
|
onDelete: "CASCADE",
|
||||||
|
})
|
||||||
|
@JoinColumn({ name: "setting_id" })
|
||||||
|
setting!: DropdownSetting;
|
||||||
|
|
||||||
|
@Column({ name: "setting_id", type: "uuid" })
|
||||||
|
settingId!: string;
|
||||||
|
|
||||||
|
@Column({ name: "value", type: "varchar", length: 256 })
|
||||||
|
value!: string;
|
||||||
|
|
||||||
|
@Column({ name: "label", type: "varchar", length: 256 })
|
||||||
|
label!: string;
|
||||||
|
|
||||||
|
@Column({ name: "note", type: "text", nullable: true })
|
||||||
|
note?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "is_disabled", type: "boolean", default: false })
|
||||||
|
disabled!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: "display_order", type: "integer", default: 0 })
|
||||||
|
order!: number;
|
||||||
|
|
||||||
|
@Column({ name: "meta", type: "jsonb", nullable: true })
|
||||||
|
meta?: DropdownOptionMeta | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { BaseEntity } from "@edr/api-common";
|
||||||
|
import { Column, Entity, Index, OneToMany } from "typeorm";
|
||||||
|
|
||||||
|
import { DropdownOption } from "./dropdown-option.entity";
|
||||||
|
|
||||||
|
export interface DropdownSettingMeta {
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
searchable?: boolean;
|
||||||
|
clearable?: boolean;
|
||||||
|
permissions?: string[];
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ name: "dropdown_settings" })
|
||||||
|
@Index(["code"], { unique: true })
|
||||||
|
export class DropdownSetting extends BaseEntity {
|
||||||
|
@Column({ name: "code", type: "varchar", length: 128, unique: true })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@Column({ name: "label", type: "varchar", length: 256 })
|
||||||
|
label!: string;
|
||||||
|
|
||||||
|
@Column({ name: "description", type: "text", nullable: true })
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "multiple", type: "boolean", default: false })
|
||||||
|
multiple!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: "meta", type: "jsonb", nullable: true })
|
||||||
|
meta?: DropdownSettingMeta | null;
|
||||||
|
|
||||||
|
@OneToMany(() => DropdownOption, (option) => option.setting, {
|
||||||
|
cascade: true,
|
||||||
|
})
|
||||||
|
children!: DropdownOption[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { DropdownOption } from "../entities/dropdown-option.entity";
|
||||||
|
import { DropdownSetting } from "../entities/dropdown-setting.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract every DropdownSettings repository must satisfy. Lets services
|
||||||
|
* depend on the abstraction and lets tests swap in an in-memory fake.
|
||||||
|
*/
|
||||||
|
export const DROPDOWN_SETTINGS_REPOSITORY = Symbol(
|
||||||
|
"DROPDOWN_SETTINGS_REPOSITORY",
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface IDropdownSettingsRepository {
|
||||||
|
findAll(): Promise<DropdownSetting[]>;
|
||||||
|
findById(id: string): Promise<DropdownSetting | null>;
|
||||||
|
findByCode(code: string): Promise<DropdownSetting | null>;
|
||||||
|
|
||||||
|
create(data: Partial<DropdownSetting>): Promise<DropdownSetting>;
|
||||||
|
update(
|
||||||
|
id: string,
|
||||||
|
data: Partial<DropdownSetting>,
|
||||||
|
): Promise<DropdownSetting | null>;
|
||||||
|
softDelete(id: string): Promise<void>;
|
||||||
|
|
||||||
|
/* Option-level helpers */
|
||||||
|
replaceOptions(
|
||||||
|
settingId: string,
|
||||||
|
options: Array<Partial<DropdownOption>>,
|
||||||
|
): Promise<DropdownOption[]>;
|
||||||
|
addOption(
|
||||||
|
settingId: string,
|
||||||
|
option: Partial<DropdownOption>,
|
||||||
|
): Promise<DropdownOption>;
|
||||||
|
updateOption(
|
||||||
|
optionId: string,
|
||||||
|
data: Partial<DropdownOption>,
|
||||||
|
): Promise<DropdownOption | null>;
|
||||||
|
removeOption(optionId: string): Promise<void>;
|
||||||
|
}
|
||||||
@@ -4,5 +4,16 @@ export const QUERY_KEYS = {
|
|||||||
FILES: {
|
FILES: {
|
||||||
FILE_UPLOAD_SETTINGS: "file-upload-settings",
|
FILE_UPLOAD_SETTINGS: "file-upload-settings",
|
||||||
BY_CODE: "by-code"
|
BY_CODE: "by-code"
|
||||||
|
},
|
||||||
|
DROPDOWN_SETTINGS: {
|
||||||
|
ROOT: "dropdown-settings",
|
||||||
|
LIST: "list",
|
||||||
|
BY_ID: "by-id",
|
||||||
|
BY_CODE: "by-code"
|
||||||
|
},
|
||||||
|
CUSTOMERS: {
|
||||||
|
ROOT: "customers",
|
||||||
|
LIST: "list",
|
||||||
|
BY_ID: "by-id"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,12 +49,27 @@ export const URL_CONSTANTS = {
|
|||||||
NOTIFICATIONS: "/settings/notifications",
|
NOTIFICATIONS: "/settings/notifications",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
DROPDOWN_SETTINGS: {
|
||||||
|
BASE: "/api/dropdown-settings",
|
||||||
|
BY_ID: (id: string) => `/api/dropdown-settings/${id}`,
|
||||||
|
BY_CODE: (code: string) =>
|
||||||
|
`/api/dropdown-settings/by-code/${encodeURIComponent(code)}`,
|
||||||
|
OPTIONS: (id: string) => `/api/dropdown-settings/${id}/options`,
|
||||||
|
OPTION_BY_ID: (optionId: string) =>
|
||||||
|
`/api/dropdown-settings/options/${optionId}`,
|
||||||
|
},
|
||||||
|
|
||||||
CUSTOMERS: {
|
CUSTOMERS: {
|
||||||
BASE: "/customers",
|
BASE: "/customers",
|
||||||
BY_ID: (id: string | number) => `/customers/${id}`,
|
BY_ID: (id: string | number) => `/customers/${id}`,
|
||||||
BOOKINGS: (id: string | number) => `/customers/${id}/bookings`,
|
BOOKINGS: (id: string | number) => `/customers/${id}/bookings`,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
CUSTOMERS_API: {
|
||||||
|
BASE: "/api/customers",
|
||||||
|
BY_ID: (id: string) => `/api/customers/${id}`,
|
||||||
|
},
|
||||||
|
|
||||||
BOOKINGS: {
|
BOOKINGS: {
|
||||||
BASE: "/bookings",
|
BASE: "/bookings",
|
||||||
BY_ID: (id: string | number) => `/bookings/${id}`,
|
BY_ID: (id: string | number) => `/bookings/${id}`,
|
||||||
|
|||||||
50
apps/edr-freight-web/portal/src/hooks/useCustomers.ts
Normal file
50
apps/edr-freight-web/portal/src/hooks/useCustomers.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { customersService } from "@/services/customers.service";
|
||||||
|
import type {
|
||||||
|
CreateCustomerDto,
|
||||||
|
UpdateCustomerDto,
|
||||||
|
} from "@/types/customers";
|
||||||
|
|
||||||
|
const KEY = ["customers"] as const;
|
||||||
|
|
||||||
|
export const useCustomers = () =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: KEY,
|
||||||
|
queryFn: customersService.list,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useCustomer = (id: string | undefined) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: [...KEY, "id", id],
|
||||||
|
queryFn: () => customersService.getById(id!),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useCreateCustomer = () => {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (dto: CreateCustomerDto) => customersService.create(dto),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateCustomer = () => {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, dto }: { id: string; dto: UpdateCustomerDto }) =>
|
||||||
|
customersService.update(id, dto),
|
||||||
|
onSuccess: (_data, { id }) => {
|
||||||
|
qc.invalidateQueries({ queryKey: KEY });
|
||||||
|
qc.invalidateQueries({ queryKey: [...KEY, "id", id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteCustomer = () => {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => customersService.remove(id),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||||
|
});
|
||||||
|
};
|
||||||
126
apps/edr-freight-web/portal/src/hooks/useDropdownSettings.ts
Normal file
126
apps/edr-freight-web/portal/src/hooks/useDropdownSettings.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { dropdownSettingsService } from "@/services/dropdownSettings.service";
|
||||||
|
import type {
|
||||||
|
CreateDropdownOptionDto,
|
||||||
|
CreateDropdownSettingDto,
|
||||||
|
UpdateDropdownOptionDto,
|
||||||
|
UpdateDropdownSettingDto,
|
||||||
|
} from "@/types/dropdownSettings";
|
||||||
|
|
||||||
|
const KEY = ["dropdown-settings"] as const;
|
||||||
|
|
||||||
|
/* ------------------------------ Queries ------------------------------ */
|
||||||
|
|
||||||
|
export const useDropdownSettings = () =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: KEY,
|
||||||
|
queryFn: dropdownSettingsService.list,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useDropdownSetting = (id: string) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: [...KEY, "id", id],
|
||||||
|
queryFn: () => dropdownSettingsService.getById(id),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useDropdownSettingByCode = (code: string) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: [...KEY, "code", code],
|
||||||
|
queryFn: () => dropdownSettingsService.getByCode(code),
|
||||||
|
enabled: Boolean(code),
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ----------------------------- Mutations ----------------------------- */
|
||||||
|
|
||||||
|
export const useCreateDropdownSetting = () => {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (dto: CreateDropdownSettingDto) =>
|
||||||
|
dropdownSettingsService.create(dto),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateDropdownSetting = () => {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
id,
|
||||||
|
dto,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
dto: UpdateDropdownSettingDto;
|
||||||
|
}) => dropdownSettingsService.update(id, dto),
|
||||||
|
onSuccess: (_data, { id }) => {
|
||||||
|
qc.invalidateQueries({ queryKey: KEY });
|
||||||
|
qc.invalidateQueries({ queryKey: [...KEY, "id", id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteDropdownSetting = () => {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => dropdownSettingsService.remove(id),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useReplaceDropdownOptions = () => {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
settingId,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
settingId: string;
|
||||||
|
options: CreateDropdownOptionDto[];
|
||||||
|
}) => dropdownSettingsService.replaceOptions(settingId, options),
|
||||||
|
onSuccess: (_data, { settingId }) => {
|
||||||
|
qc.invalidateQueries({ queryKey: KEY });
|
||||||
|
qc.invalidateQueries({ queryKey: [...KEY, "id", settingId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAddDropdownOption = () => {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
settingId,
|
||||||
|
dto,
|
||||||
|
}: {
|
||||||
|
settingId: string;
|
||||||
|
dto: CreateDropdownOptionDto;
|
||||||
|
}) => dropdownSettingsService.addOption(settingId, dto),
|
||||||
|
onSuccess: (_data, { settingId }) => {
|
||||||
|
qc.invalidateQueries({ queryKey: KEY });
|
||||||
|
qc.invalidateQueries({ queryKey: [...KEY, "id", settingId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateDropdownOption = () => {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
optionId,
|
||||||
|
dto,
|
||||||
|
}: {
|
||||||
|
optionId: string;
|
||||||
|
dto: UpdateDropdownOptionDto;
|
||||||
|
}) => dropdownSettingsService.updateOption(optionId, dto),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRemoveDropdownOption = () => {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (optionId: string) =>
|
||||||
|
dropdownSettingsService.removeOption(optionId),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -17,7 +17,9 @@ export interface DeleteDropdownSettingDialogProps {
|
|||||||
settingLabel: string;
|
settingLabel: string;
|
||||||
settingCode: string;
|
settingCode: string;
|
||||||
onConfirm?: () => void;
|
onConfirm?: () => void;
|
||||||
children: ReactNode;
|
children?: ReactNode;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DeleteDropdownSettingDialog({
|
export default function DeleteDropdownSettingDialog({
|
||||||
@@ -25,10 +27,20 @@ export default function DeleteDropdownSettingDialog({
|
|||||||
settingCode,
|
settingCode,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
children,
|
children,
|
||||||
|
open: openProp,
|
||||||
|
onOpenChange,
|
||||||
}: DeleteDropdownSettingDialogProps) {
|
}: DeleteDropdownSettingDialogProps) {
|
||||||
|
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 (
|
return (
|
||||||
<Dialog>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
|
||||||
|
|
||||||
<DialogContent className="sm:max-w-md rounded-3xl">
|
<DialogContent className="sm:max-w-md rounded-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
AlertCircle,
|
||||||
Boxes,
|
Boxes,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Eye,
|
Eye,
|
||||||
Filter,
|
Filter,
|
||||||
ListOrdered,
|
ListOrdered,
|
||||||
|
Loader2,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -19,7 +21,11 @@ import Breadcrumbs from "@/components/Breadcrumbs";
|
|||||||
import EditDropdownSettingDialog from "./EditDropdownSettingDialog";
|
import EditDropdownSettingDialog from "./EditDropdownSettingDialog";
|
||||||
import ManageDropdownOptionsDialog from "./ManageDropdownOptionsDialog";
|
import ManageDropdownOptionsDialog from "./ManageDropdownOptionsDialog";
|
||||||
import DeleteDropdownSettingDialog from "./DeleteDropdownSettingDialog";
|
import DeleteDropdownSettingDialog from "./DeleteDropdownSettingDialog";
|
||||||
import { dropdownSettings } from "./dropdownSettings.mock";
|
import {
|
||||||
|
useDeleteDropdownSetting,
|
||||||
|
useDropdownSettings,
|
||||||
|
} from "@/hooks/useDropdownSettings";
|
||||||
|
import type { DropdownSetting } from "@/types/dropdownSettings";
|
||||||
import {
|
import {
|
||||||
DataTable,
|
DataTable,
|
||||||
DataTableFooter,
|
DataTableFooter,
|
||||||
@@ -39,10 +45,55 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
type ActiveDialog = "edit" | "options" | "delete";
|
||||||
|
|
||||||
export default function DropdownSettingsPage() {
|
export default function DropdownSettingsPage() {
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const [activeDialog, setActiveDialog] = useState<ActiveDialog | null>(null);
|
||||||
|
const [activeSetting, setActiveSetting] = useState<DropdownSetting | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const openDialogFor = (dialog: ActiveDialog, setting: DropdownSetting) => {
|
||||||
|
// Defer past the DropdownMenu's close cycle. Radix's modal lock can leave
|
||||||
|
// `pointer-events: none` on <body> when a menu closes and a dialog opens
|
||||||
|
// in the same frame — wait two RAFs and then explicitly reset the body
|
||||||
|
// style so the dialog interior is interactive.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
document.body.style.pointerEvents = "";
|
||||||
|
setActiveSetting(setting);
|
||||||
|
setActiveDialog(dialog);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const closeDialog = () => {
|
||||||
|
setActiveDialog(null);
|
||||||
|
// Keep activeSetting briefly so dialog content doesn't flash empty during
|
||||||
|
// the close animation; cleared on next open.
|
||||||
|
};
|
||||||
|
|
||||||
|
// Belt-and-suspenders for the Radix pointer-events leak: any time the active
|
||||||
|
// dialog changes, schedule a body-style cleanup after the next paint.
|
||||||
|
useEffect(() => {
|
||||||
|
const id = requestAnimationFrame(() => {
|
||||||
|
if (document.body.style.pointerEvents === "none") {
|
||||||
|
document.body.style.pointerEvents = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}, [activeDialog]);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, error } = useDropdownSettings();
|
||||||
|
const deleteMutation = useDeleteDropdownSetting();
|
||||||
|
|
||||||
|
const dropdownSettings = useMemo<DropdownSetting[]>(
|
||||||
|
() => (Array.isArray(data) ? data : []),
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
if (!q) return dropdownSettings;
|
if (!q) return dropdownSettings;
|
||||||
@@ -52,10 +103,10 @@ export default function DropdownSettingsPage() {
|
|||||||
s.label.toLowerCase().includes(q) ||
|
s.label.toLowerCase().includes(q) ||
|
||||||
(s.description ?? "").toLowerCase().includes(q),
|
(s.description ?? "").toLowerCase().includes(q),
|
||||||
);
|
);
|
||||||
}, [query]);
|
}, [dropdownSettings, query]);
|
||||||
|
|
||||||
const total = filtered.length;
|
const total = filtered.length;
|
||||||
const pageCount = Math.ceil(total / pagination.pageSize);
|
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||||
const start = pagination.pageIndex * pagination.pageSize;
|
const start = pagination.pageIndex * pagination.pageSize;
|
||||||
const end = Math.min(start + pagination.pageSize, total);
|
const end = Math.min(start + pagination.pageSize, total);
|
||||||
|
|
||||||
@@ -65,7 +116,7 @@ export default function DropdownSettingsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const totalOptions = dropdownSettings.reduce(
|
const totalOptions = dropdownSettings.reduce(
|
||||||
(sum, s) => sum + s.children.length,
|
(sum, s) => sum + (s.children?.length ?? 0),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const multipleCount = dropdownSettings.filter((s) => s.multiple).length;
|
const multipleCount = dropdownSettings.filter((s) => s.multiple).length;
|
||||||
@@ -73,7 +124,13 @@ export default function DropdownSettingsPage() {
|
|||||||
(s) => s.meta?.searchable,
|
(s) => s.meta?.searchable,
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
const columns: ColumnDef<(typeof dropdownSettings)[number]>[] = [
|
const status: "loading" | "error" | "success" = isLoading
|
||||||
|
? "loading"
|
||||||
|
: isError
|
||||||
|
? "error"
|
||||||
|
: "success";
|
||||||
|
|
||||||
|
const columns: ColumnDef<DropdownSetting>[] = [
|
||||||
{
|
{
|
||||||
id: "setting",
|
id: "setting",
|
||||||
header: "Setting",
|
header: "Setting",
|
||||||
@@ -111,7 +168,7 @@ export default function DropdownSettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-700">
|
<div className="flex items-center gap-2 text-sm text-slate-700">
|
||||||
<Boxes />
|
<Boxes />
|
||||||
<span className="font-medium">{s.children.length}</span>
|
<span className="font-medium">{s.children?.length ?? 0}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -139,12 +196,13 @@ export default function DropdownSettingsPage() {
|
|||||||
header: "Permissions",
|
header: "Permissions",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const s = row.original;
|
const s = row.original;
|
||||||
|
const perms = s.meta?.permissions ?? [];
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
{(s.meta?.permissions ?? []).length === 0 ? (
|
{perms.length === 0 ? (
|
||||||
<span className="text-xs text-slate-400">—</span>
|
<span className="text-xs text-slate-400">—</span>
|
||||||
) : (
|
) : (
|
||||||
(s.meta?.permissions ?? []).map((p) => (
|
perms.map((p) => (
|
||||||
<span
|
<span
|
||||||
key={p}
|
key={p}
|
||||||
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
|
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
|
||||||
@@ -168,7 +226,7 @@ export default function DropdownSettingsPage() {
|
|||||||
className="flex justify-end"
|
className="flex justify-end"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<DropdownMenu>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="icon">
|
<Button variant="outline" size="icon">
|
||||||
<MoreHorizontal />
|
<MoreHorizontal />
|
||||||
@@ -179,32 +237,27 @@ export default function DropdownSettingsPage() {
|
|||||||
<Eye />
|
<Eye />
|
||||||
View
|
View
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<ManageDropdownOptionsDialog setting={setting}>
|
<DropdownMenuItem
|
||||||
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
|
onSelect={() => openDialogFor("options", setting)}
|
||||||
<CheckCircle2 />
|
|
||||||
Options
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</ManageDropdownOptionsDialog>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<EditDropdownSettingDialog mode="edit" setting={setting}>
|
|
||||||
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
|
|
||||||
<Pencil />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</EditDropdownSettingDialog>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DeleteDropdownSettingDialog
|
|
||||||
settingLabel={setting.label}
|
|
||||||
settingCode={setting.code}
|
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<CheckCircle2 />
|
||||||
onSelect={(e: Event) => e.preventDefault()}
|
Options
|
||||||
variant="destructive"
|
</DropdownMenuItem>
|
||||||
>
|
<DropdownMenuSeparator />
|
||||||
<Trash2 />
|
<DropdownMenuItem
|
||||||
Delete
|
onSelect={() => openDialogFor("edit", setting)}
|
||||||
</DropdownMenuItem>
|
>
|
||||||
</DeleteDropdownSettingDialog>
|
<Pencil />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => openDialogFor("delete", setting)}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
@@ -262,63 +315,38 @@ export default function DropdownSettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
<Card>
|
<StatCard
|
||||||
<CardContent className="flex items-center justify-between">
|
label="Settings"
|
||||||
<div>
|
value={dropdownSettings.length}
|
||||||
<p className="text-sm text-slate-500">Settings</p>
|
icon={<Settings />}
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
/>
|
||||||
{dropdownSettings.length}
|
<StatCard
|
||||||
</h3>
|
label="Total Options"
|
||||||
</div>
|
value={totalOptions}
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
icon={<Boxes />}
|
||||||
<Settings />
|
/>
|
||||||
</div>
|
<StatCard
|
||||||
</CardContent>
|
label="Multi-select"
|
||||||
</Card>
|
value={multipleCount}
|
||||||
|
icon={<ListOrdered />}
|
||||||
<Card>
|
/>
|
||||||
<CardContent className="flex items-center justify-between">
|
<StatCard
|
||||||
<div>
|
label="Searchable"
|
||||||
<p className="text-sm text-slate-500">Total Options</p>
|
value={searchableCount}
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
icon={<Sparkles />}
|
||||||
{totalOptions}
|
/>
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
||||||
<Boxes />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">Multi-select</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
||||||
{multipleCount}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
||||||
<ListOrdered />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">Searchable</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
||||||
{searchableCount}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
||||||
<Sparkles />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</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 dropdown settings.{" "}
|
||||||
|
{error instanceof Error ? error.message : "Unknown error."}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Card className="gap-0">
|
<Card className="gap-0">
|
||||||
<CardHeader className="flex flex-row items-center justify-between border-b">
|
<CardHeader className="flex flex-row items-center justify-between border-b">
|
||||||
<div>
|
<div>
|
||||||
@@ -335,31 +363,90 @@ export default function DropdownSettingsPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="px-0">
|
<CardContent className="px-0">
|
||||||
<DataTable
|
{isLoading ? (
|
||||||
columns={columns}
|
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
|
||||||
data={paginatedData}
|
<Loader2 className="mr-2 h-4 w-4 animate-spin text-primary" />
|
||||||
status="success"
|
Loading dropdown settings…
|
||||||
onRowClick={() => { }}
|
</div>
|
||||||
pagination={{
|
) : (
|
||||||
pageIndex: pagination.pageIndex,
|
<DataTable
|
||||||
pageSize: pagination.pageSize,
|
columns={columns}
|
||||||
pageCount: pageCount,
|
data={paginatedData}
|
||||||
totalCount: total,
|
status={status}
|
||||||
}}
|
onRowClick={() => { }}
|
||||||
tableOptions={{
|
pagination={{
|
||||||
state: { pagination },
|
pageIndex: pagination.pageIndex,
|
||||||
onPaginationChange: setPagination,
|
pageSize: pagination.pageSize,
|
||||||
}}
|
pageCount: pageCount,
|
||||||
containerClassName="border-b shadow-none"
|
totalCount: total,
|
||||||
footer={DataTableFooter}
|
}}
|
||||||
/>
|
tableOptions={{
|
||||||
|
state: { pagination },
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
}}
|
||||||
|
containerClassName="border-b shadow-none"
|
||||||
|
footer={DataTableFooter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Controlled dialogs — hoisted out of the DropdownMenu so they can open
|
||||||
|
reliably after a menu item is selected. */}
|
||||||
|
{activeSetting ? (
|
||||||
|
<>
|
||||||
|
<EditDropdownSettingDialog
|
||||||
|
key={`edit-${activeSetting.id}`}
|
||||||
|
mode="edit"
|
||||||
|
setting={activeSetting}
|
||||||
|
open={activeDialog === "edit"}
|
||||||
|
onOpenChange={(next) => (next ? null : closeDialog())}
|
||||||
|
/>
|
||||||
|
<ManageDropdownOptionsDialog
|
||||||
|
key={`options-${activeSetting.id}`}
|
||||||
|
setting={activeSetting}
|
||||||
|
open={activeDialog === "options"}
|
||||||
|
onOpenChange={(next) => (next ? null : closeDialog())}
|
||||||
|
/>
|
||||||
|
<DeleteDropdownSettingDialog
|
||||||
|
key={`delete-${activeSetting.id}`}
|
||||||
|
settingLabel={activeSetting.label}
|
||||||
|
settingCode={activeSetting.code}
|
||||||
|
onConfirm={() => deleteMutation.mutate(activeSetting.id)}
|
||||||
|
open={activeDialog === "delete"}
|
||||||
|
onOpenChange={(next) => (next ? null : closeDialog())}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">{label}</p>
|
||||||
|
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function BehaviorChip({
|
function BehaviorChip({
|
||||||
label,
|
label,
|
||||||
muted = false,
|
muted = false,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { Hash } from "lucide-react";
|
import { Hash, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
@@ -14,20 +15,58 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
import type { DropdownSetting } from "@/types/dropdownSettings";
|
import type {
|
||||||
|
CreateDropdownSettingDto,
|
||||||
|
DropdownSetting,
|
||||||
|
UpdateDropdownSettingDto,
|
||||||
|
} from "@/types/dropdownSettings";
|
||||||
|
import {
|
||||||
|
useCreateDropdownSetting,
|
||||||
|
useUpdateDropdownSetting,
|
||||||
|
} from "@/hooks/useDropdownSettings";
|
||||||
|
|
||||||
export interface EditDropdownSettingDialogProps {
|
export interface EditDropdownSettingDialogProps {
|
||||||
mode?: "create" | "edit";
|
mode?: "create" | "edit";
|
||||||
setting?: DropdownSetting;
|
setting?: DropdownSetting;
|
||||||
children: ReactNode;
|
/** Optional trigger element. When omitted, the dialog renders content only and is fully controlled. */
|
||||||
|
children?: ReactNode;
|
||||||
|
/** Controlled open state. When provided, internal state is ignored. */
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePermissions(raw: string): string[] {
|
||||||
|
return raw
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EditDropdownSettingDialog({
|
export default function EditDropdownSettingDialog({
|
||||||
mode = "create",
|
mode = "create",
|
||||||
setting,
|
setting,
|
||||||
children,
|
children,
|
||||||
|
open: openProp,
|
||||||
|
onOpenChange,
|
||||||
}: EditDropdownSettingDialogProps) {
|
}: EditDropdownSettingDialogProps) {
|
||||||
const isEdit = mode === "edit";
|
const isEdit = mode === "edit";
|
||||||
|
const isControlled = openProp !== undefined;
|
||||||
|
|
||||||
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
const open = isControlled ? openProp : internalOpen;
|
||||||
|
const setOpen = (next: boolean) => {
|
||||||
|
if (!isControlled) setInternalOpen(next);
|
||||||
|
onOpenChange?.(next);
|
||||||
|
};
|
||||||
|
const [code, setCode] = useState(setting?.code ?? "");
|
||||||
|
const [label, setLabel] = useState(setting?.label ?? "");
|
||||||
|
const [description, setDescription] = useState(setting?.description ?? "");
|
||||||
|
const [icon, setIcon] = useState(setting?.meta?.icon ?? "");
|
||||||
|
const [color, setColor] = useState(setting?.meta?.color ?? "");
|
||||||
|
const [permissions, setPermissions] = useState(
|
||||||
|
setting?.meta?.permissions?.join(", ") ?? "",
|
||||||
|
);
|
||||||
|
const [version, setVersion] = useState(setting?.meta?.version ?? "1.0");
|
||||||
const [multiple, setMultiple] = useState<boolean>(setting?.multiple ?? false);
|
const [multiple, setMultiple] = useState<boolean>(setting?.multiple ?? false);
|
||||||
const [searchable, setSearchable] = useState<boolean>(
|
const [searchable, setSearchable] = useState<boolean>(
|
||||||
setting?.meta?.searchable ?? false,
|
setting?.meta?.searchable ?? false,
|
||||||
@@ -35,10 +74,90 @@ export default function EditDropdownSettingDialog({
|
|||||||
const [clearable, setClearable] = useState<boolean>(
|
const [clearable, setClearable] = useState<boolean>(
|
||||||
setting?.meta?.clearable ?? false,
|
setting?.meta?.clearable ?? false,
|
||||||
);
|
);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const createMutation = useCreateDropdownSetting();
|
||||||
|
const updateMutation = useUpdateDropdownSetting();
|
||||||
|
const pending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setCode(setting?.code ?? "");
|
||||||
|
setLabel(setting?.label ?? "");
|
||||||
|
setDescription(setting?.description ?? "");
|
||||||
|
setIcon(setting?.meta?.icon ?? "");
|
||||||
|
setColor(setting?.meta?.color ?? "");
|
||||||
|
setPermissions(setting?.meta?.permissions?.join(", ") ?? "");
|
||||||
|
setVersion(setting?.meta?.version ?? "1.0");
|
||||||
|
setMultiple(setting?.multiple ?? false);
|
||||||
|
setSearchable(setting?.meta?.searchable ?? false);
|
||||||
|
setClearable(setting?.meta?.clearable ?? false);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPayload = (): CreateDropdownSettingDto => ({
|
||||||
|
code: code.trim(),
|
||||||
|
label: label.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
multiple,
|
||||||
|
meta: {
|
||||||
|
...(icon.trim() ? { icon: icon.trim() } : {}),
|
||||||
|
...(color.trim() ? { color: color.trim() } : {}),
|
||||||
|
searchable,
|
||||||
|
clearable,
|
||||||
|
...(version.trim() ? { version: version.trim() } : {}),
|
||||||
|
permissions: parsePermissions(permissions),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
setError(null);
|
||||||
|
if (!code.trim() || !label.trim()) {
|
||||||
|
setError("Code and label are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[a-z][a-z0-9_]*$/i.test(code.trim())) {
|
||||||
|
setError(
|
||||||
|
"Code must start with a letter and contain only letters, digits, or underscores.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildPayload();
|
||||||
|
|
||||||
|
const onDone = () => {
|
||||||
|
setOpen(false);
|
||||||
|
if (!isEdit) reset();
|
||||||
|
};
|
||||||
|
const onError = (err: unknown) => {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Something went wrong. Try again.",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit && setting) {
|
||||||
|
// Update DTO omits `code` (immutable); strip it before sending.
|
||||||
|
const { code: _unused, ...updateDto } = payload;
|
||||||
|
void _unused;
|
||||||
|
updateMutation.mutate(
|
||||||
|
{ id: setting.id, dto: updateDto as UpdateDropdownSettingDto },
|
||||||
|
{ onSuccess: onDone, onError },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(payload, { onSuccess: onDone, onError });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
setOpen(next);
|
||||||
|
if (!next) reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
|
||||||
|
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl rounded-3xl">
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl rounded-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -58,20 +177,25 @@ export default function EditDropdownSettingDialog({
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||||
<Input
|
<Input
|
||||||
defaultValue={setting?.code ?? ""}
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
placeholder="e.g. cargo_type"
|
placeholder="e.g. cargo_type"
|
||||||
className="pl-10 font-mono"
|
className="pl-10 font-mono"
|
||||||
|
disabled={isEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-slate-500">
|
||||||
Stable identifier used in code. Use snake_case.
|
{isEdit
|
||||||
|
? "Code is immutable after creation."
|
||||||
|
: "Stable identifier used in code. Use snake_case."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Label *</Label>
|
<Label>Label *</Label>
|
||||||
<Input
|
<Input
|
||||||
defaultValue={setting?.label ?? ""}
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
placeholder="e.g. Cargo Type"
|
placeholder="e.g. Cargo Type"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,7 +203,8 @@ export default function EditDropdownSettingDialog({
|
|||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<Label>Description</Label>
|
<Label>Description</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
defaultValue={setting?.description ?? ""}
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="What this dropdown represents and where it's used..."
|
placeholder="What this dropdown represents and where it's used..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,7 +212,8 @@ export default function EditDropdownSettingDialog({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Icon (meta.icon)</Label>
|
<Label>Icon (meta.icon)</Label>
|
||||||
<Input
|
<Input
|
||||||
defaultValue={setting?.meta?.icon ?? ""}
|
value={icon}
|
||||||
|
onChange={(e) => setIcon(e.target.value)}
|
||||||
placeholder="lucide icon name, e.g. package"
|
placeholder="lucide icon name, e.g. package"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,8 +221,8 @@ export default function EditDropdownSettingDialog({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Color (meta.color)</Label>
|
<Label>Color (meta.color)</Label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
value={color}
|
||||||
defaultValue={setting?.meta?.color ?? ""}
|
onChange={(e) => setColor(e.target.value)}
|
||||||
placeholder="#10B981"
|
placeholder="#10B981"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,7 +230,8 @@ export default function EditDropdownSettingDialog({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Permissions (comma-separated)</Label>
|
<Label>Permissions (comma-separated)</Label>
|
||||||
<Input
|
<Input
|
||||||
defaultValue={setting?.meta?.permissions?.join(", ") ?? ""}
|
value={permissions}
|
||||||
|
onChange={(e) => setPermissions(e.target.value)}
|
||||||
placeholder="admin, ops"
|
placeholder="admin, ops"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,7 +239,8 @@ export default function EditDropdownSettingDialog({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Version (meta.version)</Label>
|
<Label>Version (meta.version)</Label>
|
||||||
<Input
|
<Input
|
||||||
defaultValue={setting?.meta?.version ?? "1.0"}
|
value={version}
|
||||||
|
onChange={(e) => setVersion(e.target.value)}
|
||||||
placeholder="1.0"
|
placeholder="1.0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,10 +270,31 @@ export default function EditDropdownSettingDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
{error ? (
|
||||||
<Button variant="outline">Cancel</Button>
|
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
{error}
|
||||||
{isEdit ? "Save Changes" : "Create Setting"}
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-2 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" />
|
||||||
|
) : isEdit ? (
|
||||||
|
"Save Changes"
|
||||||
|
) : (
|
||||||
|
"Create Setting"
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { GripVertical, Plus, Trash2 } from "lucide-react";
|
import { GripVertical, Loader2, Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
@@ -14,41 +15,85 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
DropdownOption,
|
CreateDropdownOptionDto,
|
||||||
DropdownSetting,
|
DropdownSetting,
|
||||||
} from "@/types/dropdownSettings";
|
} from "@/types/dropdownSettings";
|
||||||
|
import { useReplaceDropdownOptions } from "@/hooks/useDropdownSettings";
|
||||||
|
|
||||||
export interface ManageDropdownOptionsDialogProps {
|
export interface ManageDropdownOptionsDialogProps {
|
||||||
setting: DropdownSetting;
|
setting: DropdownSetting;
|
||||||
children: ReactNode;
|
children?: ReactNode;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeEmptyOption(settingCode: string, idx: number): DropdownOption {
|
/**
|
||||||
|
* Local draft used by the editor — uses a stable client-only `key` so React
|
||||||
|
* keys remain stable across reorders. On save we strip `key` and POST the
|
||||||
|
* remainder as CreateDropdownOptionDto[].
|
||||||
|
*/
|
||||||
|
interface DraftOption extends CreateDropdownOptionDto {
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let draftCounter = 0;
|
||||||
|
const nextKey = () => `draft-${Date.now()}-${++draftCounter}`;
|
||||||
|
|
||||||
|
function makeEmptyDraft(idx: number): DraftOption {
|
||||||
return {
|
return {
|
||||||
id: `${settingCode}-new-${Date.now()}-${idx}`,
|
key: nextKey(),
|
||||||
value: "",
|
value: "",
|
||||||
label: "",
|
label: "",
|
||||||
|
disabled: false,
|
||||||
order: idx + 1,
|
order: idx + 1,
|
||||||
|
meta: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ManageDropdownOptionsDialog({
|
export default function ManageDropdownOptionsDialog({
|
||||||
setting,
|
setting,
|
||||||
children,
|
children,
|
||||||
|
open: openProp,
|
||||||
|
onOpenChange,
|
||||||
}: ManageDropdownOptionsDialogProps) {
|
}: ManageDropdownOptionsDialogProps) {
|
||||||
const sortedInitial = [...setting.children].sort(
|
const isControlled = openProp !== undefined;
|
||||||
(a, b) => (a.order ?? 0) - (b.order ?? 0),
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
);
|
const open = isControlled ? openProp : internalOpen;
|
||||||
const [options, setOptions] = useState<DropdownOption[]>(sortedInitial);
|
const setOpen = (next: boolean) => {
|
||||||
|
if (!isControlled) setInternalOpen(next);
|
||||||
|
onOpenChange?.(next);
|
||||||
|
};
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const update = (i: number, patch: Partial<DropdownOption>) =>
|
const seed = (): DraftOption[] =>
|
||||||
|
[...(setting.children ?? [])]
|
||||||
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||||
|
.map((o, idx) => ({
|
||||||
|
key: o.id,
|
||||||
|
value: o.value,
|
||||||
|
label: o.label,
|
||||||
|
note: o.note ?? undefined,
|
||||||
|
disabled: o.disabled,
|
||||||
|
order: o.order ?? idx + 1,
|
||||||
|
meta: {
|
||||||
|
...(o.meta?.icon ? { icon: o.meta.icon } : {}),
|
||||||
|
...(o.meta?.color ? { color: o.meta.color } : {}),
|
||||||
|
...(o.meta?.badge ? { badge: o.meta.badge } : {}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const [options, setOptions] = useState<DraftOption[]>(seed);
|
||||||
|
|
||||||
|
const replaceMutation = useReplaceDropdownOptions();
|
||||||
|
|
||||||
|
const update = (i: number, patch: Partial<DraftOption>) =>
|
||||||
setOptions((prev) =>
|
setOptions((prev) =>
|
||||||
prev.map((o, idx) => (idx === i ? { ...o, ...patch } : o)),
|
prev.map((o, idx) => (idx === i ? { ...o, ...patch } : o)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateMeta = (
|
const updateMeta = (
|
||||||
i: number,
|
i: number,
|
||||||
patch: Partial<NonNullable<DropdownOption["meta"]>>,
|
patch: Partial<NonNullable<DraftOption["meta"]>>,
|
||||||
) =>
|
) =>
|
||||||
setOptions((prev) =>
|
setOptions((prev) =>
|
||||||
prev.map((o, idx) =>
|
prev.map((o, idx) =>
|
||||||
@@ -60,26 +105,71 @@ export default function ManageDropdownOptionsDialog({
|
|||||||
setOptions((prev) => prev.filter((_, idx) => idx !== i));
|
setOptions((prev) => prev.filter((_, idx) => idx !== i));
|
||||||
|
|
||||||
const add = () =>
|
const add = () =>
|
||||||
setOptions((prev) => [
|
setOptions((prev) => [...prev, makeEmptyDraft(prev.length)]);
|
||||||
...prev,
|
|
||||||
makeEmptyOption(setting.code, prev.length),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const move = (i: number, dir: -1 | 1) =>
|
const move = (i: number, dir: -1 | 1) =>
|
||||||
setOptions((prev) => {
|
setOptions((prev) => {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
const target = i + dir;
|
const target = i + dir;
|
||||||
if (target < 0 || target >= next.length) return prev;
|
if (target < 0 || target >= next.length) return prev;
|
||||||
const a = next[i] as DropdownOption;
|
const a = next[i] as DraftOption;
|
||||||
const b = next[target] as DropdownOption;
|
const b = next[target] as DraftOption;
|
||||||
next[i] = { ...b, order: i + 1 };
|
next[i] = { ...b, order: i + 1 };
|
||||||
next[target] = { ...a, order: target + 1 };
|
next[target] = { ...a, order: target + 1 };
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const invalid = options.findIndex(
|
||||||
|
(o) => !o.label.trim() || !o.value.trim(),
|
||||||
|
);
|
||||||
|
if (invalid >= 0) {
|
||||||
|
setError(`Option ${invalid + 1} is missing a label or value.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: CreateDropdownOptionDto[] = options.map((o, idx) => {
|
||||||
|
const meta: NonNullable<CreateDropdownOptionDto["meta"]> = {};
|
||||||
|
if (o.meta?.icon?.trim()) meta.icon = o.meta.icon.trim();
|
||||||
|
if (o.meta?.color?.trim()) meta.color = o.meta.color.trim();
|
||||||
|
if (o.meta?.badge?.trim()) meta.badge = o.meta.badge.trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: o.value.trim(),
|
||||||
|
label: o.label.trim(),
|
||||||
|
note: o.note?.trim() || undefined,
|
||||||
|
disabled: o.disabled ?? false,
|
||||||
|
order: idx + 1,
|
||||||
|
...(Object.keys(meta).length > 0 ? { meta } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
replaceMutation.mutate(
|
||||||
|
{ settingId: setting.id, options: payload },
|
||||||
|
{
|
||||||
|
onSuccess: () => setOpen(false),
|
||||||
|
onError: (err) =>
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to save options. Try again.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
setOpen(next);
|
||||||
|
if (next) setOptions(seed());
|
||||||
|
if (!next) setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
|
||||||
|
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl rounded-3xl">
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl rounded-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -109,13 +199,14 @@ export default function ManageDropdownOptionsDialog({
|
|||||||
|
|
||||||
{options.length === 0 ? (
|
{options.length === 0 ? (
|
||||||
<div className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
|
<div className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
|
||||||
No options yet. Click <span className="font-medium">Add Option</span> to start.
|
No options yet. Click{" "}
|
||||||
|
<span className="font-medium">Add Option</span> to start.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{options.map((opt, i) => (
|
{options.map((opt, i) => (
|
||||||
<div
|
<div
|
||||||
key={opt.id}
|
key={opt.key}
|
||||||
className="grid gap-2 rounded-2xl border border-slate-200 bg-white p-3 md:grid-cols-[auto_1fr_1fr_1fr_auto_auto_auto]"
|
className="grid gap-2 rounded-2xl border border-slate-200 bg-white p-3 md:grid-cols-[auto_1fr_1fr_1fr_auto_auto_auto]"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1 text-slate-400">
|
<div className="flex items-center gap-1 text-slate-400">
|
||||||
@@ -152,7 +243,7 @@ export default function ManageDropdownOptionsDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Value</Label>
|
<Label className="text-xs">Value *</Label>
|
||||||
<Input
|
<Input
|
||||||
value={opt.value}
|
value={opt.value}
|
||||||
onChange={(e) => update(i, { value: e.target.value })}
|
onChange={(e) => update(i, { value: e.target.value })}
|
||||||
@@ -183,7 +274,6 @@ export default function ManageDropdownOptionsDialog({
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Color</Label>
|
<Label className="text-xs">Color</Label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
|
||||||
value={opt.meta?.color ?? ""}
|
value={opt.meta?.color ?? ""}
|
||||||
onChange={(e) => updateMeta(i, { color: e.target.value })}
|
onChange={(e) => updateMeta(i, { color: e.target.value })}
|
||||||
placeholder="#…"
|
placeholder="#…"
|
||||||
@@ -218,10 +308,29 @@ export default function ManageDropdownOptionsDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{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 border-t border-slate-100 pt-3">
|
<div className="flex justify-end gap-3 border-t border-slate-100 pt-3">
|
||||||
<Button variant="outline">Cancel</Button>
|
<DialogClose asChild>
|
||||||
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
<Button variant="outline" disabled={replaceMutation.isPending}>
|
||||||
Save Options
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={replaceMutation.isPending}
|
||||||
|
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{replaceMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Save Options"
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,388 +0,0 @@
|
|||||||
import type {
|
|
||||||
DropdownOption,
|
|
||||||
DropdownSetting,
|
|
||||||
} from "@/types/dropdownSettings";
|
|
||||||
|
|
||||||
const opt = (
|
|
||||||
setting: string,
|
|
||||||
idx: number,
|
|
||||||
label: string,
|
|
||||||
extras: Partial<DropdownOption> = {},
|
|
||||||
): DropdownOption => ({
|
|
||||||
id: `${setting}-${idx + 1}`,
|
|
||||||
value: extras.value ?? label.toLowerCase().replace(/\s+/g, "_"),
|
|
||||||
label,
|
|
||||||
order: idx + 1,
|
|
||||||
...extras,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const dropdownSettings: DropdownSetting[] = [
|
|
||||||
{
|
|
||||||
id: "ds-customer_type",
|
|
||||||
code: "customer_type",
|
|
||||||
label: "Customer Type",
|
|
||||||
description: "Used in the customer registration form.",
|
|
||||||
multiple: false,
|
|
||||||
meta: {
|
|
||||||
icon: "users",
|
|
||||||
searchable: false,
|
|
||||||
clearable: false,
|
|
||||||
permissions: ["admin", "ops"],
|
|
||||||
version: "1.0",
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
opt("customer_type", 0, "Importer"),
|
|
||||||
opt("customer_type", 1, "Exporter"),
|
|
||||||
opt("customer_type", 2, "Supplier"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ds-cargo_type",
|
|
||||||
code: "cargo_type",
|
|
||||||
label: "Cargo Type",
|
|
||||||
description: "Cargo categories used in bookings and consignments.",
|
|
||||||
multiple: false,
|
|
||||||
meta: {
|
|
||||||
icon: "package",
|
|
||||||
searchable: true,
|
|
||||||
clearable: false,
|
|
||||||
permissions: ["admin", "ops"],
|
|
||||||
version: "1.0",
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
opt("cargo_type", 0, "Containerized"),
|
|
||||||
opt("cargo_type", 1, "Bulk"),
|
|
||||||
opt("cargo_type", 2, "Liquid"),
|
|
||||||
opt("cargo_type", 3, "Refrigerated", {
|
|
||||||
meta: { badge: "Cold chain", color: "#0ea5e9" },
|
|
||||||
}),
|
|
||||||
opt("cargo_type", 4, "Hazardous", {
|
|
||||||
meta: { badge: "DG", color: "#dc2626" },
|
|
||||||
}),
|
|
||||||
opt("cargo_type", 5, "General"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ds-container_type",
|
|
||||||
code: "container_type",
|
|
||||||
label: "Container Type",
|
|
||||||
description: "Container sizes available for freight bookings.",
|
|
||||||
multiple: false,
|
|
||||||
meta: {
|
|
||||||
icon: "package",
|
|
||||||
searchable: false,
|
|
||||||
clearable: false,
|
|
||||||
permissions: ["admin", "ops"],
|
|
||||||
version: "1.0",
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
opt("container_type", 0, "20FT"),
|
|
||||||
opt("container_type", 1, "40FT"),
|
|
||||||
opt("container_type", 2, "40HC", { note: "High cube" }),
|
|
||||||
opt("container_type", 3, "Reefer", {
|
|
||||||
note: "Refrigerated",
|
|
||||||
meta: { color: "#0ea5e9" },
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ds-transport_mode",
|
|
||||||
code: "transport_mode",
|
|
||||||
label: "Transport Mode",
|
|
||||||
description: "Modes of transport. Multimodal unlocks leg-by-leg editing.",
|
|
||||||
multiple: false,
|
|
||||||
meta: {
|
|
||||||
icon: "train",
|
|
||||||
searchable: false,
|
|
||||||
clearable: false,
|
|
||||||
permissions: ["admin", "ops"],
|
|
||||||
version: "1.0",
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
opt("transport_mode", 0, "Rail"),
|
|
||||||
opt("transport_mode", 1, "Truck"),
|
|
||||||
opt("transport_mode", 2, "Multimodal", {
|
|
||||||
note: "Composes rail + truck legs",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ds-booking_priority",
|
|
||||||
code: "booking_priority",
|
|
||||||
label: "Booking Priority",
|
|
||||||
description: "Service-level priority applied to a booking.",
|
|
||||||
multiple: false,
|
|
||||||
meta: {
|
|
||||||
icon: "flag",
|
|
||||||
searchable: false,
|
|
||||||
clearable: false,
|
|
||||||
permissions: ["admin", "ops"],
|
|
||||||
version: "1.0",
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
opt("booking_priority", 0, "Normal"),
|
|
||||||
opt("booking_priority", 1, "High", {
|
|
||||||
meta: { badge: "↑", color: "#d97706" },
|
|
||||||
}),
|
|
||||||
opt("booking_priority", 2, "Urgent", {
|
|
||||||
meta: { badge: "!", color: "#dc2626" },
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ds-booking_status",
|
|
||||||
code: "booking_status",
|
|
||||||
label: "Booking Status",
|
|
||||||
description: "Lifecycle states for a freight booking.",
|
|
||||||
multiple: false,
|
|
||||||
meta: {
|
|
||||||
icon: "calendar-check",
|
|
||||||
searchable: false,
|
|
||||||
clearable: false,
|
|
||||||
permissions: ["admin", "ops"],
|
|
||||||
version: "1.0",
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
opt("booking_status", 0, "Pending", { meta: { color: "#d97706" } }),
|
|
||||||
opt("booking_status", 1, "Confirmed", { meta: { color: "#0ea5e9" } }),
|
|
||||||
opt("booking_status", 2, "In Transit", { meta: { color: "#6366f1" } }),
|
|
||||||
opt("booking_status", 3, "Delivered", { meta: { color: "#059669" } }),
|
|
||||||
opt("booking_status", 4, "Cancelled", { meta: { color: "#dc2626" } }),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ds-consignment_status",
|
|
||||||
code: "consignment_status",
|
|
||||||
label: "Consignment Status",
|
|
||||||
description: "Handling status for an individual consignment.",
|
|
||||||
multiple: false,
|
|
||||||
meta: {
|
|
||||||
icon: "package",
|
|
||||||
searchable: false,
|
|
||||||
clearable: false,
|
|
||||||
permissions: ["admin", "ops"],
|
|
||||||
version: "1.0",
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
opt("consignment_status", 0, "Pending", { meta: { color: "#d97706" } }),
|
|
||||||
opt("consignment_status", 1, "In Warehouse", {
|
|
||||||
meta: { color: "#0ea5e9" },
|
|
||||||
}),
|
|
||||||
opt("consignment_status", 2, "In Transit", {
|
|
||||||
meta: { color: "#6366f1" },
|
|
||||||
}),
|
|
||||||
opt("consignment_status", 3, "Delivered", {
|
|
||||||
meta: { color: "#059669" },
|
|
||||||
}),
|
|
||||||
opt("consignment_status", 4, "Returned", { meta: { color: "#dc2626" } }),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ds-shipment_status",
|
|
||||||
code: "shipment_status",
|
|
||||||
label: "Shipment Status",
|
|
||||||
description: "Tracking states for an in-flight shipment.",
|
|
||||||
multiple: false,
|
|
||||||
meta: {
|
|
||||||
icon: "truck",
|
|
||||||
searchable: false,
|
|
||||||
clearable: false,
|
|
||||||
permissions: ["admin", "ops"],
|
|
||||||
version: "1.0",
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
opt("shipment_status", 0, "In Transit", { meta: { color: "#6366f1" } }),
|
|
||||||
opt("shipment_status", 1, "Delivered", { meta: { color: "#059669" } }),
|
|
||||||
opt("shipment_status", 2, "Delayed", { meta: { color: "#dc2626" } }),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ds-shipment_mode",
|
|
||||||
code: "shipment_mode",
|
|
||||||
label: "Shipment Mode",
|
|
||||||
description: "Active transport mode for a shipment.",
|
|
||||||
multiple: false,
|
|
||||||
meta: {
|
|
||||||
icon: "train",
|
|
||||||
searchable: false,
|
|
||||||
clearable: false,
|
|
||||||
permissions: ["admin", "ops"],
|
|
||||||
version: "1.0",
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
opt("shipment_mode", 0, "Rail", { value: "rail" }),
|
|
||||||
opt("shipment_mode", 1, "Truck", { value: "truck" }),
|
|
||||||
opt("shipment_mode", 2, "Multimodal", { value: "multimodal" }),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ds-train_type",
|
|
||||||
code: "train_type",
|
|
||||||
label: "Train Type",
|
|
||||||
description: "Rolling stock categories.",
|
|
||||||
multiple: false,
|
|
||||||
meta: {
|
|
||||||
icon: "train",
|
|
||||||
searchable: false,
|
|
||||||
clearable: false,
|
|
||||||
permissions: ["admin", "fleet"],
|
|
||||||
version: "1.0",
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
opt("train_type", 0, "Locomotive"),
|
|
||||||
opt("train_type", 1, "Freight Wagon"),
|
|
||||||
opt("train_type", 2, "Tanker Wagon"),
|
|
||||||
opt("train_type", 3, "Container Wagon"),
|
|
||||||
opt("train_type", 4, "Reefer Wagon"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ds-train_status",
|
|
||||||
code: "train_status",
|
|
||||||
label: "Train Status",
|
|
||||||
description: "Operational status of rolling stock.",
|
|
||||||
multiple: false,
|
|
||||||
meta: {
|
|
||||||
icon: "wrench",
|
|
||||||
searchable: false,
|
|
||||||
clearable: false,
|
|
||||||
permissions: ["admin", "fleet"],
|
|
||||||
version: "1.0",
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
opt("train_status", 0, "Operational", { meta: { color: "#059669" } }),
|
|
||||||
opt("train_status", 1, "In Maintenance", {
|
|
||||||
meta: { color: "#d97706" },
|
|
||||||
}),
|
|
||||||
opt("train_status", 2, "Idle", { meta: { color: "#475569" } }),
|
|
||||||
opt("train_status", 3, "Out of Service", {
|
|
||||||
meta: { color: "#dc2626" },
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ds-currency",
|
|
||||||
code: "currency",
|
|
||||||
label: "Currency",
|
|
||||||
description: "Currencies accepted on invoices.",
|
|
||||||
multiple: false,
|
|
||||||
meta: {
|
|
||||||
icon: "dollar-sign",
|
|
||||||
searchable: true,
|
|
||||||
clearable: false,
|
|
||||||
permissions: ["admin", "finance"],
|
|
||||||
version: "1.0",
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
opt("currency", 0, "USD", { meta: { badge: "$" } }),
|
|
||||||
opt("currency", 1, "ETB", { meta: { badge: "Br" } }),
|
|
||||||
opt("currency", 2, "DJF", { meta: { badge: "DJF" } }),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ds-invoice_status",
|
|
||||||
code: "invoice_status",
|
|
||||||
label: "Invoice Status",
|
|
||||||
description: "Lifecycle of a billing invoice.",
|
|
||||||
multiple: false,
|
|
||||||
meta: {
|
|
||||||
icon: "receipt",
|
|
||||||
searchable: false,
|
|
||||||
clearable: false,
|
|
||||||
permissions: ["admin", "finance"],
|
|
||||||
version: "1.0",
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
opt("invoice_status", 0, "Draft", { meta: { color: "#475569" } }),
|
|
||||||
opt("invoice_status", 1, "Sent", { meta: { color: "#0ea5e9" } }),
|
|
||||||
opt("invoice_status", 2, "Paid", { meta: { color: "#059669" } }),
|
|
||||||
opt("invoice_status", 3, "Overdue", { meta: { color: "#dc2626" } }),
|
|
||||||
opt("invoice_status", 4, "Cancelled", { meta: { color: "#d97706" } }),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ds-document_type",
|
|
||||||
code: "document_type",
|
|
||||||
label: "Document Type",
|
|
||||||
description: "Freight document categories.",
|
|
||||||
multiple: false,
|
|
||||||
meta: {
|
|
||||||
icon: "file-text",
|
|
||||||
searchable: true,
|
|
||||||
clearable: false,
|
|
||||||
permissions: ["admin", "ops"],
|
|
||||||
version: "1.0",
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
opt("document_type", 0, "Bill of Lading"),
|
|
||||||
opt("document_type", 1, "Commercial Invoice"),
|
|
||||||
opt("document_type", 2, "Packing List"),
|
|
||||||
opt("document_type", 3, "Customs Declaration"),
|
|
||||||
opt("document_type", 4, "Certificate of Origin"),
|
|
||||||
opt("document_type", 5, "Insurance Certificate"),
|
|
||||||
opt("document_type", 6, "Delivery Receipt"),
|
|
||||||
opt("document_type", 7, "Proof of Delivery"),
|
|
||||||
opt("document_type", 8, "Contract"),
|
|
||||||
opt("document_type", 9, "Other"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ds-document_status",
|
|
||||||
code: "document_status",
|
|
||||||
label: "Document Status",
|
|
||||||
description: "Review status of uploaded documents.",
|
|
||||||
multiple: false,
|
|
||||||
meta: {
|
|
||||||
icon: "file-text",
|
|
||||||
searchable: false,
|
|
||||||
clearable: false,
|
|
||||||
permissions: ["admin", "ops"],
|
|
||||||
version: "1.0",
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
opt("document_status", 0, "Draft", { meta: { color: "#475569" } }),
|
|
||||||
opt("document_status", 1, "Pending Review", {
|
|
||||||
meta: { color: "#d97706" },
|
|
||||||
}),
|
|
||||||
opt("document_status", 2, "Approved", { meta: { color: "#059669" } }),
|
|
||||||
opt("document_status", 3, "Rejected", { meta: { color: "#dc2626" } }),
|
|
||||||
opt("document_status", 4, "Expired", { meta: { color: "#94a3b8" } }),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ds-permission",
|
|
||||||
code: "permission",
|
|
||||||
label: "Permission Tag",
|
|
||||||
description:
|
|
||||||
"Tags used to gate dropdowns (multi-select). Drives meta.permissions on other settings.",
|
|
||||||
multiple: true,
|
|
||||||
meta: {
|
|
||||||
icon: "shield",
|
|
||||||
searchable: true,
|
|
||||||
clearable: true,
|
|
||||||
permissions: ["admin"],
|
|
||||||
version: "1.0",
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
opt("permission", 0, "admin"),
|
|
||||||
opt("permission", 1, "ops"),
|
|
||||||
opt("permission", 2, "fleet"),
|
|
||||||
opt("permission", 3, "finance"),
|
|
||||||
opt("permission", 4, "viewer"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function getSettingByCode(
|
|
||||||
code: string,
|
|
||||||
): DropdownSetting | undefined {
|
|
||||||
return dropdownSettings.find((s) => s.code === code);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getOptionsByCode(code: string): DropdownOption[] {
|
|
||||||
const setting = getSettingByCode(code);
|
|
||||||
if (!setting) return [];
|
|
||||||
return [...setting.children].sort(
|
|
||||||
(a, b) => (a.order ?? 0) - (b.order ?? 0),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
|
AlertCircle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Building2,
|
Building2,
|
||||||
FileText,
|
FileText,
|
||||||
Globe,
|
Globe,
|
||||||
|
Loader2,
|
||||||
Mail,
|
Mail,
|
||||||
MapPin,
|
MapPin,
|
||||||
Phone,
|
Phone,
|
||||||
@@ -15,14 +18,36 @@ import {
|
|||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
import NewCustomerPage from "./NewCustomerPage";
|
import NewCustomerPage from "./NewCustomerPage";
|
||||||
import DeleteCustomerDialog from "./DeleteCustomerDialog";
|
import DeleteCustomerDialog from "./DeleteCustomerDialog";
|
||||||
import { getCustomerById, type CustomerStatus } from "./customers.mock";
|
import { useCustomer, useDeleteCustomer } from "@/hooks/useCustomers";
|
||||||
|
import type { CustomerStatus } from "@/types/customers";
|
||||||
|
|
||||||
export default function CustomerDetailPage() {
|
export default function CustomerDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const customer = id ? getCustomerById(id) : undefined;
|
|
||||||
|
|
||||||
if (!customer) {
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 p-6">
|
<div className="min-h-screen bg-slate-50 p-6">
|
||||||
<div className="mx-auto max-w-5xl space-y-6">
|
<div className="mx-auto max-w-5xl space-y-6">
|
||||||
@@ -34,11 +59,14 @@ export default function CustomerDetailPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="rounded-3xl bg-white p-8 text-center shadow-sm">
|
<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">
|
<h1 className="text-2xl font-bold text-slate-900">
|
||||||
Customer not found
|
{isError ? "Failed to load customer" : "Customer not found"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-slate-500">
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
The customer you're looking for doesn't exist or has been removed.
|
{isError && error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "The customer you're looking for doesn't exist or has been removed."}
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
to="/customers"
|
to="/customers"
|
||||||
@@ -53,6 +81,12 @@ export default function CustomerDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
deleteMutation.mutate(customer.id, {
|
||||||
|
onSuccess: () => navigate("/customers"),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 p-6">
|
<div className="min-h-screen bg-slate-50 p-6">
|
||||||
<div className="mx-auto max-w-5xl space-y-6">
|
<div className="mx-auto max-w-5xl space-y-6">
|
||||||
@@ -76,9 +110,9 @@ export default function CustomerDetailPage() {
|
|||||||
{customer.name}
|
{customer.name}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="mt-1 flex items-center gap-3 text-sm text-slate-500">
|
<div className="mt-1 flex items-center gap-3 text-sm text-slate-500">
|
||||||
<span>ID #{customer.id}</span>
|
<span className="font-mono text-xs">#{customer.id.slice(0, 8)}</span>
|
||||||
<span className="text-slate-300">•</span>
|
<span className="text-slate-300">•</span>
|
||||||
<span>{customer.company}</span>
|
<span>{customer.company ?? "—"}</span>
|
||||||
<span className="text-slate-300">•</span>
|
<span className="text-slate-300">•</span>
|
||||||
<StatusBadge status={customer.status} />
|
<StatusBadge status={customer.status} />
|
||||||
</div>
|
</div>
|
||||||
@@ -86,41 +120,22 @@ export default function CustomerDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<NewCustomerPage
|
<button
|
||||||
mode="edit"
|
type="button"
|
||||||
customer={{
|
onClick={() => setEditOpen(true)}
|
||||||
companyName: customer.company,
|
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"
|
||||||
customerType: customer.customerType,
|
|
||||||
contactPerson: customer.name,
|
|
||||||
email: customer.email,
|
|
||||||
phone: customer.phone,
|
|
||||||
tinNumber: customer.tinNumber,
|
|
||||||
city: customer.city,
|
|
||||||
country: customer.country,
|
|
||||||
address: customer.address,
|
|
||||||
notes: customer.notes,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<button
|
Edit Customer
|
||||||
type="button"
|
</button>
|
||||||
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>
|
|
||||||
</NewCustomerPage>
|
|
||||||
|
|
||||||
<DeleteCustomerDialog
|
<button
|
||||||
customerName={customer.name}
|
type="button"
|
||||||
onConfirm={() => navigate("/customers")}
|
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"
|
||||||
>
|
>
|
||||||
<button
|
<Trash2 className="h-4 w-4" />
|
||||||
type="button"
|
Delete
|
||||||
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"
|
</button>
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</DeleteCustomerDialog>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +146,7 @@ export default function CustomerDetailPage() {
|
|||||||
<DetailRow
|
<DetailRow
|
||||||
icon={<Building2 className="h-4 w-4" />}
|
icon={<Building2 className="h-4 w-4" />}
|
||||||
label="Company Name"
|
label="Company Name"
|
||||||
value={customer.company}
|
value={customer.company ?? "—"}
|
||||||
/>
|
/>
|
||||||
<DetailRow
|
<DetailRow
|
||||||
icon={<FileText className="h-4 w-4" />}
|
icon={<FileText className="h-4 w-4" />}
|
||||||
@@ -141,7 +156,7 @@ export default function CustomerDetailPage() {
|
|||||||
<DetailRow
|
<DetailRow
|
||||||
icon={<FileText className="h-4 w-4" />}
|
icon={<FileText className="h-4 w-4" />}
|
||||||
label="TIN Number"
|
label="TIN Number"
|
||||||
value={customer.tinNumber}
|
value={customer.tinNumber ?? "—"}
|
||||||
/>
|
/>
|
||||||
</DetailCard>
|
</DetailCard>
|
||||||
|
|
||||||
@@ -167,28 +182,41 @@ export default function CustomerDetailPage() {
|
|||||||
<DetailRow
|
<DetailRow
|
||||||
icon={<MapPin className="h-4 w-4" />}
|
icon={<MapPin className="h-4 w-4" />}
|
||||||
label="City"
|
label="City"
|
||||||
value={customer.city}
|
value={customer.city ?? "—"}
|
||||||
/>
|
/>
|
||||||
<DetailRow
|
<DetailRow
|
||||||
icon={<Globe className="h-4 w-4" />}
|
icon={<Globe className="h-4 w-4" />}
|
||||||
label="Country"
|
label="Country"
|
||||||
value={customer.country}
|
value={customer.country ?? "—"}
|
||||||
/>
|
/>
|
||||||
<DetailRow
|
<DetailRow
|
||||||
icon={<MapPin className="h-4 w-4" />}
|
icon={<MapPin className="h-4 w-4" />}
|
||||||
label="Address"
|
label="Address"
|
||||||
value={customer.address}
|
value={customer.address ?? "—"}
|
||||||
/>
|
/>
|
||||||
</DetailCard>
|
</DetailCard>
|
||||||
|
|
||||||
<DetailCard title="Notes">
|
<DetailCard title="Notes">
|
||||||
<div className="flex items-start gap-3 text-sm text-slate-700">
|
<div className="flex items-start gap-3 text-sm text-slate-700">
|
||||||
<StickyNote className="mt-0.5 h-4 w-4 text-[#10B981]" />
|
<StickyNote className="mt-0.5 h-4 w-4 text-[#10B981]" />
|
||||||
<p className="leading-relaxed">{customer.notes}</p>
|
<p className="leading-relaxed">{customer.notes ?? "—"}</p>
|
||||||
</div>
|
</div>
|
||||||
</DetailCard>
|
</DetailCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NewCustomerPage
|
||||||
|
mode="edit"
|
||||||
|
customer={customer}
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
/>
|
||||||
|
<DeleteCustomerDialog
|
||||||
|
customerName={customer.name}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={setDeleteOpen}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useMemo } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
|
AlertCircle,
|
||||||
Clock3,
|
Clock3,
|
||||||
Eye,
|
Eye,
|
||||||
Filter,
|
Filter,
|
||||||
|
Loader2,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -17,11 +19,8 @@ import {
|
|||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
import NewCustomerPage from "./NewCustomerPage";
|
import NewCustomerPage from "./NewCustomerPage";
|
||||||
import DeleteCustomerDialog from "./DeleteCustomerDialog";
|
import DeleteCustomerDialog from "./DeleteCustomerDialog";
|
||||||
import {
|
import { useCustomers, useDeleteCustomer } from "@/hooks/useCustomers";
|
||||||
customers,
|
import type { Customer, CustomerStatus } from "@/types/customers";
|
||||||
type CustomerStatus,
|
|
||||||
type Customer,
|
|
||||||
} from "./customers.mock";
|
|
||||||
import {
|
import {
|
||||||
DataTable,
|
DataTable,
|
||||||
DataTableFooter,
|
DataTableFooter,
|
||||||
@@ -41,20 +40,77 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
type ActiveDialog = "edit" | "delete";
|
||||||
|
|
||||||
export default function CustomerPage() {
|
export default function CustomerPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
const total = customers.length;
|
const [activeDialog, setActiveDialog] = useState<ActiveDialog | null>(null);
|
||||||
const pageCount = Math.ceil(total / pagination.pageSize);
|
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 start = pagination.pageIndex * pagination.pageSize;
|
||||||
const end = Math.min(start + pagination.pageSize, total);
|
const end = Math.min(start + pagination.pageSize, total);
|
||||||
|
|
||||||
const paginatedData = useMemo(
|
const paginatedData = useMemo(
|
||||||
() => customers.slice(start, end),
|
() => filtered.slice(start, end),
|
||||||
[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>[] = [
|
const columns: ColumnDef<Customer>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
@@ -68,16 +124,14 @@ export default function CustomerPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-slate-900">{customer.name}</p>
|
<p className="font-medium text-slate-900">{customer.name}</p>
|
||||||
<p className="text-sm text-slate-500">ID #{customer.id}</p>
|
<p className="text-sm text-slate-500">
|
||||||
|
{customer.company ?? "—"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
accessorKey: "company",
|
|
||||||
header: "Company",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
accessorKey: "email",
|
accessorKey: "email",
|
||||||
header: "Email",
|
header: "Email",
|
||||||
@@ -85,6 +139,22 @@ export default function CustomerPage() {
|
|||||||
<span className="text-sm text-slate-700">{row.original.email}</span>
|
<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",
|
accessorKey: "status",
|
||||||
header: "Status",
|
header: "Status",
|
||||||
@@ -100,7 +170,7 @@ export default function CustomerPage() {
|
|||||||
className="flex justify-end"
|
className="flex justify-end"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<DropdownMenu>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="icon">
|
<Button variant="outline" size="icon">
|
||||||
<MoreHorizontal />
|
<MoreHorizontal />
|
||||||
@@ -108,41 +178,25 @@ export default function CustomerPage() {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate(`/customers/${customer.id}`)}
|
onSelect={() => navigate(`/customers/${customer.id}`)}
|
||||||
>
|
>
|
||||||
<Eye />
|
<Eye />
|
||||||
View
|
View
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<NewCustomerPage
|
<DropdownMenuItem
|
||||||
mode="edit"
|
onSelect={() => openDialogFor("edit", customer)}
|
||||||
customer={{
|
|
||||||
companyName: customer.company,
|
|
||||||
customerType: customer.customerType,
|
|
||||||
contactPerson: customer.name,
|
|
||||||
email: customer.email,
|
|
||||||
phone: customer.phone,
|
|
||||||
tinNumber: customer.tinNumber,
|
|
||||||
city: customer.city,
|
|
||||||
country: customer.country,
|
|
||||||
address: customer.address,
|
|
||||||
notes: customer.notes,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
|
<Pencil />
|
||||||
<Pencil />
|
Edit
|
||||||
Edit
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
|
||||||
</NewCustomerPage>
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DeleteCustomerDialog customerName={customer.name}>
|
<DropdownMenuItem
|
||||||
<DropdownMenuItem
|
onSelect={() => openDialogFor("delete", customer)}
|
||||||
onSelect={(e: Event) => e.preventDefault()}
|
variant="destructive"
|
||||||
variant="destructive"
|
>
|
||||||
>
|
<Trash2 />
|
||||||
<Trash2 />
|
Delete
|
||||||
Delete
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
|
||||||
</DeleteCustomerDialog>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,6 +225,14 @@ export default function CustomerPage() {
|
|||||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
<Input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setPagination({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
});
|
||||||
|
}}
|
||||||
placeholder="Search customers..."
|
placeholder="Search customers..."
|
||||||
className="pl-8!"
|
className="pl-8!"
|
||||||
/>
|
/>
|
||||||
@@ -188,23 +250,31 @@ export default function CustomerPage() {
|
|||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Total Customers"
|
title="Total Customers"
|
||||||
value="1,284"
|
value={customers.length}
|
||||||
icon={<Users className="h-5 w-5" />}
|
icon={<Users className="h-5 w-5" />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Active Accounts"
|
title="Active Accounts"
|
||||||
value="964"
|
value={activeCount}
|
||||||
icon={<UserCheck className="h-5 w-5" />}
|
icon={<UserCheck className="h-5 w-5" />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Pending Requests"
|
title="Pending Requests"
|
||||||
value="48"
|
value={pendingCount}
|
||||||
icon={<Clock3 className="h-5 w-5" />}
|
icon={<Clock3 className="h-5 w-5" />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<Card className="gap-0">
|
||||||
<CardHeader className="flex flex-row items-center justify-between border-b ">
|
<CardHeader className="flex flex-row items-center justify-between border-b ">
|
||||||
<div>
|
<div>
|
||||||
@@ -221,27 +291,55 @@ export default function CustomerPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="px-0">
|
<CardContent className="px-0">
|
||||||
<DataTable
|
{isLoading ? (
|
||||||
columns={columns}
|
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
|
||||||
data={paginatedData}
|
<Loader2 className="mr-2 h-4 w-4 animate-spin text-primary" />
|
||||||
status="success"
|
Loading customers…
|
||||||
onRowClick={(row) => navigate(`/customers/${row.id}`)}
|
</div>
|
||||||
pagination={{
|
) : (
|
||||||
pageIndex: pagination.pageIndex,
|
<DataTable
|
||||||
pageSize: pagination.pageSize,
|
columns={columns}
|
||||||
pageCount: pageCount,
|
data={paginatedData}
|
||||||
totalCount: total,
|
status={status}
|
||||||
}}
|
onRowClick={(row) => navigate(`/customers/${row.id}`)}
|
||||||
tableOptions={{
|
pagination={{
|
||||||
state: { pagination },
|
pageIndex: pagination.pageIndex,
|
||||||
onPaginationChange: setPagination,
|
pageSize: pagination.pageSize,
|
||||||
}}
|
pageCount: pageCount,
|
||||||
containerClassName="border-b shadow-none"
|
totalCount: total,
|
||||||
footer={DataTableFooter}
|
}}
|
||||||
/>
|
tableOptions={{
|
||||||
|
state: { pagination },
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
}}
|
||||||
|
containerClassName="border-b shadow-none"
|
||||||
|
footer={DataTableFooter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -252,7 +350,7 @@ function StatCard({
|
|||||||
icon,
|
icon,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
value: string;
|
value: number;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -9,24 +9,35 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
|
Button,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
import { Button } from "@edr/ui-common";
|
|
||||||
|
|
||||||
export interface DeleteCustomerDialogProps {
|
export interface DeleteCustomerDialogProps {
|
||||||
customerName: string;
|
customerName: string;
|
||||||
onConfirm?: () => void;
|
onConfirm?: () => void;
|
||||||
children: ReactNode;
|
children?: ReactNode;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DeleteCustomerDialog({
|
export default function DeleteCustomerDialog({
|
||||||
customerName,
|
customerName,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
children,
|
children,
|
||||||
|
open: openProp,
|
||||||
|
onOpenChange,
|
||||||
}: DeleteCustomerDialogProps) {
|
}: 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 (
|
return (
|
||||||
<Dialog>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { IFileUploadSetting } from "@edr/types/freight";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { useState, type ReactNode } from "react";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
@@ -24,106 +26,165 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
FileText,
|
FileText,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCreateCustomer,
|
||||||
|
useUpdateCustomer,
|
||||||
|
} from "@/hooks/useCustomers";
|
||||||
import { getFileUploadSettingByCode } from "@/services/fileUploadSettings.service";
|
import { getFileUploadSettingByCode } from "@/services/fileUploadSettings.service";
|
||||||
import { FILE_SETTINGS } from "@/constants/FILE_SETTINGS";
|
import { FILE_SETTINGS } from "@/constants/FILE_SETTINGS";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import type {
|
||||||
|
CreateCustomerDto,
|
||||||
|
Customer,
|
||||||
|
CustomerStatus,
|
||||||
|
CustomerType,
|
||||||
|
} from "@/types/customers";
|
||||||
|
|
||||||
export interface CustomerFormData {
|
const CUSTOMER_TYPES: CustomerType[] = ["Importer", "Exporter", "Supplier"];
|
||||||
companyName?: string;
|
const CUSTOMER_STATUSES: CustomerStatus[] = ["Active", "Pending", "Inactive"];
|
||||||
customerType?: string;
|
|
||||||
contactPerson?: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
tinNumber?: string;
|
|
||||||
city?: string;
|
|
||||||
country?: string;
|
|
||||||
address?: string;
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NewCustomerPageProps {
|
export interface NewCustomerPageProps {
|
||||||
mode?: "create" | "edit";
|
mode?: "create" | "edit";
|
||||||
customer?: CustomerFormData;
|
customer?: Customer;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
/** Controlled open. When omitted, the dialog manages its own open state. */
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
company: string;
|
||||||
|
customerType: CustomerType;
|
||||||
|
status: CustomerStatus;
|
||||||
|
tinNumber: string;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
address: string;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyForm = (): FormState => ({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
company: "",
|
||||||
|
customerType: "Importer",
|
||||||
|
status: "Active",
|
||||||
|
tinNumber: "",
|
||||||
|
city: "",
|
||||||
|
country: "",
|
||||||
|
address: "",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fromCustomer = (c: Customer): FormState => ({
|
||||||
|
name: c.name ?? "",
|
||||||
|
email: c.email ?? "",
|
||||||
|
phone: c.phone ?? "",
|
||||||
|
company: c.company ?? "",
|
||||||
|
customerType: c.customerType ?? "Importer",
|
||||||
|
status: c.status ?? "Active",
|
||||||
|
tinNumber: c.tinNumber ?? "",
|
||||||
|
city: c.city ?? "",
|
||||||
|
country: c.country ?? "",
|
||||||
|
address: c.address ?? "",
|
||||||
|
notes: c.notes ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
export default function NewCustomerPage({
|
export default function NewCustomerPage({
|
||||||
mode = "create",
|
mode = "create",
|
||||||
customer,
|
customer,
|
||||||
children,
|
children,
|
||||||
}: NewCustomerPageProps = {}) { //getFileUploadSettingByCode
|
open: openProp,
|
||||||
const [files, setFiles] = useState<
|
onOpenChange,
|
||||||
Record<string, File | File[] | null>
|
}: 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 { data: customerRegistrationFiles, isLoading, isError, error } = useQuery(
|
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({
|
getFileUploadSettingByCode.queryOptions({
|
||||||
input: FILE_SETTINGS.CUSTOMER_REGISTRATION,
|
input: FILE_SETTINGS.CUSTOMER_REGISTRATION,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
console.log("customerRegistrationFiles", customerRegistrationFiles)
|
|
||||||
// const shipmentFileUploadSetting: IFileUploadSetting = {
|
|
||||||
// id: "setting_1",
|
|
||||||
// code: "shipment_documents",
|
|
||||||
// label: "Shipment Documents",
|
|
||||||
// description: "Upload all required shipment-related documents",
|
|
||||||
|
|
||||||
// entity: "shipment", // depends on your FileUploadEntity enum/type
|
const createMutation = useCreateCustomer();
|
||||||
|
const updateMutation = useUpdateCustomer();
|
||||||
|
const pending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
// fields: [
|
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||||
// {
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
// id: "field_1",
|
|
||||||
// fileKey: "invoice",
|
|
||||||
// fileLabel: "Invoice",
|
|
||||||
// order: 1,
|
|
||||||
// isRequired: true,
|
|
||||||
// isMultiple: false,
|
|
||||||
// maxFiles: 1,
|
|
||||||
// maxSizeMb: 5,
|
|
||||||
// allowedExtensions: ["pdf", "jpg", "png"],
|
|
||||||
// helpText: "Upload commercial invoice",
|
|
||||||
// settingId: "",
|
|
||||||
// createdAt: "",
|
|
||||||
// updatedAt: ""
|
|
||||||
// },
|
|
||||||
|
|
||||||
// {
|
const handleSubmit = () => {
|
||||||
// id: "field_2",
|
setError(null);
|
||||||
// fileKey: "packing_list",
|
|
||||||
// fileLabel: "Packing List",
|
|
||||||
// order: 2,
|
|
||||||
// isRequired: false,
|
|
||||||
// isMultiple: true,
|
|
||||||
// maxFiles: 3,
|
|
||||||
// maxSizeMb: 10,
|
|
||||||
// allowedExtensions: ["pdf", "xlsx"],
|
|
||||||
// helpText: "Optional packing list documents",
|
|
||||||
// settingId: "",
|
|
||||||
// createdAt: "",
|
|
||||||
// updatedAt: ""
|
|
||||||
// },
|
|
||||||
|
|
||||||
// {
|
if (!form.name.trim() || !form.email.trim() || !form.phone.trim()) {
|
||||||
// id: "field_3",
|
setError("Name, email, and phone are required.");
|
||||||
// fileKey: "cargo_images",
|
return;
|
||||||
// fileLabel: "Cargo Images",
|
}
|
||||||
// order: 3,
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
|
||||||
// isRequired: false,
|
setError("Please enter a valid email address.");
|
||||||
// isMultiple: true,
|
return;
|
||||||
// maxFiles: 5,
|
}
|
||||||
// maxSizeMb: 2,
|
|
||||||
// allowedExtensions: ["jpg", "jpeg", "png"],
|
const payload: CreateCustomerDto = {
|
||||||
// helpText: "Photos of cargo condition",
|
name: form.name.trim(),
|
||||||
// settingId: "",
|
email: form.email.trim(),
|
||||||
// createdAt: "",
|
phone: form.phone.trim(),
|
||||||
// updatedAt: ""
|
customerType: form.customerType,
|
||||||
// },
|
status: form.status,
|
||||||
// ],
|
company: form.company.trim() || undefined,
|
||||||
// createdAt: "",
|
tinNumber: form.tinNumber.trim() || undefined,
|
||||||
// updatedAt: ""
|
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 isEdit = mode === "edit";
|
|
||||||
const title = isEdit ? "Edit Customer" : "New Customer";
|
const title = isEdit ? "Edit Customer" : "New Customer";
|
||||||
const description = isEdit
|
const description = isEdit
|
||||||
? "Update existing customer information."
|
? "Update existing customer information."
|
||||||
@@ -131,177 +192,193 @@ export default function NewCustomerPage({
|
|||||||
const submitLabel = isEdit ? "Save Changes" : "Create Customer";
|
const submitLabel = isEdit ? "Save Changes" : "Create Customer";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
{!isControlled ? (
|
||||||
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
|
<DialogTrigger asChild>
|
||||||
</DialogTrigger>
|
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
|
||||||
|
</DialogTrigger>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl!">
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl!">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>{description}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
<div className="grid gap-5 py-4 md:grid-cols-2">
|
||||||
{/* Company Name */}
|
<Field label="Company Name">
|
||||||
<div className="space-y-2">
|
<Building2 className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Label>Company Name *</Label>
|
<Input
|
||||||
|
value={form.company}
|
||||||
|
onChange={(e) => set("company", e.target.value)}
|
||||||
|
placeholder="Enter company name"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<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">
|
<div className="space-y-2">
|
||||||
<Label>Customer Type *</Label>
|
<Label>Customer Type *</Label>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
defaultValue={customer?.customerType ?? "Importer"}
|
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"
|
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>
|
{CUSTOMER_TYPES.map((t) => (
|
||||||
<option>Exporter</option>
|
<option key={t} value={t}>
|
||||||
<option>Supplier</option>
|
{t}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contact Person */}
|
<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">
|
<div className="space-y-2">
|
||||||
<Label>Contact Person</Label>
|
<Label>Status</Label>
|
||||||
|
<select
|
||||||
<div className="relative">
|
value={form.status}
|
||||||
<User className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
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"
|
||||||
<Input
|
>
|
||||||
defaultValue={customer?.contactPerson ?? ""}
|
{CUSTOMER_STATUSES.map((s) => (
|
||||||
placeholder="Enter contact person"
|
<option key={s} value={s}>
|
||||||
className="pl-10"
|
{s}
|
||||||
/>
|
</option>
|
||||||
</div>
|
))}
|
||||||
|
</select>
|
||||||
</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">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<Label>Address</Label>
|
<Label>Address</Label>
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
defaultValue={customer?.address ?? ""}
|
value={form.address}
|
||||||
|
onChange={(e) => set("address", e.target.value)}
|
||||||
placeholder="Enter address"
|
placeholder="Enter address"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notes */}
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<Label>Notes</Label>
|
<Label>Notes</Label>
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
defaultValue={customer?.notes ?? ""}
|
value={form.notes}
|
||||||
|
onChange={(e) => set("notes", e.target.value)}
|
||||||
placeholder="Additional notes..."
|
placeholder="Additional notes..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
{
|
{customerRegistrationFiles ? (
|
||||||
customerRegistrationFiles &&
|
<div>
|
||||||
<SmartFileInput
|
<SmartFileInput
|
||||||
file={customerRegistrationFiles}
|
file={customerRegistrationFiles}
|
||||||
value={files}
|
value={files}
|
||||||
onChange={setFiles}
|
onChange={setFiles}
|
||||||
/>
|
/>
|
||||||
}
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
|
|
||||||
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
{error ? (
|
||||||
{submitLabel}
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{label}</Label>
|
||||||
|
<div className="relative">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { client } from "@/utils/api";
|
||||||
|
import { unwrap } from "@/utils/endpoint";
|
||||||
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
import type { ApiResponse } from "@/types/apiResponse";
|
||||||
|
import type {
|
||||||
|
CreateCustomerDto,
|
||||||
|
Customer,
|
||||||
|
UpdateCustomerDto,
|
||||||
|
} from "@/types/customers";
|
||||||
|
|
||||||
|
const BASE = URL_CONSTANTS.CUSTOMERS_API.BASE;
|
||||||
|
|
||||||
|
export const customersService = {
|
||||||
|
list: async (): Promise<Customer[]> => {
|
||||||
|
const response = await client.get<ApiResponse<Customer[]>>(BASE);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: string): Promise<Customer> => {
|
||||||
|
const response = await client.get<ApiResponse<Customer>>(
|
||||||
|
URL_CONSTANTS.CUSTOMERS_API.BY_ID(id),
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (payload: CreateCustomerDto): Promise<Customer> => {
|
||||||
|
const response = await client.post<ApiResponse<Customer>>(BASE, payload);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (
|
||||||
|
id: string,
|
||||||
|
payload: UpdateCustomerDto,
|
||||||
|
): Promise<Customer> => {
|
||||||
|
const response = await client.patch<ApiResponse<Customer>>(
|
||||||
|
URL_CONSTANTS.CUSTOMERS_API.BY_ID(id),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
remove: async (id: string): Promise<void> => {
|
||||||
|
await client.delete(URL_CONSTANTS.CUSTOMERS_API.BY_ID(id));
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { client } from "@/utils/api";
|
||||||
|
import { unwrap } from "@/utils/endpoint";
|
||||||
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
import type { ApiResponse } from "@/types/apiResponse";
|
||||||
|
import type {
|
||||||
|
CreateDropdownOptionDto,
|
||||||
|
CreateDropdownSettingDto,
|
||||||
|
DropdownOption,
|
||||||
|
DropdownSetting,
|
||||||
|
UpdateDropdownOptionDto,
|
||||||
|
UpdateDropdownSettingDto,
|
||||||
|
} from "@/types/dropdownSettings";
|
||||||
|
|
||||||
|
const BASE = URL_CONSTANTS.DROPDOWN_SETTINGS.BASE;
|
||||||
|
|
||||||
|
export const dropdownSettingsService = {
|
||||||
|
list: async (): Promise<DropdownSetting[]> => {
|
||||||
|
const response = await client.get<ApiResponse<DropdownSetting[]>>(BASE);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: string): Promise<DropdownSetting> => {
|
||||||
|
const response = await client.get<ApiResponse<DropdownSetting>>(
|
||||||
|
URL_CONSTANTS.DROPDOWN_SETTINGS.BY_ID(id),
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
getByCode: async (code: string): Promise<DropdownSetting> => {
|
||||||
|
const response = await client.get<ApiResponse<DropdownSetting>>(
|
||||||
|
URL_CONSTANTS.DROPDOWN_SETTINGS.BY_CODE(code),
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (
|
||||||
|
payload: CreateDropdownSettingDto,
|
||||||
|
): Promise<DropdownSetting> => {
|
||||||
|
const response = await client.post<ApiResponse<DropdownSetting>>(
|
||||||
|
BASE,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (
|
||||||
|
id: string,
|
||||||
|
payload: UpdateDropdownSettingDto,
|
||||||
|
): Promise<DropdownSetting> => {
|
||||||
|
const response = await client.patch<ApiResponse<DropdownSetting>>(
|
||||||
|
URL_CONSTANTS.DROPDOWN_SETTINGS.BY_ID(id),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
remove: async (id: string): Promise<void> => {
|
||||||
|
await client.delete(URL_CONSTANTS.DROPDOWN_SETTINGS.BY_ID(id));
|
||||||
|
},
|
||||||
|
|
||||||
|
replaceOptions: async (
|
||||||
|
id: string,
|
||||||
|
options: CreateDropdownOptionDto[],
|
||||||
|
): Promise<DropdownOption[]> => {
|
||||||
|
const response = await client.put<ApiResponse<DropdownOption[]>>(
|
||||||
|
URL_CONSTANTS.DROPDOWN_SETTINGS.OPTIONS(id),
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
addOption: async (
|
||||||
|
id: string,
|
||||||
|
payload: CreateDropdownOptionDto,
|
||||||
|
): Promise<DropdownOption> => {
|
||||||
|
const response = await client.post<ApiResponse<DropdownOption>>(
|
||||||
|
URL_CONSTANTS.DROPDOWN_SETTINGS.OPTIONS(id),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateOption: async (
|
||||||
|
optionId: string,
|
||||||
|
payload: UpdateDropdownOptionDto,
|
||||||
|
): Promise<DropdownOption> => {
|
||||||
|
const response = await client.patch<ApiResponse<DropdownOption>>(
|
||||||
|
URL_CONSTANTS.DROPDOWN_SETTINGS.OPTION_BY_ID(optionId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeOption: async (optionId: string): Promise<void> => {
|
||||||
|
await client.delete(URL_CONSTANTS.DROPDOWN_SETTINGS.OPTION_BY_ID(optionId));
|
||||||
|
},
|
||||||
|
};
|
||||||
9
apps/edr-freight-web/portal/src/types/customers.ts
Normal file
9
apps/edr-freight-web/portal/src/types/customers.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// Re-export the shared types from @edr/types.
|
||||||
|
// Canonical source: packages/types/src/freight/index.ts
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
export type Customer = Freight.ICustomer;
|
||||||
|
export type CustomerStatus = Freight.CustomerStatus;
|
||||||
|
export type CustomerType = Freight.CustomerType;
|
||||||
|
export type CreateCustomerDto = Freight.CreateCustomerDto;
|
||||||
|
export type UpdateCustomerDto = Freight.UpdateCustomerDto;
|
||||||
@@ -1,33 +1,12 @@
|
|||||||
export type DropdownOption = {
|
// Re-export the shared types from @edr/types so existing local imports keep
|
||||||
id: string;
|
// working. Canonical source: packages/types/src/freight/dropdown_settings.ts
|
||||||
value: string;
|
import type { Freight } from "@edr/types";
|
||||||
label: string;
|
|
||||||
note?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
order?: number;
|
|
||||||
|
|
||||||
meta?: {
|
export type DropdownOptionMeta = Freight.IDropdownOptionMeta;
|
||||||
icon?: string;
|
export type DropdownOption = Freight.IDropdownOption;
|
||||||
color?: string;
|
export type DropdownSettingMeta = Freight.IDropdownSettingMeta;
|
||||||
badge?: string;
|
export type DropdownSetting = Freight.IDropdownSetting;
|
||||||
};
|
export type CreateDropdownOptionDto = Freight.CreateDropdownOptionDto;
|
||||||
};
|
export type CreateDropdownSettingDto = Freight.CreateDropdownSettingDto;
|
||||||
|
export type UpdateDropdownOptionDto = Freight.UpdateDropdownOptionDto;
|
||||||
export type DropdownSetting = {
|
export type UpdateDropdownSettingDto = Freight.UpdateDropdownSettingDto;
|
||||||
id: string;
|
|
||||||
code: string;
|
|
||||||
label: string;
|
|
||||||
description?: string;
|
|
||||||
multiple?: boolean;
|
|
||||||
|
|
||||||
meta?: {
|
|
||||||
icon?: string;
|
|
||||||
color?: string;
|
|
||||||
searchable?: boolean;
|
|
||||||
clearable?: boolean;
|
|
||||||
permissions?: string[];
|
|
||||||
version?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
children: DropdownOption[];
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ client.interceptors.request.use((config) => {
|
|||||||
.split("; ")
|
.split("; ")
|
||||||
.find((row) => row.startsWith("auth-token="))
|
.find((row) => row.startsWith("auth-token="))
|
||||||
?.split("=")[1];
|
?.split("=")[1];
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|||||||
72
packages/types/src/freight/dropdown_settings.ts
Normal file
72
packages/types/src/freight/dropdown_settings.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { BaseEntity } from "../common";
|
||||||
|
|
||||||
|
export interface IDropdownOptionMeta {
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
badge?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDropdownOption extends BaseEntity {
|
||||||
|
settingId: string;
|
||||||
|
/** Stored value (machine-readable identifier). */
|
||||||
|
value: string;
|
||||||
|
/** Display label shown to end users. */
|
||||||
|
label: string;
|
||||||
|
/** Optional helper text. */
|
||||||
|
note?: string | null;
|
||||||
|
disabled: boolean;
|
||||||
|
order: number;
|
||||||
|
meta?: IDropdownOptionMeta | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDropdownSettingMeta {
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
searchable?: boolean;
|
||||||
|
clearable?: boolean;
|
||||||
|
permissions?: string[];
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDropdownSetting extends BaseEntity {
|
||||||
|
/** Stable code referenced from forms (snake_case). */
|
||||||
|
code: string;
|
||||||
|
/** Display label for admins. */
|
||||||
|
label: string;
|
||||||
|
description?: string | null;
|
||||||
|
multiple: boolean;
|
||||||
|
meta?: IDropdownSettingMeta | null;
|
||||||
|
/**
|
||||||
|
* Options that belong to this dropdown. Named `children` to match the shape
|
||||||
|
* historically consumed by the freight portal.
|
||||||
|
*/
|
||||||
|
children: IDropdownOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* Wire DTOs (shared between API and frontend)
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export interface CreateDropdownOptionDto {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
note?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
order?: number;
|
||||||
|
meta?: IDropdownOptionMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateDropdownOptionDto = Partial<CreateDropdownOptionDto>;
|
||||||
|
|
||||||
|
export interface CreateDropdownSettingDto {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
multiple?: boolean;
|
||||||
|
meta?: IDropdownSettingMeta;
|
||||||
|
children?: CreateDropdownOptionDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateDropdownSettingDto = Partial<
|
||||||
|
Omit<CreateDropdownSettingDto, "children">
|
||||||
|
>;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { BaseEntity } from "../common";
|
import type { BaseEntity } from "../common";
|
||||||
|
|
||||||
export * from "./file_upload_settings";
|
export * from "./file_upload_settings";
|
||||||
|
export * from "./dropdown_settings";
|
||||||
|
|
||||||
export enum BookingStatus {
|
export enum BookingStatus {
|
||||||
Draft = "DRAFT",
|
Draft = "DRAFT",
|
||||||
@@ -44,14 +45,41 @@ export enum PaymentStatus {
|
|||||||
Refunded = "REFUNDED",
|
Refunded = "REFUNDED",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CustomerStatus = "Active" | "Pending" | "Inactive";
|
||||||
|
export type CustomerType = "Importer" | "Exporter" | "Supplier";
|
||||||
|
|
||||||
export interface ICustomer extends BaseEntity {
|
export interface ICustomer extends BaseEntity {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
|
company?: string | null;
|
||||||
|
customerType: CustomerType;
|
||||||
|
status: CustomerStatus;
|
||||||
|
tinNumber?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
address?: string | null;
|
||||||
|
taxId?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCustomerDto {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
company?: string;
|
||||||
|
customerType?: CustomerType;
|
||||||
|
status?: CustomerStatus;
|
||||||
|
tinNumber?: string;
|
||||||
|
city?: string;
|
||||||
|
country?: string;
|
||||||
address?: string;
|
address?: string;
|
||||||
taxId?: string;
|
taxId?: string;
|
||||||
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UpdateCustomerDto = Partial<CreateCustomerDto>;
|
||||||
|
|
||||||
export interface ITrain extends BaseEntity {
|
export interface ITrain extends BaseEntity {
|
||||||
code: string;
|
code: string;
|
||||||
capacityTons: number;
|
capacityTons: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user