fix conflict

This commit is contained in:
yaschalew
2026-05-29 19:46:18 +03:00
148 changed files with 6507 additions and 12683 deletions

View File

@@ -18,11 +18,11 @@ import { NotificationsModule } from "./modules/notifications/notifications.modul
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { OtpModule } from './modules/otp/otp.module';
import { ServiceTypesModule } from "./modules/service-types/service-types.module";
import { CargoTypesModule } from "./modules/cargo-types/cargo-types.module";
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
@Module({
imports: [
@@ -48,20 +48,22 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder";
FileUploadSettingsModule,
DropdownSettingsModule,
OtpModule,
ServiceTypesModule,
CargoTypesModule,
RuleEngineModule,
BackofficeModule,
DemoPermissionsModule,
],
providers: [EdrOrgSeeder],
providers: [EdrOrgSeeder, DemoUsersSeeder],
})
export class AppModule implements OnApplicationBootstrap {
constructor(
private readonly seeder: DataSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
) {}
private readonly demoUsersSeeder: DemoUsersSeeder,
) { }
async onApplicationBootstrap() {
await this.seeder.run();
await this.edrOrgSeeder.run();
await this.demoUsersSeeder.run();
}
}

View File

@@ -0,0 +1,277 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableIndex,
TableForeignKey,
TableColumn,
} from 'typeorm';
export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterface {
name = 'AddRuleEngineTablesAndCodes1748514000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// ── 1. Add `code` column to existing tables ───────────────────────────
await queryRunner.addColumn(
'freight.service_types',
new TableColumn({
name: 'code',
type: 'varchar',
length: '50',
isNullable: true,
}),
);
await queryRunner.query(
`UPDATE freight.service_types SET code = upper(replace(service_name, ' ', '_')) WHERE code IS NULL`,
);
await queryRunner.changeColumn(
'freight.service_types',
'code',
new TableColumn({ name: 'code', type: 'varchar', length: '50', isNullable: false }),
);
await queryRunner.createIndex(
'freight.service_types',
new TableIndex({ name: 'IDX_service_types_code', columnNames: ['code'], isUnique: true }),
);
await queryRunner.addColumn(
'freight.cargo_types',
new TableColumn({
name: 'code',
type: 'varchar',
length: '50',
isNullable: true,
}),
);
await queryRunner.query(
`UPDATE freight.cargo_types SET code = upper(replace(cargo_type_name, ' ', '_')) WHERE code IS NULL`,
);
await queryRunner.changeColumn(
'freight.cargo_types',
'code',
new TableColumn({ name: 'code', type: 'varchar', length: '50', isNullable: false }),
);
await queryRunner.createIndex(
'freight.cargo_types',
new TableIndex({ name: 'IDX_cargo_types_code', columnNames: ['code'], isUnique: true }),
);
// ── 2. surcharge_types ────────────────────────────────────────────────
await queryRunner.createTable(
new Table({
name: 'surcharge_types',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'code', type: 'varchar', length: '50', isNullable: false },
{ name: 'name', type: 'varchar', length: '100', isNullable: false },
{ name: 'description', type: 'text', isNullable: true },
{ name: 'is_active', type: 'boolean', default: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createIndex(
'freight.surcharge_types',
new TableIndex({ name: 'IDX_surcharge_types_code', columnNames: ['code'], isUnique: true }),
);
await queryRunner.createIndex(
'freight.surcharge_types',
new TableIndex({ name: 'IDX_surcharge_types_is_active', columnNames: ['is_active'] }),
);
// ── 3. surcharges ─────────────────────────────────────────────────────
await queryRunner.createTable(
new Table({
name: 'surcharges',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'surcharge_type_id', type: 'uuid', isNullable: false },
{ name: 'fee_name', type: 'varchar', length: '255', isNullable: false },
{ name: 'trigger_description', type: 'text', isNullable: true },
{
name: 'calculation_method',
type: 'enum',
enum: ['PER_TON', 'FLAT_FEE', 'PERCENTAGE'],
default: `'PER_TON'`,
},
{ name: 'rate', type: 'numeric', precision: 10, scale: 2, isNullable: false },
{ name: 'currency', type: 'char', length: '3', default: `'USD'` },
{ name: 'apply_to_rail', type: 'boolean', default: false },
{ name: 'apply_to_first_mile', type: 'boolean', default: false },
{ name: 'apply_to_last_mile', type: 'boolean', default: false },
{ name: 'is_active', type: 'boolean', default: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createForeignKey(
'freight.surcharges',
new TableForeignKey({
name: 'FK_surcharges_surcharge_type',
columnNames: ['surcharge_type_id'],
referencedTableName: 'freight.surcharge_types',
referencedColumnNames: ['id'],
onDelete: 'RESTRICT',
}),
);
await queryRunner.createIndex(
'freight.surcharges',
new TableIndex({ name: 'IDX_surcharges_surcharge_type_id', columnNames: ['surcharge_type_id'] }),
);
await queryRunner.createIndex(
'freight.surcharges',
new TableIndex({ name: 'IDX_surcharges_is_active', columnNames: ['is_active'] }),
);
// ── 4. container_types ────────────────────────────────────────────────
await queryRunner.createTable(
new Table({
name: 'container_types',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'size_code', type: 'varchar', length: '20', isNullable: false },
{ name: 'description', type: 'varchar', length: '100', isNullable: true },
{ name: 'containers_per_wagon', type: 'int', isNullable: false },
{ name: 'is_active', type: 'boolean', default: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createIndex(
'freight.container_types',
new TableIndex({ name: 'IDX_container_types_size_code', columnNames: ['size_code'], isUnique: true }),
);
await queryRunner.createIndex(
'freight.container_types',
new TableIndex({ name: 'IDX_container_types_is_active', columnNames: ['is_active'] }),
);
// ── 5. weight_limit_rules ─────────────────────────────────────────────
await queryRunner.createTable(
new Table({
name: 'weight_limit_rules',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'container_type_id', type: 'uuid', isNullable: false },
{
name: 'trade_direction',
type: 'enum',
enum: ['IMPORT', 'EXPORT', 'BOTH'],
isNullable: false,
},
{ name: 'max_weight_tons', type: 'numeric', precision: 10, scale: 2, isNullable: false },
{ name: 'warning_threshold_tons', type: 'numeric', precision: 10, scale: 2, isNullable: false },
{
name: 'exceeded_action',
type: 'enum',
enum: ['WARNING_ONLY', 'HARD_BLOCK'],
default: `'WARNING_ONLY'`,
},
{ name: 'surcharge_id', type: 'uuid', isNullable: true },
{ name: 'is_active', type: 'boolean', default: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createForeignKey(
'freight.weight_limit_rules',
new TableForeignKey({
name: 'FK_weight_limit_rules_container_type',
columnNames: ['container_type_id'],
referencedTableName: 'freight.container_types',
referencedColumnNames: ['id'],
onDelete: 'RESTRICT',
}),
);
await queryRunner.createForeignKey(
'freight.weight_limit_rules',
new TableForeignKey({
name: 'FK_weight_limit_rules_surcharge',
columnNames: ['surcharge_id'],
referencedTableName: 'freight.surcharges',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
}),
);
await queryRunner.createIndex(
'freight.weight_limit_rules',
new TableIndex({ name: 'IDX_weight_limit_rules_container_type_id', columnNames: ['container_type_id'] }),
);
await queryRunner.createIndex(
'freight.weight_limit_rules',
new TableIndex({ name: 'IDX_weight_limit_rules_surcharge_id', columnNames: ['surcharge_id'] }),
);
await queryRunner.createIndex(
'freight.weight_limit_rules',
new TableIndex({ name: 'IDX_weight_limit_rules_is_active', columnNames: ['is_active'] }),
);
// ── 6. priority_rules ─────────────────────────────────────────────────
await queryRunner.createTable(
new Table({
name: 'priority_rules',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{
name: 'priority_type',
type: 'enum',
enum: ['USD_PAYER', 'RAIL_AND_FORWARDING', 'GOVERNMENT_ACCOUNT', 'HIGH_VOLUME_SHIPMENT'],
isNullable: false,
},
{ name: 'rule_name', type: 'varchar', length: '255', isNullable: false },
{ name: 'description', type: 'text', isNullable: true },
{ name: 'activation_condition', type: 'text', isNullable: true },
{ name: 'bonus_points', type: 'int', default: 0 },
{ name: 'is_active', type: 'boolean', default: false },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createIndex(
'freight.priority_rules',
new TableIndex({ name: 'IDX_priority_rules_priority_type', columnNames: ['priority_type'], isUnique: true }),
);
await queryRunner.createIndex(
'freight.priority_rules',
new TableIndex({ name: 'IDX_priority_rules_is_active', columnNames: ['is_active'] }),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.priority_rules', true);
await queryRunner.dropTable('freight.weight_limit_rules', true);
await queryRunner.dropTable('freight.container_types', true);
await queryRunner.dropTable('freight.surcharges', true);
await queryRunner.dropTable('freight.surcharge_types', true);
await queryRunner.dropIndex('freight.cargo_types', 'IDX_cargo_types_code');
await queryRunner.dropColumn('freight.cargo_types', 'code');
await queryRunner.dropIndex('freight.service_types', 'IDX_service_types_code');
await queryRunner.dropColumn('freight.service_types', 'code');
}
}

View File

@@ -4,13 +4,14 @@ import { TypeOrmModule } from "@nestjs/typeorm";
import { CustomersModule } from "../customers/customers.module";
import { FilesModule } from "../files/files.module";
import { MinioModule } from "../minio/minio.module";
import { RuleEngineModule } from "../rule-engine/rule-engine.module";
import { BookingsController } from "./bookings.controller";
import { BookingsRepository } from "./bookings.repository";
import { BookingsService } from "./bookings.service";
import { Booking } from "./entities/booking.entity";
@Module({
imports: [TypeOrmModule.forFeature([Booking]), FilesModule, MinioModule, CustomersModule],
imports: [TypeOrmModule.forFeature([Booking]), FilesModule, MinioModule, CustomersModule, RuleEngineModule],
controllers: [BookingsController],
providers: [BookingsService, BookingsRepository],
exports: [BookingsService],

View File

@@ -9,6 +9,7 @@ import { IsNull, Not } from "typeorm";
import { CustomersService } from "../customers/customers.service";
import { FilesService } from "../files/files.service";
import { MinioService } from "../minio/minio.service";
import { RuleEngineService } from "../rule-engine/rule-engine.service";
import { BookingsRepository } from "./bookings.repository";
import { CreateBookingDto } from "./dto/create-booking.dto";
import { FilterBookingDto } from "./dto/filter-booking.dto";
@@ -17,16 +18,6 @@ import { UpdateStatusDto } from "./dto/update-status.dto";
import { Booking } from "./entities/booking.entity";
import { FileRecord } from "../files/entities/file.entity";
/** Weight thresholds (tons) that trigger overweight surcharge alerts. */
const WEIGHT_LIMITS = {
IMPORT_20FT: 20,
EXPORT_20FT: 25,
ANY_40FT: 32.5,
} as const;
/** Bookings above this total VGM are considered high-volume. */
const HIGH_VOLUME_THRESHOLD_TONS = 500;
@Injectable()
export class BookingsService {
constructor(
@@ -34,6 +25,7 @@ export class BookingsService {
private readonly filesService: FilesService,
private readonly minioService: MinioService,
private readonly customersService: CustomersService,
private readonly ruleEngineService: RuleEngineService,
) {}
// ── helpers ──────────────────────────────────────────────────────────
@@ -65,15 +57,6 @@ export class BookingsService {
return explicit ?? false;
}
/** Calculate priority score based on currency and service type. */
private calculatePriorityScore(currency: string, serviceType: string): number {
let score = 0;
if (currency === "USD") score += 100;
if (serviceType === "RAIL_AND_FORWARDING") score += 50;
else if (serviceType === "RAIL_ONLY") score += 25;
return score;
}
/** Calculate required wagons: each 40ft = 1 wagon, each pair of 20ft = 1 wagon. */
private calculateWagonCount(
containers: Array<{ type: string; qty: number }>,
@@ -87,33 +70,6 @@ export class BookingsService {
}, 0);
}
/** Check per-container weight limits and return warnings if exceeded. */
private checkOverweight(
containers: Array<{ type: string; vgm: number }>,
tradeDirection: string,
): string[] {
const warnings: string[] = [];
for (const container of containers) {
if (container.type === "40FT" && container.vgm > WEIGHT_LIMITS.ANY_40FT) {
warnings.push(
`40FT container VGM ${container.vgm}t exceeds limit of ${WEIGHT_LIMITS.ANY_40FT}t`
);
}
if (container.type === "20FT") {
const limit =
tradeDirection === "IMPORT"
? WEIGHT_LIMITS.IMPORT_20FT
: WEIGHT_LIMITS.EXPORT_20FT;
if (container.vgm > limit) {
warnings.push(
`20FT ${tradeDirection} container VGM ${container.vgm}t exceeds limit of ${limit}t`
);
}
}
}
return warnings;
}
// ── CRUD ─────────────────────────────────────────────────────────────
@@ -143,16 +99,19 @@ export class BookingsService {
dto.allowConsolidation,
);
const priorityScore = this.calculatePriorityScore(
dto.paymentCurrency,
dto.serviceType,
);
const overweightWarnings = this.checkOverweight(
dto.containers,
dto.tradeDirection,
);
warnings.push(...overweightWarnings);
// ── Rule engine evaluation ──────────────────────────────────────────
const ruleResult = await this.ruleEngineService.evaluate({
freightType: dto.freightType,
serviceType: dto.serviceType,
paymentCurrency: dto.paymentCurrency,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
tradeDirection: dto.tradeDirection,
isHazardous: dto.isHazardous ?? false,
isRefrigerated: dto.isRefrigerated ?? false,
containers: dto.containers,
});
this.ruleEngineService.assertNoHardBlocks(ruleResult);
warnings.push(...ruleResult.warnings);
const wagonCount = this.calculateWagonCount(dto.containers);
warnings.push(`Estimated wagons required: ${wagonCount}`);
@@ -168,7 +127,7 @@ export class BookingsService {
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
status: "DRAFT",
allowConsolidation,
priorityScore,
priorityScore: ruleResult.priorityScore,
});
if (files.length > 0) {
@@ -208,15 +167,20 @@ export class BookingsService {
dto.allowConsolidation,
);
// Recalculate priority
const currency = dto.paymentCurrency ?? existing.paymentCurrency;
const serviceType = dto.serviceType ?? existing.serviceType;
updates.priorityScore = this.calculatePriorityScore(currency, serviceType);
// Overweight check
const direction = dto.tradeDirection ?? existing.tradeDirection;
const overweightWarnings = this.checkOverweight(containers, direction);
warnings.push(...overweightWarnings);
// ── Rule engine re-evaluation ────────────────────────────────────────
const ruleResult = await this.ruleEngineService.evaluate({
freightType: dto.freightType ?? existing.freightType,
serviceType: dto.serviceType ?? existing.serviceType,
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm ?? existing.cargoTotalWeightVgm,
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
isHazardous: dto.isHazardous ?? existing.isHazardous ?? false,
isRefrigerated: dto.isRefrigerated ?? existing.isRefrigerated ?? false,
containers,
});
this.ruleEngineService.assertNoHardBlocks(ruleResult);
warnings.push(...ruleResult.warnings);
updates.priorityScore = ruleResult.priorityScore;
if (files.length > 0) {
await this.filesService.uploadMany(id, "bookings", files);
@@ -348,13 +312,13 @@ export class BookingsService {
}
}
/** SUBMIT: DRAFT → PENDING_LINE_STAFF or PENDING_DIRECTOR (bulk). */
/** SUBMIT: DRAFT → PENDING_LINE_STAFF or PENDING_DIRECTOR (cargo routing from rule engine). */
private async handleSubmit(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ["DRAFT"]);
const isBulk =
booking.freightType === "BULK" ||
booking.cargoTotalWeightVgm > HIGH_VOLUME_THRESHOLD_TONS;
const nextStatus = isBulk ? "PENDING_DIRECTOR" : "PENDING_LINE_STAFF";
const ruleResult = await this.ruleEngineService.evaluate(booking);
const nextStatus = ruleResult.requiresDirectorApproval
? "PENDING_DIRECTOR"
: "PENDING_LINE_STAFF";
const updated = await this.bookingsRepository.update(booking.id, {
status: nextStatus,
} as never);
@@ -370,13 +334,11 @@ export class BookingsService {
if (!actorId)
throw new BadRequestException("actorId is required for APPROVE_STAFF");
// Line staff cannot approve bulk
if (
booking.freightType === "BULK" ||
booking.cargoTotalWeightVgm > HIGH_VOLUME_THRESHOLD_TONS
) {
// Line staff cannot approve bookings that require director approval
const ruleResult = await this.ruleEngineService.evaluate(booking);
if (ruleResult.requiresDirectorApproval) {
throw new BadRequestException(
"Line staff cannot approve bulk or high-volume bookings",
"Line staff cannot approve bookings that require director approval",
);
}
@@ -400,10 +362,8 @@ export class BookingsService {
if (!actorId)
throw new BadRequestException("actorId is required for APPROVE_DIRECTOR");
const isBulk =
booking.freightType === "BULK" ||
booking.cargoTotalWeightVgm > HIGH_VOLUME_THRESHOLD_TONS;
const nextStatus = isBulk ? "PENDING_CEO" : "SIGNED";
const ruleResult = await this.ruleEngineService.evaluate(booking);
const nextStatus = ruleResult.requiresDirectorApproval ? "PENDING_CEO" : "SIGNED";
const updated = await this.bookingsRepository.update(booking.id, {
status: nextStatus,

View File

@@ -1,64 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CargoTypesService } from "./cargo-types.service";
import { CreateCargoTypeDto } from "./dto/create-cargo-type.dto";
import { FilterCargoTypeDto } from "./dto/filter-cargo-type.dto";
import { UpdateCargoTypeDto } from "./dto/update-cargo-type.dto";
@ApiTags("cargo-types")
@Controller("cargo-types")
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class CargoTypesController {
constructor(private readonly service: CargoTypesService) {}
@Get()
@ApiOperation({
summary: "List cargo types",
description: "Paginated list with optional filtering by isActive, requiresDirectorApproval, parentGroupId, and name search.",
})
findAll(@Query() filter: FilterCargoTypeDto) {
return this.service.findAll(filter);
}
@Get(":id")
@ApiOperation({ summary: "Get a cargo type by ID", description: "Returns the cargo type with parent and children relations." })
findOne(@Param("id", ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@ApiOperation({ summary: "Create a new cargo type" })
create(@Body() dto: CreateCargoTypeDto) {
return this.service.create(dto);
}
@Patch(":id")
@ApiOperation({ summary: "Update a cargo type" })
update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateCargoTypeDto,
) {
return this.service.update(id, dto);
}
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: "Soft-delete a cargo type" })
remove(@Param("id", ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -1,23 +0,0 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { CargoType } from "./entities/cargo-type.entity";
import { CARGO_TYPES_REPOSITORY } from "./interfaces/cargo-types.repository.interface";
import { CargoTypesRepository } from "./cargo-types.repository";
import { CargoTypesController } from "./cargo-types.controller";
import { CargoTypesService } from "./cargo-types.service";
@Module({
imports: [TypeOrmModule.forFeature([CargoType])],
controllers: [CargoTypesController],
providers: [
CargoTypesRepository,
{
provide: CARGO_TYPES_REPOSITORY,
useExisting: CargoTypesRepository,
},
CargoTypesService,
],
exports: [CargoTypesService],
})
export class CargoTypesModule {}

View File

@@ -1,31 +0,0 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { FindManyOptions, Repository } from "typeorm";
import { CargoType } from "./entities/cargo-type.entity";
import { ICargoTypesRepository } from "./interfaces/cargo-types.repository.interface";
@Injectable()
export class CargoTypesRepository
extends BaseRepository<CargoType>
implements ICargoTypesRepository
{
constructor(
@InjectRepository(CargoType)
repository: Repository<CargoType>,
) {
super(repository);
}
override findById(id: string): Promise<CargoType | null> {
return this.repository.findOne({
where: { id },
relations: { parent: true, children: true },
});
}
override findAndCount(options?: FindManyOptions<CargoType>): Promise<[CargoType[], number]> {
return this.repository.findAndCount(options);
}
}

View File

@@ -1,135 +0,0 @@
import { ConflictException, Inject, Injectable, NotFoundException } from "@nestjs/common";
import { ILike } from "typeorm";
import { CreateCargoTypeDto } from "./dto/create-cargo-type.dto";
import { FilterCargoTypeDto } from "./dto/filter-cargo-type.dto";
import { UpdateCargoTypeDto } from "./dto/update-cargo-type.dto";
import { CargoType } from "./entities/cargo-type.entity";
import {
CARGO_TYPES_REPOSITORY,
ICargoTypesRepository,
} from "./interfaces/cargo-types.repository.interface";
@Injectable()
export class CargoTypesService {
constructor(
@Inject(CARGO_TYPES_REPOSITORY)
private readonly repository: ICargoTypesRepository,
) {}
/**
* Find all cargo types with pagination and optional filtering.
*/
async findAll(filter: FilterCargoTypeDto): Promise<{
data: CargoType[];
meta: {
total: number;
page: number;
pageSize: number;
totalPages: number;
};
}> {
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) {
where.isActive = filter.isActive;
}
if (filter.requiresDirectorApproval !== undefined) {
where.requiresDirectorApproval = filter.requiresDirectorApproval;
}
if (filter.parentGroupId !== undefined) {
where.parentGroupId = filter.parentGroupId;
}
if (filter.search) {
where.cargoTypeName = ILike(`%${filter.search}%`);
}
const [data, total] = await this.repository.findAndCount({
where,
order: { [filter.sortBy!]: filter.sortOrder },
skip: (filter.page! - 1) * filter.pageSize!,
take: filter.pageSize,
relations: { parent: true },
});
return {
data,
meta: {
total,
page: filter.page!,
pageSize: filter.pageSize!,
totalPages: Math.ceil(total / filter.pageSize!),
},
};
}
/**
* Get a single cargo type by ID.
*/
async findById(id: string): Promise<CargoType> {
const entity = await this.repository.findById(id);
if (!entity) {
throw new NotFoundException(`Cargo type ${id} not found`);
}
return entity;
}
/**
* Create a new cargo type.
*/
async create(dto: CreateCargoTypeDto): Promise<CargoType> {
// Validate parent exists if provided
if (dto.parentGroupId) {
const parent = await this.repository.findById(dto.parentGroupId);
if (!parent) {
throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
}
}
return this.repository.create({
cargoTypeName: dto.cargoTypeName,
parentGroupId: dto.parentGroupId ?? null,
showFreeTextBox: dto.showFreeTextBox ?? false,
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
isActive: dto.isActive ?? true,
displayOrder: dto.displayOrder ?? 1,
});
}
/**
* Update an existing cargo type.
*/
async update(id: string, dto: UpdateCargoTypeDto): Promise<CargoType> {
await this.findById(id);
// Validate parent exists if provided
const parentGroupId = dto.parentGroupId;
if (parentGroupId) {
const parent = await this.repository.findById(parentGroupId);
if (!parent) {
throw new NotFoundException(`Parent cargo type ${parentGroupId} not found`);
}
// Prevent circular reference
if (parentGroupId === id) {
throw new ConflictException("A cargo type cannot be its own parent");
}
}
const updated = await this.repository.update(id, dto);
if (!updated) {
throw new NotFoundException(`Cargo type ${id} not found`);
}
return this.findById(id);
}
/**
* Soft-delete a cargo type.
*/
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}

View File

@@ -1,35 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from "class-validator";
export class CreateCargoTypeDto {
@ApiProperty({ description: "Cargo type name", maxLength: 255 })
@IsString()
@MaxLength(255)
cargoTypeName!: string;
@ApiPropertyOptional({ description: "Parent cargo type group ID (UUID) for hierarchical structure" })
@IsOptional()
@IsUUID()
parentGroupId?: string;
@ApiPropertyOptional({ description: "Show free text box for this cargo type", default: false })
@IsOptional()
@IsBoolean()
showFreeTextBox?: boolean = false;
@ApiPropertyOptional({ description: "Requires director approval for this cargo type", default: false })
@IsOptional()
@IsBoolean()
requiresDirectorApproval?: boolean = false;
@ApiPropertyOptional({ description: "Is the cargo type active", default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean = true;
@ApiPropertyOptional({ description: "Display order for UI", default: 1 })
@IsOptional()
@IsInt()
@Min(1)
displayOrder?: number = 1;
}

View File

@@ -1,51 +0,0 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, IsUUID, Min } from "class-validator";
export class FilterCargoTypeDto {
@ApiPropertyOptional({ description: "Filter by active status" })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
isActive?: boolean;
@ApiPropertyOptional({ description: "Filter by requires director approval" })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
requiresDirectorApproval?: boolean;
@ApiPropertyOptional({ description: "Filter by parent group ID (or 'root' for top-level only)" })
@IsOptional()
@IsUUID()
parentGroupId?: string;
@ApiPropertyOptional({ description: "Search by cargo type name (case-insensitive)" })
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ enum: ["cargoTypeName", "displayOrder", "createdAt"], default: "displayOrder" })
@IsOptional()
@IsIn(["cargoTypeName", "displayOrder", "createdAt"])
sortBy?: string = "displayOrder";
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" })
@IsOptional()
@IsIn(["ASC", "DESC"])
sortOrder?: "ASC" | "DESC" = "ASC";
@ApiPropertyOptional({ description: "Page number", default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ description: "Items per page", default: 20, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number = 20;
}

View File

@@ -1,5 +0,0 @@
import { PartialType } from "@nestjs/mapped-types";
import { CreateCargoTypeDto } from "./create-cargo-type.dto";
export class UpdateCargoTypeDto extends PartialType(CreateCargoTypeDto) {}

View File

@@ -1,36 +0,0 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index, ManyToOne, OneToMany, JoinColumn } from "typeorm";
@Entity({ schema: "freight", name: "cargo_types" })
@Index(["isActive"])
@Index(["displayOrder"])
@Index(["parentGroupId"])
export class CargoType extends BaseEntity {
@Column({ name: "cargo_type_name", type: "varchar", length: 255, nullable: false })
cargoTypeName!: string;
@Column({ name: "parent_group_id", type: "uuid", nullable: true })
parentGroupId?: string | null;
@Column({ name: "show_free_text_box", type: "boolean", default: false })
showFreeTextBox!: boolean;
@Column({ name: "requires_director_approval", type: "boolean", default: false })
requiresDirectorApproval!: boolean;
@Column({ name: "is_active", type: "boolean", default: true })
isActive!: boolean;
@Column({ name: "display_order", type: "int", default: 1 })
displayOrder!: number;
@ManyToOne(() => CargoType, (cargoType) => cargoType.children, {
nullable: true,
onDelete: "SET NULL",
})
@JoinColumn({ name: "parent_group_id" })
parent?: CargoType | null;
@OneToMany(() => CargoType, (cargoType) => cargoType.parent)
children?: CargoType[];
}

View File

@@ -0,0 +1,22 @@
import { Controller, Get, UseGuards } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { PermissionGuard } from "@tria-plc/api-common/modules/auth/services/permission.guard";
@ApiTags("demo-permissions")
@Controller()
export class DemoPermissionsController {
@Get("test_user1")
@ApiOperation({ summary: "Permission demo (can:demo:user1)" })
@UseGuards(PermissionGuard(["can:demo:user1"]))
testUser1() {
return { ok: true, permission: "can:demo:user1" };
}
@Get("test_user2")
@ApiOperation({ summary: "Permission demo (can:demo:user2)" })
@UseGuards(PermissionGuard(["can:demo:user2"]))
testUser2() {
return { ok: true, permission: "can:demo:user2" };
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { DemoPermissionsController } from "./demo-permissions.controller";
@Module({
controllers: [DemoPermissionsController],
})
export class DemoPermissionsModule {}

View File

@@ -0,0 +1,58 @@
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
import { CargoTypesService } from '../services/cargo-types.service';
@ApiTags('cargo-types')
@Controller('cargo-types')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class CargoTypesController {
constructor(private readonly service: CargoTypesService) {}
@Get()
@ApiOperation({ summary: 'List cargo types' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
requiresDirectorApproval: query['requiresDirectorApproval'] !== undefined
? query['requiresDirectorApproval'] === 'true'
: undefined,
parentGroupId: query['parentGroupId'],
search: query['search'],
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
sortBy: query['sortBy'],
sortOrder: (query['sortOrder'] as 'ASC' | 'DESC') ?? 'ASC',
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a cargo type by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create a cargo type' })
create(@Body() dto: CreateCargoTypeDto) {
return this.service.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a cargo type' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoTypeDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a cargo type' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,51 @@
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
import { ContainerTypesService } from '../services/container-types.service';
@ApiTags('container-types')
@Controller('container-types')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class ContainerTypesController {
constructor(private readonly service: ContainerTypesService) {}
@Get()
@ApiOperation({ summary: 'List container types' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a container type by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create a container type' })
create(@Body() dto: CreateContainerTypeDto) {
return this.service.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a container type' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerTypeDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a container type' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,51 @@
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreatePriorityRuleDto } from '../dto/create-priority-rule.dto';
import { UpdatePriorityRuleDto } from '../dto/update-priority-rule.dto';
import { PriorityRulesService } from '../services/priority-rules.service';
@ApiTags('priority-rules')
@Controller('priority-rules')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class PriorityRulesController {
constructor(private readonly service: PriorityRulesService) {}
@Get()
@ApiOperation({ summary: 'List priority rules' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a priority rule by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create a priority rule' })
create(@Body() dto: CreatePriorityRuleDto) {
return this.service.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a priority rule' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdatePriorityRuleDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a priority rule' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,55 @@
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
import { ServiceTypesService } from '../services/service-types.service';
@ApiTags('service-types')
@Controller('service-types')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class ServiceTypesController {
constructor(private readonly service: ServiceTypesService) {}
@Get()
@ApiOperation({ summary: 'List service types' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
canBeBookedAlone: query['canBeBookedAlone'] !== undefined ? query['canBeBookedAlone'] === 'true' : undefined,
search: query['search'],
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
sortBy: query['sortBy'],
sortOrder: (query['sortOrder'] as 'ASC' | 'DESC') ?? 'ASC',
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a service type by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create a service type' })
create(@Body() dto: CreateServiceTypeDto) {
return this.service.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a service type' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateServiceTypeDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a service type' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,51 @@
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateSurchargeTypeDto } from '../dto/create-surcharge-type.dto';
import { UpdateSurchargeTypeDto } from '../dto/update-surcharge-type.dto';
import { SurchargeTypesService } from '../services/surcharge-types.service';
@ApiTags('surcharge-types')
@Controller('surcharge-types')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class SurchargeTypesController {
constructor(private readonly service: SurchargeTypesService) {}
@Get()
@ApiOperation({ summary: 'List surcharge types' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a surcharge type by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create a surcharge type' })
create(@Body() dto: CreateSurchargeTypeDto) {
return this.service.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a surcharge type' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateSurchargeTypeDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a surcharge type' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,52 @@
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateSurchargeDto } from '../dto/create-surcharge.dto';
import { UpdateSurchargeDto } from '../dto/update-surcharge.dto';
import { SurchargesService } from '../services/surcharges.service';
@ApiTags('surcharges')
@Controller('surcharges')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class SurchargesController {
constructor(private readonly service: SurchargesService) {}
@Get()
@ApiOperation({ summary: 'List surcharges' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
surchargeTypeId: query['surchargeTypeId'],
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a surcharge by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create a surcharge' })
create(@Body() dto: CreateSurchargeDto) {
return this.service.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a surcharge' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateSurchargeDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a surcharge' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,52 @@
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
import { WeightLimitRulesService } from '../services/weight-limit-rules.service';
@ApiTags('weight-limit-rules')
@Controller('weight-limit-rules')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class WeightLimitRulesController {
constructor(private readonly service: WeightLimitRulesService) {}
@Get()
@ApiOperation({ summary: 'List weight limit rules' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
containerTypeId: query['containerTypeId'],
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a weight limit rule by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create a weight limit rule' })
create(@Body() dto: CreateWeightLimitRuleDto) {
return this.service.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a weight limit rule' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWeightLimitRuleDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a weight limit rule' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,40 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
export class CreateCargoTypeDto {
@ApiProperty({ description: 'Machine-readable code, e.g. BULK, BREAK_BULK', maxLength: 50 })
@IsString()
@MaxLength(50)
code!: string;
@ApiProperty({ description: 'Cargo type display name', maxLength: 255 })
@IsString()
@MaxLength(255)
cargoTypeName!: string;
@ApiPropertyOptional({ description: 'Parent group ID for hierarchical cargo types' })
@IsOptional()
@IsUUID()
parentGroupId?: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
showFreeTextBox?: boolean;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
requiresDirectorApproval?: boolean;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@IsInt()
@Min(1)
displayOrder?: number;
}

View File

@@ -0,0 +1,25 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
export class CreateContainerTypeDto {
@ApiProperty({ description: 'Size code, e.g. 20FT or 40FT', maxLength: 20 })
@IsString()
@MaxLength(20)
sizeCode!: string;
@ApiPropertyOptional({ description: 'Human-readable description', maxLength: 100 })
@IsOptional()
@IsString()
@MaxLength(100)
description?: string;
@ApiProperty({ description: 'Number of containers that fit per rail wagon (2 for 20FT, 1 for 40FT)' })
@IsInt()
@Min(1)
containersPerWagon!: number;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,34 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
import { Freight } from '@edr/types';
export class CreatePriorityRuleDto {
@ApiProperty({ enum: Freight.PriorityType, description: 'Priority type (unique per rule)' })
@IsEnum(Freight.PriorityType)
priorityType!: Freight.PriorityType;
@ApiProperty({ description: 'Human-readable rule name', maxLength: 255 })
@IsString()
@MaxLength(255)
ruleName!: string;
@ApiPropertyOptional({ description: 'Explanation of when this rule is triggered' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: 'Technical expression describing the activation condition' })
@IsOptional()
@IsString()
activationCondition?: string;
@ApiProperty({ description: 'Points added to booking.priorityScore when this rule matches', default: 0 })
@IsInt()
@Min(0)
bonusPoints!: number;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,56 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
export class CreateServiceTypeDto {
@ApiProperty({ description: 'Machine-readable code, e.g. RAIL_ONLY', maxLength: 50 })
@IsString()
@MaxLength(50)
code!: string;
@ApiProperty({ description: 'Service type display name', maxLength: 255 })
@IsString()
@MaxLength(255)
serviceName!: string;
@ApiPropertyOptional({ description: 'Detailed description' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
canBeBookedAlone?: boolean;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
includesFirstMile?: boolean;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
includesLastMile?: boolean;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
includesCustoms?: boolean;
@ApiPropertyOptional({ description: 'Priority bonus points awarded when this service is used', default: 0 })
@IsOptional()
@IsInt()
@Min(0)
priorityBonusPoints?: number;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@IsInt()
@Min(1)
displayOrder?: number;
}

View File

@@ -0,0 +1,24 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator';
export class CreateSurchargeTypeDto {
@ApiProperty({ description: 'Unique code, e.g. HAZARDOUS, REFRIGERATED', maxLength: 50 })
@IsString()
@MaxLength(50)
code!: string;
@ApiProperty({ description: 'Display name', maxLength: 100 })
@IsString()
@MaxLength(100)
name!: string;
@ApiPropertyOptional({ description: 'Description of when this surcharge type is triggered' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,63 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsEnum,
IsNumber,
IsOptional,
IsString,
IsUUID,
Length,
MaxLength,
Min,
} from 'class-validator';
import { Freight } from '@edr/types';
export class CreateSurchargeDto {
@ApiProperty({ description: 'FK to surcharge_types.id' })
@IsUUID()
surchargeTypeId!: string;
@ApiProperty({ description: 'Display name for this surcharge line item', maxLength: 255 })
@IsString()
@MaxLength(255)
feeName!: string;
@ApiPropertyOptional({ description: 'Human-readable description of when this surcharge is triggered' })
@IsOptional()
@IsString()
triggerDescription?: string;
@ApiProperty({ enum: Freight.CalculationMethod, default: Freight.CalculationMethod.PER_TON })
@IsEnum(Freight.CalculationMethod)
calculationMethod!: Freight.CalculationMethod;
@ApiProperty({ description: 'Rate amount (per ton, flat, or percentage)' })
@IsNumber()
@Min(0)
rate!: number;
@ApiProperty({ description: 'ISO 4217 currency code', default: 'USD' })
@IsString()
@Length(3, 3)
currency!: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
applyToRail?: boolean;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
applyToFirstMile?: boolean;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
applyToLastMile?: boolean;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,38 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsEnum, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
import { Freight } from '@edr/types';
export class CreateWeightLimitRuleDto {
@ApiProperty({ description: 'FK to container_types.id' })
@IsUUID()
containerTypeId!: string;
@ApiProperty({ enum: Freight.TradeDirection, description: 'Trade direction this rule applies to' })
@IsEnum(Freight.TradeDirection)
tradeDirection!: Freight.TradeDirection;
@ApiProperty({ description: 'Maximum allowed weight in tons before surcharge is applied' })
@IsNumber()
@Min(0)
maxWeightTons!: number;
@ApiProperty({ description: 'Weight at which a warning is issued (must be ≤ maxWeightTons)' })
@IsNumber()
@Min(0)
warningThresholdTons!: number;
@ApiPropertyOptional({ enum: Freight.ExceededAction, default: Freight.ExceededAction.WARNING_ONLY })
@IsOptional()
@IsEnum(Freight.ExceededAction)
exceededAction?: Freight.ExceededAction;
@ApiPropertyOptional({ description: 'FK to surcharges.id — surcharge billed when max is exceeded' })
@IsOptional()
@IsUUID()
surchargeId?: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateCargoTypeDto } from './create-cargo-type.dto';
export class UpdateCargoTypeDto extends PartialType(CreateCargoTypeDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateContainerTypeDto } from './create-container-type.dto';
export class UpdateContainerTypeDto extends PartialType(CreateContainerTypeDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreatePriorityRuleDto } from './create-priority-rule.dto';
export class UpdatePriorityRuleDto extends PartialType(CreatePriorityRuleDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateServiceTypeDto } from './create-service-type.dto';
export class UpdateServiceTypeDto extends PartialType(CreateServiceTypeDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateSurchargeTypeDto } from './create-surcharge-type.dto';
export class UpdateSurchargeTypeDto extends PartialType(CreateSurchargeTypeDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateSurchargeDto } from './create-surcharge.dto';
export class UpdateSurchargeDto extends PartialType(CreateSurchargeDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateWeightLimitRuleDto } from './create-weight-limit-rule.dto';
export class UpdateWeightLimitRuleDto extends PartialType(CreateWeightLimitRuleDto) {}

View File

@@ -0,0 +1,37 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
@Entity({ schema: 'freight', name: 'cargo_types' })
@Index(['isActive'])
@Index(['displayOrder'])
@Index(['parentGroupId'])
@Index(['code'])
export class CargoType extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' })
code!: string;
@Column({ name: 'cargo_type_name', type: 'varchar', length: 255 })
cargoTypeName!: string;
@Column({ name: 'parent_group_id', type: 'uuid', nullable: true })
parentGroupId?: string | null;
@Column({ name: 'show_free_text_box', type: 'boolean', default: false })
showFreeTextBox!: boolean;
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
requiresDirectorApproval!: boolean;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@Column({ name: 'display_order', type: 'int', default: 1 })
displayOrder!: number;
@ManyToOne(() => CargoType, (ct) => ct.children, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'parent_group_id' })
parent?: CargoType | null;
@OneToMany(() => CargoType, (ct) => ct.parent)
children?: CargoType[];
}

View File

@@ -0,0 +1,23 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { WeightLimitRule } from './weight-limit-rule.entity';
@Entity({ schema: 'freight', name: 'container_types' })
@Index(['sizeCode'])
@Index(['isActive'])
export class ContainerType extends BaseEntity {
@Column({ name: 'size_code', type: 'varchar', length: 20, unique: true })
sizeCode!: string;
@Column({ name: 'description', type: 'varchar', length: 100, nullable: true })
description?: string | null;
@Column({ name: 'containers_per_wagon', type: 'int' })
containersPerWagon!: number;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@OneToMany(() => WeightLimitRule, (rule) => rule.containerType)
weightLimitRules?: WeightLimitRule[];
}

View File

@@ -0,0 +1,26 @@
import { BaseEntity } from '@edr/api-common';
import { Freight } from '@edr/types';
import { Column, Entity, Index } from 'typeorm';
@Entity({ schema: 'freight', name: 'priority_rules' })
@Index(['priorityType'])
@Index(['isActive'])
export class PriorityRule extends BaseEntity {
@Column({ name: 'priority_type', type: 'enum', enum: Freight.PriorityType, unique: true })
priorityType!: Freight.PriorityType;
@Column({ name: 'rule_name', type: 'varchar', length: 255 })
ruleName!: string;
@Column({ name: 'description', type: 'text', nullable: true })
description?: string | null;
@Column({ name: 'activation_condition', type: 'text', nullable: true })
activationCondition?: string | null;
@Column({ name: 'bonus_points', type: 'int', default: 0 })
bonusPoints!: number;
@Column({ name: 'is_active', type: 'boolean', default: false })
isActive!: boolean;
}

View File

@@ -0,0 +1,38 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
@Entity({ schema: 'freight', name: 'service_types' })
@Index(['isActive'])
@Index(['displayOrder'])
@Index(['code'])
export class ServiceType extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' })
code!: string;
@Column({ name: 'service_name', type: 'varchar', length: 255 })
serviceName!: string;
@Column({ name: 'description', type: 'text', nullable: true })
description?: string | null;
@Column({ name: 'can_be_booked_alone', type: 'boolean', default: true })
canBeBookedAlone!: boolean;
@Column({ name: 'includes_first_mile', type: 'boolean', default: false })
includesFirstMile!: boolean;
@Column({ name: 'includes_last_mile', type: 'boolean', default: false })
includesLastMile!: boolean;
@Column({ name: 'includes_customs', type: 'boolean', default: false })
includesCustoms!: boolean;
@Column({ name: 'priority_bonus_points', type: 'int', default: 0 })
priorityBonusPoints!: number;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@Column({ name: 'display_order', type: 'int', default: 1 })
displayOrder!: number;
}

View File

@@ -0,0 +1,23 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { Surcharge } from './surcharge.entity';
@Entity({ schema: 'freight', name: 'surcharge_types' })
@Index(['code'])
@Index(['isActive'])
export class SurchargeType extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 50, unique: true })
code!: string;
@Column({ name: 'name', type: 'varchar', length: 100 })
name!: string;
@Column({ name: 'description', type: 'text', nullable: true })
description?: string | null;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@OneToMany(() => Surcharge, (s) => s.surchargeType)
surcharges?: Surcharge[];
}

View File

@@ -0,0 +1,52 @@
import { BaseEntity } from '@edr/api-common';
import { Freight } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { SurchargeType } from './surcharge-type.entity';
import { WeightLimitRule } from './weight-limit-rule.entity';
@Entity({ schema: 'freight', name: 'surcharges' })
@Index(['surchargeTypeId'])
@Index(['isActive'])
export class Surcharge extends BaseEntity {
@Column({ name: 'surcharge_type_id', type: 'uuid' })
surchargeTypeId!: string;
@ManyToOne(() => SurchargeType, (st) => st.surcharges)
@JoinColumn({ name: 'surcharge_type_id' })
surchargeType!: SurchargeType;
@Column({ name: 'fee_name', type: 'varchar', length: 255 })
feeName!: string;
@Column({ name: 'trigger_description', type: 'text', nullable: true })
triggerDescription?: string | null;
@Column({
name: 'calculation_method',
type: 'enum',
enum: Freight.CalculationMethod,
default: Freight.CalculationMethod.PER_TON,
})
calculationMethod!: Freight.CalculationMethod;
@Column({ name: 'rate', type: 'numeric', precision: 10, scale: 2 })
rate!: number;
@Column({ name: 'currency', type: 'char', length: 3, default: 'USD' })
currency!: string;
@Column({ name: 'apply_to_rail', type: 'boolean', default: false })
applyToRail!: boolean;
@Column({ name: 'apply_to_first_mile', type: 'boolean', default: false })
applyToFirstMile!: boolean;
@Column({ name: 'apply_to_last_mile', type: 'boolean', default: false })
applyToLastMile!: boolean;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@OneToMany(() => WeightLimitRule, (rule) => rule.surcharge)
weightLimitRules?: WeightLimitRule[];
}

View File

@@ -0,0 +1,45 @@
import { BaseEntity } from '@edr/api-common';
import { Freight } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { ContainerType } from './container-type.entity';
import { Surcharge } from './surcharge.entity';
@Entity({ schema: 'freight', name: 'weight_limit_rules' })
@Index(['containerTypeId'])
@Index(['surchargeId'])
@Index(['isActive'])
export class WeightLimitRule extends BaseEntity {
@Column({ name: 'container_type_id', type: 'uuid' })
containerTypeId!: string;
@ManyToOne(() => ContainerType, (ct) => ct.weightLimitRules)
@JoinColumn({ name: 'container_type_id' })
containerType!: ContainerType;
@Column({ name: 'trade_direction', type: 'enum', enum: Freight.TradeDirection })
tradeDirection!: Freight.TradeDirection;
@Column({ name: 'max_weight_tons', type: 'numeric', precision: 10, scale: 2 })
maxWeightTons!: number;
@Column({ name: 'warning_threshold_tons', type: 'numeric', precision: 10, scale: 2 })
warningThresholdTons!: number;
@Column({
name: 'exceeded_action',
type: 'enum',
enum: Freight.ExceededAction,
default: Freight.ExceededAction.WARNING_ONLY,
})
exceededAction!: Freight.ExceededAction;
@Column({ name: 'surcharge_id', type: 'uuid', nullable: true })
surchargeId?: string | null;
@ManyToOne(() => Surcharge, (s) => s.weightLimitRules, { nullable: true })
@JoinColumn({ name: 'surcharge_id' })
surcharge?: Surcharge | null;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}

View File

@@ -1,9 +1,9 @@
import { FindManyOptions } from "typeorm";
import { CargoType } from "../entities/cargo-type.entity";
import { FindManyOptions } from 'typeorm';
import { CargoType } from '../entities/cargo-type.entity';
export interface ICargoTypesRepository {
findById(id: string): Promise<CargoType | null>;
findByCode(code: string): Promise<CargoType | null>;
findAll(options?: FindManyOptions<CargoType>): Promise<CargoType[]>;
findAndCount(options?: FindManyOptions<CargoType>): Promise<[CargoType[], number]>;
create(data: Partial<CargoType>): Promise<CargoType>;
@@ -11,4 +11,4 @@ export interface ICargoTypesRepository {
softDelete(id: string): Promise<void>;
}
export const CARGO_TYPES_REPOSITORY = Symbol("CARGO_TYPES_REPOSITORY");
export const CARGO_TYPES_REPOSITORY = Symbol('CARGO_TYPES_REPOSITORY');

View File

@@ -0,0 +1,14 @@
import { FindManyOptions } from 'typeorm';
import { ContainerType } from '../entities/container-type.entity';
export interface IContainerTypesRepository {
findById(id: string): Promise<ContainerType | null>;
findBySizeCode(sizeCode: string): Promise<ContainerType | null>;
findAll(options?: FindManyOptions<ContainerType>): Promise<ContainerType[]>;
findAndCount(options?: FindManyOptions<ContainerType>): Promise<[ContainerType[], number]>;
create(data: Partial<ContainerType>): Promise<ContainerType>;
update(id: string, data: Partial<ContainerType>): Promise<ContainerType | null>;
softDelete(id: string): Promise<void>;
}
export const CONTAINER_TYPES_REPOSITORY = Symbol('CONTAINER_TYPES_REPOSITORY');

View File

@@ -0,0 +1,14 @@
import { FindManyOptions } from 'typeorm';
import { PriorityRule } from '../entities/priority-rule.entity';
export interface IPriorityRulesRepository {
findById(id: string): Promise<PriorityRule | null>;
findAllActive(): Promise<PriorityRule[]>;
findAll(options?: FindManyOptions<PriorityRule>): Promise<PriorityRule[]>;
findAndCount(options?: FindManyOptions<PriorityRule>): Promise<[PriorityRule[], number]>;
create(data: Partial<PriorityRule>): Promise<PriorityRule>;
update(id: string, data: Partial<PriorityRule>): Promise<PriorityRule | null>;
softDelete(id: string): Promise<void>;
}
export const PRIORITY_RULES_REPOSITORY = Symbol('PRIORITY_RULES_REPOSITORY');

View File

@@ -1,9 +1,9 @@
import { FindManyOptions } from "typeorm";
import { ServiceType } from "../entities/service-type.entity";
import { FindManyOptions } from 'typeorm';
import { ServiceType } from '../entities/service-type.entity';
export interface IServiceTypesRepository {
findById(id: string): Promise<ServiceType | null>;
findByCode(code: string): Promise<ServiceType | null>;
findAll(options?: FindManyOptions<ServiceType>): Promise<ServiceType[]>;
findAndCount(options?: FindManyOptions<ServiceType>): Promise<[ServiceType[], number]>;
create(data: Partial<ServiceType>): Promise<ServiceType>;
@@ -11,4 +11,4 @@ export interface IServiceTypesRepository {
softDelete(id: string): Promise<void>;
}
export const SERVICE_TYPES_REPOSITORY = Symbol("SERVICE_TYPES_REPOSITORY");
export const SERVICE_TYPES_REPOSITORY = Symbol('SERVICE_TYPES_REPOSITORY');

View File

@@ -0,0 +1,14 @@
import { FindManyOptions } from 'typeorm';
import { SurchargeType } from '../entities/surcharge-type.entity';
export interface ISurchargeTypesRepository {
findById(id: string): Promise<SurchargeType | null>;
findByCode(code: string): Promise<SurchargeType | null>;
findAll(options?: FindManyOptions<SurchargeType>): Promise<SurchargeType[]>;
findAndCount(options?: FindManyOptions<SurchargeType>): Promise<[SurchargeType[], number]>;
create(data: Partial<SurchargeType>): Promise<SurchargeType>;
update(id: string, data: Partial<SurchargeType>): Promise<SurchargeType | null>;
softDelete(id: string): Promise<void>;
}
export const SURCHARGE_TYPES_REPOSITORY = Symbol('SURCHARGE_TYPES_REPOSITORY');

View File

@@ -0,0 +1,14 @@
import { FindManyOptions } from 'typeorm';
import { Surcharge } from '../entities/surcharge.entity';
export interface ISurchargesRepository {
findById(id: string): Promise<Surcharge | null>;
findByTypeCode(typeCode: string): Promise<Surcharge | null>;
findAll(options?: FindManyOptions<Surcharge>): Promise<Surcharge[]>;
findAndCount(options?: FindManyOptions<Surcharge>): Promise<[Surcharge[], number]>;
create(data: Partial<Surcharge>): Promise<Surcharge>;
update(id: string, data: Partial<Surcharge>): Promise<Surcharge | null>;
softDelete(id: string): Promise<void>;
}
export const SURCHARGES_REPOSITORY = Symbol('SURCHARGES_REPOSITORY');

View File

@@ -0,0 +1,17 @@
import { FindManyOptions } from 'typeorm';
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
export interface IWeightLimitRulesRepository {
findById(id: string): Promise<WeightLimitRule | null>;
findActiveByContainerTypeAndDirection(
sizeCode: string,
tradeDirection: string,
): Promise<WeightLimitRule[]>;
findAll(options?: FindManyOptions<WeightLimitRule>): Promise<WeightLimitRule[]>;
findAndCount(options?: FindManyOptions<WeightLimitRule>): Promise<[WeightLimitRule[], number]>;
create(data: Partial<WeightLimitRule>): Promise<WeightLimitRule>;
update(id: string, data: Partial<WeightLimitRule>): Promise<WeightLimitRule | null>;
softDelete(id: string): Promise<void>;
}
export const WEIGHT_LIMIT_RULES_REPOSITORY = Symbol('WEIGHT_LIMIT_RULES_REPOSITORY');

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { CargoType } from '../entities/cargo-type.entity';
import { ICargoTypesRepository } from '../interfaces/cargo-types.repository.interface';
@Injectable()
export class CargoTypesRepository implements ICargoTypesRepository {
private readonly repo: Repository<CargoType>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(CargoType);
}
findById(id: string): Promise<CargoType | null> {
return this.repo.findOne({ where: { id }, relations: { parent: true } });
}
findByCode(code: string): Promise<CargoType | null> {
return this.repo.findOne({ where: { code } });
}
findAll(options?: FindManyOptions<CargoType>): Promise<CargoType[]> {
return this.repo.find(options);
}
findAndCount(options?: FindManyOptions<CargoType>): Promise<[CargoType[], number]> {
return this.repo.findAndCount(options);
}
async create(data: Partial<CargoType>): Promise<CargoType> {
const entity = this.repo.create(data);
return this.repo.save(entity);
}
async update(id: string, data: Partial<CargoType>): Promise<CargoType | null> {
await this.repo.update(id, data);
return this.findById(id);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { ContainerType } from '../entities/container-type.entity';
import { IContainerTypesRepository } from '../interfaces/container-types.repository.interface';
@Injectable()
export class ContainerTypesRepository implements IContainerTypesRepository {
private readonly repo: Repository<ContainerType>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(ContainerType);
}
findById(id: string): Promise<ContainerType | null> {
return this.repo.findOne({ where: { id } });
}
findBySizeCode(sizeCode: string): Promise<ContainerType | null> {
return this.repo.findOne({ where: { sizeCode } });
}
findAll(options?: FindManyOptions<ContainerType>): Promise<ContainerType[]> {
return this.repo.find(options);
}
findAndCount(options?: FindManyOptions<ContainerType>): Promise<[ContainerType[], number]> {
return this.repo.findAndCount(options);
}
async create(data: Partial<ContainerType>): Promise<ContainerType> {
const entity = this.repo.create(data);
return this.repo.save(entity);
}
async update(id: string, data: Partial<ContainerType>): Promise<ContainerType | null> {
await this.repo.update(id, data);
return this.findById(id);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { PriorityRule } from '../entities/priority-rule.entity';
import { IPriorityRulesRepository } from '../interfaces/priority-rules.repository.interface';
@Injectable()
export class PriorityRulesRepository implements IPriorityRulesRepository {
private readonly repo: Repository<PriorityRule>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(PriorityRule);
}
findById(id: string): Promise<PriorityRule | null> {
return this.repo.findOne({ where: { id } });
}
findAllActive(): Promise<PriorityRule[]> {
return this.repo.find({ where: { isActive: true } });
}
findAll(options?: FindManyOptions<PriorityRule>): Promise<PriorityRule[]> {
return this.repo.find(options);
}
findAndCount(options?: FindManyOptions<PriorityRule>): Promise<[PriorityRule[], number]> {
return this.repo.findAndCount(options);
}
async create(data: Partial<PriorityRule>): Promise<PriorityRule> {
const entity = this.repo.create(data);
return this.repo.save(entity);
}
async update(id: string, data: Partial<PriorityRule>): Promise<PriorityRule | null> {
await this.repo.update(id, data);
return this.findById(id);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { ServiceType } from '../entities/service-type.entity';
import { IServiceTypesRepository } from '../interfaces/service-types.repository.interface';
@Injectable()
export class ServiceTypesRepository implements IServiceTypesRepository {
private readonly repo: Repository<ServiceType>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(ServiceType);
}
findById(id: string): Promise<ServiceType | null> {
return this.repo.findOne({ where: { id } });
}
findByCode(code: string): Promise<ServiceType | null> {
return this.repo.findOne({ where: { code } });
}
findAll(options?: FindManyOptions<ServiceType>): Promise<ServiceType[]> {
return this.repo.find(options);
}
findAndCount(options?: FindManyOptions<ServiceType>): Promise<[ServiceType[], number]> {
return this.repo.findAndCount(options);
}
async create(data: Partial<ServiceType>): Promise<ServiceType> {
const entity = this.repo.create(data);
return this.repo.save(entity);
}
async update(id: string, data: Partial<ServiceType>): Promise<ServiceType | null> {
await this.repo.update(id, data);
return this.findById(id);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { SurchargeType } from '../entities/surcharge-type.entity';
import { ISurchargeTypesRepository } from '../interfaces/surcharge-types.repository.interface';
@Injectable()
export class SurchargeTypesRepository implements ISurchargeTypesRepository {
private readonly repo: Repository<SurchargeType>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(SurchargeType);
}
findById(id: string): Promise<SurchargeType | null> {
return this.repo.findOne({ where: { id } });
}
findByCode(code: string): Promise<SurchargeType | null> {
return this.repo.findOne({ where: { code } });
}
findAll(options?: FindManyOptions<SurchargeType>): Promise<SurchargeType[]> {
return this.repo.find(options);
}
findAndCount(options?: FindManyOptions<SurchargeType>): Promise<[SurchargeType[], number]> {
return this.repo.findAndCount(options);
}
async create(data: Partial<SurchargeType>): Promise<SurchargeType> {
const entity = this.repo.create(data);
return this.repo.save(entity);
}
async update(id: string, data: Partial<SurchargeType>): Promise<SurchargeType | null> {
await this.repo.update(id, data);
return this.findById(id);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
}

View File

@@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { Surcharge } from '../entities/surcharge.entity';
import { ISurchargesRepository } from '../interfaces/surcharges.repository.interface';
@Injectable()
export class SurchargesRepository implements ISurchargesRepository {
private readonly repo: Repository<Surcharge>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(Surcharge);
}
findById(id: string): Promise<Surcharge | null> {
return this.repo.findOne({ where: { id }, relations: { surchargeType: true } });
}
findByTypeCode(typeCode: string): Promise<Surcharge | null> {
return this.repo.findOne({
where: { isActive: true, surchargeType: { code: typeCode } },
relations: { surchargeType: true },
});
}
findAll(options?: FindManyOptions<Surcharge>): Promise<Surcharge[]> {
return this.repo.find(options);
}
findAndCount(options?: FindManyOptions<Surcharge>): Promise<[Surcharge[], number]> {
return this.repo.findAndCount(options);
}
async create(data: Partial<Surcharge>): Promise<Surcharge> {
const entity = this.repo.create(data);
return this.repo.save(entity);
}
async update(id: string, data: Partial<Surcharge>): Promise<Surcharge | null> {
await this.repo.update(id, data);
return this.findById(id);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
}

View File

@@ -0,0 +1,60 @@
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
import { IWeightLimitRulesRepository } from '../interfaces/weight-limit-rules.repository.interface';
@Injectable()
export class WeightLimitRulesRepository implements IWeightLimitRulesRepository {
private readonly repo: Repository<WeightLimitRule>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(WeightLimitRule);
}
findById(id: string): Promise<WeightLimitRule | null> {
return this.repo.findOne({
where: { id },
relations: { containerType: true, surcharge: { surchargeType: true } },
});
}
findActiveByContainerTypeAndDirection(
sizeCode: string,
tradeDirection: string,
): Promise<WeightLimitRule[]> {
return this.repo
.createQueryBuilder('rule')
.innerJoinAndSelect('rule.containerType', 'ct')
.leftJoinAndSelect('rule.surcharge', 'surcharge')
.leftJoinAndSelect('surcharge.surchargeType', 'surchargeType')
.where('ct.size_code = :sizeCode', { sizeCode })
.andWhere('(rule.trade_direction = :dir OR rule.trade_direction = :both)', {
dir: tradeDirection,
both: 'BOTH',
})
.andWhere('rule.is_active = true')
.getMany();
}
findAll(options?: FindManyOptions<WeightLimitRule>): Promise<WeightLimitRule[]> {
return this.repo.find(options);
}
findAndCount(options?: FindManyOptions<WeightLimitRule>): Promise<[WeightLimitRule[], number]> {
return this.repo.findAndCount(options);
}
async create(data: Partial<WeightLimitRule>): Promise<WeightLimitRule> {
const entity = this.repo.create(data);
return this.repo.save(entity);
}
async update(id: string, data: Partial<WeightLimitRule>): Promise<WeightLimitRule | null> {
await this.repo.update(id, data);
return this.findById(id);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
}

View File

@@ -0,0 +1,106 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CargoType } from './entities/cargo-type.entity';
import { ContainerType } from './entities/container-type.entity';
import { PriorityRule } from './entities/priority-rule.entity';
import { Surcharge } from './entities/surcharge.entity';
import { SurchargeType } from './entities/surcharge-type.entity';
import { ServiceType } from './entities/service-type.entity';
import { WeightLimitRule } from './entities/weight-limit-rule.entity';
import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface';
import { CONTAINER_TYPES_REPOSITORY } from './interfaces/container-types.repository.interface';
import { PRIORITY_RULES_REPOSITORY } from './interfaces/priority-rules.repository.interface';
import { SURCHARGES_REPOSITORY } from './interfaces/surcharges.repository.interface';
import { SURCHARGE_TYPES_REPOSITORY } from './interfaces/surcharge-types.repository.interface';
import { SERVICE_TYPES_REPOSITORY } from './interfaces/service-types.repository.interface';
import { WEIGHT_LIMIT_RULES_REPOSITORY } from './interfaces/weight-limit-rules.repository.interface';
import { CargoTypesRepository } from './repositories/cargo-types.repository';
import { ContainerTypesRepository } from './repositories/container-types.repository';
import { PriorityRulesRepository } from './repositories/priority-rules.repository';
import { SurchargesRepository } from './repositories/surcharges.repository';
import { SurchargeTypesRepository } from './repositories/surcharge-types.repository';
import { ServiceTypesRepository } from './repositories/service-types.repository';
import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.repository';
import { CargoTypesService } from './services/cargo-types.service';
import { ContainerTypesService } from './services/container-types.service';
import { PriorityRulesService } from './services/priority-rules.service';
import { SurchargesService } from './services/surcharges.service';
import { SurchargeTypesService } from './services/surcharge-types.service';
import { ServiceTypesService } from './services/service-types.service';
import { WeightLimitRulesService } from './services/weight-limit-rules.service';
import { CargoTypesController } from './controllers/cargo-types.controller';
import { ContainerTypesController } from './controllers/container-types.controller';
import { PriorityRulesController } from './controllers/priority-rules.controller';
import { SurchargesController } from './controllers/surcharges.controller';
import { SurchargeTypesController } from './controllers/surcharge-types.controller';
import { ServiceTypesController } from './controllers/service-types.controller';
import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller';
import { RuleEngineService } from './rule-engine.service';
@Global()
@Module({
imports: [
TypeOrmModule.forFeature([
CargoType,
ContainerType,
PriorityRule,
Surcharge,
SurchargeType,
ServiceType,
WeightLimitRule,
]),
],
controllers: [
CargoTypesController,
ContainerTypesController,
PriorityRulesController,
SurchargesController,
SurchargeTypesController,
ServiceTypesController,
WeightLimitRulesController,
],
providers: [
// Repositories
CargoTypesRepository,
{ provide: CARGO_TYPES_REPOSITORY, useExisting: CargoTypesRepository },
ContainerTypesRepository,
{ provide: CONTAINER_TYPES_REPOSITORY, useExisting: ContainerTypesRepository },
PriorityRulesRepository,
{ provide: PRIORITY_RULES_REPOSITORY, useExisting: PriorityRulesRepository },
SurchargesRepository,
{ provide: SURCHARGES_REPOSITORY, useExisting: SurchargesRepository },
SurchargeTypesRepository,
{ provide: SURCHARGE_TYPES_REPOSITORY, useExisting: SurchargeTypesRepository },
ServiceTypesRepository,
{ provide: SERVICE_TYPES_REPOSITORY, useExisting: ServiceTypesRepository },
WeightLimitRulesRepository,
{ provide: WEIGHT_LIMIT_RULES_REPOSITORY, useExisting: WeightLimitRulesRepository },
// CRUD services
CargoTypesService,
ContainerTypesService,
PriorityRulesService,
SurchargesService,
SurchargeTypesService,
ServiceTypesService,
WeightLimitRulesService,
// Evaluation engine
RuleEngineService,
],
exports: [
RuleEngineService,
CargoTypesService,
ServiceTypesService,
ContainerTypesService,
SurchargeTypesService,
SurchargesService,
WeightLimitRulesService,
PriorityRulesService,
],
})
export class RuleEngineModule {}

View File

@@ -0,0 +1,196 @@
import { Inject, Injectable, BadRequestException } from '@nestjs/common';
import { Freight } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import {
ICargoTypesRepository,
CARGO_TYPES_REPOSITORY,
} from './interfaces/cargo-types.repository.interface';
import {
IServiceTypesRepository,
SERVICE_TYPES_REPOSITORY,
} from './interfaces/service-types.repository.interface';
import {
ISurchargesRepository,
SURCHARGES_REPOSITORY,
} from './interfaces/surcharges.repository.interface';
import {
IWeightLimitRulesRepository,
WEIGHT_LIMIT_RULES_REPOSITORY,
} from './interfaces/weight-limit-rules.repository.interface';
import {
IPriorityRulesRepository,
PRIORITY_RULES_REPOSITORY,
} from './interfaces/priority-rules.repository.interface';
export interface AppliedSurcharge {
feeName: string;
rate: number;
currency: string;
calculationMethod: Freight.CalculationMethod;
applyToRail: boolean;
applyToFirstMile: boolean;
applyToLastMile: boolean;
}
export interface RuleEvaluationResult {
priorityScore: number;
appliedSurcharges: AppliedSurcharge[];
warnings: string[];
hardBlocked: string[];
requiresDirectorApproval: boolean;
}
@Injectable()
export class RuleEngineService {
constructor(
@Inject(CARGO_TYPES_REPOSITORY)
private readonly cargoTypesRepo: ICargoTypesRepository,
@Inject(SERVICE_TYPES_REPOSITORY)
private readonly serviceTypesRepo: IServiceTypesRepository,
@Inject(SURCHARGES_REPOSITORY)
private readonly surchargesRepo: ISurchargesRepository,
@Inject(WEIGHT_LIMIT_RULES_REPOSITORY)
private readonly weightLimitRulesRepo: IWeightLimitRulesRepository,
@Inject(PRIORITY_RULES_REPOSITORY)
private readonly priorityRulesRepo: IPriorityRulesRepository,
) {}
/**
* Evaluate all rule engine rules against a booking snapshot.
* Returns the computed priority score, surcharges to apply, warnings,
* hard-block messages, and whether director approval is required.
* Callers must throw BadRequestException if hardBlocked is non-empty.
*/
async evaluate(
booking: Pick<
Booking,
| 'freightType'
| 'serviceType'
| 'paymentCurrency'
| 'cargoTotalWeightVgm'
| 'tradeDirection'
| 'isHazardous'
| 'isRefrigerated'
| 'containers'
>,
): Promise<RuleEvaluationResult> {
const warnings: string[] = [];
const hardBlocked: string[] = [];
const appliedSurcharges: AppliedSurcharge[] = [];
let priorityScore = 0;
let requiresDirectorApproval = false;
// ── 1. Cargo routing ─────────────────────────────────────────────────
// Look up CargoType by code to determine director-approval routing.
if (booking.freightType) {
const cargoType = await this.cargoTypesRepo.findByCode(booking.freightType);
if (cargoType?.requiresDirectorApproval) {
requiresDirectorApproval = true;
}
}
// ── 2. Weight-limit check ────────────────────────────────────────────
// For each container group in the booking, find matching active rules
// and check whether the per-container VGM exceeds the max weight.
const containers = booking.containers ?? [];
for (const container of containers) {
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeAndDirection(
container.type,
booking.tradeDirection,
);
for (const rule of rules) {
if (container.vgm > rule.maxWeightTons) {
const msg =
`${container.type} container VGM ${container.vgm}t exceeds max ` +
`${rule.maxWeightTons}t (${booking.tradeDirection})`;
if (rule.exceededAction === Freight.ExceededAction.HARD_BLOCK) {
hardBlocked.push(msg);
} else {
warnings.push(msg);
}
if (rule.surcharge) {
appliedSurcharges.push(this.mapSurcharge(rule.surcharge));
}
} else if (container.vgm > rule.warningThresholdTons) {
warnings.push(
`${container.type} container VGM ${container.vgm}t is approaching limit ` +
`of ${rule.maxWeightTons}t (${booking.tradeDirection})`,
);
}
}
}
// ── 3. Surcharge flags ───────────────────────────────────────────────
if (booking.isHazardous) {
const surcharge = await this.surchargesRepo.findByTypeCode('HAZARDOUS');
if (surcharge) appliedSurcharges.push(this.mapSurcharge(surcharge));
}
if (booking.isRefrigerated) {
const surcharge = await this.surchargesRepo.findByTypeCode('REFRIGERATED');
if (surcharge) appliedSurcharges.push(this.mapSurcharge(surcharge));
}
// ── 4. Priority scoring ──────────────────────────────────────────────
const priorityRules = await this.priorityRulesRepo.findAllActive();
for (const rule of priorityRules) {
switch (rule.priorityType) {
case Freight.PriorityType.USD_PAYER:
if (booking.paymentCurrency === 'USD') {
priorityScore += rule.bonusPoints;
}
break;
case Freight.PriorityType.RAIL_AND_FORWARDING: {
// Read bonus points from the matching ServiceType DB row
const serviceType = await this.serviceTypesRepo.findByCode(booking.serviceType);
if (serviceType && serviceType.priorityBonusPoints > 0) {
priorityScore += serviceType.priorityBonusPoints;
} else if (booking.serviceType === 'RAIL_AND_FORWARDING') {
// Fall back to the rule's own bonus_points if no ServiceType found
priorityScore += rule.bonusPoints;
}
break;
}
case Freight.PriorityType.HIGH_VOLUME_SHIPMENT:
if (booking.cargoTotalWeightVgm >= 300) {
priorityScore += rule.bonusPoints;
}
break;
case Freight.PriorityType.GOVERNMENT_ACCOUNT:
// TODO: integrate customer accountTier — evaluate when Customer entity is extended
break;
}
}
return { priorityScore, appliedSurcharges, warnings, hardBlocked, requiresDirectorApproval };
}
/**
* Guard helper — throws BadRequestException if hardBlocked is non-empty.
* Call this immediately after evaluate() in BookingsService.
*/
assertNoHardBlocks(result: RuleEvaluationResult): void {
if (result.hardBlocked.length > 0) {
throw new BadRequestException(result.hardBlocked.join('; '));
}
}
private mapSurcharge(s: { feeName: string; rate: number; currency: string; calculationMethod: Freight.CalculationMethod; applyToRail: boolean; applyToFirstMile: boolean; applyToLastMile: boolean }): AppliedSurcharge {
return {
feeName: s.feeName,
rate: s.rate,
currency: s.currency,
calculationMethod: s.calculationMethod,
applyToRail: s.applyToRail,
applyToFirstMile: s.applyToFirstMile,
applyToLastMile: s.applyToLastMile,
};
}
}

View File

@@ -0,0 +1,102 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { ILike } from 'typeorm';
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
import { CargoType } from '../entities/cargo-type.entity';
import {
CARGO_TYPES_REPOSITORY,
ICargoTypesRepository,
} from '../interfaces/cargo-types.repository.interface';
@Injectable()
export class CargoTypesService {
constructor(
@Inject(CARGO_TYPES_REPOSITORY)
private readonly repository: ICargoTypesRepository,
) {}
/** List cargo types with pagination and optional filtering. */
async findAll(filter: {
isActive?: boolean;
requiresDirectorApproval?: boolean;
parentGroupId?: string;
search?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}): Promise<{ data: CargoType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
if (filter.requiresDirectorApproval !== undefined) where.requiresDirectorApproval = filter.requiresDirectorApproval;
if (filter.parentGroupId !== undefined) where.parentGroupId = filter.parentGroupId;
if (filter.search) where.cargoTypeName = ILike(`%${filter.search}%`);
const [data, total] = await this.repository.findAndCount({
where,
order: { [filter.sortBy ?? 'displayOrder']: filter.sortOrder ?? 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
relations: { parent: true },
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
}
/** Get a single cargo type by ID. */
async findById(id: string): Promise<CargoType> {
const entity = await this.repository.findById(id);
if (!entity) throw new NotFoundException(`Cargo type ${id} not found`);
return entity;
}
/** Get a cargo type by code. */
async findByCode(code: string): Promise<CargoType | null> {
return this.repository.findByCode(code);
}
/** Create a new cargo type. */
async create(dto: CreateCargoTypeDto): Promise<CargoType> {
const existing = await this.repository.findByCode(dto.code);
if (existing) throw new ConflictException(`Cargo type with code "${dto.code}" already exists`);
if (dto.parentGroupId) {
const parent = await this.repository.findById(dto.parentGroupId);
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
}
return this.repository.create({
code: dto.code,
cargoTypeName: dto.cargoTypeName,
parentGroupId: dto.parentGroupId ?? null,
showFreeTextBox: dto.showFreeTextBox ?? false,
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
isActive: dto.isActive ?? true,
displayOrder: dto.displayOrder ?? 1,
});
}
/** Update an existing cargo type. */
async update(id: string, dto: UpdateCargoTypeDto): Promise<CargoType> {
await this.findById(id);
if (dto.code) {
const conflict = await this.repository.findByCode(dto.code);
if (conflict && conflict.id !== id) {
throw new ConflictException(`Cargo type with code "${dto.code}" already exists`);
}
}
if (dto.parentGroupId) {
if (dto.parentGroupId === id) throw new ConflictException('A cargo type cannot be its own parent');
const parent = await this.repository.findById(dto.parentGroupId);
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
}
const updated = await this.repository.update(id, dto);
if (!updated) throw new NotFoundException(`Cargo type ${id} not found`);
return updated;
}
/** Soft-delete a cargo type. */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}

View File

@@ -0,0 +1,75 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
import { ContainerType } from '../entities/container-type.entity';
import {
CONTAINER_TYPES_REPOSITORY,
IContainerTypesRepository,
} from '../interfaces/container-types.repository.interface';
@Injectable()
export class ContainerTypesService {
constructor(
@Inject(CONTAINER_TYPES_REPOSITORY)
private readonly repository: IContainerTypesRepository,
) {}
/** List container types with pagination. */
async findAll(filter: {
isActive?: boolean;
page?: number;
pageSize?: number;
}): Promise<{ data: ContainerType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
const [data, total] = await this.repository.findAndCount({
where,
order: { sizeCode: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
}
/** Get a single container type by ID. */
async findById(id: string): Promise<ContainerType> {
const entity = await this.repository.findById(id);
if (!entity) throw new NotFoundException(`Container type ${id} not found`);
return entity;
}
/** Create a new container type. */
async create(dto: CreateContainerTypeDto): Promise<ContainerType> {
const existing = await this.repository.findBySizeCode(dto.sizeCode);
if (existing) throw new ConflictException(`Container type with sizeCode "${dto.sizeCode}" already exists`);
return this.repository.create({
sizeCode: dto.sizeCode,
description: dto.description ?? null,
containersPerWagon: dto.containersPerWagon,
isActive: dto.isActive ?? true,
});
}
/** Update an existing container type. */
async update(id: string, dto: UpdateContainerTypeDto): Promise<ContainerType> {
await this.findById(id);
if (dto.sizeCode) {
const conflict = await this.repository.findBySizeCode(dto.sizeCode);
if (conflict && conflict.id !== id) {
throw new ConflictException(`Container type with sizeCode "${dto.sizeCode}" already exists`);
}
}
const updated = await this.repository.update(id, dto);
if (!updated) throw new NotFoundException(`Container type ${id} not found`);
return updated;
}
/** Soft-delete a container type. */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}

View File

@@ -0,0 +1,73 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreatePriorityRuleDto } from '../dto/create-priority-rule.dto';
import { UpdatePriorityRuleDto } from '../dto/update-priority-rule.dto';
import { PriorityRule } from '../entities/priority-rule.entity';
import {
IPriorityRulesRepository,
PRIORITY_RULES_REPOSITORY,
} from '../interfaces/priority-rules.repository.interface';
@Injectable()
export class PriorityRulesService {
constructor(
@Inject(PRIORITY_RULES_REPOSITORY)
private readonly repository: IPriorityRulesRepository,
) {}
/** List priority rules with pagination. */
async findAll(filter: {
isActive?: boolean;
page?: number;
pageSize?: number;
}): Promise<{ data: PriorityRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
const [data, total] = await this.repository.findAndCount({
where,
order: { priorityType: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
}
/** Get a single priority rule by ID. */
async findById(id: string): Promise<PriorityRule> {
const entity = await this.repository.findById(id);
if (!entity) throw new NotFoundException(`Priority rule ${id} not found`);
return entity;
}
/** Create a new priority rule. */
async create(dto: CreatePriorityRuleDto): Promise<PriorityRule> {
const existing = await this.repository.findAll({ where: { priorityType: dto.priorityType } });
if (existing.length > 0) {
throw new ConflictException(`Priority rule for type "${dto.priorityType}" already exists`);
}
return this.repository.create({
priorityType: dto.priorityType,
ruleName: dto.ruleName,
description: dto.description ?? null,
activationCondition: dto.activationCondition ?? null,
bonusPoints: dto.bonusPoints,
isActive: dto.isActive ?? false,
});
}
/** Update an existing priority rule. */
async update(id: string, dto: UpdatePriorityRuleDto): Promise<PriorityRule> {
await this.findById(id);
const updated = await this.repository.update(id, dto);
if (!updated) throw new NotFoundException(`Priority rule ${id} not found`);
return updated;
}
/** Soft-delete a priority rule. */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}

View File

@@ -0,0 +1,93 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { ILike } from 'typeorm';
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
import { ServiceType } from '../entities/service-type.entity';
import {
IServiceTypesRepository,
SERVICE_TYPES_REPOSITORY,
} from '../interfaces/service-types.repository.interface';
@Injectable()
export class ServiceTypesService {
constructor(
@Inject(SERVICE_TYPES_REPOSITORY)
private readonly repository: IServiceTypesRepository,
) {}
/** List service types with pagination and optional filtering. */
async findAll(filter: {
isActive?: boolean;
canBeBookedAlone?: boolean;
search?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}): Promise<{ data: ServiceType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
if (filter.canBeBookedAlone !== undefined) where.canBeBookedAlone = filter.canBeBookedAlone;
if (filter.search) where.serviceName = ILike(`%${filter.search}%`);
const [data, total] = await this.repository.findAndCount({
where,
order: { [filter.sortBy ?? 'displayOrder']: filter.sortOrder ?? 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
}
/** Get a single service type by ID. */
async findById(id: string): Promise<ServiceType> {
const entity = await this.repository.findById(id);
if (!entity) throw new NotFoundException(`Service type ${id} not found`);
return entity;
}
/** Get a service type by code. */
async findByCode(code: string): Promise<ServiceType | null> {
return this.repository.findByCode(code);
}
/** Create a new service type. */
async create(dto: CreateServiceTypeDto): Promise<ServiceType> {
const existing = await this.repository.findByCode(dto.code);
if (existing) throw new ConflictException(`Service type with code "${dto.code}" already exists`);
return this.repository.create({
code: dto.code,
serviceName: dto.serviceName,
description: dto.description ?? null,
canBeBookedAlone: dto.canBeBookedAlone ?? true,
includesFirstMile: dto.includesFirstMile ?? false,
includesLastMile: dto.includesLastMile ?? false,
includesCustoms: dto.includesCustoms ?? false,
priorityBonusPoints: dto.priorityBonusPoints ?? 0,
isActive: dto.isActive ?? true,
displayOrder: dto.displayOrder ?? 1,
});
}
/** Update an existing service type. */
async update(id: string, dto: UpdateServiceTypeDto): Promise<ServiceType> {
await this.findById(id);
if (dto.code) {
const conflict = await this.repository.findByCode(dto.code);
if (conflict && conflict.id !== id) {
throw new ConflictException(`Service type with code "${dto.code}" already exists`);
}
}
const updated = await this.repository.update(id, dto);
if (!updated) throw new NotFoundException(`Service type ${id} not found`);
return updated;
}
/** Soft-delete a service type. */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}

View File

@@ -0,0 +1,75 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreateSurchargeTypeDto } from '../dto/create-surcharge-type.dto';
import { UpdateSurchargeTypeDto } from '../dto/update-surcharge-type.dto';
import { SurchargeType } from '../entities/surcharge-type.entity';
import {
ISurchargeTypesRepository,
SURCHARGE_TYPES_REPOSITORY,
} from '../interfaces/surcharge-types.repository.interface';
@Injectable()
export class SurchargeTypesService {
constructor(
@Inject(SURCHARGE_TYPES_REPOSITORY)
private readonly repository: ISurchargeTypesRepository,
) {}
/** List surcharge types with pagination. */
async findAll(filter: {
isActive?: boolean;
page?: number;
pageSize?: number;
}): Promise<{ data: SurchargeType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
const [data, total] = await this.repository.findAndCount({
where,
order: { name: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
}
/** Get a single surcharge type by ID. */
async findById(id: string): Promise<SurchargeType> {
const entity = await this.repository.findById(id);
if (!entity) throw new NotFoundException(`Surcharge type ${id} not found`);
return entity;
}
/** Create a new surcharge type. */
async create(dto: CreateSurchargeTypeDto): Promise<SurchargeType> {
const existing = await this.repository.findByCode(dto.code);
if (existing) throw new ConflictException(`Surcharge type with code "${dto.code}" already exists`);
return this.repository.create({
code: dto.code,
name: dto.name,
description: dto.description ?? null,
isActive: dto.isActive ?? true,
});
}
/** Update an existing surcharge type. */
async update(id: string, dto: UpdateSurchargeTypeDto): Promise<SurchargeType> {
await this.findById(id);
if (dto.code) {
const conflict = await this.repository.findByCode(dto.code);
if (conflict && conflict.id !== id) {
throw new ConflictException(`Surcharge type with code "${dto.code}" already exists`);
}
}
const updated = await this.repository.update(id, dto);
if (!updated) throw new NotFoundException(`Surcharge type ${id} not found`);
return updated;
}
/** Soft-delete a surcharge type. */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}

View File

@@ -0,0 +1,76 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreateSurchargeDto } from '../dto/create-surcharge.dto';
import { UpdateSurchargeDto } from '../dto/update-surcharge.dto';
import { Surcharge } from '../entities/surcharge.entity';
import {
ISurchargesRepository,
SURCHARGES_REPOSITORY,
} from '../interfaces/surcharges.repository.interface';
@Injectable()
export class SurchargesService {
constructor(
@Inject(SURCHARGES_REPOSITORY)
private readonly repository: ISurchargesRepository,
) {}
/** List surcharges with pagination. */
async findAll(filter: {
isActive?: boolean;
surchargeTypeId?: string;
page?: number;
pageSize?: number;
}): Promise<{ data: Surcharge[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
if (filter.surchargeTypeId) where.surchargeTypeId = filter.surchargeTypeId;
const [data, total] = await this.repository.findAndCount({
where,
relations: { surchargeType: true },
order: { feeName: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
}
/** Get a single surcharge by ID. */
async findById(id: string): Promise<Surcharge> {
const entity = await this.repository.findById(id);
if (!entity) throw new NotFoundException(`Surcharge ${id} not found`);
return entity;
}
/** Create a new surcharge. */
async create(dto: CreateSurchargeDto): Promise<Surcharge> {
return this.repository.create({
surchargeTypeId: dto.surchargeTypeId,
feeName: dto.feeName,
triggerDescription: dto.triggerDescription ?? null,
calculationMethod: dto.calculationMethod,
rate: dto.rate,
currency: dto.currency,
applyToRail: dto.applyToRail ?? false,
applyToFirstMile: dto.applyToFirstMile ?? false,
applyToLastMile: dto.applyToLastMile ?? false,
isActive: dto.isActive ?? true,
});
}
/** Update an existing surcharge. */
async update(id: string, dto: UpdateSurchargeDto): Promise<Surcharge> {
await this.findById(id);
const updated = await this.repository.update(id, dto);
if (!updated) throw new NotFoundException(`Surcharge ${id} not found`);
return updated;
}
/** Soft-delete a surcharge. */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}

View File

@@ -0,0 +1,78 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
import {
IWeightLimitRulesRepository,
WEIGHT_LIMIT_RULES_REPOSITORY,
} from '../interfaces/weight-limit-rules.repository.interface';
@Injectable()
export class WeightLimitRulesService {
constructor(
@Inject(WEIGHT_LIMIT_RULES_REPOSITORY)
private readonly repository: IWeightLimitRulesRepository,
) {}
/** List weight limit rules with pagination. */
async findAll(filter: {
isActive?: boolean;
containerTypeId?: string;
page?: number;
pageSize?: number;
}): Promise<{ data: WeightLimitRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
if (filter.containerTypeId) where.containerTypeId = filter.containerTypeId;
const [data, total] = await this.repository.findAndCount({
where,
relations: { containerType: true, surcharge: { surchargeType: true } },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
}
/** Get a single weight limit rule by ID. */
async findById(id: string): Promise<WeightLimitRule> {
const entity = await this.repository.findById(id);
if (!entity) throw new NotFoundException(`Weight limit rule ${id} not found`);
return entity;
}
/** Create a new weight limit rule. */
async create(dto: CreateWeightLimitRuleDto): Promise<WeightLimitRule> {
if (dto.warningThresholdTons > dto.maxWeightTons) {
throw new BadRequestException('warningThresholdTons must be ≤ maxWeightTons');
}
return this.repository.create({
containerTypeId: dto.containerTypeId,
tradeDirection: dto.tradeDirection,
maxWeightTons: dto.maxWeightTons,
warningThresholdTons: dto.warningThresholdTons,
exceededAction: dto.exceededAction,
surchargeId: dto.surchargeId ?? null,
isActive: dto.isActive ?? true,
});
}
/** Update an existing weight limit rule. */
async update(id: string, dto: UpdateWeightLimitRuleDto): Promise<WeightLimitRule> {
const existing = await this.findById(id);
const warning = dto.warningThresholdTons ?? existing.warningThresholdTons;
const max = dto.maxWeightTons ?? existing.maxWeightTons;
if (warning > max) throw new BadRequestException('warningThresholdTons must be ≤ maxWeightTons');
const updated = await this.repository.update(id, dto);
if (!updated) throw new NotFoundException(`Weight limit rule ${id} not found`);
return updated;
}
/** Soft-delete a weight limit rule. */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}

View File

@@ -1,51 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from "class-validator";
export class CreateServiceTypeDto {
@ApiProperty({ description: "Service type name", maxLength: 255 })
@IsString()
@MaxLength(255)
serviceName!: string;
@ApiPropertyOptional({ description: "Detailed description of the service" })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: "Can be booked alone", default: true })
@IsOptional()
@IsBoolean()
canBeBookedAlone?: boolean = true;
@ApiPropertyOptional({ description: "Includes first mile service", default: false })
@IsOptional()
@IsBoolean()
includesFirstMile?: boolean = false;
@ApiPropertyOptional({ description: "Includes last mile service", default: false })
@IsOptional()
@IsBoolean()
includesLastMile?: boolean = false;
@ApiPropertyOptional({ description: "Includes customs clearance", default: false })
@IsOptional()
@IsBoolean()
includesCustoms?: boolean = false;
@ApiPropertyOptional({ description: "Priority bonus points for booking priority", default: 0 })
@IsOptional()
@IsInt()
@Min(0)
priorityBonusPoints?: number = 0;
@ApiPropertyOptional({ description: "Is the service type active", default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean = true;
@ApiPropertyOptional({ description: "Display order for UI", default: 1 })
@IsOptional()
@IsInt()
@Min(1)
displayOrder?: number = 1;
}

View File

@@ -1,46 +0,0 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
export class FilterServiceTypeDto {
@ApiPropertyOptional({ description: "Filter by active status" })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
isActive?: boolean;
@ApiPropertyOptional({ description: "Filter by can be booked alone" })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
canBeBookedAlone?: boolean;
@ApiPropertyOptional({ description: "Search by service name (case-insensitive)" })
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ enum: ["serviceName", "displayOrder", "createdAt"], default: "displayOrder" })
@IsOptional()
@IsIn(["serviceName", "displayOrder", "createdAt"])
sortBy?: string = "displayOrder";
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" })
@IsOptional()
@IsIn(["ASC", "DESC"])
sortOrder?: "ASC" | "DESC" = "ASC";
@ApiPropertyOptional({ description: "Page number", default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ description: "Items per page", default: 20, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number = 20;
}

View File

@@ -1,5 +0,0 @@
import { PartialType } from "@nestjs/mapped-types";
import { CreateServiceTypeDto } from "./create-service-type.dto";
export class UpdateServiceTypeDto extends PartialType(CreateServiceTypeDto) {}

View File

@@ -1,34 +0,0 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index } from "typeorm";
@Entity({ schema: "freight", name: "service_types" })
@Index(["isActive"])
@Index(["displayOrder"])
export class ServiceType extends BaseEntity {
@Column({ name: "service_name", type: "varchar", length: 255, nullable: false })
serviceName!: string;
@Column({ name: "description", type: "text", nullable: true })
description?: string | null;
@Column({ name: "can_be_booked_alone", type: "boolean", default: true })
canBeBookedAlone!: boolean;
@Column({ name: "includes_first_mile", type: "boolean", default: false })
includesFirstMile!: boolean;
@Column({ name: "includes_last_mile", type: "boolean", default: false })
includesLastMile!: boolean;
@Column({ name: "includes_customs", type: "boolean", default: false })
includesCustoms!: boolean;
@Column({ name: "priority_bonus_points", type: "int", default: 0 })
priorityBonusPoints!: number;
@Column({ name: "is_active", type: "boolean", default: true })
isActive!: boolean;
@Column({ name: "display_order", type: "int", default: 1 })
displayOrder!: number;
}

View File

@@ -1,64 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CreateServiceTypeDto } from "./dto/create-service-type.dto";
import { FilterServiceTypeDto } from "./dto/filter-service-type.dto";
import { UpdateServiceTypeDto } from "./dto/update-service-type.dto";
import { ServiceTypesService } from "./service-types.service";
@ApiTags("service-types")
@Controller("service-types")
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class ServiceTypesController {
constructor(private readonly service: ServiceTypesService) {}
@Get()
@ApiOperation({
summary: "List service types",
description: "Paginated list with optional filtering by isActive, canBeBookedAlone, and name search.",
})
findAll(@Query() filter: FilterServiceTypeDto) {
return this.service.findAll(filter);
}
@Get(":id")
@ApiOperation({ summary: "Get a service type by ID" })
findOne(@Param("id", ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@ApiOperation({ summary: "Create a new service type" })
create(@Body() dto: CreateServiceTypeDto) {
return this.service.create(dto);
}
@Patch(":id")
@ApiOperation({ summary: "Update a service type" })
update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateServiceTypeDto,
) {
return this.service.update(id, dto);
}
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: "Soft-delete a service type" })
remove(@Param("id", ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -1,23 +0,0 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { ServiceType } from "./entities/service-type.entity";
import { SERVICE_TYPES_REPOSITORY } from "./interfaces/service-types.repository.interface";
import { ServiceTypesRepository } from "./service-types.repository";
import { ServiceTypesController } from "./service-types.controller";
import { ServiceTypesService } from "./service-types.service";
@Module({
imports: [TypeOrmModule.forFeature([ServiceType])],
controllers: [ServiceTypesController],
providers: [
ServiceTypesRepository,
{
provide: SERVICE_TYPES_REPOSITORY,
useExisting: ServiceTypesRepository,
},
ServiceTypesService,
],
exports: [ServiceTypesService],
})
export class ServiceTypesModule {}

View File

@@ -1,30 +0,0 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { FindManyOptions, Repository } from "typeorm";
import { ServiceType } from "./entities/service-type.entity";
import { IServiceTypesRepository } from "./interfaces/service-types.repository.interface";
@Injectable()
export class ServiceTypesRepository
extends BaseRepository<ServiceType>
implements IServiceTypesRepository
{
constructor(
@InjectRepository(ServiceType)
repository: Repository<ServiceType>,
) {
super(repository);
}
override findById(id: string): Promise<ServiceType | null> {
return this.repository.findOne({
where: { id },
});
}
override findAndCount(options?: FindManyOptions<ServiceType>): Promise<[ServiceType[], number]> {
return this.repository.findAndCount(options);
}
}

View File

@@ -1,111 +0,0 @@
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
import { ILike } from "typeorm";
import { CreateServiceTypeDto } from "./dto/create-service-type.dto";
import { FilterServiceTypeDto } from "./dto/filter-service-type.dto";
import { UpdateServiceTypeDto } from "./dto/update-service-type.dto";
import { ServiceType } from "./entities/service-type.entity";
import {
IServiceTypesRepository,
SERVICE_TYPES_REPOSITORY,
} from "./interfaces/service-types.repository.interface";
@Injectable()
export class ServiceTypesService {
constructor(
@Inject(SERVICE_TYPES_REPOSITORY)
private readonly repository: IServiceTypesRepository,
) {}
/**
* Find all service types with pagination and optional filtering.
*/
async findAll(filter: FilterServiceTypeDto): Promise<{
data: ServiceType[];
meta: {
total: number;
page: number;
pageSize: number;
totalPages: number;
};
}> {
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) {
where.isActive = filter.isActive;
}
if (filter.canBeBookedAlone !== undefined) {
where.canBeBookedAlone = filter.canBeBookedAlone;
}
if (filter.search) {
where.serviceName = ILike(`%${filter.search}%`);
}
const [data, total] = await this.repository.findAndCount({
where,
order: { [filter.sortBy!]: filter.sortOrder },
skip: (filter.page! - 1) * filter.pageSize!,
take: filter.pageSize,
});
return {
data,
meta: {
total,
page: filter.page!,
pageSize: filter.pageSize!,
totalPages: Math.ceil(total / filter.pageSize!),
},
};
}
/**
* Get a single service type by ID.
*/
async findById(id: string): Promise<ServiceType> {
const entity = await this.repository.findById(id);
if (!entity) {
throw new NotFoundException(`Service type ${id} not found`);
}
return entity;
}
/**
* Create a new service type.
*/
async create(dto: CreateServiceTypeDto): Promise<ServiceType> {
return this.repository.create({
serviceName: dto.serviceName,
description: dto.description ?? null,
canBeBookedAlone: dto.canBeBookedAlone ?? true,
includesFirstMile: dto.includesFirstMile ?? false,
includesLastMile: dto.includesLastMile ?? false,
includesCustoms: dto.includesCustoms ?? false,
priorityBonusPoints: dto.priorityBonusPoints ?? 0,
isActive: dto.isActive ?? true,
displayOrder: dto.displayOrder ?? 1,
});
}
/**
* Update an existing service type.
*/
async update(id: string, dto: UpdateServiceTypeDto): Promise<ServiceType> {
await this.findById(id);
const updated = await this.repository.update(id, dto);
if (!updated) {
throw new NotFoundException(`Service type ${id} not found`);
}
return this.findById(id);
}
/**
* Soft-delete a service type.
*/
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}

View File

@@ -0,0 +1,213 @@
import { Injectable, Logger } from "@nestjs/common";
import { hashPassword } from "@tria-plc/api-common/utils/argon";
import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum";
import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum";
import {
Employee,
Organization,
Permission,
Role,
RolePermission,
User,
UserCredential,
UserRole,
} from "@tria-plc/iamapi-common";
import { DataSource } from "typeorm";
const SEED_FLAG = "SEED_DEMO_USERS";
const DEMO_ORG_KEY = "demo_iam";
const DEMO_ORG_NAME = { en: "Demo IAM" };
const DEMO_PERMISSIONS = [
{ key: "can:demo:user1", name: { en: "Can access demo user1" } },
{ key: "can:demo:user2", name: { en: "Can access demo user2" } },
];
const DEMO_ROLES = [
{ key: "demo_user1", name: { en: "Demo User1" } },
{ key: "demo_user2", name: { en: "Demo User2" } },
];
const DEMO_USERS = [
{
email: "user@gmail.com",
username: "user",
name: { en: "Demo User 1" },
roleKey: "demo_user1",
},
{
email: "user2@gmail.com",
username: "user2",
name: { en: "Demo User 2" },
roleKey: "demo_user2",
},
];
@Injectable()
export class DemoUsersSeeder {
private readonly logger = new Logger(DemoUsersSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run() {
const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === "true";
if (!shouldSeed) {
this.logger.log(`Skipping demo user seed because ${SEED_FLAG} is not enabled`);
return;
}
await this.dataSource.transaction(async (manager) => {
const organizationRepository = manager.getRepository(Organization);
const employeeRepository = manager.getRepository(Employee);
const permissionRepository = manager.getRepository(Permission);
const roleRepository = manager.getRepository(Role);
const rolePermissionRepository = manager.getRepository(RolePermission);
const userRepository = manager.getRepository(User);
const userCredentialRepository = manager.getRepository(UserCredential);
const userRoleRepository = manager.getRepository(UserRole);
await organizationRepository.upsert(
{
key: DEMO_ORG_KEY,
name: DEMO_ORG_NAME,
// status defaults to ACTIVE in IAM entity
isGovernmentOrganization: true,
},
{ conflictPaths: { key: true } },
);
const organization = await organizationRepository.findOne({
where: { key: DEMO_ORG_KEY },
select: { id: true, key: true },
});
if (!organization) {
throw new Error("demo_org_seed_failed");
}
await permissionRepository.upsert(DEMO_PERMISSIONS, {
conflictPaths: { key: true },
});
await roleRepository.upsert(DEMO_ROLES, {
conflictPaths: { key: true },
});
const roles = await roleRepository.find({ where: DEMO_ROLES.map((r) => ({ key: r.key })) });
const permissions = await permissionRepository.find({
where: DEMO_PERMISSIONS.map((p) => ({ key: p.key })),
});
const roleByKey = new Map(roles.map((r) => [r.key, r]));
const permissionByKey = new Map(permissions.map((p) => [p.key, p]));
const superAdminRole = await roleRepository.findOne({
where: { key: ERoleKey.SUPER_ADMIN },
select: { id: true, key: true },
});
const rolePermissionsToUpsert = [
{
roleId: roleByKey.get("demo_user1")!.id,
permissionId: permissionByKey.get("can:demo:user1")!.id,
},
{
roleId: roleByKey.get("demo_user2")!.id,
permissionId: permissionByKey.get("can:demo:user2")!.id,
},
...(superAdminRole
? ([
{
roleId: superAdminRole.id,
permissionId: permissionByKey.get("can:demo:user1")!.id,
},
{
roleId: superAdminRole.id,
permissionId: permissionByKey.get("can:demo:user2")!.id,
},
] as Array<{ roleId: string; permissionId: string }>)
: []),
];
await rolePermissionRepository.upsert(rolePermissionsToUpsert, {
conflictPaths: { roleId: true, permissionId: true },
});
const hashedPassword = await hashPassword("12345678");
for (const demoUser of DEMO_USERS) {
const existingUser = await userRepository.findOne({
where: { email: demoUser.email },
select: { id: true, email: true },
});
let user = existingUser;
if (!user) {
user = await userRepository.save(
userRepository.create({
email: demoUser.email,
username: demoUser.username,
name: demoUser.name,
isActive: true,
hasSetPassword: true,
status: EUserStatus.ACCEPTED,
}),
);
}
// Ensure an active credential exists for login.
const activeCredentialExists = await userCredentialRepository.exists({
where: {
userId: user.id,
isActive: true,
},
});
if (!activeCredentialExists) {
await userCredentialRepository.insert({
userId: user.id,
password: hashedPassword,
isActive: true,
});
}
// Login query requires a current employee in an ACTIVE organization.
const employeeExists = await employeeRepository.exists({
where: {
userId: user.id,
organizationId: organization.id,
isCurrent: true,
},
});
if (!employeeExists) {
await employeeRepository.insert({
userId: user.id,
organizationId: organization.id,
isCurrent: true,
name: demoUser.name,
});
}
const role = roleByKey.get(demoUser.roleKey);
if (!role) {
throw new Error(`missing_role:${demoUser.roleKey}`);
}
await userRoleRepository.upsert(
{
userId: user.id,
roleId: role.id,
organizationId: organization.id,
},
{ conflictPaths: { userId: true, roleId: true } },
);
}
});
this.logger.log(
"Seeded demo users + permissions (user@gmail.com, user2@gmail.com; permissions can:demo:user1/can:demo:user2)",
);
}
}

View File

@@ -1,7 +1,6 @@
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
import { LayoutDashboard, Network, Paperclip, Settings } from "lucide-react";
import { useAuth } from "./auth/useAuth";
import LoginPage from "./pages/auth/LoginPage";
import OverviewPage from "./pages/dashboard/OverviewPage";
@@ -12,6 +11,8 @@ import UserManagementPage from "./pages/dashboard/user-management/UserManagement
import LoadingScreen from "./components/LoadingScreen";
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
const sidebarItems: SidebarItem[] = [
{
@@ -50,11 +51,73 @@ const sidebarItems: SidebarItem[] = [
}
];
const hasPermission = (
user: ReturnType<typeof useAuth>["user"],
key: string,
) => {
if (!user) return false;
if (user.permissions?.some((p) => p.key === key)) return true;
return (user.employee ?? []).some((emp) =>
(emp.positions ?? []).some((pos) =>
(pos.permissions ?? []).some((p) => p.key === key),
),
);
};
const DashboardShell = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuth();
const sidebarItems: SidebarItem[] = [
{
label: "Overview",
href: "/dashboard/overview",
icon: <LayoutDashboard />,
},
{
label: "User management",
href: "/dashboard/user-management",
icon: <Network />,
children: [
{
label: "Employees",
href: "/dashboard/user-management/employees",
},
{
label: "Permissions",
href: "/dashboard/user-management/permissions",
},
{
label: "Roles",
href: "/dashboard/user-management/roles",
},
],
},
{
label: "Rule Engine",
href: "/dashboard/rule-engine",
icon: <Settings />,
},
...(hasPermission(user, "can:demo:user1")
? ([
{
label: "User1",
href: "/dashboard/user1",
icon: <Settings />,
},
] as SidebarItem[])
: []),
...(hasPermission(user, "can:demo:user2")
? ([
{
label: "User2",
href: "/dashboard/user2",
icon: <Settings />,
},
] as SidebarItem[])
: []),
];
const displayName = user?.name?.en || user?.username || user?.email || "User";
return (
@@ -98,9 +161,12 @@ const App = () => {
<Route path="user-management" element={<UserManagementPage />} />
<Route path="file-settings" element={<FileUploadSettingsPage />} />
<Route path="dropdown-settings" element={<DropdownSettingsPage />} />
<Route path="rule-engine" element={<RuleEnginePage />} />
<Route path="user-management/employees" element={<EmployeesPage />} />
<Route path="user-management/permissions" element={<PermissionsPage />} />
<Route path="user-management/roles" element={<RolesPage />} />
<Route path="user1" element={<DemoUser1Page />} />
<Route path="user2" element={<DemoUser2Page />} />
<Route path="org-structure" element={<Navigate to="/dashboard/user-management" replace />} />
<Route path="org-structure/*" element={<Navigate to="/dashboard/user-management" replace />} />
</Route>

View File

@@ -0,0 +1,221 @@
import { JSXElementConstructor, MouseEventHandler, ReactElement, ReactNode, SetStateAction, useState } from 'react';
export const ContractTypePage = () => {
const [expandedSections, setExpandedSections] = useState({
contractType: true,
serviceType: false,
cargoType: false
});
const [contractTypes, setContractTypes] = useState([
{ id: 1, name: 'Shipper', description: 'Company that sends the freight' },
{ id: 2, name: 'Consignee', description: 'Company that receives the freight' },
{ id: 3, name: 'Third Party', description: 'Company that is neither the shipper nor the consignee but is involved in the freight process' }
]);
const [serviceTypes, setServiceTypes] = useState([
{ id: 1, name: 'Standard', description: 'Regular shipping service' },
{ id: 2, name: 'Express', description: 'Fast delivery service' },
{ id: 3, name: 'Economy', description: 'Cost-effective shipping option' }
]);
const [cargoTypes, setCargoTypes] = useState([
{ id: 1, name: 'General Cargo', description: 'Standard packaged goods' },
{ id: 2, name: 'Temperature Controlled', description: 'Goods requiring specific temperature' },
{ id: 3, name: 'Hazardous Materials', description: 'Dangerous goods requiring special handling' }
]);
const [newContractType, setNewContractType] = useState({ name: '', description: '' });
const [newServiceType, setNewServiceType] = useState({ name: '', description: '' });
const [newCargoType, setNewCargoType] = useState({ name: '', description: '' });
const [showAddForms, setShowAddForms] = useState({
contractType: false,
serviceType: false,
cargoType: false
});
type SectionKey = 'contractType' | 'serviceType' | 'cargoType';
const toggleSection = (section: SectionKey) => {
setExpandedSections(prev => ({
...prev,
[section]: !prev[section]
}));
};
const toggleAddForm = (section: SectionKey) => {
setShowAddForms(prev => ({
...prev,
[section]: !prev[section]
}));
};
const handleAddContractType = () => {
if (newContractType.name && newContractType.description) {
setContractTypes([
...contractTypes,
{ id: Date.now(), ...newContractType }
]);
setNewContractType({ name: '', description: '' });
toggleAddForm('contractType');
}
};
const handleAddServiceType = () => {
if (newServiceType.name && newServiceType.description) {
setServiceTypes([
...serviceTypes,
{ id: Date.now(), ...newServiceType }
]);
setNewServiceType({ name: '', description: '' });
toggleAddForm('serviceType');
}
};
const handleAddCargoType = () => {
if (newCargoType.name && newCargoType.description) {
setCargoTypes([
...cargoTypes,
{ id: Date.now(), ...newCargoType }
]);
setNewCargoType({ name: '', description: '' });
toggleAddForm('cargoType');
}
};
const handleDelete = (type: string, id: number) => {
if (type === 'contract') {
setContractTypes(contractTypes.filter(item => item.id !== id));
} else if (type === 'service') {
setServiceTypes(serviceTypes.filter(item => item.id !== id));
} else if (type === 'cargo') {
setCargoTypes(cargoTypes.filter(item => item.id !== id));
}
};
const handleEdit = (type: any, id: any) => {
// Implement edit functionality as needed
alert(`Edit ${type} type with id: ${id}`);
};
const renderTable = (title: string | number | boolean | ReactElement<any, string | JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined, types: any[], onAdd: { (): void; (): void; (): void; }, newItem: { name: any; description: any; }, setNewItem: { (value: SetStateAction<{ name: string; description: string; }>): void; (value: SetStateAction<{ name: string; description: string; }>): void; (value: SetStateAction<{ name: string; description: string; }>): void; (arg0: any): void; }, showAddForm: boolean, typeKey: string, addHandler: MouseEventHandler<HTMLButtonElement> | undefined) => (
<div style={{ marginBottom: '20px' }}>
<div
style={{
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
padding: '10px',
backgroundColor: '#f0f0f0',
marginBottom: '10px'
}}
onClick={() => toggleSection(typeKey)}
>
<span style={{ marginRight: '10px', fontSize: '20px', color: '#138a49' }}>
{expandedSections[typeKey] ? '▼' : '▶'}
</span>
<h3 style={{ margin: 0 }}>{title}</h3>
</div>
{expandedSections[typeKey] && (
<div style={{ marginLeft: '20px' }}>
<button onClick={() => toggleAddForm(typeKey)}>
Add {title.replace(' Types', ' type')}
</button>
{showAddForm && (
<div style={{
marginTop: '10px',
marginBottom: '10px',
padding: '10px',
border: '1px solid #ccc',
borderRadius: '4px'
}}>
<h4>Add New {title.replace(' Types', '')}</h4>
<div>
<input
type="text"
placeholder="Name"
value={newItem.name}
onChange={(e) => setNewItem({ ...newItem, name: e.target.value })}
style={{ marginRight: '10px', padding: '5px' }}
/>
<input
type="text"
placeholder="Description"
value={newItem.description}
onChange={(e) => setNewItem({ ...newItem, description: e.target.value })}
style={{ marginRight: '10px', padding: '5px' }}
/>
<button onClick={addHandler}>Save</button>
<button onClick={() => toggleAddForm(typeKey)} style={{ marginLeft: '5px' }}>Cancel</button>
</div>
</div>
)}
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '10px' }}>
<thead>
<tr style={{ backgroundColor: '#f2f2f2' }}>
<th style={{ border: '1px solid #ddd', padding: '8px', textAlign: 'left' }}>ID</th>
<th style={{ border: '1px solid #ddd', padding: '8px', textAlign: 'left' }}>Name</th>
<th style={{ border: '1px solid #ddd', padding: '8px', textAlign: 'left' }}>Description</th>
<th style={{ border: '1px solid #ddd', padding: '8px', textAlign: 'left' }}>Actions</th>
</tr>
</thead>
<tbody>
{types.map((type) => (
<tr key={type.id}>
<td style={{ border: '1px solid #ddd', padding: '8px' }}>{type.id}</td>
<td style={{ border: '1px solid #ddd', padding: '8px' }}>{type.name}</td>
<td style={{ border: '1px solid #ddd', padding: '8px' }}>{type.description}</td>
<td style={{ border: '1px solid #ddd', padding: '8px' }}>
<button onClick={() => handleEdit(typeKey, type.id)} style={{ marginRight: '5px' }}>Edit</button>
<button onClick={() => handleDelete(typeKey === 'contractType' ? 'contract' : typeKey === 'serviceType' ? 'service' : 'cargo', type.id)}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
return (
<div>
{renderTable(
'Contract Types',
contractTypes,
handleAddContractType,
newContractType,
setNewContractType,
showAddForms.contractType,
'contractType',
handleAddContractType
)}
{renderTable(
'Service Types',
serviceTypes,
handleAddServiceType,
newServiceType,
setNewServiceType,
showAddForms.serviceType,
'serviceType',
handleAddServiceType
)}
{renderTable(
'Cargo Types',
cargoTypes,
handleAddCargoType,
newCargoType,
setNewCargoType,
showAddForms.cargoType,
'cargoType',
handleAddCargoType
)}
</div>
);
};

View File

@@ -0,0 +1,67 @@
import { useEffect, useState } from "react";
import { api } from "@/auth/http";
const DemoUser1Page = () => {
const [data, setData] = useState<unknown>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
const run = async () => {
setLoading(true);
setError(null);
try {
const response = await api.get("/test_user1");
if (cancelled) return;
setData(response.data);
} catch (e: any) {
if (cancelled) return;
const message =
e?.response?.data?.message ||
e?.response?.data?.error ||
e?.message ||
"Request failed";
setError(String(message));
} finally {
if (!cancelled) setLoading(false);
}
};
void run();
return () => {
cancelled = true;
};
}, []);
return (
<div className="p-6">
<div className="rounded-2xl border border-border bg-card p-6">
<h1 className="text-lg font-semibold text-foreground">User1 Demo</h1>
<p className="mt-1 text-sm text-muted-foreground">
Calls <code className="font-mono">GET /api/test_user1</code> (requires{' '}
<code className="font-mono">can:demo:user1</code>).
</p>
<div className="mt-4">
{loading ? <p className="text-sm text-muted-foreground">Loading...</p> : null}
{error ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
) : null}
{!loading && !error ? (
<pre className="mt-3 overflow-auto rounded-xl border border-border bg-background p-4 text-xs text-foreground">
{JSON.stringify(data, null, 2)}
</pre>
) : null}
</div>
</div>
</div>
);
};
export default DemoUser1Page;

View File

@@ -0,0 +1,67 @@
import { useEffect, useState } from "react";
import { api } from "@/auth/http";
const DemoUser2Page = () => {
const [data, setData] = useState<unknown>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
const run = async () => {
setLoading(true);
setError(null);
try {
const response = await api.get("/test_user2");
if (cancelled) return;
setData(response.data);
} catch (e: any) {
if (cancelled) return;
const message =
e?.response?.data?.message ||
e?.response?.data?.error ||
e?.message ||
"Request failed";
setError(String(message));
} finally {
if (!cancelled) setLoading(false);
}
};
void run();
return () => {
cancelled = true;
};
}, []);
return (
<div className="p-6">
<div className="rounded-2xl border border-border bg-card p-6">
<h1 className="text-lg font-semibold text-foreground">User2 Demo</h1>
<p className="mt-1 text-sm text-muted-foreground">
Calls <code className="font-mono">GET /api/test_user2</code> (requires{' '}
<code className="font-mono">can:demo:user2</code>).
</p>
<div className="mt-4">
{loading ? <p className="text-sm text-muted-foreground">Loading...</p> : null}
{error ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
) : null}
{!loading && !error ? (
<pre className="mt-3 overflow-auto rounded-xl border border-border bg-background p-4 text-xs text-foreground">
{JSON.stringify(data, null, 2)}
</pre>
) : null}
</div>
</div>
</div>
);
};
export default DemoUser2Page;

View File

@@ -0,0 +1,14 @@
import { ContractTypePage, } from "@/components/ruleEngine/ContractType";
export const RuleEnginePage = () => {
return <div>
<h3>
Rule Engine Page
</h3>
<div>
<ContractTypePage />
</div>
</div>;
};

View File

@@ -7,109 +7,78 @@ import {
} from "react-router-dom";
import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
import {
LayoutDashboard,
Users,
CalendarCheck,
Package,
MapPin,
Train,
Receipt,
FileText,
Settings,
UserCircle,
FileUp,
MapPinned,
Home,
Loader2,
} from "lucide-react";
import BookingsPage from "./pages/bookings/BookingsPage";
import useAuth from "./hooks/useAuth";
import MyPortalPage from "./pages/MyPortalPage";
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import SignupPage from "./pages/accounts/SignupPage";
import OnboardingPage from "./pages/accounts/OnboardingPage";
import VerificationOtpPage from "./pages/accounts/VerificationOtpPage";
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
import LoginPage from "./pages/accounts/LoginPage";
import MyBookings from "./pages/bookings/MyBookings";
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import ConsignmentsPage from "./pages/consignments/ConsignmentsPage";
import ConsignmentDetailPage from "./pages/consignments/ConsignmentDetailPage";
import TrackingPage from "./pages/tracking/TrackingPage";
import BillingPage from "./pages/billing/BillingPage";
import TrainsPage from "./pages/trains/TrainsPage";
import DashboardPage from "./pages/dashboard/DashboardPage";
import {
IamLoginPage,
LoadingScreen,
useAuth,
useAuthUser,
} from "@tria-plc/iamui-common";
import CustomersPage from "./pages/customers/CustomersPage";
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
import NewCustomerPage from "./pages/customers/NewCustomerPage";
import DocumentsPage from "./pages/documents/DocumentsPage";
import DropdownSettingsPage from "./pages/admin/DropdownSettingsPage";
import FileUploadSettingsPage from "./pages/admin/FileUploadSettingsPage";
import MyPortalPage from "./pages/portal/MyPortalPage";
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import SignupPage from "./pages/accounts/SignupPage";
import VerificationOtpPage from "./pages/accounts/VerificationOtpPage";
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
import Station from "./components/stations/Station";
import { useEffect } from "react";
const sidebarItems: SidebarItem[] = [
{ label: "My Portal", href: "/", icon: <UserCircle /> },
{ label: "Dashboard", href: "/dashboard", icon: <LayoutDashboard /> },
{ label: "Customers", href: "/customers", icon: <Users /> },
{ label: "Home", href: "/", icon: <Home /> },
{ label: "My Bookings", href: "/bookings", icon: <CalendarCheck /> },
{ label: "Consignments", href: "/consignments", icon: <Package /> },
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
{ label: "Stations", href: "/stations", icon: <MapPinned /> },
{ label: "Trains", href: "/trains", icon: <Train /> },
{ label: "Billing", href: "/billing", icon: <Receipt /> },
{ label: "Documents", href: "/documents", icon: <FileText /> },
{ label: "Dropdown Settings", href: "/admin/dropdowns", icon: <Settings /> },
{
label: "File Upload Settings",
href: "/admin/file-uploads",
icon: <FileUp />,
},
];
const App = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, loading } = useAuth();
const { logout } = useAuthUser();
const { user, isPending, logout, customer, customerQuery } = useAuth();
if (loading) {
return <LoadingScreen />;
console.log({ customer, isPending, user });
useEffect(() => {
if (!user) return;
// if (!user.hasSetPassword) navigate("/set-password");
}, [user]);
if (isPending) {
return (
<div className="flex items-center justify-center h-screen">
<Loader2 className="animate-spin text-primary" />
</div>
);
}
if (user) {
if (!user) {
return (
<Routes>
<Route path="/" element={<EDRFreightLandingPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route path="/otp" element={<VerificationOtpPage />} />
<Route path="/set-password" element={<SetPasswordPage />} />
<Route path="/auth" element={<IamLoginPage />} />
{/* <Route path="*" element={<Navigate to="/auth" replace />} /> */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
if (user && !customer && !customerQuery.isPending) {
return <OnboardingPage />;
}
const displayName = user?.name?.en || user?.username || user?.email || "User";
const userEmail = user?.email;
const handleLogout = () => {
logout();
[
"auth-token",
"refresh-token",
"auth-user",
"current-position-id",
"selected-position-id",
].forEach((name) => {
document.cookie = `${name}=; Max-Age=0; path=/`;
});
localStorage.clear();
window.location.replace("/auth");
};
return (
<DashboardLayout
title="EDR Freight"
@@ -119,31 +88,16 @@ const App = () => {
enableThemeToggle
userName={displayName}
userEmail={userEmail}
onLogout={handleLogout}
onLogout={logout}
>
<Routes>
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/" element={<MyPortalPage />} />
<Route path="/bookings" element={<MyBookings />} />
<Route path="/admin/bookings" element={<BookingsPage />} />
<Route path="/customers" element={<CustomersPage />} />
<Route path="/customers/:id" element={<CustomerDetailPage />} />
<Route path="/new-customer" element={<NewCustomerPage />} />
<Route path="/bookings/new" element={<NewBookingPage />} />
<Route path="/bookings/:id" element={<BookingDetailPage />} />
<Route path="/consignments" element={<ConsignmentsPage />} />
<Route path="/consignments/:id" element={<ConsignmentDetailPage />} />
<Route path="/tracking" element={<TrackingPage />} />
<Route path="/stations" element={<Station />} />
<Route path="/trains" element={<TrainsPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/documents" element={<DocumentsPage />} />
<Route path="/admin/dropdowns" element={<DropdownSettingsPage />} />
<Route
path="/admin/file-uploads"
element={<FileUploadSettingsPage />}
/>
<Route path="/user-management" element={<Navigate to="/" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</DashboardLayout>

View File

@@ -0,0 +1,93 @@
import type { ReactNode } from "react";
import { ShieldCheck, Train } from "lucide-react";
import { cn } from "@/lib/utils";
export interface AuthLayoutProps {
children: ReactNode;
parentClassName?: string;
contentClassName?: string;
left: {
badge: string;
title: string;
description: string;
features: string[];
stats: {
label: string;
value: string;
footer: string;
progress: string;
};
};
}
export default function AuthLayout({
children,
parentClassName,
contentClassName,
left,
}: AuthLayoutProps) {
return (
<div className="min-h-screen bg-background text-foreground">
<div className={cn("grid min-h-screen lg:grid-cols-2", parentClassName)}>
<div className="relative hidden overflow-hidden bg-primary p-8 px-12 text-primary-foreground lg:flex lg:flex-col lg:justify-between">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.14),transparent_35%)]" />
<div className="relative z-10">
<div className="flex items-center gap-3">
<div className="flex size-14 items-center justify-center rounded-3xl bg-white/10 backdrop-blur">
<Train className="size-7" />
</div>
<div>
<h1 className="text-xl font-bold">EDR Freight</h1>
<p className="mt-1 text-sm opacity-80">
Railway Logistics Platform
</p>
</div>
</div>
<div className="mt-16 max-w-lg">
<div className="inline-flex rounded-full bg-white/10 px-4 py-2 text-sm font-semibold backdrop-blur">
{left.badge}
</div>
<h2 className="mt-6 text-4xl font-bold leading-tight tracking-tight">
{left.title}
</h2>
<p className="mt-6 text-lg leading-8 opacity-85">
{left.description}
</p>
</div>
<div className="mt-14 grid gap-5">
{left.features.map((item) => (
<div key={item} className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-2xl bg-white/10">
<ShieldCheck className="size-5" />
</div>
<span className="font-medium">{item}</span>
</div>
))}
</div>
</div>
</div>
<div
className={cn(
"flex items-center justify-center p-6 md:p-10",
contentClassName,
)}
>
<div className="w-full max-w-lg">
<div className="mb-8 flex items-center gap-3 lg:hidden">
<div className="flex size-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Train className="size-6" />
</div>
<div>
<h1 className="text-2xl font-bold">EDR Freight</h1>
<p className="text-sm text-muted-foreground">
Railway Logistics Platform
</p>
</div>
</div>
<div>{children}</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { Field, FieldLabel, FieldError, Input } from "@edr/ui-common";
interface PhoneInputProps {
disabled?: boolean;
countryCode?: React.ComponentProps<typeof Input>;
phone?: React.ComponentProps<typeof Input>;
countryCodeError?: { message?: string };
phoneError?: { message?: string };
label?: string;
}
export default function PhoneInput({
disabled,
countryCode: countryCodeProps,
phone: phoneProps,
countryCodeError,
phoneError,
label = "Phone Number",
}: PhoneInputProps) {
return (
<Field data-invalid={Boolean(countryCodeError || phoneError)}>
<FieldLabel>{label}</FieldLabel>
<div className="flex gap-2">
<Input
type="text"
disabled={disabled}
className="w-20"
aria-invalid={Boolean(countryCodeError)}
{...countryCodeProps}
/>
<Input
type="tel"
placeholder="912345678"
disabled={disabled}
className="flex-1"
aria-invalid={Boolean(phoneError)}
{...phoneProps}
/>
</div>
<FieldError errors={[countryCodeError, phoneError]} />
</Field>
);
}

View File

@@ -1,228 +0,0 @@
import { useMemo, useState } from "react";
import {
AlertCircle,
CircleOff,
Loader2,
MapPin,
Search,
TrainFront,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings";
import type { DropdownOption } from "@/types/dropdownSettings";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
DataTable,
DataTableFooter,
Input,
type ColumnDef,
usePagination,
} from "@edr/ui-common";
const STATION_DROPDOWN_CODE = "stations_ter";
export default function Station() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const { data, isLoading, isError, error } = useDropdownSettingByCode(
STATION_DROPDOWN_CODE,
);
const stations = useMemo<DropdownOption[]>(
() => [...(data?.children ?? [])].sort((a, b) => a.order - b.order),
[data?.children],
);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return stations;
return stations.filter(
(station) =>
station.label.toLowerCase().includes(q) ||
station.value.toLowerCase().includes(q) ||
(station.note ?? "").toLowerCase().includes(q),
);
}, [query, stations]);
const total = filtered.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(
() => filtered.slice(start, end),
[end, filtered, start],
);
const activeCount = stations.filter((station) => !station.disabled).length;
const disabledCount = stations.length - activeCount;
const status: "loading" | "error" | "success" = isLoading
? "loading"
: isError
? "error"
: "success";
const columns: ColumnDef<DropdownOption>[] = [
{
id: "station",
header: "Station",
cell: ({ row }) => {
const station = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<MapPin />
</div>
<div>
<p className="font-medium text-slate-900">{station.label}</p>
<p className="text-xs text-slate-500">
{station.note ?? "No station note"}
</p>
</div>
</div>
);
},
},
{
id: "value",
header: "Code",
cell: ({ row }) => (
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
{row.original.value}
</span>
),
},
{
accessorKey: "order",
header: "Order",
},
{
id: "status",
header: "Status",
cell: ({ row }) =>
row.original.disabled ? (
<span className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
<CircleOff className="h-3 w-3" />
Disabled
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<TrainFront className="h-3 w-3" />
Active
</span>
),
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "Stations" }]} />
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Stations
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
Station options loaded from dropdown code{" "}
<span className="font-mono">stations_ter</span>.
</p>
</div>
<div className="relative w-full sm:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type="search"
value={query}
onChange={(event) => {
setQuery(event.target.value);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
placeholder="Search stations..."
className="pl-8!"
/>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<StationStat label="Stations" value={stations.length} />
<StationStat label="Active" value={activeCount} />
<StationStat label="Disabled" value={disabledCount} />
</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 stations.{" "}
{error instanceof Error ? error.message : "Unknown error."}
</CardContent>
</Card>
) : null}
<Card className="gap-0">
<CardHeader className="border-b">
<CardTitle>Station List</CardTitle>
<CardDescription>
All configured freight stations from the dropdown service.
</CardDescription>
</CardHeader>
<CardContent className="px-0">
{isLoading ? (
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin text-primary" />
Loading stations...
</div>
) : (
<DataTable
columns={columns}
data={paginatedData}
status={status}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-b shadow-none"
footer={DataTableFooter}
/>
)}
</CardContent>
</Card>
</div>
</div>
);
}
function StationStat({ label, value }: { label: string; value: number }) {
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">
<MapPin />
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,21 +1,25 @@
export const URL_CONSTANTS = {
AUTH: {
LOGIN: "/auth/login",
REGISTER: "/auth/register",
REFRESH_TOKEN: "/auth/refresh-token",
LOGOUT: "/auth/logout",
LOGIN: "/api/auth/login",
REGISTER: "/api/auth/register",
REFRESH_TOKEN: "/api/auth/refresh-token",
LOGOUT: "/api/auth/logout",
PROFILE: "/auth/profile",
},
USERS: {
SIGN_UP: "/api/auth/signup",
GENERATE_VERIFICATION_CODE: "/api/auth/generate-verification-code",
BASE: "/users",
BY_ID: (id: string | number) => `/users/${id}`,
SIGN_UP: "/api/auth/signup",
SET_PASSWORD: "/api/auth/set-password",
ME: "/api/auth/me"
ME: "/api/auth/me",
GENERATE_VERIFICATION_CODE: "/users/generate-verification-code",
},
OTP: {
SEND: "/api/otp/send",
VERIFY: "/api/otp/verify",
},
ROLES: {
BASE: "/roles",
BY_ID: (id: string | number) => `/roles/${id}`,
@@ -68,11 +72,11 @@ export const URL_CONSTANTS = {
BY_ID: (id: string | number) => `/customers/${id}`,
BOOKINGS: (id: string | number) => `/customers/${id}/bookings`,
},
CUSTOMERS_API: {
BASE: "/api/customers",
BY_ID: (id: string) => `/api/customers/${id}`,
BY_USER_ID: (id: string) => `/api/customers/user/${id}`
BY_USER_ID: (id: string) => `/api/customers/user/${id}`,
},
BOOKINGS: {
@@ -81,9 +85,4 @@ export const URL_CONSTANTS = {
CANCEL: (id: string | number) => `/bookings/${id}/cancel`,
CONFIRM: (id: string | number) => `/bookings/${id}/confirm`,
},
OTP: {
SEND: "/api/otp/send",
VERIFY: "/api/otp/verify",
}
};
};

View File

@@ -0,0 +1,185 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/services/api";
import type {
LoginPayload,
LoginResponse,
SignupPayload,
SignupResponse,
OtpResponse,
} from "@/types/auth";
import type { Result } from "@/utils/result";
import { extractApiError } from "@/utils/result";
function setCookie(name: string, value: string, days: number) {
const expires = new Date();
expires.setDate(expires.getDate() + days);
document.cookie = `${name}=${value}; path=/; expires=${expires.toUTCString()}; SameSite=Lax`;
}
function getCookie(name: string): string | undefined {
return document.cookie
.split("; ")
.find((row) => row.startsWith(`${name}=`))
?.split("=")[1];
}
const useAuth = () => {
const queryClient = useQueryClient();
const authQuery = useQuery(
api.auth.getMyInfo.queryOptions({
enabled: !!getCookie("auth-token"),
retry: false,
staleTime: 10 * 60 * 1000,
}),
);
const customerQuery = useQuery(
api.customers.getByUserId.queryOptions({
input: { id: authQuery.data?.id ?? "" },
enabled: !!authQuery.data?.id,
retry: false,
staleTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
}),
);
const hasToken = !!getCookie("auth-token");
const isPending = authQuery.isPending && hasToken;
const login = async (
payload: LoginPayload,
): Promise<Result<LoginResponse>> => {
try {
const res = await api.auth.login.call(payload);
setCookie("auth-token", res.token, 7);
setCookie("refresh-token", res.refreshToken, 7);
await authQuery.refetch();
return { success: true, data: res };
} catch (err) {
return { success: false, error: extractApiError(err) };
}
};
const signup = async (
payload: SignupPayload,
): Promise<Result<SignupResponse>> => {
try {
const res = await api.auth.createUser.call(payload);
setCookie("auth-token", res.token, 7);
setCookie("refresh-token", res.refreshToken, 7);
await authQuery.refetch();
const otpCode = res.otp?.split(" ")?.[6] ?? "";
localStorage.setItem("otp", otpCode);
localStorage.setItem("otp-phone", payload.phoneNumber);
localStorage.setItem("otp-email", payload.email);
api.auth.sendOTP
.call({ phone: payload.phoneNumber, otp: otpCode })
.catch(() => { });
return { success: true, data: res };
} catch (err) {
return { success: false, error: extractApiError(err) };
}
};
const setPassword = async (data: {
newPassword: string;
confirmPassword: string;
}): Promise<Result<void>> => {
try {
const userId = authQuery.data?.id ?? "";
const email = localStorage.getItem("otp-email") ?? "";
const verificationCode = localStorage.getItem("otp") ?? "";
await api.auth.setPassword.call({
newPassword: data.newPassword,
confirmPassword: data.confirmPassword,
userId,
email,
verificationCode,
});
["userId", "otp", "otp-phone", "otp-email"].forEach((k) =>
localStorage.removeItem(k),
);
await queryClient.invalidateQueries({
queryKey: api.auth.getMyInfo.queryKey(),
});
return { success: true, data: undefined };
} catch (err) {
return { success: false, error: extractApiError(err) };
}
};
const verifyOTP = async (otp: string): Promise<Result<OtpResponse>> => {
try {
const phone = localStorage.getItem("otp-phone") ?? "";
const res = await api.auth.verifyOTP.call({ phone, otp });
return { success: true, data: res };
} catch (err) {
return { success: false, error: extractApiError(err) };
}
};
const sendOTP = async (otp: string): Promise<Result<OtpResponse>> => {
try {
const phone = localStorage.getItem("otp-phone") ?? "";
const res = await api.auth.sendOTP.call({ phone, otp });
return { success: true, data: res };
} catch (err) {
return { success: false, error: extractApiError(err) };
}
};
const generateVerificationCode = async (
type: string,
): Promise<Result<string>> => {
try {
const email = localStorage.getItem("otp-email") ?? "";
const phoneNumber = localStorage.getItem("otp-phone") ?? "";
const res = await api.auth.generateVerificationCode.call({
email,
phoneNumber,
type,
});
return { success: true, data: res };
} catch (err) {
return { success: false, error: extractApiError(err) };
}
};
const logout = async () => {
try {
await api.auth.logout.call();
} catch {
// proceed with client-side cleanup even if server call fails
}
[
"auth-token",
"refresh-token",
"auth-user",
"current-position-id",
"selected-position-id",
].forEach((name) => {
document.cookie = `${name}=; Max-Age=0; path=/`;
});
localStorage.clear();
queryClient.clear();
window.location.href = "/login";
};
return {
isPending,
user: authQuery.data ?? null,
customer: customerQuery.data ?? null,
login,
signup,
setPassword,
verifyOTP,
sendOTP,
generateVerificationCode,
logout,
authQuery,
customerQuery,
};
};
export default useAuth;

View File

@@ -2,18 +2,11 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import "@tria-plc/iamui-common/styles.css";
import "@edr/ui-common/styles.css";
import "../index.css";
import "@edr/ui-common/theme.css";
import App from "./App";
import {
AuthProvider,
configureIam,
UserProvider,
axiosInstance,
} from "@tria-plc/iamui-common";
// Purge cookies that were stored as the literal string "undefined" before the
// envelope interceptor fix. Without this, stale sessions would keep sending
@@ -29,36 +22,6 @@ import {
});
const queryClient = new QueryClient();
window.__IAM_CONFIG__ = {
apiUrl: `${import.meta.env.VITE_BASE_API_URL}/api`,
postLoginPath: "/",
};
// Unwrap the StandardResponse envelope ({ success, data, timestamp }) that the
// freight API's ResponseTransformInterceptor adds to every response, so that
// iamui-common can read response.data.token / response.data fields as expected.
axiosInstance.interceptors.response.use((response) => {
if (
response.data &&
typeof response.data === "object" &&
"success" in response.data &&
"data" in response.data
) {
response.data = response.data.data;
}
return response;
});
window.__USER_MANAGEMENT_BRANDING__ = {
organizationName: "EDR Platform",
appName: "EDR Portal",
moduleBasePath: "/user-management",
backToAppPath: "/",
backToAppLabel: "Back to dashboard",
};
configureIam({
apiUrl: `${import.meta.env.VITE_BASE_API_URL}/api`,
});
const rootElement = document.getElementById("root");
@@ -70,11 +33,7 @@ createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<UserProvider>
<App />
</UserProvider>
</AuthProvider>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,

View File

@@ -1,3 +1,4 @@
import { Link } from "react-router-dom";
import {
ArrowRight,
BarChart3,
@@ -85,9 +86,7 @@ export default function EDRFreightLandingPage() {
</div>
<div>
<h1 className="text-xl font-bold tracking-tight">
EDR Freight
</h1>
<h1 className="text-xl font-bold tracking-tight">EDR Freight</h1>
<p className="text-sm text-muted-foreground">
Rail Logistics Platform
@@ -119,20 +118,20 @@ export default function EDRFreightLandingPage() {
</nav>
<div className="flex items-center gap-3">
<a
href="http://localhost:5173/auth"
<Link
to="/login"
className="hidden rounded-2xl border border-border bg-card px-5 py-2.5 text-sm font-medium transition hover:bg-accent md:block"
>
Login
</a>
</Link>
<a
href="http://localhost:5173/signup"
<Link
to="/signup"
className="hidden items-center gap-2 rounded-2xl bg-primary px-5 py-2.5 text-sm font-semibold text-primary-foreground shadow-lg transition hover:opacity-90 md:flex"
>
Get Started
<ArrowRight className="size-4" />
</a>
</Link>
<button className="rounded-2xl border border-border p-2 md:hidden">
<Menu className="size-5" />
@@ -145,7 +144,7 @@ export default function EDRFreightLandingPage() {
<section className="relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(16,185,129,0.16),transparent_35%)]" />
<div className="mx-auto grid max-w-7xl gap-16 px-6 py-20 lg:grid-cols-2 lg:items-center">
<div className="mx-auto grid relative z-10 max-w-7xl gap-16 px-6 py-20 lg:grid-cols-2 lg:items-center">
<div>
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-border bg-accent px-4 py-2 text-sm font-medium text-primary shadow-sm">
<span className="size-2 rounded-full bg-primary" />
@@ -157,26 +156,26 @@ export default function EDRFreightLandingPage() {
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-muted-foreground">
EDR Freight enables logistics companies and railway operators
to manage shipments, monitor freight corridors, optimize train
EDR Freight enables logistics companies and railway operators to
manage shipments, monitor freight corridors, optimize train
operations, and streamline enterprise logistics workflows.
</p>
<div className="mt-10 flex flex-wrap gap-4">
<a
href="http://localhost:5173/signup"
<Link
to="/signup"
className="flex items-center gap-2 rounded-2xl bg-primary px-6 py-3 font-semibold text-primary-foreground shadow-lg transition hover:-translate-y-0.5 hover:opacity-90"
>
Get Started
<ArrowRight className="size-5" />
</a>
</Link>
<a
href="http://localhost:5173/auth"
<Link
to="/login"
className="rounded-2xl border border-border bg-card px-6 py-3 font-semibold transition hover:bg-accent"
>
Login
</a>
</Link>
</div>
<div className="mt-12 flex flex-wrap gap-6">
@@ -206,9 +205,7 @@ export default function EDRFreightLandingPage() {
Freight Operations
</p>
<h3 className="mt-2 text-3xl font-bold">
Live Statistics
</h3>
<h3 className="mt-2 text-3xl font-bold">Live Statistics</h3>
</div>
<div className="rounded-2xl bg-primary/10 p-4 text-primary">
@@ -229,9 +226,7 @@ export default function EDRFreightLandingPage() {
<Icon className="size-6" />
</div>
<h4 className="text-3xl font-black">
{item.value}
</h4>
<h4 className="text-3xl font-black">{item.value}</h4>
<p className="mt-1 text-sm text-muted-foreground">
{item.label}
@@ -271,9 +266,7 @@ export default function EDRFreightLandingPage() {
</div>
<div>
<h4 className="text-lg font-bold">
16 Trains Active
</h4>
<h4 className="text-lg font-bold">16 Trains Active</h4>
<p className="text-sm text-muted-foreground">
Across all freight corridors
@@ -302,8 +295,8 @@ export default function EDRFreightLandingPage() {
<p className="mt-4 text-lg leading-8 text-muted-foreground">
Centralized railway freight operations with live shipment
visibility, operational monitoring, customer management,
and intelligent logistics insights.
visibility, operational monitoring, customer management, and
intelligent logistics insights.
</p>
</div>
@@ -320,9 +313,7 @@ export default function EDRFreightLandingPage() {
<Icon className="size-7" />
</div>
<h3 className="mt-6 text-xl font-bold">
{feature.title}
</h3>
<h3 className="mt-6 text-xl font-bold">{feature.title}</h3>
<p className="mt-3 leading-7 text-muted-foreground">
{feature.description}
@@ -444,10 +435,7 @@ export default function EDRFreightLandingPage() {
</section>
{/* Contact */}
<section
id="contact"
className="border-t border-border bg-card/40 py-24"
>
<section id="contact" className="border-t border-border bg-card/40 py-24">
<div className="mx-auto max-w-7xl px-6">
<div className="grid gap-12 lg:grid-cols-2">
<div>
@@ -460,8 +448,8 @@ export default function EDRFreightLandingPage() {
</h2>
<p className="mt-5 text-lg leading-8 text-muted-foreground">
Contact EDR Freight for partnership opportunities,
enterprise onboarding, or logistics support.
Contact EDR Freight for partnership opportunities, enterprise
onboarding, or logistics support.
</p>
<div className="mt-10 space-y-5">
@@ -485,9 +473,7 @@ export default function EDRFreightLandingPage() {
<div>
<p className="font-semibold">Phone</p>
<p className="text-muted-foreground">
+251 11 000 0000
</p>
<p className="text-muted-foreground">+251 11 000 0000</p>
</div>
</div>
@@ -508,9 +494,7 @@ export default function EDRFreightLandingPage() {
{/* Contact Form */}
<div className="rounded-[32px] border border-border bg-card p-8 shadow-xl">
<h3 className="text-2xl font-bold">
Send us a message
</h3>
<h3 className="text-2xl font-bold">Send us a message</h3>
<p className="mt-2 text-muted-foreground">
Well get back to you as soon as possible.
@@ -559,26 +543,26 @@ export default function EDRFreightLandingPage() {
</h2>
<p className="mt-6 text-lg leading-8 opacity-85">
Centralize logistics workflows, optimize freight movement,
and gain real-time operational visibility across all
railway corridors.
Centralize logistics workflows, optimize freight movement, and
gain real-time operational visibility across all railway
corridors.
</p>
<div className="mt-10 flex flex-wrap items-center justify-center gap-4">
<a
href="http://localhost:5173/signup"
<Link
to="/signup"
className="flex items-center gap-2 rounded-2xl bg-white px-7 py-3 font-semibold text-primary shadow-lg transition hover:opacity-90"
>
Create Account
<ArrowRight className="size-5" />
</a>
</Link>
<a
href="http://localhost:5173/auth"
<Link
to="/login"
className="rounded-2xl border border-white/20 bg-white/10 px-7 py-3 font-semibold backdrop-blur transition hover:bg-white/20"
>
Sign In
</a>
</Link>
</div>
</div>
</div>
@@ -594,19 +578,15 @@ export default function EDRFreightLandingPage() {
</div>
<div>
<p className="font-semibold text-foreground">
EDR Freight
</p>
<p className="font-semibold text-foreground">EDR Freight</p>
<p>Modern railway logistics management platform</p>
</div>
</div>
<div>
© 2026 EDR Freight. All rights reserved.
</div>
<div>© 2026 EDR Freight. All rights reserved.</div>
</div>
</footer>
</div>
);
}
}

View File

@@ -0,0 +1,360 @@
import { useMemo } from "react";
import { Link } from "react-router-dom";
import {
ArrowRight,
Building2,
CheckCircle2,
Clock,
DollarSign,
Eye,
Mail,
MapPin,
Package,
Phone,
Plus,
Receipt,
Truck,
} from "lucide-react";
import {
getCurrentCustomer,
getMyBookings,
getMyInvoices,
getMyShipments,
} from "@/lib/currentCustomer";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import type { ShipmentStatus } from "@/pages/tracking/shipments.mock";
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
import type { BookingStatus } from "@/pages/bookings/bookings.mock";
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@edr/ui-common";
export default function MyPortalPage() {
const me = useMemo(() => getCurrentCustomer(), []);
const myBookings = useMemo(() => getMyBookings(), []);
const myShipments = useMemo(() => getMyShipments(), []);
const myInvoices = useMemo(() => getMyInvoices(), []);
const activeBookings = myBookings.filter(
(b) => b.status === "Confirmed" || b.status === "In Transit",
);
const activeShipments = myShipments.filter((s) => s.status === "In Transit");
const outstandingInvoices = myInvoices.filter(
(inv) => inv.status === "Sent" || inv.status === "Overdue",
);
const totalOutstanding = outstandingInvoices
.filter((inv) => inv.currency === "USD")
.reduce((sum, inv) => sum + inv.amount, 0);
const totalSpent = myInvoices
.filter((inv) => inv.status === "Paid" && inv.currency === "USD")
.reduce((sum, inv) => sum + inv.amount, 0);
const recentBookings = [...myBookings].slice(0, 5);
const recentInvoices = [...myInvoices].slice(0, 4);
return (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-7xl space-y-6">
{/* Welcome banner */}
<div className="overflow-hidden rounded-3xl bg-gradient-to-r from-[#059669] to-[#10B981] p-6 text-white shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-white/15 text-2xl font-bold backdrop-blur">
{me.company.charAt(0)}
</div>
<div>
<p className="text-sm text-white/80">Welcome back</p>
<h1 className="text-3xl font-bold tracking-tight">{me.name}</h1>
<p className="mt-1 flex items-center gap-2 text-sm text-white/80">
<Building2 className="h-4 w-4" />
{me.company}
<span className="text-white/40">·</span>
<span className="rounded-full bg-white/15 px-2 py-0.5 text-xs">
{me.customerType}
</span>
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Link to="/bookings/new">
<Button
variant="secondary"
className="bg-white text-[#10B981] hover:bg-slate-100"
>
<Plus className="h-4 w-4" />
New Booking
</Button>
</Link>
<Link to="/tracking">
<Button
variant="outline"
className="border-white/40 text-white hover:bg-white/10"
>
<Truck className="h-4 w-4" />
Track Shipment
</Button>
</Link>
</div>
</div>
</div>
{/* Active Shipments */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Active Shipments</CardTitle>
<CardDescription>
Live tracking for your in-flight cargo
</CardDescription>
</div>
<Link
to="/tracking"
className="inline-flex items-center gap-1 text-sm font-medium text-primary transition hover:underline"
>
View all
<ArrowRight className="h-4 w-4" />
</Link>
</CardHeader>
<CardContent>
{activeShipments.length === 0 ? (
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
No shipments currently in transit.
</p>
) : (
<div className="grid gap-3 md:grid-cols-2">
{activeShipments.slice(0, 4).map((shipment) => (
<div
key={shipment.id}
className="rounded-2xl border border-slate-100 p-4 transition hover:border-primary/20 hover:bg-primary/5"
>
<div className="flex items-center justify-between">
<span className="font-semibold text-slate-900">
{shipment.reference}
</span>
<ShipmentBadge status={shipment.status} />
</div>
<p className="mt-1 text-sm text-slate-700">
{shipment.originStation}
<ArrowRight className="mx-2 inline h-3 w-3 text-slate-400" />
{shipment.destinationStation}
</p>
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3 text-primary" />
{shipment.currentLocation}
</span>
<span>ETA {shipment.eta}</span>
</div>
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${shipment.progress}%` }}
/>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Recent bookings */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Recent Bookings</CardTitle>
<CardDescription>Your latest freight requests</CardDescription>
</div>
<Link
to="/bookings"
className="inline-flex items-center gap-1 text-sm font-medium text-primary transition hover:underline"
>
View all
<ArrowRight className="h-4 w-4" />
</Link>
</CardHeader>
<CardContent className="px-0">
{recentBookings.length === 0 ? (
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
You haven't booked any freight yet.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[600px] whitespace-nowrap text-left text-sm">
<thead className="text-xs text-slate-500">
<tr>
<th className="px-6 py-2 font-medium">Reference</th>
<th className="py-2 font-medium">Route</th>
<th className="py-2 font-medium">Cargo</th>
<th className="py-2 font-medium">Status</th>
<th className="px-6 py-2 text-right font-medium">
Action
</th>
</tr>
</thead>
<tbody>
{recentBookings.map((booking) => (
<tr
key={booking.id}
className="border-t border-slate-100 transition hover:bg-primary/5"
>
<td className="px-6 py-3 font-medium text-slate-900">
{booking.reference}
</td>
<td className="py-3 text-slate-700">
{booking.originStation} {booking.destinationStation}
</td>
<td className="py-3 text-slate-700">
{booking.cargoType}
</td>
<td className="py-3">
<BookingBadge status={booking.status} />
</td>
<td className="px-6 py-3 text-right">
<Link
to={`/bookings/${booking.id}`}
aria-label="View booking"
className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 transition hover:bg-primary/10 hover:text-primary"
>
<Eye className="h-4 w-4" />
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Invoices */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Recent Invoices</CardTitle>
<CardDescription>
{outstandingInvoices.length} outstanding · {myInvoices.length}{" "}
total
</CardDescription>
</div>
<Link
to="/billing"
className="inline-flex items-center gap-1 text-sm font-medium text-primary transition hover:underline"
>
View all
<ArrowRight className="h-4 w-4" />
</Link>
</CardHeader>
<CardContent>
{recentInvoices.length === 0 ? (
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
No invoices yet.
</p>
) : (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
{recentInvoices.map((invoice) => (
<div
key={invoice.id}
className="rounded-2xl border border-slate-100 p-4 transition hover:border-primary/20 hover:bg-primary/5"
>
<div className="flex items-center justify-between">
<Receipt className="h-4 w-4 text-primary" />
<InvoiceBadge status={invoice.status} />
</div>
<p className="mt-0.5 pt-2 text-lg font-bold text-slate-900">
{formatCurrency(invoice.amount, invoice.currency)}
</p>
<p className="mt-1 flex items-center gap-1 text-xs text-slate-500">
<Clock className="h-3 w-3" />
Due {invoice.dueDate}
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}
function ProfileRow({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-start gap-3">
<div className="mt-0.5 text-primary">{icon}</div>
<div className="flex-1">
<p className="text-xs font-medium text-slate-500">{label}</p>
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
</div>
</div>
);
}
function ShipmentBadge({ status }: { status: ShipmentStatus }) {
const styles: Record<ShipmentStatus, string> = {
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Delayed: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}
function BookingBadge({ status }: { status: BookingStatus }) {
const styles: Record<BookingStatus, string> = {
Pending: "bg-amber-100 text-amber-700",
Confirmed: "bg-sky-100 text-sky-700",
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Cancelled: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}
function InvoiceBadge({ status }: { status: InvoiceStatus }) {
const styles: Record<InvoiceStatus, string> = {
Draft: "bg-slate-100 text-slate-600",
Sent: "bg-sky-100 text-sky-700",
Paid: "bg-emerald-100 text-emerald-700",
Overdue: "bg-red-100 text-red-700",
Cancelled: "bg-amber-100 text-amber-700",
};
return (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -0,0 +1,190 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowRight, Mail, Phone, Loader2 } from "lucide-react";
import useAuth from "@/hooks/useAuth";
import AuthLayout from "@/components/auth/AuthLayout";
import {
Button,
Input,
Field,
FieldLabel,
FieldError,
FieldGroup,
} from "@edr/ui-common";
import PhoneInput from "@/components/auth/PhoneInput";
type LoginMethod = "email" | "phone";
export default function LoginPage() {
const navigate = useNavigate();
const { login } = useAuth();
const [method, setMethod] = useState<LoginMethod>("email");
const [identifier, setIdentifier] = useState("");
const [countryCode, setCountryCode] = useState("+251");
const [phoneNumber, setPhoneNumber] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const loginId = method === "email"
? identifier
: `${countryCode}${phoneNumber.startsWith("0") ? phoneNumber.slice(1) : phoneNumber}`;
const result = await login({ email: loginId, password });
if (result.success) {
navigate("/");
} else {
setError(result.error.message);
}
} catch {
setError("An unexpected error occurred");
} finally {
setLoading(false);
}
};
return (
<AuthLayout
left={{
badge: "Welcome Back",
title: "Sign in to your freight operations account",
description:
"Access your dashboard to manage shipments, monitor railway operations, track consignments, and streamline logistics workflows.",
features: [
"Real-time shipment tracking",
"Secure logistics management",
"Enterprise-grade operations",
"Multi-corridor freight monitoring",
],
stats: {
label: "Active Corridors",
value: "24+",
footer: "Operational",
progress: "w-[95%]",
},
}}
>
<div className="mb-6">
<div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Mail className="size-6" />
</div>
<h2 className="text-2xl font-black tracking-tight">Welcome back</h2>
<p className="mt-2 text-base text-muted-foreground">
Enter your credentials to access your portal
</p>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex gap-2 rounded-lg bg-muted p-1">
<Button
type="button"
variant={"ghost"}
size="sm"
onClick={() => setMethod("email")}
className={`flex-1 hover:bg-background/40! ${method === "email" ? "bg-background shadow border" : ""}`}
>
<Mail data-icon="inline-start" />
Email
</Button>
<Button
type="button"
variant={"ghost"}
size="sm"
onClick={() => setMethod("phone")}
className={`flex-1 hover:bg-background/40! ${method === "phone" ? "bg-background shadow border" : ""}`}
>
<Phone data-icon="inline-start" />
Phone
</Button>
</div>
<FieldGroup>
{method === "email" ? (
<Field>
<FieldLabel>Email Address</FieldLabel>
<Input
type="email"
placeholder="name@company.com"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
required
disabled={loading}
/>
</Field>
) : (
<PhoneInput
disabled={loading}
countryCode={{
value: countryCode,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => setCountryCode(e.target.value),
}}
phone={{
value: phoneNumber,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => setPhoneNumber(e.target.value),
}}
/>
)}
<Field>
<div className="flex items-center justify-between">
<FieldLabel>Password</FieldLabel>
<Button
type="button"
variant="link"
size="xs"
className="h-auto p-0"
>
Forgot password?
</Button>
</div>
<Input
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={loading}
/>
</Field>
</FieldGroup>
{error && (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-sm text-red-700">
{error}
</div>
)}
<Button type="submit" disabled={loading} size="lg" className="w-full">
{loading ? (
<>
<Loader2 className="animate-spin" data-icon="inline-start" />
Signing in...
</>
) : (
<>
Sign In
<ArrowRight data-icon="inline-end" />
</>
)}
</Button>
<p className="text-center text-sm text-muted-foreground">
Don't have an account?{" "}
<Button
type="button"
variant="link"
size="sm"
onClick={() => navigate("/signup")}
className="h-auto p-0 font-semibold"
>
Create an account
</Button>
</p>
</form>
</AuthLayout>
);
}

View File

@@ -0,0 +1,520 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
ArrowRight,
ArrowLeft,
Building2,
User,
FileText,
CheckCircle2,
Loader2,
} from "lucide-react";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import type { CreateCustomerDto } from "@/types/customers";
import AuthLayout from "@/components/auth/AuthLayout";
import PhoneInput from "@/components/auth/PhoneInput";
import {
Button,
Input,
Field,
FieldLabel,
FieldError,
FieldGroup,
} from "@edr/ui-common";
type OnboardingStep = "company" | "personnel" | "poa";
const onboardingSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"),
companyPhone: z.string().min(1, "Company phone is required"),
companyPhoneCountryCode: z.string().min(1, "Country code is required"),
companyLocation: z.string().min(1, "Location is required"),
companyAddress: z.string().min(1, "Address is required"),
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
vatNumber: z
.string()
.min(1, "VAT number is required")
.length(10, "VAT number must be exactly 10 digits"),
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
contactPersonName: z.string().min(1, "Contact person name is required"),
contactPersonPhone: z.string().min(1, "Contact person phone is required"),
contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"),
generalManagerName: z.string().min(1, "GM name is required"),
generalManagerEmail: z.string().email("Invalid GM email"),
generalManagerPhone: z.string().min(1, "GM phone is required"),
generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"),
poaName: z.string().optional(),
poaPhone: z.string().optional(),
poaPhoneCountryCode: z.string().optional(),
poaAddress: z.string().optional(),
poaEmail: z.string().optional(),
poaLocation: z.string().optional(),
});
type FormData = z.infer<typeof onboardingSchema>;
const stepFields: Record<OnboardingStep, (keyof FormData)[]> = {
company: [
"companyName",
"companyEmail",
"companyPhone",
"companyPhoneCountryCode",
"companyLocation",
"companyAddress",
"tinNumber",
"vatNumber",
"fanNumber",
],
personnel: [
"contactPersonName",
"contactPersonPhone",
"contactPersonPhoneCountryCode",
"generalManagerName",
"generalManagerEmail",
"generalManagerPhone",
"generalManagerPhoneCountryCode",
],
poa: [],
};
export default function OnboardingPage() {
const queryClient = useQueryClient();
const { user } = useAuth();
const [step, setStep] = useState<OnboardingStep>("company");
const {
register,
handleSubmit,
trigger,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(onboardingSchema),
defaultValues: {
companyName: "",
companyEmail: "",
companyPhone: "",
companyPhoneCountryCode: "+251",
companyLocation: "",
companyAddress: "",
tinNumber: "",
vatNumber: "",
fanNumber: "",
contactPersonName: "",
contactPersonPhone: "",
contactPersonPhoneCountryCode: "+251",
generalManagerName: "",
generalManagerEmail: "",
generalManagerPhone: "",
generalManagerPhoneCountryCode: "+251",
poaName: "",
poaPhone: "",
poaPhoneCountryCode: "+251",
poaAddress: "",
poaEmail: "",
poaLocation: "",
},
});
const createCustomerMutation = useMutation({
mutationFn: (payload: CreateCustomerDto) =>
api.customers.create.call(payload),
onSuccess: () => {
if (user)
queryClient.invalidateQueries({
queryKey: api.customers.getByUserId.queryKey({ id: user.id }),
});
},
});
const nextStep = async () => {
if (step === "poa") {
handleSubmit(onSubmit)();
return;
}
const fields = stepFields[step];
const isValid = await trigger(fields);
if (!isValid) return;
setStep(step === "company" ? "personnel" : "poa");
};
const prevStep = () => {
if (step === "personnel") setStep("company");
else if (step === "poa") setStep("personnel");
};
const onSubmit = async (data: FormData) => {
const nameParts = (user?.name?.en ?? "").split(" ");
const payload: CreateCustomerDto = {
userId: user!.id,
firstName: nameParts[0] || "",
lastName: nameParts.slice(-1)[0] || "",
email: user!.email,
phone: user!.phoneNumber,
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
companyLocation: data.companyLocation,
companyAddress: data.companyAddress,
contactPersonName: data.contactPersonName,
contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
tinNumber: data.tinNumber,
vatNumber: data.vatNumber,
fanNumber: data.fanNumber,
generalManagerName: data.generalManagerName,
generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
poaName: data.poaName || undefined,
poaPhone:
data.poaPhone && data.poaPhoneCountryCode
? `${data.poaPhoneCountryCode}${data.poaPhone}`
: undefined,
poaAddress: data.poaAddress || undefined,
poaEmail: data.poaEmail || undefined,
poaLocation: data.poaLocation || undefined,
};
createCustomerMutation.mutate(payload);
};
return (
<AuthLayout
left={{
badge: "Complete Your Profile",
title: "Set up your company profile",
description:
"Provide your business details to start using EDR Freight for managing shipments, tracking consignments, and streamlining logistics operations across Ethiopia and Djibouti.",
features: [
"Company registration details",
"Contact and management personnel",
"Power of Attorney (optional)",
],
stats: {
label: "Active Customers",
value: "500+",
footer: "And growing",
progress: "w-[95%]",
},
}}
>
<div className="mb-8 lg:col-span-2">
<div className="flex items-center justify-between max-w-lg mx-auto relative px-2">
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
<StepIcon
icon={<Building2 className="size-5" />}
active={step === "company"}
completed={step !== "company"}
/>
<StepIcon
icon={<User className="size-5" />}
active={step === "personnel"}
completed={step === "poa"}
/>
<StepIcon
icon={<FileText className="size-5" />}
active={step === "poa"}
completed={false}
/>
</div>
<p className="text-center text-sm text-muted-foreground mt-3">
{step === "company" && "Step 1 of 3 — Company Information"}
{step === "personnel" && "Step 2 of 3 — Personnel Details"}
{step === "poa" && "Step 3 of 3 — Power of Attorney (Optional)"}
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<FieldGroup className="gap-4">
{step === "company" && (
<>
<Field data-invalid={Boolean(errors.companyName)}>
<FieldLabel>Company Name</FieldLabel>
<Input
placeholder="Global Logistics Ltd"
aria-invalid={Boolean(errors.companyName)}
{...register("companyName")}
/>
<FieldError errors={[errors.companyName]} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.companyEmail)}>
<FieldLabel>Company Email</FieldLabel>
<Input
type="email"
placeholder="ops@company.com"
aria-invalid={Boolean(errors.companyEmail)}
{...register("companyEmail")}
/>
<FieldError errors={[errors.companyEmail]} />
</Field>
<PhoneInput
countryCode={{ ...register("companyPhoneCountryCode") }}
phone={{
...register("companyPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.companyPhoneCountryCode}
phoneError={errors.companyPhone}
label="Company Phone"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.companyLocation)}>
<FieldLabel>Location</FieldLabel>
<Input
placeholder="Addis Ababa, Ethiopia"
aria-invalid={Boolean(errors.companyLocation)}
{...register("companyLocation")}
/>
<FieldError errors={[errors.companyLocation]} />
</Field>
<Field data-invalid={Boolean(errors.companyAddress)}>
<FieldLabel>Address</FieldLabel>
<Input
placeholder="Bole Subcity, Woreda 03"
aria-invalid={Boolean(errors.companyAddress)}
{...register("companyAddress")}
/>
<FieldError errors={[errors.companyAddress]} />
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.tinNumber)}>
<FieldLabel>TIN Number (10 digits)</FieldLabel>
<Input
placeholder="1234567890"
maxLength={10}
aria-invalid={Boolean(errors.tinNumber)}
{...register("tinNumber")}
/>
<FieldError errors={[errors.tinNumber]} />
</Field>
<Field data-invalid={Boolean(errors.vatNumber)}>
<FieldLabel>VAT Number</FieldLabel>
<Input
placeholder="VAT-12345"
aria-invalid={Boolean(errors.vatNumber)}
maxLength={10}
{...register("vatNumber")}
/>
<FieldError errors={[errors.vatNumber]} />
</Field>
</div>
<Field data-invalid={Boolean(errors.fanNumber)}>
<FieldLabel>FAN Number (16 digits)</FieldLabel>
<Input
placeholder="1234567890123456"
maxLength={16}
aria-invalid={Boolean(errors.fanNumber)}
{...register("fanNumber")}
/>
<FieldError errors={[errors.fanNumber]} />
</Field>
</>
)}
{step === "personnel" && (
<>
<p className="text-sm text-muted-foreground">
Personal details are pulled from your account. Contact and
management info is collected below.
</p>
<div>
<h3 className="text-sm font-semibold text-foreground mb-3">
Contact Person
</h3>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.contactPersonName)}>
<FieldLabel>Name</FieldLabel>
<Input
placeholder="Jane Smith"
aria-invalid={Boolean(errors.contactPersonName)}
{...register("contactPersonName")}
/>
<FieldError errors={[errors.contactPersonName]} />
</Field>
<PhoneInput
countryCode={{
...register("contactPersonPhoneCountryCode"),
}}
phone={{
...register("contactPersonPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.contactPersonPhoneCountryCode}
phoneError={errors.contactPersonPhone}
label="Phone"
/>
</div>
</div>
<hr className="border-border" />
<div>
<h3 className="text-sm font-semibold text-foreground mb-3">
General Manager
</h3>
<div className="grid grid-cols-2 gap-4">
<Field
className="col-span-2"
data-invalid={Boolean(errors.generalManagerName)}
>
<FieldLabel>Name</FieldLabel>
<Input
placeholder="Abebe Bikila"
aria-invalid={Boolean(errors.generalManagerName)}
{...register("generalManagerName")}
/>
<FieldError errors={[errors.generalManagerName]} />
</Field>
<Field data-invalid={Boolean(errors.generalManagerEmail)}>
<FieldLabel>Email</FieldLabel>
<Input
type="email"
placeholder="gm@company.com"
aria-invalid={Boolean(errors.generalManagerEmail)}
{...register("generalManagerEmail")}
/>
<FieldError errors={[errors.generalManagerEmail]} />
</Field>
<PhoneInput
countryCode={{
...register("generalManagerPhoneCountryCode"),
}}
phone={{
...register("generalManagerPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.generalManagerPhoneCountryCode}
phoneError={errors.generalManagerPhone}
label="Phone"
/>
</div>
</div>
</>
)}
{step === "poa" && (
<>
<p className="text-sm text-muted-foreground">
Power of Attorney details are optional. Skip if not applicable.
</p>
<Field>
<FieldLabel>PoA Name</FieldLabel>
<Input
placeholder="Authorized Representative Name"
{...register("poaName")}
/>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel>PoA Email</FieldLabel>
<Input
type="email"
placeholder="poa@company.com"
{...register("poaEmail")}
/>
</Field>
<PhoneInput
countryCode={{ ...register("poaPhoneCountryCode") }}
phone={{ ...register("poaPhone"), placeholder: "912345678" }}
label="PoA Phone"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel>PoA Location</FieldLabel>
<Input
placeholder="City, Country"
{...register("poaLocation")}
/>
</Field>
<Field>
<FieldLabel>PoA Address</FieldLabel>
<Input
placeholder="Full Address"
{...register("poaAddress")}
/>
</Field>
</div>
</>
)}
</FieldGroup>
<div className="flex items-center justify-between pt-2">
<Button
type="button"
variant="outline"
onClick={prevStep}
disabled={step === "company"}
>
<ArrowLeft />
Back
</Button>
<Button
type="button"
onClick={nextStep}
disabled={createCustomerMutation.isPending}
>
{createCustomerMutation.isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "poa" ? (
"Complete Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
</Button>
</div>
</form>
</AuthLayout>
);
}
function StepIcon({
icon,
active,
completed,
}: {
icon: React.ReactNode;
active: boolean;
completed: boolean;
}) {
return (
<div
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${completed
? "bg-primary border-primary text-primary-foreground"
: active
? "bg-background border-primary text-primary shadow-md"
: "bg-background border-border text-muted-foreground"
}`}
>
{completed ? <CheckCircle2 className="size-5" /> : icon}
</div>
);
}

View File

@@ -1,418 +1,217 @@
import { setPassword } from "@/services/account";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import {
ArrowRight,
LockKeyhole,
ShieldCheck,
Train,
Eye,
EyeOff,
} from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { ArrowRight, Check, Eye, EyeOff, LockKeyhole, Loader2, X } from "lucide-react";
import useAuth from "@/hooks/useAuth";
import AuthLayout from "@/components/auth/AuthLayout";
import { cn } from "@/lib/utils";
import {
Button,
Input,
Field,
FieldLabel,
FieldError,
FieldGroup,
} from "@edr/ui-common";
// -----------------------------------------------------------------------------
// Schema
// -----------------------------------------------------------------------------
const passwordRequirements = [
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
{ label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) },
{ label: "One number", test: (v: string) => /\d/.test(v) },
{ label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
] as const;
const passwordSchema = z
.object({
password: z
.string()
.min(
8,
"Password must be at least 8 characters"
),
confirmPassword: z
.string()
.min(
8,
"Confirm password is required"
),
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must include an uppercase letter")
.regex(/[a-z]/, "Password must include a lowercase letter")
.regex(/\d/, "Password must include a number")
.regex(/[^A-Za-z0-9]/, "Password must include a special character"),
confirmPassword: z.string().min(1, "Please confirm your password"),
})
.refine(
(data) =>
data.password ===
data.confirmPassword,
{
message:
"Passwords do not match",
path: ["confirmPassword"],
}
);
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
type FormData = z.infer<
typeof passwordSchema
>;
// -----------------------------------------------------------------------------
// Component
// -----------------------------------------------------------------------------
type FormData = z.infer<typeof passwordSchema>;
export default function SetPasswordPage() {
const [
showPassword,
setShowPassword,
] = useState(false);
const [
showConfirmPassword,
setShowConfirmPassword,
] = useState(false);
const navigate = useNavigate();
const { setPassword } = useAuth();
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
watch,
formState: { errors },
reset,
} = useForm<FormData>({
resolver:
zodResolver(passwordSchema),
defaultValues: {
password: "",
confirmPassword: "",
},
resolver: zodResolver(passwordSchema),
defaultValues: { password: "", confirmPassword: "" },
});
const naviagte = useNavigate();
const password = watch("password");
// ---------------------------------------------------------------------------
// Mutation
// ---------------------------------------------------------------------------
const requirements = useMemo(
() => passwordRequirements.map((r) => ({ ...r, met: r.test(password || "") })),
[password],
);
const setPasswordMutation =
useMutation({
mutationFn: async (
data: FormData
) => setPassword({
newPassword: data?.password,
confirmPassword: data?.confirmPassword,
userId: localStorage.getItem("userId"),
email: localStorage.getItem("otp-email"),
verificationCode: localStorage.getItem("otp"),
}),
const allMet = requirements.every((r) => r.met);
onSuccess: () => {
naviagte("/auth");
reset();
},
});
// ---------------------------------------------------------------------------
// Submit
// ---------------------------------------------------------------------------
const onSubmit = async (
data: FormData
) => {
const onSubmit = async (data: FormData) => {
setError(null);
setLoading(true);
try {
await setPasswordMutation.mutateAsync(
data
);
} catch (err) {
console.error(err);
const result = await setPassword({
newPassword: data.password,
confirmPassword: data.confirmPassword,
});
if (result.success) {
navigate("/auth");
} else {
setError(result.error.message);
}
} catch {
setError("An unexpected error occurred");
} finally {
setLoading(false);
}
};
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
return (
<div className="min-h-screen bg-background text-foreground">
<div className="grid min-h-screen lg:grid-cols-2">
{/* ------------------------------------------------------------------ */}
{/* Left Side */}
{/* ------------------------------------------------------------------ */}
<div className="relative hidden overflow-hidden bg-primary p-12 text-primary-foreground lg:flex lg:flex-col lg:justify-between">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.14),transparent_35%)]" />
<div className="relative z-10">
{/* Logo */}
<div className="flex items-center gap-3">
<div className="flex size-14 items-center justify-center rounded-3xl bg-white/10 backdrop-blur">
<Train className="size-7" />
</div>
<div>
<h1 className="text-3xl font-black tracking-tight">
EDR Freight
</h1>
<p className="mt-1 text-sm opacity-80">
Railway Logistics
Platform
</p>
</div>
</div>
{/* Hero */}
<div className="mt-20 max-w-lg">
<div className="inline-flex rounded-full bg-white/10 px-4 py-2 text-sm font-semibold backdrop-blur">
Account Security
</div>
<h2 className="mt-6 text-5xl font-black leading-tight tracking-tight">
Set your secure
password
</h2>
<p className="mt-6 text-lg leading-8 opacity-85">
Create a strong
password to secure
your EDR Freight
account and protect
railway logistics
operations and shipment
data.
</p>
</div>
{/* Features */}
<div className="mt-14 grid gap-5">
{[
"Enterprise-grade security",
"Protected account access",
"Secure freight operations",
"Advanced authentication system",
].map((item) => (
<div
key={item}
className="flex items-center gap-3"
>
<div className="flex size-10 items-center justify-center rounded-2xl bg-white/10">
<ShieldCheck className="size-5" />
</div>
<span className="font-medium">
{item}
</span>
</div>
))}
</div>
</div>
{/* Stats */}
<div className="relative z-10 rounded-[32px] border border-white/10 bg-white/10 p-6 backdrop-blur-xl">
<div className="flex items-center justify-between">
<div>
<p className="text-sm opacity-80">
Security Protection
</p>
<h3 className="mt-2 text-4xl font-black">
256-bit
</h3>
</div>
<div className="rounded-2xl bg-white/10 px-4 py-2 text-sm font-semibold">
Encrypted
</div>
</div>
<div className="mt-6 h-3 overflow-hidden rounded-full bg-white/10">
<div className="h-full w-[98%] rounded-full bg-white" />
</div>
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Right Side */}
{/* ------------------------------------------------------------------ */}
<div className="flex items-center justify-center p-6 md:p-10">
<div className="w-full max-w-xl">
{/* Mobile Logo */}
<div className="mb-8 flex items-center gap-3 lg:hidden">
<div className="flex size-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Train className="size-6" />
</div>
<div>
<h1 className="text-2xl font-bold">
EDR Freight
</h1>
<p className="text-sm text-muted-foreground">
Railway Logistics
Platform
</p>
</div>
</div>
{/* Card */}
<div className="rounded-[36px] border border-border bg-card p-8 shadow-2xl md:p-10">
{/* Header */}
<div className="mb-8">
<div className="mb-4 flex size-16 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<LockKeyhole className="size-8" />
</div>
<h2 className="text-4xl font-black tracking-tight">
Set Password
</h2>
<p className="mt-3 text-lg text-muted-foreground">
Create a secure
password for your
EDR Freight account.
</p>
</div>
{/* Success */}
{setPasswordMutation.isSuccess && (
<div className="mb-6 rounded-2xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">
Password updated
successfully.
</div>
)}
{/* Error */}
{setPasswordMutation.isError && (
<div className="mb-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
Failed to set
password. Please try
again.
</div>
)}
{/* Form */}
<form
onSubmit={handleSubmit(
onSubmit
)}
className="space-y-6"
>
{/* Password */}
<div>
<label className="mb-2 block text-sm font-semibold">
Password
</label>
<div className="relative">
<input
type={
showPassword
? "text"
: "password"
}
placeholder="Enter password"
disabled={
setPasswordMutation.isPending
}
{...register(
"password"
)}
className="h-14 w-full rounded-2xl border border-input bg-background px-4 pr-14 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
<button
type="button"
onClick={() =>
setShowPassword(
!showPassword
)
}
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground"
>
{showPassword ? (
<EyeOff className="size-5" />
) : (
<Eye className="size-5" />
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-500">
{
errors.password
.message
}
</p>
)}
</div>
{/* Confirm Password */}
<div>
<label className="mb-2 block text-sm font-semibold">
Confirm Password
</label>
<div className="relative">
<input
type={
showConfirmPassword
? "text"
: "password"
}
placeholder="Confirm password"
disabled={
setPasswordMutation.isPending
}
{...register(
"confirmPassword"
)}
className="h-14 w-full rounded-2xl border border-input bg-background px-4 pr-14 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
<button
type="button"
onClick={() =>
setShowConfirmPassword(
!showConfirmPassword
)
}
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground"
>
{showConfirmPassword ? (
<EyeOff className="size-5" />
) : (
<Eye className="size-5" />
)}
</button>
</div>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-500">
{
errors
.confirmPassword
.message
}
</p>
)}
</div>
{/* Submit */}
<button
type="submit"
disabled={
setPasswordMutation.isPending
}
className="flex h-14 w-full items-center justify-center gap-2 rounded-2xl bg-primary text-lg font-semibold text-primary-foreground shadow-lg transition hover:-translate-y-0.5 hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
>
{setPasswordMutation.isPending ? (
"Saving..."
) : (
<>
Save Password
<ArrowRight className="size-5" />
</>
)}
</button>
</form>
</div>
</div>
<AuthLayout
left={{
badge: "Account Security",
title: "Set your secure password",
description:
"Create a strong password to secure your EDR Freight account and protect railway logistics operations and shipment data.",
features: [
"Enterprise-grade security",
"Protected account access",
"Secure freight operations",
"Advanced authentication system",
],
stats: { label: "Security Protection", value: "256-bit", footer: "Encrypted", progress: "w-[98%]" },
}}
>
<div className="mb-6">
<div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<LockKeyhole className="size-6" />
</div>
<h2 className="text-2xl font-black tracking-tight">Set Password</h2>
<p className="mt-2 text-base text-muted-foreground">
Create a secure password for your account.
</p>
</div>
</div>
{error && (
<div className="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-sm text-red-700">
{error}
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<FieldGroup>
<Field data-invalid={Boolean(errors.password)}>
<FieldLabel>Password</FieldLabel>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
placeholder="Enter password"
disabled={loading}
aria-invalid={Boolean(errors.password)}
className="pr-12"
{...register("password")}
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground"
>
{showPassword ? <EyeOff /> : <Eye />}
</Button>
</div>
<FieldError errors={[errors.password]} />
</Field>
{password && (
<ul className="space-y-1.5">
{requirements.map((req) => (
<li
key={req.label}
className={cn(
"flex items-center gap-2 text-sm",
req.met ? "text-emerald-600" : "text-muted-foreground",
)}
>
{req.met ? (
<Check className="size-4 shrink-0 text-emerald-500" />
) : (
<X className="size-4 shrink-0 text-muted-foreground/50" />
)}
{req.label}
</li>
))}
</ul>
)}
<Field data-invalid={Boolean(errors.confirmPassword)}>
<FieldLabel>Confirm Password</FieldLabel>
<div className="relative">
<Input
type={showConfirmPassword ? "text" : "password"}
placeholder="Confirm password"
disabled={loading}
aria-invalid={Boolean(errors.confirmPassword)}
className="pr-12"
{...register("confirmPassword")}
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground"
>
{showConfirmPassword ? <EyeOff /> : <Eye />}
</Button>
</div>
<FieldError errors={[errors.confirmPassword]} />
</Field>
</FieldGroup>
<Button
type="submit"
disabled={loading || !allMet}
size="lg"
className="w-full"
>
{loading ? (
<>
<Loader2 className="animate-spin" data-icon="inline-start" />
Saving...
</>
) : (
<>
Save Password
<ArrowRight data-icon="inline-end" />
</>
)}
</Button>
</form>
</AuthLayout>
);
}
}

View File

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

View File

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

View File

@@ -1,77 +0,0 @@
import { useState, type ReactNode } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export interface DeleteDropdownSettingDialogProps {
settingLabel: string;
settingCode: string;
onConfirm?: () => void;
children?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export default function DeleteDropdownSettingDialog({
settingLabel,
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 (
<Dialog open={open} onOpenChange={setOpen}>
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Delete dropdown setting?
</DialogTitle>
<DialogDescription>
This will remove{" "}
<span className="font-semibold text-slate-900">{settingLabel}</span>{" "}
(<span className="font-mono text-xs">{settingCode}</span>) and all
of its options. Forms referencing this code will fall back to
empty options.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Delete
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,65 +0,0 @@
import type { ReactNode } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export interface DeleteFileUploadSettingDialogProps {
settingLabel: string;
settingCode: string;
onConfirm?: () => void;
children: ReactNode;
}
export default function DeleteFileUploadSettingDialog({
settingLabel,
settingCode,
onConfirm,
children,
}: DeleteFileUploadSettingDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Delete file upload setting?
</DialogTitle>
<DialogDescription>
This will remove{" "}
<span className="font-semibold text-slate-900">{settingLabel}</span>{" "}
(<span className="font-mono text-xs">{settingCode}</span>) and all
of its fields. Forms referencing this code will fall back to no
uploads.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Delete
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,468 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import {
AlertCircle,
Boxes,
CheckCircle2,
Eye,
Filter,
ListOrdered,
Loader2,
MoreHorizontal,
Pencil,
Plus,
Search,
Settings,
Shield,
Sparkles,
Trash2,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import EditDropdownSettingDialog from "./EditDropdownSettingDialog";
import ManageDropdownOptionsDialog from "./ManageDropdownOptionsDialog";
import DeleteDropdownSettingDialog from "./DeleteDropdownSettingDialog";
import {
useDeleteDropdownSetting,
useDropdownSettings,
} from "@/hooks/useDropdownSettings";
import type { DropdownSetting } from "@/types/dropdownSettings";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@edr/ui-common";
type ActiveDialog = "edit" | "options" | "delete";
export default function DropdownSettingsPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
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 q = query.trim().toLowerCase();
if (!q) return dropdownSettings;
return dropdownSettings.filter(
(s) =>
s.code.toLowerCase().includes(q) ||
s.label.toLowerCase().includes(q) ||
(s.description ?? "").toLowerCase().includes(q),
);
}, [dropdownSettings, query]);
const total = filtered.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(
() => filtered.slice(start, end),
[start, end, filtered],
);
const totalOptions = dropdownSettings.reduce(
(sum, s) => sum + (s.children?.length ?? 0),
0,
);
const multipleCount = dropdownSettings.filter((s) => s.multiple).length;
const searchableCount = dropdownSettings.filter(
(s) => s.meta?.searchable,
).length;
const status: "loading" | "error" | "success" = isLoading
? "loading"
: isError
? "error"
: "success";
const columns: ColumnDef<DropdownSetting>[] = [
{
id: "setting",
header: "Setting",
cell: ({ row }) => {
const s = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Settings />
</div>
<div>
<p className="font-medium text-slate-900">{s.label}</p>
<p className="text-xs text-slate-500">
{s.description ?? "No description"}
</p>
</div>
</div>
);
},
},
{
id: "code",
header: "Code",
cell: ({ row }) => (
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
{row.original.code}
</span>
),
},
{
id: "options",
header: "Options",
cell: ({ row }) => {
const s = row.original;
return (
<div className="flex items-center gap-2 text-sm text-slate-700">
<Boxes />
<span className="font-medium">{s.children?.length ?? 0}</span>
</div>
);
},
},
{
id: "behavior",
header: "Behavior",
cell: ({ row }) => {
const s = row.original;
return (
<div className="flex flex-wrap gap-1">
{s.multiple ? (
<BehaviorChip label="Multi" />
) : (
<BehaviorChip label="Single" muted />
)}
{s.meta?.searchable ? <BehaviorChip label="Searchable" /> : null}
{s.meta?.clearable ? <BehaviorChip label="Clearable" /> : null}
</div>
);
},
},
{
id: "permissions",
header: "Permissions",
cell: ({ row }) => {
const s = row.original;
const perms = s.meta?.permissions ?? [];
return (
<div className="flex flex-wrap items-center gap-1">
{perms.length === 0 ? (
<span className="text-xs text-slate-400"></span>
) : (
perms.map((p) => (
<span
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"
>
<Shield />
{p}
</span>
))
)}
</div>
);
},
},
{
id: "actions",
size: 40,
cell: ({ row }) => {
const setting = row.original;
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Eye />
View
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => openDialogFor("options", setting)}
>
<CheckCircle2 />
Options
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => openDialogFor("edit", setting)}
>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => openDialogFor("delete", setting)}
variant="destructive"
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs
items={[
{ label: "Admin", href: "/admin" },
{ label: "Dropdown Settings" },
]}
/>
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Dropdown Settings
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
Manage every dynamic dropdown across the platform labels,
options, ordering, and permissions.
</p>
</div>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
placeholder="Search by code, label, description..."
className="pl-8!"
/>
</div>
<EditDropdownSettingDialog mode="create">
<Button>
<Plus />
New Setting
</Button>
</EditDropdownSettingDialog>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-4">
<StatCard
label="Settings"
value={dropdownSettings.length}
icon={<Settings />}
/>
<StatCard
label="Total Options"
value={totalOptions}
icon={<Boxes />}
/>
<StatCard
label="Multi-select"
value={multipleCount}
icon={<ListOrdered />}
/>
<StatCard
label="Searchable"
value={searchableCount}
icon={<Sparkles />}
/>
</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">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>Registered Dropdowns</CardTitle>
<CardDescription>
Every dynamic dropdown the platform reads from.
</CardDescription>
</div>
<Button variant="secondary" size="sm">
<Filter />
Filter
</Button>
</CardHeader>
<CardContent className="px-0">
{isLoading ? (
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin text-primary" />
Loading dropdown settings
</div>
) : (
<DataTable
columns={columns}
data={paginatedData}
status={status}
onRowClick={() => { }}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount: pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-b shadow-none"
footer={DataTableFooter}
/>
)}
</CardContent>
</Card>
</div>
{/* 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>
);
}
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({
label,
muted = false,
}: {
label: string;
muted?: boolean;
}) {
return (
<span
className={
muted
? "rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600"
: "rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
}
>
{label}
</span>
);
}

View File

@@ -1,336 +0,0 @@
import { useState, type ReactNode } from "react";
import { Hash, Loader2 } from "lucide-react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import type {
CreateDropdownSettingDto,
DropdownSetting,
UpdateDropdownSettingDto,
} from "@/types/dropdownSettings";
import {
useCreateDropdownSetting,
useUpdateDropdownSetting,
} from "@/hooks/useDropdownSettings";
export interface EditDropdownSettingDialogProps {
mode?: "create" | "edit";
setting?: DropdownSetting;
/** 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<boolean>(setting?.multiple ?? false);
const [searchable, setSearchable] = useState<boolean>(
setting?.meta?.searchable ?? false,
);
const [clearable, setClearable] = useState<boolean>(
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 (
<Dialog
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">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
{isEdit ? "Edit Dropdown Setting" : "New Dropdown Setting"}
</DialogTitle>
<DialogDescription>
{isEdit
? "Update the metadata for this dropdown setting."
: "Define a new dynamic dropdown that admins can manage."}
</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Code *</Label>
<div className="relative">
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="e.g. cargo_type"
className="pl-10 font-mono"
disabled={isEdit}
/>
</div>
<p className="text-xs text-slate-500">
{isEdit
? "Code is immutable after creation."
: "Stable identifier used in code. Use snake_case."}
</p>
</div>
<div className="space-y-2">
<Label>Label *</Label>
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. Cargo Type"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Description</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What this dropdown represents and where it's used..."
/>
</div>
<div className="space-y-2">
<Label>Icon (meta.icon)</Label>
<Input
value={icon}
onChange={(e) => setIcon(e.target.value)}
placeholder="lucide icon name, e.g. package"
/>
</div>
<div className="space-y-2">
<Label>Color (meta.color)</Label>
<Input
value={color}
onChange={(e) => setColor(e.target.value)}
placeholder="#10B981"
/>
</div>
<div className="space-y-2">
<Label>Permissions (comma-separated)</Label>
<Input
value={permissions}
onChange={(e) => setPermissions(e.target.value)}
placeholder="admin, ops"
/>
</div>
<div className="space-y-2">
<Label>Version (meta.version)</Label>
<Input
value={version}
onChange={(e) => setVersion(e.target.value)}
placeholder="1.0"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Behavior</Label>
<div className="flex flex-wrap gap-3">
<ToggleChip
checked={multiple}
onChange={setMultiple}
label="Multi-select"
description="Users can pick more than one option"
/>
<ToggleChip
checked={searchable}
onChange={setSearchable}
label="Searchable"
description="Show a search input in the dropdown"
/>
<ToggleChip
checked={clearable}
onChange={setClearable}
label="Clearable"
description="Allow users to clear the selection"
/>
</div>
</div>
</div>
{error ? (
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</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>
</div>
</DialogContent>
</Dialog>
);
}
function ToggleChip({
checked,
onChange,
label,
description,
}: {
checked: boolean;
onChange: (next: boolean) => void;
label: string;
description: string;
}) {
return (
<label
className={
checked
? "flex cursor-pointer items-start gap-2 rounded-2xl border border-[#10B981]/40 bg-[#10B981]/10 px-3 py-2 text-sm"
: "flex cursor-pointer items-start gap-2 rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm transition hover:border-[#10B981]/30 hover:bg-[#10B981]/5"
}
>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
/>
<div>
<p className="font-medium text-slate-900">{label}</p>
<p className="text-xs text-slate-500">{description}</p>
</div>
</label>
);
}

View File

@@ -1,229 +0,0 @@
import { useState, type ReactNode } from "react";
import { Hash, Loader2 } from "lucide-react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import type {
FileUploadEntity,
FileUploadSetting,
} from "@/types/fileUploadSettings";
import {
useCreateFileUploadSetting,
useUpdateFileUploadSetting,
} from "@/hooks/useFileUploadSettings";
export interface EditFileUploadSettingDialogProps {
mode?: "create" | "edit";
setting?: FileUploadSetting;
children: ReactNode;
}
const selectClass =
"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";
const ENTITIES: FileUploadEntity[] = [
"customer",
"booking",
"consignment",
"shipment",
"invoice",
"train",
"other",
];
export default function EditFileUploadSettingDialog({
mode = "create",
setting,
children,
}: EditFileUploadSettingDialogProps) {
const isEdit = mode === "edit";
const [open, setOpen] = useState(false);
const [code, setCode] = useState(setting?.code ?? "");
const [label, setLabel] = useState(setting?.label ?? "");
const [entity, setEntity] = useState<FileUploadEntity>(
setting?.entity ?? "other",
);
const [description, setDescription] = useState(setting?.description ?? "");
const [error, setError] = useState<string | null>(null);
const createMutation = useCreateFileUploadSetting();
const updateMutation = useUpdateFileUploadSetting();
const pending = createMutation.isPending || updateMutation.isPending;
const reset = () => {
setCode(setting?.code ?? "");
setLabel(setting?.label ?? "");
setEntity(setting?.entity ?? "other");
setDescription(setting?.description ?? "");
setError(null);
};
const handleSubmit = () => {
setError(null);
if (!code.trim() || !label.trim()) {
setError("Code and label are required.");
return;
}
const payload = {
code: code.trim(),
label: label.trim(),
entity,
description: description.trim() || undefined,
};
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) {
updateMutation.mutate(
{ id: setting.id, dto: payload },
{ onSuccess: onDone, onError },
);
} else {
createMutation.mutate(payload, { onSuccess: onDone, onError });
}
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) reset();
}}
>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
{isEdit ? "Edit File Upload Setting" : "New File Upload Setting"}
</DialogTitle>
<DialogDescription>
{isEdit
? "Update the metadata for this file upload group."
: "Define a new file upload group that a form can reference by code."}
</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Code *</Label>
<div className="relative">
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="e.g. customer_registration"
className="pl-10 font-mono"
disabled={isEdit}
/>
</div>
<p className="text-xs text-slate-500">
{isEdit
? "Code is immutable after creation."
: "Stable identifier used in code. Use snake_case."}
</p>
</div>
<div className="space-y-2">
<Label>Label *</Label>
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. Customer Registration"
/>
</div>
<div className="space-y-2">
<Label>Entity</Label>
<select
value={entity}
onChange={(e) => setEntity(e.target.value as FileUploadEntity)}
className={selectClass}
>
{ENTITIES.map((e) => (
<option key={e} value={e} className="capitalize">
{e[0]!.toUpperCase() + e.slice(1)}
</option>
))}
</select>
<p className="text-xs text-slate-500">
Domain the upload group applies to.
</p>
</div>
<div className="space-y-2">
<Label>Field Count</Label>
<Input
disabled
value={String(setting?.fields.length ?? 0)}
className="bg-slate-50 text-slate-600"
/>
<p className="text-xs text-slate-500">
Manage fields from the "Fields" action on the list.
</p>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Description</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What this upload group represents and where it's used..."
/>
</div>
</div>
{error ? (
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</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>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,460 +0,0 @@
import { useMemo, useState } from "react";
import {
AlertCircle,
Filter,
FileUp,
HardDrive,
Layers,
Loader2,
Paperclip,
Pencil,
Plus,
Search,
Settings,
Trash2,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import EditFileUploadSettingDialog from "./EditFileUploadSettingDialog";
import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog";
import DeleteFileUploadSettingDialog from "./DeleteFileUploadSettingDialog";
import {
useDeleteFileUploadSetting,
useFileUploadSettings,
} from "@/hooks/useFileUploadSettings";
import { getMinFiles } from "@/types/fileUploadSettings";
export default function FileUploadSettingsPage() {
const [query, setQuery] = useState("");
const { data, isLoading, isError, error } = useFileUploadSettings();
const deleteMutation = useDeleteFileUploadSetting();
const fileUploadSettings = useMemo(
() => (Array.isArray(data) ? data : []),
[data],
);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return fileUploadSettings;
return fileUploadSettings.filter(
(s) =>
s.code.toLowerCase().includes(q) ||
s.label.toLowerCase().includes(q) ||
(s.description ?? "").toLowerCase().includes(q) ||
s.fields.some(
(f) =>
f.fileKey.toLowerCase().includes(q) ||
f.fileLabel.toLowerCase().includes(q),
),
);
}, [fileUploadSettings, query]);
const totalFields = fileUploadSettings.reduce(
(sum, s) => sum + s.fields.length,
0,
);
const requiredFields = fileUploadSettings.reduce(
(sum, s) => sum + s.fields.filter((f) => f.isRequired).length,
0,
);
const multiFields = fileUploadSettings.reduce(
(sum, s) => sum + s.fields.filter((f) => f.isMultiple).length,
0,
);
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-7xl space-y-6">
<Breadcrumbs
items={[
{ label: "Admin", href: "/admin" },
{ label: "File Upload Settings" },
]}
/>
{/* Header */}
<div className="flex flex-col gap-4 rounded-3xl bg-white p-6 shadow-sm md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
File Upload Settings
</h1>
<p className="mt-1 text-sm text-slate-500">
Define the file inputs every form in the platform should render
required/optional, single/multiple, allowed types and size.
</p>
</div>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by code, label, or file key..."
className="h-10 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
/>
</div>
<EditFileUploadSettingDialog mode="create">
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
>
<Plus className="h-4 w-4" />
New Setting
</button>
</EditFileUploadSettingDialog>
</div>
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-4">
<StatCard
title="Settings"
value={String(fileUploadSettings.length)}
icon={<Settings className="h-5 w-5" />}
/>
<StatCard
title="Total Fields"
value={String(totalFields)}
icon={<Paperclip className="h-5 w-5" />}
/>
<StatCard
title="Required"
value={String(requiredFields)}
icon={<FileUp className="h-5 w-5" />}
/>
<StatCard
title="Multi-file"
value={String(multiFields)}
icon={<Layers className="h-5 w-5" />}
/>
</div>
{/* Table */}
<div className="overflow-hidden rounded-3xl bg-white shadow-sm">
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">
Registered File Upload Groups
</h2>
<p className="text-sm text-slate-500">
Every group a form can reference by code.
</p>
</div>
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]">
<Filter className="h-4 w-4" />
Filter
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[1100px] whitespace-nowrap text-left">
<thead className="bg-slate-50 text-sm text-slate-500">
<tr>
<th className="px-6 py-4 font-medium">Setting</th>
<th className="px-6 py-4 font-medium">Code</th>
<th className="px-6 py-4 font-medium">Entity</th>
<th className="px-6 py-4 font-medium">Fields</th>
<th className="px-6 py-4 font-medium">Required / Multi</th>
<th className="px-6 py-4 font-medium">Max Size</th>
<th className="px-6 py-4 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center">
<Loader2 className="mx-auto h-6 w-6 animate-spin text-[#10B981]" />
<p className="mt-2 text-sm text-slate-500">
Loading file upload settings
</p>
</td>
</tr>
) : isError ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center">
<AlertCircle className="mx-auto h-6 w-6 text-red-500" />
<p className="mt-2 text-sm text-red-600">
Failed to load settings.{" "}
{error instanceof Error ? error.message : "Unknown error."}
</p>
</td>
</tr>
) : filtered.length === 0 ? (
<tr>
<td
colSpan={7}
className="px-6 py-12 text-center text-sm text-slate-500"
>
{fileUploadSettings.length === 0
? "No file upload settings yet. Click \"New Setting\" to add one."
: "No file upload settings match your search."}
</td>
</tr>
) : (
filtered.map((setting) => {
const required = setting.fields.filter(
(f) => f.isRequired,
).length;
const multi = setting.fields.filter(
(f) => f.isMultiple,
).length;
const maxSize = Math.max(
0,
...setting.fields.map((f) => f.maxSizeMb),
);
return (
<tr
key={setting.id}
className="border-t border-slate-100 transition hover:bg-[#10B981]/5"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#10B981] text-white">
<FileUp className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-slate-900">
{setting.label}
</p>
<p className="text-xs text-slate-500">
{setting.description ?? "No description"}
</p>
</div>
</div>
</td>
<td className="px-6 py-4">
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
{setting.code}
</span>
</td>
<td className="px-6 py-4">
<span className="inline-flex rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium capitalize text-slate-600">
{setting.entity ?? "—"}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-sm text-slate-700">
<Paperclip className="h-4 w-4 text-[#10B981]" />
<span className="font-medium">
{setting.fields.length}
</span>
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
<div className="flex flex-wrap items-center gap-1">
<Chip>{required} required</Chip>
<Chip muted>{multi} multi</Chip>
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
<div className="flex items-center gap-1.5">
<HardDrive className="h-4 w-4 text-slate-400" />
{maxSize ? `${maxSize} MB` : "—"}
</div>
</td>
<td className="px-6 py-4">
<div className="flex justify-end gap-2">
<ManageFileUploadFieldsDialog setting={setting}>
<button
type="button"
className="inline-flex items-center gap-1 rounded-xl border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<Paperclip className="h-3.5 w-3.5" />
Fields
</button>
</ManageFileUploadFieldsDialog>
<EditFileUploadSettingDialog
mode="edit"
setting={setting}
>
<button
type="button"
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<Pencil className="h-4 w-4" />
</button>
</EditFileUploadSettingDialog>
<DeleteFileUploadSettingDialog
settingLabel={setting.label}
settingCode={setting.code}
onConfirm={() =>
deleteMutation.mutate(setting.id)
}
>
<button
type="button"
disabled={deleteMutation.isPending}
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
</button>
</DeleteFileUploadSettingDialog>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{/* Behavior reference card */}
<div className="rounded-3xl bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">
Required × Multiple behavior
</h2>
<p className="mt-1 text-sm text-slate-500">
Min and max file counts are derived from these two toggles. The
"Max Files" you set on a field is only used when{" "}
<span className="font-medium">Multiple</span> is on.
</p>
<div className="mt-4 overflow-x-auto">
<table className="w-full whitespace-nowrap text-left text-sm">
<thead className="text-xs text-slate-500">
<tr>
<th className="py-2 font-medium">Required</th>
<th className="py-2 font-medium">Multiple</th>
<th className="py-2 font-medium">min_files</th>
<th className="py-2 font-medium">max_files</th>
</tr>
</thead>
<tbody>
<BehaviorRow
required={false}
multiple={false}
min="0"
max="1"
/>
<BehaviorRow
required={true}
multiple={false}
min="1"
max="1"
/>
<BehaviorRow
required={false}
multiple={true}
min="0"
max="field.maxFiles"
/>
<BehaviorRow
required={true}
multiple={true}
min="1"
max="field.maxFiles"
/>
</tbody>
</table>
</div>
<p className="mt-3 text-xs text-slate-500">
Helpers <span className="font-mono">getMinFiles</span> and{" "}
<span className="font-mono">getEffectiveMaxFiles</span> live in{" "}
<span className="font-mono">@/types/fileUploadSettings</span> use
them when wiring real uploaders. Example: a field with{" "}
<span className="font-mono">isRequired=false</span>,{" "}
<span className="font-mono">isMultiple=true</span>,{" "}
<span className="font-mono">maxFiles=5</span> gives{" "}
<span className="font-mono">{getMinFiles({
id: "demo",
fileKey: "demo",
fileLabel: "demo",
isRequired: false,
isMultiple: true,
maxFiles: 5,
allowedExtensions: [],
maxSizeMb: 1,
})}</span>
5.
</p>
</div>
</div>
</div>
);
}
function BehaviorRow({
required,
multiple,
min,
max,
}: {
required: boolean;
multiple: boolean;
min: string;
max: string;
}) {
return (
<tr className="border-t border-slate-100">
<td className="py-2.5">
<Chip muted={!required}>{required ? "Required" : "Optional"}</Chip>
</td>
<td className="py-2.5">
<Chip muted={!multiple}>{multiple ? "Multiple" : "Single"}</Chip>
</td>
<td className="py-2.5 font-mono text-slate-700">{min}</td>
<td className="py-2.5 font-mono text-slate-700">{max}</td>
</tr>
);
}
function Chip({
children,
muted = false,
}: {
children: React.ReactNode;
muted?: boolean;
}) {
return (
<span
className={
muted
? "rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600"
: "rounded-full bg-[#10B981]/10 px-2 py-0.5 text-xs font-medium text-[#10B981]"
}
>
{children}
</span>
);
}
function StatCard({
title,
value,
icon,
}: {
title: string;
value: string;
icon: React.ReactNode;
}) {
return (
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{title}</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white">
{icon}
</div>
</div>
</div>
);
}

View File

@@ -1,339 +0,0 @@
import { useState, type ReactNode } from "react";
import { GripVertical, Loader2, Plus, Trash2 } from "lucide-react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import type {
CreateDropdownOptionDto,
DropdownSetting,
} from "@/types/dropdownSettings";
import { useReplaceDropdownOptions } from "@/hooks/useDropdownSettings";
export interface ManageDropdownOptionsDialogProps {
setting: DropdownSetting;
children?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
/**
* 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 {
key: nextKey(),
value: "",
label: "",
disabled: false,
order: idx + 1,
meta: {},
};
}
export default function ManageDropdownOptionsDialog({
setting,
children,
open: openProp,
onOpenChange,
}: ManageDropdownOptionsDialogProps) {
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 [error, setError] = useState<string | null>(null);
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) =>
prev.map((o, idx) => (idx === i ? { ...o, ...patch } : o)),
);
const updateMeta = (
i: number,
patch: Partial<NonNullable<DraftOption["meta"]>>,
) =>
setOptions((prev) =>
prev.map((o, idx) =>
idx === i ? { ...o, meta: { ...(o.meta ?? {}), ...patch } } : o,
),
);
const remove = (i: number) =>
setOptions((prev) => prev.filter((_, idx) => idx !== i));
const add = () =>
setOptions((prev) => [...prev, makeEmptyDraft(prev.length)]);
const move = (i: number, dir: -1 | 1) =>
setOptions((prev) => {
const next = [...prev];
const target = i + dir;
if (target < 0 || target >= next.length) return prev;
const a = next[i] as DraftOption;
const b = next[target] as DraftOption;
next[i] = { ...b, order: i + 1 };
next[target] = { ...a, order: target + 1 };
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 (
<Dialog
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">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
Manage Options · {setting.label}
</DialogTitle>
<DialogDescription>
Add, edit, reorder, or remove options for{" "}
<span className="font-mono text-slate-700">{setting.code}</span>.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-4">
<div className="flex items-center justify-between">
<p className="text-sm text-slate-500">
{options.length} option{options.length === 1 ? "" : "s"}
</p>
<button
type="button"
onClick={add}
className="inline-flex items-center gap-1.5 rounded-xl bg-[#10B981] px-3 py-1.5 text-xs font-medium text-white transition hover:bg-[#10B981]/90"
>
<Plus className="h-3.5 w-3.5" />
Add Option
</button>
</div>
{options.length === 0 ? (
<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.
</div>
) : (
<div className="space-y-2">
{options.map((opt, i) => (
<div
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]"
>
<div className="flex items-center gap-1 text-slate-400">
<GripVertical className="h-4 w-4" />
<div className="flex flex-col">
<button
type="button"
onClick={() => move(i, -1)}
aria-label="Move up"
disabled={i === 0}
className="text-[10px] leading-none text-slate-400 transition hover:text-[#10B981] disabled:opacity-30"
>
</button>
<button
type="button"
onClick={() => move(i, 1)}
aria-label="Move down"
disabled={i === options.length - 1}
className="text-[10px] leading-none text-slate-400 transition hover:text-[#10B981] disabled:opacity-30"
>
</button>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Label *</Label>
<Input
value={opt.label}
onChange={(e) => update(i, { label: e.target.value })}
placeholder="Display label"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Value *</Label>
<Input
value={opt.value}
onChange={(e) => update(i, { value: e.target.value })}
placeholder="Stored value"
className="font-mono"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Note</Label>
<Input
value={opt.note ?? ""}
onChange={(e) => update(i, { note: e.target.value })}
placeholder="Helper text"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Badge</Label>
<Input
value={opt.meta?.badge ?? ""}
onChange={(e) => updateMeta(i, { badge: e.target.value })}
placeholder="—"
className="w-20"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Color</Label>
<Input
value={opt.meta?.color ?? ""}
onChange={(e) => updateMeta(i, { color: e.target.value })}
placeholder="#…"
className="w-24 font-mono"
/>
</div>
<div className="flex flex-col items-center justify-between gap-2">
<label className="flex items-center gap-1 text-xs text-slate-600">
<input
type="checkbox"
checked={opt.disabled ?? false}
onChange={(e) =>
update(i, { disabled: e.target.checked })
}
className="h-3.5 w-3.5 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
/>
Off
</label>
<button
type="button"
onClick={() => remove(i)}
aria-label={`Remove ${opt.label || "option"}`}
className="rounded-lg p-1 text-red-500 transition hover:bg-red-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
))}
</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">
<DialogClose asChild>
<Button variant="outline" disabled={replaceMutation.isPending}>
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>
</div>
</DialogContent>
</Dialog>
);
}

Some files were not shown because too many files have changed in this diff Show More