From c7347b88d78fcfc289ae5fcd06c9a019c6bd5c37 Mon Sep 17 00:00:00 2001 From: yaschalew Date: Thu, 21 May 2026 02:21:32 +0300 Subject: [PATCH] customer registration api integration --- apps/edr-freight-api/src/app.module.ts | 2 + .../modules/customers/customers.controller.ts | 21 + .../modules/customers/customers.service.ts | 42 +- .../customers/dto/create-customer.dto.ts | 55 +- .../customers/dto/update-customer.dto.ts | 5 + .../customers/entities/customer.entity.ts | 34 ++ .../dropdown-settings.controller.ts | 102 ++++ .../dropdown-settings.module.ts | 24 + .../dropdown-settings.repository.ts | 82 +++ .../dropdown-settings.service.ts | 110 ++++ .../dto/create-dropdown-option.dto.ts | 40 ++ .../dto/create-dropdown-setting.dto.ts | 45 ++ .../dto/dropdown-option-meta.dto.ts | 18 + .../dto/dropdown-setting-meta.dto.ts | 43 ++ .../dto/update-dropdown-option.dto.ts | 7 + .../dto/update-dropdown-setting.dto.ts | 7 + .../entities/dropdown-option.entity.ts | 47 ++ .../entities/dropdown-setting.entity.ts | 37 ++ .../dropdown-settings.repository.interface.ts | 38 ++ .../portal/src/constants/TANSTACK_QUEY_KEY.ts | 11 + .../portal/src/constants/URLS.ts | 15 + .../portal/src/hooks/useCustomers.ts | 50 ++ .../portal/src/hooks/useDropdownSettings.ts | 126 +++++ .../admin/DeleteDropdownSettingDialog.tsx | 20 +- .../src/pages/admin/DropdownSettingsPage.tsx | 303 +++++++---- .../pages/admin/EditDropdownSettingDialog.tsx | 185 ++++++- .../admin/ManageDropdownOptionsDialog.tsx | 161 +++++- .../src/pages/admin/dropdownSettings.mock.ts | 388 -------------- .../pages/customers/CustomerDetailPage.tsx | 118 +++-- .../src/pages/customers/CustomersPage.tsx | 236 ++++++--- .../pages/customers/DeleteCustomerDialog.tsx | 23 +- .../src/pages/customers/NewCustomerPage.tsx | 493 ++++++++++-------- .../portal/src/services/customers.service.ts | 45 ++ .../src/services/dropdownSettings.service.ts | 97 ++++ .../portal/src/types/customers.ts | 9 + .../portal/src/types/dropdownSettings.ts | 43 +- apps/edr-freight-web/portal/src/utils/api.ts | 1 + .../types/src/freight/dropdown_settings.ts | 72 +++ packages/types/src/freight/index.ts | 28 + 39 files changed, 2273 insertions(+), 910 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts create mode 100644 apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts create mode 100644 apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.module.ts create mode 100644 apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.repository.ts create mode 100644 apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.service.ts create mode 100644 apps/edr-freight-api/src/modules/dropdown-settings/dto/create-dropdown-option.dto.ts create mode 100644 apps/edr-freight-api/src/modules/dropdown-settings/dto/create-dropdown-setting.dto.ts create mode 100644 apps/edr-freight-api/src/modules/dropdown-settings/dto/dropdown-option-meta.dto.ts create mode 100644 apps/edr-freight-api/src/modules/dropdown-settings/dto/dropdown-setting-meta.dto.ts create mode 100644 apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-option.dto.ts create mode 100644 apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-setting.dto.ts create mode 100644 apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-option.entity.ts create mode 100644 apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-setting.entity.ts create mode 100644 apps/edr-freight-api/src/modules/dropdown-settings/interfaces/dropdown-settings.repository.interface.ts create mode 100644 apps/edr-freight-web/portal/src/hooks/useCustomers.ts create mode 100644 apps/edr-freight-web/portal/src/hooks/useDropdownSettings.ts delete mode 100644 apps/edr-freight-web/portal/src/pages/admin/dropdownSettings.mock.ts create mode 100644 apps/edr-freight-web/portal/src/services/customers.service.ts create mode 100644 apps/edr-freight-web/portal/src/services/dropdownSettings.service.ts create mode 100644 apps/edr-freight-web/portal/src/types/customers.ts create mode 100644 packages/types/src/freight/dropdown_settings.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 28a45a796..60ca553f7 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -15,6 +15,7 @@ import { TrackingModule } from "./modules/tracking/tracking.module"; import { BillingModule } from "./modules/billing/billing.module"; import { NotificationsModule } from "./modules/notifications/notifications.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; +import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; @Module({ imports: [ @@ -37,6 +38,7 @@ import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-up BillingModule, NotificationsModule, FileUploadSettingsModule, + DropdownSettingsModule, ], }) export class AppModule implements OnApplicationBootstrap { diff --git a/apps/edr-freight-api/src/modules/customers/customers.controller.ts b/apps/edr-freight-api/src/modules/customers/customers.controller.ts index 7eaa42d5b..85b470453 100644 --- a/apps/edr-freight-api/src/modules/customers/customers.controller.ts +++ b/apps/edr-freight-api/src/modules/customers/customers.controller.ts @@ -1,15 +1,20 @@ import { Body, Controller, + Delete, Get, + HttpCode, + HttpStatus, Param, ParseUUIDPipe, + Patch, Post, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { CustomersService } from "./customers.service"; import { CreateCustomerDto } from "./dto/create-customer.dto"; +import { UpdateCustomerDto } from "./dto/update-customer.dto"; @ApiTags("customers") @Controller("customers") @@ -33,4 +38,20 @@ export class CustomersController { findOne(@Param("id", ParseUUIDPipe) id: string) { return this.customersService.findById(id); } + + @Patch(":id") + @ApiOperation({ summary: "Update a customer" }) + update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateCustomerDto, + ) { + return this.customersService.update(id, dto); + } + + @Delete(":id") + @ApiOperation({ summary: "Soft-delete a customer" }) + @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param("id", ParseUUIDPipe) id: string) { + return this.customersService.remove(id); + } } diff --git a/apps/edr-freight-api/src/modules/customers/customers.service.ts b/apps/edr-freight-api/src/modules/customers/customers.service.ts index d3be75800..6394e1ad9 100644 --- a/apps/edr-freight-api/src/modules/customers/customers.service.ts +++ b/apps/edr-freight-api/src/modules/customers/customers.service.ts @@ -1,24 +1,32 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; +import { + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; import { CustomersRepository } from "./customers.repository"; import { CreateCustomerDto } from "./dto/create-customer.dto"; +import { UpdateCustomerDto } from "./dto/update-customer.dto"; import { Customer } from "./entities/customer.entity"; @Injectable() export class CustomersService { constructor(private readonly customersRepository: CustomersRepository) {} - /** Create a new freight customer. */ - create(dto: CreateCustomerDto): Promise { + async create(dto: CreateCustomerDto): Promise { + 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); } - /** List every customer (alphabetical). */ findAll(): Promise { return this.customersRepository.findAll({ order: { name: "ASC" } }); } - /** Get a single customer by ID. */ async findById(id: string): Promise { const customer = await this.customersRepository.findById(id); if (!customer) { @@ -26,4 +34,28 @@ export class CustomersService { } return customer; } + + async update(id: string, dto: UpdateCustomerDto): Promise { + 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 { + await this.findById(id); + await this.customersRepository.softDelete(id); + } } diff --git a/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts b/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts index bcaa13e78..854b3eaf1 100644 --- a/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts +++ b/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts @@ -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 { @IsString() + @MaxLength(256) name!: string; @IsEmail() email!: string; @IsString() + @MaxLength(32) phone!: string; + @IsOptional() + @IsString() + @MaxLength(256) + company?: string; + + @IsOptional() + @IsEnum(CustomerTypeDto) + customerType?: CustomerTypeDto; + + @IsOptional() + @IsEnum(CustomerStatusDto) + status?: CustomerStatusDto; + + @IsOptional() + @IsString() + @MaxLength(64) + tinNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(128) + city?: string; + + @IsOptional() + @IsString() + @MaxLength(128) + country?: string; + @IsOptional() @IsString() address?: string; @IsOptional() @IsString() + @MaxLength(64) taxId?: string; + + @IsOptional() + @IsString() + notes?: string; } diff --git a/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts b/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts new file mode 100644 index 000000000..94b49d0fd --- /dev/null +++ b/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from "@nestjs/swagger"; + +import { CreateCustomerDto } from "./create-customer.dto"; + +export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {} diff --git a/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts b/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts index 28f79f4c8..8a632f508 100644 --- a/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts +++ b/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts @@ -1,6 +1,9 @@ import { BaseEntity } from "@edr/api-common"; import { Column, Entity } from "typeorm"; +export type CustomerStatus = "Active" | "Pending" | "Inactive"; +export type CustomerType = "Importer" | "Exporter" | "Supplier"; + @Entity({ name: "customers" }) export class Customer extends BaseEntity { @Column({ name: "name", type: "varchar", length: 256 }) @@ -12,9 +15,40 @@ export class Customer extends BaseEntity { @Column({ name: "phone", type: "varchar", length: 32 }) phone!: string; + @Column({ name: "company", type: "varchar", length: 256, nullable: true }) + company?: string | null; + + @Column({ + name: "customer_type", + type: "varchar", + length: 32, + default: "Importer", + }) + customerType!: CustomerType; + + @Column({ + name: "status", + type: "varchar", + length: 32, + default: "Active", + }) + status!: CustomerStatus; + + @Column({ name: "tin_number", type: "varchar", length: 64, nullable: true }) + tinNumber?: string | null; + + @Column({ name: "city", type: "varchar", length: 128, nullable: true }) + city?: string | null; + + @Column({ name: "country", type: "varchar", length: 128, nullable: true }) + country?: string | null; + @Column({ name: "address", type: "text", nullable: true }) address?: string | null; @Column({ name: "tax_id", type: "varchar", length: 64, nullable: true }) taxId?: string | null; + + @Column({ name: "notes", type: "text", nullable: true }) + notes?: string | null; } diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts new file mode 100644 index 000000000..e8c3fbba0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.module.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.module.ts new file mode 100644 index 000000000..59fc3ebbb --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.repository.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.repository.ts new file mode 100644 index 000000000..a4f851e53 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.repository.ts @@ -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 + implements IDropdownSettingsRepository +{ + constructor( + @InjectRepository(DropdownSetting) + repository: Repository, + @InjectRepository(DropdownOption) + private readonly optionsRepository: Repository, + ) { + super(repository); + } + + findByCode(code: string): Promise { + return this.repository.findOne({ + where: { code }, + relations: { children: true }, + order: { children: { order: "ASC" } }, + }); + } + + override findById(id: string): Promise { + return this.repository.findOne({ + where: { id }, + relations: { children: true }, + order: { children: { order: "ASC" } }, + }); + } + + override findAll(): Promise { + return this.repository.find({ + order: { label: "ASC", children: { order: "ASC" } }, + relations: { children: true }, + }); + } + + async replaceOptions( + settingId: string, + options: Array>, + ): Promise { + 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, + ): Promise { + const entity = this.optionsRepository.create({ ...option, settingId }); + return this.optionsRepository.save(entity); + } + + async updateOption( + optionId: string, + data: Partial, + ): Promise { + await this.optionsRepository.update(optionId, data as never); + return this.optionsRepository.findOne({ where: { id: optionId } }); + } + + async removeOption(optionId: string): Promise { + await this.optionsRepository.softDelete(optionId); + } +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.service.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.service.ts new file mode 100644 index 000000000..f310795c3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.service.ts @@ -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 { + return this.repository.findAll(); + } + + async getById(id: string): Promise { + const setting = await this.repository.findById(id); + if (!setting) throw new NotFoundException(`Setting ${id} not found`); + return setting; + } + + async getByCode(code: string): Promise { + const setting = await this.repository.findByCode(code); + if (!setting) throw new NotFoundException(`Setting "${code}" not found`); + return setting; + } + + async create(dto: CreateDropdownSettingDto): Promise { + 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 { + 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 { + await this.getById(id); + await this.repository.softDelete(id); + } + + /* ------------------------ option operations ------------------------ */ + + async replaceOptions( + settingId: string, + options: CreateDropdownOptionDto[], + ): Promise { + await this.getById(settingId); + return this.repository.replaceOptions(settingId, options); + } + + async addOption( + settingId: string, + dto: CreateDropdownOptionDto, + ): Promise { + await this.getById(settingId); + return this.repository.addOption(settingId, dto); + } + + async updateOption( + optionId: string, + dto: UpdateDropdownOptionDto, + ): Promise { + const updated = await this.repository.updateOption(optionId, dto); + if (!updated) throw new NotFoundException(`Option ${optionId} not found`); + return updated; + } + + async removeOption(optionId: string): Promise { + await this.repository.removeOption(optionId); + } +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dto/create-dropdown-option.dto.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dto/create-dropdown-option.dto.ts new file mode 100644 index 000000000..675c0794f --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dto/create-dropdown-option.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dto/create-dropdown-setting.dto.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dto/create-dropdown-setting.dto.ts new file mode 100644 index 000000000..a4cedb1df --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dto/create-dropdown-setting.dto.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dto/dropdown-option-meta.dto.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dto/dropdown-option-meta.dto.ts new file mode 100644 index 000000000..9d7381fb0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dto/dropdown-option-meta.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dto/dropdown-setting-meta.dto.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dto/dropdown-setting-meta.dto.ts new file mode 100644 index 000000000..7eb16aa33 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dto/dropdown-setting-meta.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-option.dto.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-option.dto.ts new file mode 100644 index 000000000..aaa34dd2e --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-option.dto.ts @@ -0,0 +1,7 @@ +import { PartialType } from "@nestjs/swagger"; + +import { CreateDropdownOptionDto } from "./create-dropdown-option.dto"; + +export class UpdateDropdownOptionDto extends PartialType( + CreateDropdownOptionDto, +) {} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-setting.dto.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-setting.dto.ts new file mode 100644 index 000000000..8ad26229f --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-setting.dto.ts @@ -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), +) {} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-option.entity.ts b/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-option.entity.ts new file mode 100644 index 000000000..6f88c27a5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-option.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-setting.entity.ts b/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-setting.entity.ts new file mode 100644 index 000000000..9d4a7a7e7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-setting.entity.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/interfaces/dropdown-settings.repository.interface.ts b/apps/edr-freight-api/src/modules/dropdown-settings/interfaces/dropdown-settings.repository.interface.ts new file mode 100644 index 000000000..7b004a066 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/interfaces/dropdown-settings.repository.interface.ts @@ -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; + findById(id: string): Promise; + findByCode(code: string): Promise; + + create(data: Partial): Promise; + update( + id: string, + data: Partial, + ): Promise; + softDelete(id: string): Promise; + + /* Option-level helpers */ + replaceOptions( + settingId: string, + options: Array>, + ): Promise; + addOption( + settingId: string, + option: Partial, + ): Promise; + updateOption( + optionId: string, + data: Partial, + ): Promise; + removeOption(optionId: string): Promise; +} diff --git a/apps/edr-freight-web/portal/src/constants/TANSTACK_QUEY_KEY.ts b/apps/edr-freight-web/portal/src/constants/TANSTACK_QUEY_KEY.ts index 4670b9751..6ca9c2361 100644 --- a/apps/edr-freight-web/portal/src/constants/TANSTACK_QUEY_KEY.ts +++ b/apps/edr-freight-web/portal/src/constants/TANSTACK_QUEY_KEY.ts @@ -4,5 +4,16 @@ export const QUERY_KEYS = { FILES: { FILE_UPLOAD_SETTINGS: "file-upload-settings", 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" } } \ No newline at end of file diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 927f671e4..c4ad9512d 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -49,12 +49,27 @@ export const URL_CONSTANTS = { 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: { BASE: "/customers", BY_ID: (id: string | number) => `/customers/${id}`, BOOKINGS: (id: string | number) => `/customers/${id}/bookings`, }, + CUSTOMERS_API: { + BASE: "/api/customers", + BY_ID: (id: string) => `/api/customers/${id}`, + }, + BOOKINGS: { BASE: "/bookings", BY_ID: (id: string | number) => `/bookings/${id}`, diff --git a/apps/edr-freight-web/portal/src/hooks/useCustomers.ts b/apps/edr-freight-web/portal/src/hooks/useCustomers.ts new file mode 100644 index 000000000..ec26af310 --- /dev/null +++ b/apps/edr-freight-web/portal/src/hooks/useCustomers.ts @@ -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 }), + }); +}; diff --git a/apps/edr-freight-web/portal/src/hooks/useDropdownSettings.ts b/apps/edr-freight-web/portal/src/hooks/useDropdownSettings.ts new file mode 100644 index 000000000..c2b8a4423 --- /dev/null +++ b/apps/edr-freight-web/portal/src/hooks/useDropdownSettings.ts @@ -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 }), + }); +}; diff --git a/apps/edr-freight-web/portal/src/pages/admin/DeleteDropdownSettingDialog.tsx b/apps/edr-freight-web/portal/src/pages/admin/DeleteDropdownSettingDialog.tsx index c6589c4eb..1ca4e0d23 100644 --- a/apps/edr-freight-web/portal/src/pages/admin/DeleteDropdownSettingDialog.tsx +++ b/apps/edr-freight-web/portal/src/pages/admin/DeleteDropdownSettingDialog.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { Dialog, @@ -17,7 +17,9 @@ export interface DeleteDropdownSettingDialogProps { settingLabel: string; settingCode: string; onConfirm?: () => void; - children: ReactNode; + children?: ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; } export default function DeleteDropdownSettingDialog({ @@ -25,10 +27,20 @@ export default function DeleteDropdownSettingDialog({ settingCode, onConfirm, children, + open: openProp, + onOpenChange, }: 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 ( - - {children} + + {children ? {children} : null} diff --git a/apps/edr-freight-web/portal/src/pages/admin/DropdownSettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/admin/DropdownSettingsPage.tsx index 44cfaa8b0..1b77dcc59 100644 --- a/apps/edr-freight-web/portal/src/pages/admin/DropdownSettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/admin/DropdownSettingsPage.tsx @@ -1,10 +1,12 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { + AlertCircle, Boxes, CheckCircle2, Eye, Filter, ListOrdered, + Loader2, MoreHorizontal, Pencil, Plus, @@ -19,7 +21,11 @@ import Breadcrumbs from "@/components/Breadcrumbs"; import EditDropdownSettingDialog from "./EditDropdownSettingDialog"; import ManageDropdownOptionsDialog from "./ManageDropdownOptionsDialog"; import DeleteDropdownSettingDialog from "./DeleteDropdownSettingDialog"; -import { dropdownSettings } from "./dropdownSettings.mock"; +import { + useDeleteDropdownSetting, + useDropdownSettings, +} from "@/hooks/useDropdownSettings"; +import type { DropdownSetting } from "@/types/dropdownSettings"; import { DataTable, DataTableFooter, @@ -39,10 +45,55 @@ import { DropdownMenuSeparator, } from "@edr/ui-common"; +type ActiveDialog = "edit" | "options" | "delete"; + export default function DropdownSettingsPage() { const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); + const [activeDialog, setActiveDialog] = useState(null); + const [activeSetting, setActiveSetting] = useState( + null, + ); + + const openDialogFor = (dialog: ActiveDialog, setting: DropdownSetting) => { + // Defer past the DropdownMenu's close cycle. Radix's modal lock can leave + // `pointer-events: none` on 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( + () => (Array.isArray(data) ? data : []), + [data], + ); + const filtered = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return dropdownSettings; @@ -52,10 +103,10 @@ export default function DropdownSettingsPage() { s.label.toLowerCase().includes(q) || (s.description ?? "").toLowerCase().includes(q), ); - }, [query]); + }, [dropdownSettings, query]); 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 end = Math.min(start + pagination.pageSize, total); @@ -65,7 +116,7 @@ export default function DropdownSettingsPage() { ); const totalOptions = dropdownSettings.reduce( - (sum, s) => sum + s.children.length, + (sum, s) => sum + (s.children?.length ?? 0), 0, ); const multipleCount = dropdownSettings.filter((s) => s.multiple).length; @@ -73,7 +124,13 @@ export default function DropdownSettingsPage() { (s) => s.meta?.searchable, ).length; - const columns: ColumnDef<(typeof dropdownSettings)[number]>[] = [ + const status: "loading" | "error" | "success" = isLoading + ? "loading" + : isError + ? "error" + : "success"; + + const columns: ColumnDef[] = [ { id: "setting", header: "Setting", @@ -111,7 +168,7 @@ export default function DropdownSettingsPage() { return (
- {s.children.length} + {s.children?.length ?? 0}
); }, @@ -139,12 +196,13 @@ export default function DropdownSettingsPage() { header: "Permissions", cell: ({ row }) => { const s = row.original; + const perms = s.meta?.permissions ?? []; return (
- {(s.meta?.permissions ?? []).length === 0 ? ( + {perms.length === 0 ? ( ) : ( - (s.meta?.permissions ?? []).map((p) => ( + perms.map((p) => ( e.stopPropagation()} > - +
@@ -262,63 +315,38 @@ export default function DropdownSettingsPage() {
- - -
-

Settings

-

- {dropdownSettings.length} -

-
-
- -
-
-
- - - -
-

Total Options

-

- {totalOptions} -

-
-
- -
-
-
- - - -
-

Multi-select

-

- {multipleCount} -

-
-
- -
-
-
- - - -
-

Searchable

-

- {searchableCount} -

-
-
- -
-
-
+ } + /> + } + /> + } + /> + } + />
+ {isError ? ( + + + + Failed to load dropdown settings.{" "} + {error instanceof Error ? error.message : "Unknown error."} + + + ) : null} +
@@ -335,31 +363,90 @@ export default function DropdownSettingsPage() { - { }} - pagination={{ - pageIndex: pagination.pageIndex, - pageSize: pagination.pageSize, - pageCount: pageCount, - totalCount: total, - }} - tableOptions={{ - state: { pagination }, - onPaginationChange: setPagination, - }} - containerClassName="border-b shadow-none" - footer={DataTableFooter} - /> + {isLoading ? ( +
+ + Loading dropdown settings… +
+ ) : ( + { }} + pagination={{ + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + pageCount: pageCount, + totalCount: total, + }} + tableOptions={{ + state: { pagination }, + onPaginationChange: setPagination, + }} + containerClassName="border-b shadow-none" + footer={DataTableFooter} + /> + )}
+ + {/* Controlled dialogs — hoisted out of the DropdownMenu so they can open + reliably after a menu item is selected. */} + {activeSetting ? ( + <> + (next ? null : closeDialog())} + /> + (next ? null : closeDialog())} + /> + deleteMutation.mutate(activeSetting.id)} + open={activeDialog === "delete"} + onOpenChange={(next) => (next ? null : closeDialog())} + /> + + ) : null} ); } +function StatCard({ + label, + value, + icon, +}: { + label: string; + value: number; + icon: React.ReactNode; +}) { + return ( + + +
+

{label}

+

{value}

+
+
+ {icon} +
+
+
+ ); +} + function BehaviorChip({ label, muted = false, diff --git a/apps/edr-freight-web/portal/src/pages/admin/EditDropdownSettingDialog.tsx b/apps/edr-freight-web/portal/src/pages/admin/EditDropdownSettingDialog.tsx index 2bea74fbf..14922446c 100644 --- a/apps/edr-freight-web/portal/src/pages/admin/EditDropdownSettingDialog.tsx +++ b/apps/edr-freight-web/portal/src/pages/admin/EditDropdownSettingDialog.tsx @@ -1,8 +1,9 @@ import { useState, type ReactNode } from "react"; -import { Hash } from "lucide-react"; +import { Hash, Loader2 } from "lucide-react"; import { Dialog, + DialogClose, DialogContent, DialogDescription, DialogHeader, @@ -14,20 +15,58 @@ import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; 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 { mode?: "create" | "edit"; 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({ mode = "create", setting, children, + open: openProp, + onOpenChange, }: EditDropdownSettingDialogProps) { 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(setting?.multiple ?? false); const [searchable, setSearchable] = useState( setting?.meta?.searchable ?? false, @@ -35,10 +74,90 @@ export default function EditDropdownSettingDialog({ const [clearable, setClearable] = useState( setting?.meta?.clearable ?? false, ); + const [error, setError] = useState(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 ( - - {children} + { + setOpen(next); + if (!next) reset(); + }} + > + {children ? {children} : null} @@ -58,20 +177,25 @@ export default function EditDropdownSettingDialog({
setCode(e.target.value)} placeholder="e.g. cargo_type" className="pl-10 font-mono" + disabled={isEdit} />

- Stable identifier used in code. Use snake_case. + {isEdit + ? "Code is immutable after creation." + : "Stable identifier used in code. Use snake_case."}

setLabel(e.target.value)} placeholder="e.g. Cargo Type" />
@@ -79,7 +203,8 @@ export default function EditDropdownSettingDialog({